@kindgi/platform 0.1.3 → 0.1.4-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/in-memory/index.d.ts +1 -0
- package/dist/in-memory/index.d.ts.map +1 -1
- package/dist/in-memory/org-binding.d.ts.map +1 -1
- package/dist/in-memory/org-binding.js +33 -16
- package/dist/in-memory/org-binding.js.map +1 -1
- package/dist/in-memory/project-binding.d.ts +26 -1
- package/dist/in-memory/project-binding.d.ts.map +1 -1
- package/dist/in-memory/project-binding.js +47 -8
- package/dist/in-memory/project-binding.js.map +1 -1
- package/dist/in-memory/team-binding.d.ts +2 -1
- package/dist/in-memory/team-binding.d.ts.map +1 -1
- package/dist/in-memory/team-binding.js +31 -7
- package/dist/in-memory/team-binding.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/org-binding.d.ts +56 -12
- package/dist/org-binding.d.ts.map +1 -1
- package/dist/project-binding.d.ts +76 -12
- package/dist/project-binding.d.ts.map +1 -1
- package/dist/team-binding.d.ts +70 -12
- package/dist/team-binding.d.ts.map +1 -1
- package/dist/tenant-hierarchy-binding.d.ts +31 -5
- package/dist/tenant-hierarchy-binding.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/in-memory/index.ts +1 -0
- package/src/in-memory/org-binding.ts +41 -19
- package/src/in-memory/project-binding.ts +85 -16
- package/src/in-memory/team-binding.ts +48 -12
- package/src/index.ts +14 -1
- package/src/org-binding.ts +60 -12
- package/src/project-binding.ts +88 -12
- package/src/team-binding.ts +82 -12
- package/src/tenant-hierarchy-binding.ts +21 -6
- package/tests/in-memory-project-binding.test.ts +13 -1
- package/tests/in-memory-team-binding.test.ts +8 -1
- package/tests/org-binding.conformance.ts +93 -18
- package/tests/project-binding.conformance.ts +221 -37
- package/tests/team-binding.conformance.ts +120 -28
package/src/team-binding.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* truth.
|
|
11
11
|
*
|
|
12
12
|
* Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
|
|
13
|
-
* return, `Filter`-extending filter shapes
|
|
13
|
+
* return, `Filter`-extending filter shapes, and writes the caller can
|
|
14
|
+
* get wrong resolve to an outcome discriminated on `kind` instead of
|
|
15
|
+
* rejecting.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import type { Filter, OrgId, Page, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
@@ -46,10 +48,11 @@ export interface TeamMembershipAddInput {
|
|
|
46
48
|
export interface TeamBinding {
|
|
47
49
|
/**
|
|
48
50
|
* Create a `Team` within the tenant. `spec.orgId` optional (a team
|
|
49
|
-
* may be cross-org — nullable FK).
|
|
50
|
-
* `TeamId
|
|
51
|
+
* may be cross-org — nullable FK). Resolves to `ok` with the
|
|
52
|
+
* assigned `TeamId`, or to `slug-conflict` when another team in the
|
|
53
|
+
* tenant has `spec.slug`.
|
|
51
54
|
*/
|
|
52
|
-
create(tenantId: TenantId, spec: TeamSpec): Promise<
|
|
55
|
+
create(tenantId: TenantId, spec: TeamSpec): Promise<TeamCreateOutcome>;
|
|
53
56
|
/**
|
|
54
57
|
* Look up a `Team` by id. `undefined` when unknown or in a different
|
|
55
58
|
* tenant.
|
|
@@ -63,9 +66,11 @@ export interface TeamBinding {
|
|
|
63
66
|
/**
|
|
64
67
|
* Partially update a `Team`. `TeamPatch.orgId` supports `null` to
|
|
65
68
|
* explicitly re-assign the team out of any org (cross-org), and
|
|
66
|
-
* `undefined` to leave the FK untouched.
|
|
69
|
+
* `undefined` to leave the FK untouched. Resolves to `team-not-found`
|
|
70
|
+
* when no team has `teamId` in the tenant, and to `slug-conflict`
|
|
71
|
+
* when `patch.slug` is another team's slug.
|
|
67
72
|
*/
|
|
68
|
-
update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<
|
|
73
|
+
update(tenantId: TenantId, teamId: TeamId, patch: TeamPatch): Promise<TeamUpdateOutcome>;
|
|
69
74
|
/**
|
|
70
75
|
* Delete a `Team`. Its membership rows are removed with it (the
|
|
71
76
|
* in-memory adapter deletes them from its map).
|
|
@@ -88,9 +93,10 @@ export interface TeamMembershipBinding {
|
|
|
88
93
|
* silently mutate a differing role).
|
|
89
94
|
*
|
|
90
95
|
* The conformance suite pins this: adding an existing member whose
|
|
91
|
-
* stored role differs is a no-op (not a role overwrite).
|
|
96
|
+
* stored role differs is a no-op (not a role overwrite). Resolves to
|
|
97
|
+
* `team-not-found` when no team has `input.teamId` in the tenant.
|
|
92
98
|
*/
|
|
93
|
-
add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<
|
|
99
|
+
add(tenantId: TenantId, input: TeamMembershipAddInput): Promise<TeamMembershipAddOutcome>;
|
|
94
100
|
/**
|
|
95
101
|
* Remove a user from a team. Missing membership is a no-op (not an
|
|
96
102
|
* error) — matches every peer platform's membership-remove shape.
|
|
@@ -108,9 +114,73 @@ export interface TeamMembershipBinding {
|
|
|
108
114
|
*/
|
|
109
115
|
listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<TeamMembership>>;
|
|
110
116
|
/**
|
|
111
|
-
* Update an existing membership's role.
|
|
112
|
-
*
|
|
113
|
-
* not
|
|
117
|
+
* Update an existing membership's role. Resolves to `team-not-found`
|
|
118
|
+
* when the team isn't in the tenant, and to
|
|
119
|
+
* `team-membership-not-found` when the user isn't a member — role
|
|
120
|
+
* mutation of a non-member is a caller mistake, not a silent add.
|
|
114
121
|
*/
|
|
115
|
-
updateRole(
|
|
122
|
+
updateRole(
|
|
123
|
+
tenantId: TenantId,
|
|
124
|
+
teamId: TeamId,
|
|
125
|
+
userId: UserId,
|
|
126
|
+
role: TeamRole,
|
|
127
|
+
): Promise<TeamMembershipUpdateRoleOutcome>;
|
|
116
128
|
}
|
|
129
|
+
|
|
130
|
+
/** What `TeamBinding.create` did. */
|
|
131
|
+
export type TeamCreateOutcome =
|
|
132
|
+
| { readonly kind: 'ok'; readonly teamId: TeamId }
|
|
133
|
+
| {
|
|
134
|
+
/** Another team in the tenant already has this slug. */
|
|
135
|
+
readonly kind: 'slug-conflict';
|
|
136
|
+
readonly slug: string;
|
|
137
|
+
}
|
|
138
|
+
| {
|
|
139
|
+
/**
|
|
140
|
+
* The org the team would be in isn't one of the tenant's: it never
|
|
141
|
+
* existed, or it was deleted. Nothing changed.
|
|
142
|
+
*/
|
|
143
|
+
readonly kind: 'org-not-found';
|
|
144
|
+
readonly orgId: OrgId;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** What `TeamBinding.update` did. */
|
|
148
|
+
export type TeamUpdateOutcome =
|
|
149
|
+
| { readonly kind: 'ok' }
|
|
150
|
+
| {
|
|
151
|
+
/** No team with this id in the tenant. */
|
|
152
|
+
readonly kind: 'team-not-found';
|
|
153
|
+
}
|
|
154
|
+
| {
|
|
155
|
+
/** Another team in the tenant already has the patched slug. */
|
|
156
|
+
readonly kind: 'slug-conflict';
|
|
157
|
+
readonly slug: string;
|
|
158
|
+
}
|
|
159
|
+
| {
|
|
160
|
+
/**
|
|
161
|
+
* The org the team would be in isn't one of the tenant's: it never
|
|
162
|
+
* existed, or it was deleted. Nothing changed.
|
|
163
|
+
*/
|
|
164
|
+
readonly kind: 'org-not-found';
|
|
165
|
+
readonly orgId: OrgId;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** What `TeamMembershipBinding.add` did (`ok` when already a member, too). */
|
|
169
|
+
export type TeamMembershipAddOutcome =
|
|
170
|
+
| { readonly kind: 'ok' }
|
|
171
|
+
| {
|
|
172
|
+
/** No team with this id in the tenant. */
|
|
173
|
+
readonly kind: 'team-not-found';
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** What `TeamMembershipBinding.updateRole` did. */
|
|
177
|
+
export type TeamMembershipUpdateRoleOutcome =
|
|
178
|
+
| { readonly kind: 'ok' }
|
|
179
|
+
| {
|
|
180
|
+
/** No team with this id in the tenant. */
|
|
181
|
+
readonly kind: 'team-not-found';
|
|
182
|
+
}
|
|
183
|
+
| {
|
|
184
|
+
/** The user isn't a member of the team. */
|
|
185
|
+
readonly kind: 'team-membership-not-found';
|
|
186
|
+
};
|
|
@@ -35,6 +35,8 @@ export interface CreateTeamParams {
|
|
|
35
35
|
|
|
36
36
|
export type CreateTeamError =
|
|
37
37
|
| { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
|
|
38
|
+
/** `spec.orgId` isn't the tenant's: it never existed, or it was deleted. */
|
|
39
|
+
| { readonly code: 'org-not-found'; readonly message: string; readonly orgId: OrgId }
|
|
38
40
|
| { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
|
|
39
41
|
|
|
40
42
|
export interface AddTeamMemberParams {
|
|
@@ -52,6 +54,8 @@ export interface CreateProjectParams {
|
|
|
52
54
|
|
|
53
55
|
export type CreateProjectError =
|
|
54
56
|
| { readonly code: 'slug-conflict'; readonly message: string; readonly cause?: unknown }
|
|
57
|
+
/** `spec.orgId` isn't the tenant's: it never existed, or it was deleted. */
|
|
58
|
+
| { readonly code: 'org-not-found'; readonly message: string; readonly orgId: OrgId }
|
|
55
59
|
| { readonly code: 'default-conflict'; readonly message: string; readonly cause?: unknown }
|
|
56
60
|
| { readonly code: 'insert-failed'; readonly message: string; readonly cause?: unknown };
|
|
57
61
|
|
|
@@ -62,10 +66,21 @@ export interface AddProjectMemberParams {
|
|
|
62
66
|
readonly role: ProjectRole;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
export type AddTeamMemberError =
|
|
70
|
+
| {
|
|
71
|
+
/** No team with this id in the tenant. */
|
|
72
|
+
readonly code: 'team-not-found';
|
|
73
|
+
readonly message: string;
|
|
74
|
+
}
|
|
75
|
+
| { readonly code: 'add-failed'; readonly message: string; readonly cause?: unknown };
|
|
76
|
+
|
|
77
|
+
export type AddProjectMemberError =
|
|
78
|
+
| {
|
|
79
|
+
/** No project with this id in the tenant. */
|
|
80
|
+
readonly code: 'project-not-found';
|
|
81
|
+
readonly message: string;
|
|
82
|
+
}
|
|
83
|
+
| { readonly code: 'add-failed'; readonly message: string; readonly cause?: unknown };
|
|
69
84
|
|
|
70
85
|
/**
|
|
71
86
|
* Tenant-summary read shape returned by `getTenant`. Only the fields
|
|
@@ -96,8 +111,8 @@ export interface TenantHierarchyBinding {
|
|
|
96
111
|
createProject(
|
|
97
112
|
params: CreateProjectParams,
|
|
98
113
|
): Promise<Result<{ readonly projectId: ProjectId }, CreateProjectError>>;
|
|
99
|
-
addTeamMember(params: AddTeamMemberParams): Promise<Result<void,
|
|
100
|
-
addProjectMember(params: AddProjectMemberParams): Promise<Result<void,
|
|
114
|
+
addTeamMember(params: AddTeamMemberParams): Promise<Result<void, AddTeamMemberError>>;
|
|
115
|
+
addProjectMember(params: AddProjectMemberParams): Promise<Result<void, AddProjectMemberError>>;
|
|
101
116
|
/**
|
|
102
117
|
* Look up a tenant by id. Returns `null` when no tenant exists.
|
|
103
118
|
* Not itself tenant-scoped: it is how a caller finds out which
|
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { makeInMemoryProjectBinding } from '../src/in-memory/project-binding.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
CONFORMANCE_MISSING_ORG,
|
|
12
|
+
runProjectBindingConformance,
|
|
13
|
+
} from './project-binding.conformance.js';
|
|
11
14
|
|
|
12
15
|
runProjectBindingConformance(makeInMemoryProjectBinding, 'InMemoryProjectBinding');
|
|
16
|
+
// With an org check: every org but the missing one is the tenant's.
|
|
17
|
+
runProjectBindingConformance(
|
|
18
|
+
() =>
|
|
19
|
+
makeInMemoryProjectBinding({
|
|
20
|
+
orgExists: (_tenantId, orgId) => orgId !== CONFORMANCE_MISSING_ORG,
|
|
21
|
+
}),
|
|
22
|
+
'InMemoryProjectBinding (checking orgs)',
|
|
23
|
+
{ checksOrgs: true },
|
|
24
|
+
);
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { makeInMemoryTeamBinding } from '../src/in-memory/team-binding.js';
|
|
10
|
-
import { runTeamBindingConformance } from './team-binding.conformance.js';
|
|
10
|
+
import { CONFORMANCE_MISSING_ORG, runTeamBindingConformance } from './team-binding.conformance.js';
|
|
11
11
|
|
|
12
12
|
runTeamBindingConformance(makeInMemoryTeamBinding, 'InMemoryTeamBinding');
|
|
13
|
+
// With an org check: every org but the missing one is the tenant's.
|
|
14
|
+
runTeamBindingConformance(
|
|
15
|
+
() =>
|
|
16
|
+
makeInMemoryTeamBinding({ orgExists: (_tenantId, orgId) => orgId !== CONFORMANCE_MISSING_ORG }),
|
|
17
|
+
'InMemoryTeamBinding (checking orgs)',
|
|
18
|
+
{ checksOrgs: true },
|
|
19
|
+
);
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
* asserted at every reachable read op.
|
|
15
15
|
* - Pagination via `Filter.cursor`.
|
|
16
16
|
* - `nameContains` substring filter.
|
|
17
|
-
* - `get`/`update`/`delete` behaviour on unknown orgIds
|
|
17
|
+
* - `get`/`update`/`delete` behaviour on unknown orgIds: `update`
|
|
18
|
+
* resolves to `org-not-found`.
|
|
19
|
+
* - Slug uniqueness within a tenant: `create` and `update` resolve to
|
|
20
|
+
* `slug-conflict`; another tenant may reuse the slug.
|
|
21
|
+
* - `delete` (a tombstone in a durable binding): the org is unknown to
|
|
22
|
+
* every read, its slug is free, and deleting again is a no-op.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { describe, expect, it } from 'vitest';
|
|
@@ -22,10 +27,18 @@ import { describe, expect, it } from 'vitest';
|
|
|
22
27
|
import type { OrgId, TenantId } from '@kindgi/types';
|
|
23
28
|
|
|
24
29
|
import type { OrgBinding } from '../src/org-binding.js';
|
|
30
|
+
import type { OrgSpec } from '../src/types.js';
|
|
25
31
|
|
|
26
32
|
const T1 = 'tenant-1' as TenantId;
|
|
27
33
|
const T2 = 'tenant-2' as TenantId;
|
|
28
34
|
|
|
35
|
+
/** Create an org the test expects to succeed; its id. */
|
|
36
|
+
async function createOrg(b: OrgBinding, tenantId: TenantId, spec: OrgSpec): Promise<OrgId> {
|
|
37
|
+
const outcome = await b.create(tenantId, spec);
|
|
38
|
+
if (outcome.kind !== 'ok') throw new Error(`expected the org to be created, got ${outcome.kind}`);
|
|
39
|
+
return outcome.orgId;
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
export function runOrgBindingConformance(
|
|
30
43
|
makeBinding: () => OrgBinding,
|
|
31
44
|
label = 'OrgBinding',
|
|
@@ -33,7 +46,7 @@ export function runOrgBindingConformance(
|
|
|
33
46
|
describe(`${label} — CRUD happy paths`, () => {
|
|
34
47
|
it('creates + gets an Org', async () => {
|
|
35
48
|
const b = makeBinding();
|
|
36
|
-
const id = await b
|
|
49
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
37
50
|
const got = await b.get(T1, id);
|
|
38
51
|
expect(got).toBeDefined();
|
|
39
52
|
expect(got?.id).toBe(id);
|
|
@@ -44,8 +57,8 @@ export function runOrgBindingConformance(
|
|
|
44
57
|
|
|
45
58
|
it('updates an Org partially', async () => {
|
|
46
59
|
const b = makeBinding();
|
|
47
|
-
const id = await b
|
|
48
|
-
await b.update(T1, id, { name: 'Acme Corp' });
|
|
60
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
61
|
+
expect(await b.update(T1, id, { name: 'Acme Corp' })).toEqual({ kind: 'ok' });
|
|
49
62
|
const got = await b.get(T1, id);
|
|
50
63
|
expect(got?.name).toBe('Acme Corp');
|
|
51
64
|
expect(got?.slug).toBe('acme');
|
|
@@ -53,12 +66,38 @@ export function runOrgBindingConformance(
|
|
|
53
66
|
|
|
54
67
|
it('deletes an Org', async () => {
|
|
55
68
|
const b = makeBinding();
|
|
56
|
-
const id = await b
|
|
69
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
57
70
|
await b.delete(T1, id);
|
|
58
71
|
const got = await b.get(T1, id);
|
|
59
72
|
expect(got).toBeUndefined();
|
|
60
73
|
});
|
|
61
74
|
|
|
75
|
+
it('a deleted Org is unknown to list and update, and deleting it again is a no-op', async () => {
|
|
76
|
+
const b = makeBinding();
|
|
77
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
78
|
+
const kept = await createOrg(b, T1, { name: 'Kept', slug: 'kept' });
|
|
79
|
+
await b.delete(T1, id);
|
|
80
|
+
const page = await b.list(T1, {});
|
|
81
|
+
expect(page.items.map((o) => o.id)).toEqual([kept]);
|
|
82
|
+
expect(await b.update(T1, id, { name: 'Acme 2' })).toEqual({ kind: 'org-not-found' });
|
|
83
|
+
await b.delete(T1, id);
|
|
84
|
+
expect(await b.get(T1, kept)).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a deleted Org's slug is free for a new Org", async () => {
|
|
88
|
+
const b = makeBinding();
|
|
89
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
90
|
+
await b.delete(T1, id);
|
|
91
|
+
const again = await createOrg(b, T1, { name: 'Acme again', slug: 'acme' });
|
|
92
|
+
expect(again).not.toBe(id);
|
|
93
|
+
expect((await b.get(T1, again))?.slug).toBe('acme');
|
|
94
|
+
const other = await createOrg(b, T1, { name: 'Other', slug: 'other' });
|
|
95
|
+
expect(await b.update(T1, other, { slug: 'acme' })).toEqual({
|
|
96
|
+
kind: 'slug-conflict',
|
|
97
|
+
slug: 'acme',
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
62
101
|
it('lists orgs (empty tenant)', async () => {
|
|
63
102
|
const b = makeBinding();
|
|
64
103
|
const page = await b.list(T1, {});
|
|
@@ -70,27 +109,28 @@ export function runOrgBindingConformance(
|
|
|
70
109
|
describe(`${label} — cross-tenant isolation`, () => {
|
|
71
110
|
it('get on tenant B for tenant-A orgId returns undefined', async () => {
|
|
72
111
|
const b = makeBinding();
|
|
73
|
-
const id = await b
|
|
112
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
74
113
|
expect(await b.get(T2, id)).toBeUndefined();
|
|
75
114
|
});
|
|
76
115
|
|
|
77
116
|
it('list on tenant B does not surface tenant-A orgs', async () => {
|
|
78
117
|
const b = makeBinding();
|
|
79
|
-
await b
|
|
80
|
-
await b
|
|
118
|
+
await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
119
|
+
await createOrg(b, T1, { name: 'Globex', slug: 'globex' });
|
|
81
120
|
const t2Page = await b.list(T2, {});
|
|
82
121
|
expect(t2Page.items).toEqual([]);
|
|
83
122
|
});
|
|
84
123
|
|
|
85
|
-
it('update on tenant B for tenant-A orgId
|
|
124
|
+
it('update on tenant B for tenant-A orgId → org-not-found', async () => {
|
|
86
125
|
const b = makeBinding();
|
|
87
|
-
const id = await b
|
|
88
|
-
await
|
|
126
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
127
|
+
expect(await b.update(T2, id, { name: 'X' })).toEqual({ kind: 'org-not-found' });
|
|
128
|
+
expect((await b.get(T1, id))?.name).toBe('Acme');
|
|
89
129
|
});
|
|
90
130
|
|
|
91
131
|
it('delete on tenant B for tenant-A orgId is a no-op (does not mutate A)', async () => {
|
|
92
132
|
const b = makeBinding();
|
|
93
|
-
const id = await b
|
|
133
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
94
134
|
await b.delete(T2, id); // no-op
|
|
95
135
|
const got = await b.get(T1, id);
|
|
96
136
|
expect(got).toBeDefined();
|
|
@@ -102,7 +142,7 @@ export function runOrgBindingConformance(
|
|
|
102
142
|
const b = makeBinding();
|
|
103
143
|
const created: OrgId[] = [];
|
|
104
144
|
for (let i = 0; i < 5; i += 1) {
|
|
105
|
-
created.push(await b
|
|
145
|
+
created.push(await createOrg(b, T1, { name: `Org${i}`, slug: `org-${i}` }));
|
|
106
146
|
}
|
|
107
147
|
const page1 = await b.list(T1, { limit: 2 });
|
|
108
148
|
expect(page1.items).toHaveLength(2);
|
|
@@ -133,9 +173,9 @@ export function runOrgBindingConformance(
|
|
|
133
173
|
describe(`${label} — nameContains filter`, () => {
|
|
134
174
|
it('narrows to orgs whose name contains the substring', async () => {
|
|
135
175
|
const b = makeBinding();
|
|
136
|
-
await b
|
|
137
|
-
await b
|
|
138
|
-
await b
|
|
176
|
+
await createOrg(b, T1, { name: 'Litigation', slug: 'lit' });
|
|
177
|
+
await createOrg(b, T1, { name: 'Corporate', slug: 'corp' });
|
|
178
|
+
await createOrg(b, T1, { name: 'Litigation-Sub', slug: 'lit-sub' });
|
|
139
179
|
|
|
140
180
|
const page = await b.list(T1, { nameContains: 'Litigation' });
|
|
141
181
|
expect(page.items).toHaveLength(2);
|
|
@@ -152,9 +192,9 @@ export function runOrgBindingConformance(
|
|
|
152
192
|
expect(await b.get(T1, unknown)).toBeUndefined();
|
|
153
193
|
});
|
|
154
194
|
|
|
155
|
-
it('update on unknown orgId
|
|
195
|
+
it('update on unknown orgId → org-not-found', async () => {
|
|
156
196
|
const b = makeBinding();
|
|
157
|
-
await
|
|
197
|
+
expect(await b.update(T1, unknown, { name: 'X' })).toEqual({ kind: 'org-not-found' });
|
|
158
198
|
});
|
|
159
199
|
|
|
160
200
|
it('delete on unknown orgId is a no-op', async () => {
|
|
@@ -162,4 +202,39 @@ export function runOrgBindingConformance(
|
|
|
162
202
|
await expect(b.delete(T1, unknown)).resolves.toBeUndefined();
|
|
163
203
|
});
|
|
164
204
|
});
|
|
205
|
+
|
|
206
|
+
describe(`${label} — slug uniqueness`, () => {
|
|
207
|
+
it('create with a slug the tenant already has → slug-conflict', async () => {
|
|
208
|
+
const b = makeBinding();
|
|
209
|
+
await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
210
|
+
expect(await b.create(T1, { name: 'Acme again', slug: 'acme' })).toEqual({
|
|
211
|
+
kind: 'slug-conflict',
|
|
212
|
+
slug: 'acme',
|
|
213
|
+
});
|
|
214
|
+
expect((await b.list(T1, {})).items).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('another tenant may use the same slug', async () => {
|
|
218
|
+
const b = makeBinding();
|
|
219
|
+
await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
220
|
+
expect((await b.create(T2, { name: 'Acme', slug: 'acme' })).kind).toBe('ok');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("update to another org's slug → slug-conflict; the org is unchanged", async () => {
|
|
224
|
+
const b = makeBinding();
|
|
225
|
+
await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
226
|
+
const id = await createOrg(b, T1, { name: 'Globex', slug: 'globex' });
|
|
227
|
+
expect(await b.update(T1, id, { slug: 'acme' })).toEqual({
|
|
228
|
+
kind: 'slug-conflict',
|
|
229
|
+
slug: 'acme',
|
|
230
|
+
});
|
|
231
|
+
expect((await b.get(T1, id))?.slug).toBe('globex');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('update to its own slug → ok', async () => {
|
|
235
|
+
const b = makeBinding();
|
|
236
|
+
const id = await createOrg(b, T1, { name: 'Acme', slug: 'acme' });
|
|
237
|
+
expect(await b.update(T1, id, { name: 'Acme Corp', slug: 'acme' })).toEqual({ kind: 'ok' });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
165
240
|
}
|