@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.
Files changed (40) hide show
  1. package/README.md +31 -0
  2. package/dist/in-memory/index.d.ts +1 -0
  3. package/dist/in-memory/index.d.ts.map +1 -1
  4. package/dist/in-memory/org-binding.d.ts.map +1 -1
  5. package/dist/in-memory/org-binding.js +33 -16
  6. package/dist/in-memory/org-binding.js.map +1 -1
  7. package/dist/in-memory/project-binding.d.ts +26 -1
  8. package/dist/in-memory/project-binding.d.ts.map +1 -1
  9. package/dist/in-memory/project-binding.js +47 -8
  10. package/dist/in-memory/project-binding.js.map +1 -1
  11. package/dist/in-memory/team-binding.d.ts +2 -1
  12. package/dist/in-memory/team-binding.d.ts.map +1 -1
  13. package/dist/in-memory/team-binding.js +31 -7
  14. package/dist/in-memory/team-binding.js.map +1 -1
  15. package/dist/index.d.ts +5 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/org-binding.d.ts +56 -12
  19. package/dist/org-binding.d.ts.map +1 -1
  20. package/dist/project-binding.d.ts +76 -12
  21. package/dist/project-binding.d.ts.map +1 -1
  22. package/dist/team-binding.d.ts +70 -12
  23. package/dist/team-binding.d.ts.map +1 -1
  24. package/dist/tenant-hierarchy-binding.d.ts +31 -5
  25. package/dist/tenant-hierarchy-binding.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/in-memory/index.ts +1 -0
  28. package/src/in-memory/org-binding.ts +41 -19
  29. package/src/in-memory/project-binding.ts +85 -16
  30. package/src/in-memory/team-binding.ts +48 -12
  31. package/src/index.ts +14 -1
  32. package/src/org-binding.ts +60 -12
  33. package/src/project-binding.ts +88 -12
  34. package/src/team-binding.ts +82 -12
  35. package/src/tenant-hierarchy-binding.ts +21 -6
  36. package/tests/in-memory-project-binding.test.ts +13 -1
  37. package/tests/in-memory-team-binding.test.ts +8 -1
  38. package/tests/org-binding.conformance.ts +93 -18
  39. package/tests/project-binding.conformance.ts +221 -37
  40. package/tests/team-binding.conformance.ts +120 -28
@@ -10,8 +10,15 @@
10
10
  * - CRUD happy paths.
11
11
  * - `getDefault` returns the tenant's Default row when `isDefault=true`.
12
12
  * - `isDefault` uniqueness guardrail — creating a second Default in
13
- * the same tenant rejects (in-memory adapter enforces at write
14
- * time; Postgres impl enforces via partial-unique-index).
13
+ * the same tenant resolves to `project-default-already-exists`
14
+ * (in-memory adapter enforces at write time; Postgres impl enforces
15
+ * via partial-unique-index).
16
+ * - Slug uniqueness within an org (and, for projects without an org,
17
+ * among those): `create` and `update` (a new slug, or a move to
18
+ * another org) resolve to `slug-conflict`; another org, or another
19
+ * tenant, may reuse the slug.
20
+ * - `project-not-found` / `project-membership-not-found` outcomes from
21
+ * writes on a missing row.
15
22
  * - `listForUser` aggregates memberships across projects.
16
23
  * - Cross-tenant isolation on every reachable read.
17
24
  */
@@ -22,6 +29,7 @@ import type { OrgId, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
22
29
 
23
30
  import type { ProjectBinding, ProjectMembershipBinding } from '../src/project-binding.js';
24
31
  import type { TeamProjectGrantBinding } from '../src/team-project-grant-binding.js';
32
+ import type { ProjectSpec } from '../src/types.js';
25
33
 
26
34
  const T1 = 'tenant-1' as TenantId;
27
35
  const T2 = 'tenant-2' as TenantId;
@@ -30,6 +38,25 @@ const U2 = 'user-2' as UserId;
30
38
  const TEAM_A = 'team-A' as TeamId;
31
39
  const TEAM_B = 'team-B' as TeamId;
32
40
  const ORG_A = 'org-A' as OrgId;
41
+ const ORG_B = 'org-B' as OrgId;
42
+ /**
43
+ * An org the binding doesn't know (T220), for the `checksOrgs` cases: a
44
+ * binding that checks orgs is made so this one isn't the tenant's.
45
+ */
46
+ export const CONFORMANCE_MISSING_ORG = 'org-missing' as OrgId;
47
+
48
+ /** Create a project the test expects to succeed; its id. */
49
+ async function createProject(
50
+ projects: ProjectBinding,
51
+ tenantId: TenantId,
52
+ spec: ProjectSpec,
53
+ ): Promise<ProjectId> {
54
+ const outcome = await projects.create(tenantId, spec);
55
+ if (outcome.kind !== 'ok') {
56
+ throw new Error(`expected the project to be created, got ${outcome.kind}`);
57
+ }
58
+ return outcome.projectId;
59
+ }
33
60
 
34
61
  export function runProjectBindingConformance(
35
62
  makeBinding: () => {
@@ -38,11 +65,19 @@ export function runProjectBindingConformance(
38
65
  readonly grants: TeamProjectGrantBinding;
39
66
  },
40
67
  label = 'ProjectBinding',
68
+ options: {
69
+ /**
70
+ * The binding checks that an org exists (`CONFORMANCE_MISSING_ORG`
71
+ * doesn't): a project created in or moved to a missing org is
72
+ * `org-not-found`.
73
+ */
74
+ readonly checksOrgs?: boolean;
75
+ } = {},
41
76
  ): void {
42
77
  describe(`${label} — project CRUD`, () => {
43
78
  it('creates + gets a project', async () => {
44
79
  const { projects } = makeBinding();
45
- const id = await projects.create(T1, { name: 'Matter #1', slug: 'm1' });
80
+ const id = await createProject(projects, T1, { name: 'Matter #1', slug: 'm1' });
46
81
  const got = await projects.get(T1, id);
47
82
  expect(got?.name).toBe('Matter #1');
48
83
  expect(got?.isDefault).toBe(false);
@@ -50,8 +85,8 @@ export function runProjectBindingConformance(
50
85
 
51
86
  it('lists projects filtered by orgId', async () => {
52
87
  const { projects } = makeBinding();
53
- await projects.create(T1, { name: 'A', slug: 'a', orgId: ORG_A });
54
- await projects.create(T1, { name: 'B', slug: 'b' });
88
+ await createProject(projects, T1, { name: 'A', slug: 'a', orgId: ORG_A });
89
+ await createProject(projects, T1, { name: 'B', slug: 'b' });
55
90
  const inOrg = await projects.list(T1, { orgId: ORG_A });
56
91
  expect(inOrg.items).toHaveLength(1);
57
92
  expect(inOrg.items[0]?.name).toBe('A');
@@ -59,15 +94,15 @@ export function runProjectBindingConformance(
59
94
 
60
95
  it('updates a project', async () => {
61
96
  const { projects } = makeBinding();
62
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
63
- await projects.update(T1, id, { name: 'A-updated' });
97
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
98
+ expect(await projects.update(T1, id, { name: 'A-updated' })).toEqual({ kind: 'ok' });
64
99
  const got = await projects.get(T1, id);
65
100
  expect(got?.name).toBe('A-updated');
66
101
  });
67
102
 
68
103
  it('deletes a project + cascades memberships + grants', async () => {
69
104
  const { projects, memberships, grants } = makeBinding();
70
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
105
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
71
106
  await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
72
107
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
73
108
  await projects.delete(T1, id);
@@ -86,7 +121,7 @@ export function runProjectBindingConformance(
86
121
 
87
122
  it('getDefault returns the isDefault=true row', async () => {
88
123
  const { projects } = makeBinding();
89
- const id = await projects.create(T1, {
124
+ const id = await createProject(projects, T1, {
90
125
  name: 'Default',
91
126
  slug: 'default',
92
127
  isDefault: true,
@@ -97,18 +132,28 @@ export function runProjectBindingConformance(
97
132
  expect(got?.isDefault).toBe(true);
98
133
  });
99
134
 
100
- it('isDefault uniqueness — a second Default in the same tenant errors', async () => {
135
+ it('isDefault uniqueness — a second Default in the same tenant → project-default-already-exists', async () => {
101
136
  const { projects } = makeBinding();
102
- await projects.create(T1, { name: 'D1', slug: 'd1', isDefault: true });
103
- await expect(
104
- projects.create(T1, { name: 'D2', slug: 'd2', isDefault: true }),
105
- ).rejects.toThrow();
137
+ await createProject(projects, T1, { name: 'D1', slug: 'd1', isDefault: true });
138
+ expect(await projects.create(T1, { name: 'D2', slug: 'd2', isDefault: true })).toEqual({
139
+ kind: 'project-default-already-exists',
140
+ });
141
+ });
142
+
143
+ it('isDefault with only the slug taken → slug-conflict', async () => {
144
+ const { projects } = makeBinding();
145
+ await createProject(projects, T1, { name: 'P', slug: 'p' });
146
+ expect(await projects.create(T1, { name: 'D', slug: 'p', isDefault: true })).toEqual({
147
+ kind: 'slug-conflict',
148
+ slug: 'p',
149
+ });
150
+ expect(await projects.getDefault(T1)).toBeUndefined();
106
151
  });
107
152
 
108
153
  it('a Default in tenant A does NOT block a Default in tenant B', async () => {
109
154
  const { projects } = makeBinding();
110
- await projects.create(T1, { name: 'D1', slug: 'd1', isDefault: true });
111
- const d2 = await projects.create(T2, {
155
+ await createProject(projects, T1, { name: 'D1', slug: 'd1', isDefault: true });
156
+ const d2 = await createProject(projects, T2, {
112
157
  name: 'D2',
113
158
  slug: 'd2',
114
159
  isDefault: true,
@@ -121,28 +166,45 @@ export function runProjectBindingConformance(
121
166
  describe(`${label} — cross-tenant isolation`, () => {
122
167
  it('project get across tenants returns undefined', async () => {
123
168
  const { projects } = makeBinding();
124
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
169
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
125
170
  expect(await projects.get(T2, id)).toBeUndefined();
126
171
  });
127
172
 
128
173
  it('project list across tenants does not leak', async () => {
129
174
  const { projects } = makeBinding();
130
- await projects.create(T1, { name: 'A', slug: 'a' });
175
+ await createProject(projects, T1, { name: 'A', slug: 'a' });
131
176
  const page = await projects.list(T2, {});
132
177
  expect(page.items).toEqual([]);
133
178
  });
134
179
 
135
- it('project-membership add under wrong tenant errors', async () => {
180
+ it('project-membership add under wrong tenant → project-not-found', async () => {
136
181
  const { projects, memberships } = makeBinding();
137
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
138
- await expect(
139
- memberships.add(T2, { projectId: id, userId: U1, role: 'editor' }),
140
- ).rejects.toThrow();
182
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
183
+ expect(await memberships.add(T2, { projectId: id, userId: U1, role: 'editor' })).toEqual({
184
+ kind: 'project-not-found',
185
+ });
186
+ expect((await memberships.list(T1, id, {})).items).toEqual([]);
187
+ });
188
+
189
+ it('project update under wrong tenant → project-not-found', async () => {
190
+ const { projects } = makeBinding();
191
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
192
+ expect(await projects.update(T2, id, { name: 'B' })).toEqual({ kind: 'project-not-found' });
193
+ expect((await projects.get(T1, id))?.name).toBe('A');
194
+ });
195
+
196
+ it('membership updateRole under wrong tenant → project-not-found', async () => {
197
+ const { projects, memberships } = makeBinding();
198
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
199
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
200
+ expect(await memberships.updateRole(T2, id, U1, 'owner')).toEqual({
201
+ kind: 'project-not-found',
202
+ });
141
203
  });
142
204
 
143
205
  it('team-project grant add under wrong tenant errors', async () => {
144
206
  const { projects, grants } = makeBinding();
145
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
207
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
146
208
  await expect(
147
209
  grants.add(T2, { teamId: TEAM_A, projectId: id, role: 'editor' }),
148
210
  ).rejects.toThrow();
@@ -150,8 +212,8 @@ export function runProjectBindingConformance(
150
212
 
151
213
  it('listForUser across tenants only returns tenant-scoped rows', async () => {
152
214
  const { projects, memberships } = makeBinding();
153
- const p1 = await projects.create(T1, { name: 'A', slug: 'a' });
154
- const p2 = await projects.create(T2, { name: 'B', slug: 'b' });
215
+ const p1 = await createProject(projects, T1, { name: 'A', slug: 'a' });
216
+ const p2 = await createProject(projects, T2, { name: 'B', slug: 'b' });
155
217
  await memberships.add(T1, { projectId: p1, userId: U1, role: 'editor' });
156
218
  await memberships.add(T2, { projectId: p2, userId: U1, role: 'editor' });
157
219
  const t1Page = await memberships.listForUser(T1, U1, {});
@@ -163,17 +225,37 @@ export function runProjectBindingConformance(
163
225
  describe(`${label} — membership + grant idempotency`, () => {
164
226
  it('project-membership add is idempotent (does not overwrite role)', async () => {
165
227
  const { projects, memberships } = makeBinding();
166
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
167
- await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' });
168
- await memberships.add(T1, { projectId: id, userId: U1, role: 'viewer' });
228
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
229
+ expect(await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' })).toEqual({
230
+ kind: 'ok',
231
+ });
232
+ expect(await memberships.add(T1, { projectId: id, userId: U1, role: 'viewer' })).toEqual({
233
+ kind: 'ok',
234
+ });
169
235
  const page = await memberships.list(T1, id, {});
170
236
  expect(page.items).toHaveLength(1);
171
237
  expect(page.items[0]?.role).toBe('owner');
172
238
  });
173
239
 
240
+ it('project-membership updateRole mutates the role', async () => {
241
+ const { projects, memberships } = makeBinding();
242
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
243
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
244
+ expect(await memberships.updateRole(T1, id, U1, 'owner')).toEqual({ kind: 'ok' });
245
+ expect((await memberships.list(T1, id, {})).items[0]?.role).toBe('owner');
246
+ });
247
+
248
+ it('project-membership updateRole on a missing membership → project-membership-not-found', async () => {
249
+ const { projects, memberships } = makeBinding();
250
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
251
+ expect(await memberships.updateRole(T1, id, U1, 'owner')).toEqual({
252
+ kind: 'project-membership-not-found',
253
+ });
254
+ });
255
+
174
256
  it('grant add is idempotent', async () => {
175
257
  const { projects, grants } = makeBinding();
176
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
258
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
177
259
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'owner' });
178
260
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'viewer' });
179
261
  const page = await grants.listForProject(T1, id, {});
@@ -183,7 +265,7 @@ export function runProjectBindingConformance(
183
265
 
184
266
  it('grant updateRole mutates the role', async () => {
185
267
  const { projects, grants } = makeBinding();
186
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
268
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
187
269
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
188
270
  await grants.updateRole(T1, TEAM_A, id, 'owner');
189
271
  const page = await grants.listForTeam(T1, TEAM_A, {});
@@ -194,9 +276,9 @@ export function runProjectBindingConformance(
194
276
  describe(`${label} — listForUser aggregates across projects`, () => {
195
277
  it('returns every direct-grant project the user has', async () => {
196
278
  const { projects, memberships } = makeBinding();
197
- const a = await projects.create(T1, { name: 'A', slug: 'a' });
198
- const b = await projects.create(T1, { name: 'B', slug: 'b' });
199
- const c = await projects.create(T1, { name: 'C', slug: 'c' });
279
+ const a = await createProject(projects, T1, { name: 'A', slug: 'a' });
280
+ const b = await createProject(projects, T1, { name: 'B', slug: 'b' });
281
+ const c = await createProject(projects, T1, { name: 'C', slug: 'c' });
200
282
  await memberships.add(T1, { projectId: a, userId: U1, role: 'editor' });
201
283
  await memberships.add(T1, { projectId: b, userId: U1, role: 'viewer' });
202
284
  await memberships.add(T1, { projectId: c, userId: U2, role: 'editor' });
@@ -211,7 +293,7 @@ export function runProjectBindingConformance(
211
293
  describe(`${label} — grant listForProject + listForTeam`, () => {
212
294
  it('listForProject returns every team-grant on the project', async () => {
213
295
  const { projects, grants } = makeBinding();
214
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
296
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
215
297
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
216
298
  await grants.add(T1, { teamId: TEAM_B, projectId: id, role: 'viewer' });
217
299
  const page = await grants.listForProject(T1, id, {});
@@ -220,12 +302,114 @@ export function runProjectBindingConformance(
220
302
 
221
303
  it('listForTeam returns every project the team has a grant on', async () => {
222
304
  const { projects, grants } = makeBinding();
223
- const a = await projects.create(T1, { name: 'A', slug: 'a' });
224
- const b = await projects.create(T1, { name: 'B', slug: 'b' });
305
+ const a = await createProject(projects, T1, { name: 'A', slug: 'a' });
306
+ const b = await createProject(projects, T1, { name: 'B', slug: 'b' });
225
307
  await grants.add(T1, { teamId: TEAM_A, projectId: a, role: 'editor' });
226
308
  await grants.add(T1, { teamId: TEAM_A, projectId: b, role: 'viewer' });
227
309
  const page = await grants.listForTeam(T1, TEAM_A, {});
228
310
  expect(page.items).toHaveLength(2);
229
311
  });
230
312
  });
313
+
314
+ describe(`${label} — slug uniqueness`, () => {
315
+ it('create with a slug a project without an org already has → slug-conflict', async () => {
316
+ const { projects } = makeBinding();
317
+ await createProject(projects, T1, { name: 'Matter', slug: 'm1' });
318
+ expect(await projects.create(T1, { name: 'Matter again', slug: 'm1' })).toEqual({
319
+ kind: 'slug-conflict',
320
+ slug: 'm1',
321
+ });
322
+ expect((await projects.list(T1, {})).items).toHaveLength(1);
323
+ });
324
+
325
+ it('another tenant may use the same slug', async () => {
326
+ const { projects } = makeBinding();
327
+ await createProject(projects, T1, { name: 'Matter', slug: 'm1' });
328
+ expect((await projects.create(T2, { name: 'Matter', slug: 'm1' })).kind).toBe('ok');
329
+ });
330
+
331
+ it("update to another project's slug → slug-conflict; the project is unchanged", async () => {
332
+ const { projects } = makeBinding();
333
+ await createProject(projects, T1, { name: 'A', slug: 'a' });
334
+ const id = await createProject(projects, T1, { name: 'B', slug: 'b' });
335
+ expect(await projects.update(T1, id, { slug: 'a' })).toEqual({
336
+ kind: 'slug-conflict',
337
+ slug: 'a',
338
+ });
339
+ expect((await projects.get(T1, id))?.slug).toBe('b');
340
+ });
341
+
342
+ it('two orgs may each have a project with the same slug', async () => {
343
+ const { projects } = makeBinding();
344
+ await createProject(projects, T1, { name: 'A', slug: 'shared', orgId: ORG_A });
345
+ await createProject(projects, T1, { name: 'B', slug: 'shared', orgId: ORG_B });
346
+ // And a project without an org may have it too.
347
+ await createProject(projects, T1, { name: 'C', slug: 'shared' });
348
+ expect((await projects.list(T1, {})).items.map((p) => p.slug)).toEqual([
349
+ 'shared',
350
+ 'shared',
351
+ 'shared',
352
+ ]);
353
+ });
354
+
355
+ it("create with a slug the org's project already has → slug-conflict", async () => {
356
+ const { projects } = makeBinding();
357
+ await createProject(projects, T1, { name: 'A', slug: 'a', orgId: ORG_A });
358
+ expect(await projects.create(T1, { name: 'A again', slug: 'a', orgId: ORG_A })).toEqual({
359
+ kind: 'slug-conflict',
360
+ slug: 'a',
361
+ });
362
+ });
363
+
364
+ it('a move to an org that has the slug → slug-conflict; the project stays where it was', async () => {
365
+ const { projects } = makeBinding();
366
+ await createProject(projects, T1, { name: 'In A', slug: 's', orgId: ORG_A });
367
+ const id = await createProject(projects, T1, { name: 'In B', slug: 's', orgId: ORG_B });
368
+ expect(await projects.update(T1, id, { orgId: ORG_A })).toEqual({
369
+ kind: 'slug-conflict',
370
+ slug: 's',
371
+ });
372
+ expect((await projects.get(T1, id))?.orgId).toBe(ORG_B);
373
+ });
374
+
375
+ it('a move out of its org, where a project without one has the slug → slug-conflict', async () => {
376
+ const { projects } = makeBinding();
377
+ await createProject(projects, T1, { name: 'Org-less', slug: 's' });
378
+ const id = await createProject(projects, T1, { name: 'In A', slug: 's', orgId: ORG_A });
379
+ expect(await projects.update(T1, id, { orgId: null })).toEqual({
380
+ kind: 'slug-conflict',
381
+ slug: 's',
382
+ });
383
+ });
384
+
385
+ it('a move to an org without the slug, and a rename to a slug only another org has: ok', async () => {
386
+ const { projects } = makeBinding();
387
+ await createProject(projects, T1, { name: 'In A', slug: 'a', orgId: ORG_A });
388
+ const id = await createProject(projects, T1, { name: 'In B', slug: 'b', orgId: ORG_B });
389
+ expect(await projects.update(T1, id, { slug: 'a' })).toEqual({ kind: 'ok' });
390
+ expect(await projects.update(T1, id, { slug: 'c', orgId: ORG_A })).toEqual({ kind: 'ok' });
391
+ });
392
+ });
393
+
394
+ if (options.checksOrgs === true) {
395
+ describe(`${label} — an org that isn't the tenant's (T220)`, () => {
396
+ it('creating a project in it: org-not-found, and nothing is created', async () => {
397
+ const { projects } = makeBinding();
398
+ expect(
399
+ await projects.create(T1, { name: 'P', slug: 'p', orgId: CONFORMANCE_MISSING_ORG }),
400
+ ).toEqual({ kind: 'org-not-found', orgId: CONFORMANCE_MISSING_ORG });
401
+ expect((await projects.list(T1, { orgId: CONFORMANCE_MISSING_ORG })).items).toEqual([]);
402
+ });
403
+
404
+ it('moving a project to it: org-not-found, and the project stays where it was', async () => {
405
+ const { projects } = makeBinding();
406
+ const id = await createProject(projects, T1, { name: 'P', slug: 'p', orgId: ORG_A });
407
+ expect(await projects.update(T1, id, { orgId: CONFORMANCE_MISSING_ORG })).toEqual({
408
+ kind: 'org-not-found',
409
+ orgId: CONFORMANCE_MISSING_ORG,
410
+ });
411
+ expect((await projects.get(T1, id))?.orgId).toBe(ORG_A);
412
+ });
413
+ });
414
+ }
231
415
  }
@@ -12,6 +12,9 @@
12
12
  * `updateRole`).
13
13
  * - `listForUser` returns memberships across teams within a tenant.
14
14
  * - Cross-tenant isolation on every reachable read.
15
+ * - Outcomes: `team-not-found` and `team-membership-not-found` from
16
+ * writes on a missing row, `slug-conflict` from a slug the tenant
17
+ * already has (another tenant may reuse it).
15
18
  */
16
19
 
17
20
  import { describe, expect, it } from 'vitest';
@@ -19,12 +22,23 @@ import { describe, expect, it } from 'vitest';
19
22
  import type { OrgId, TeamId, TenantId, UserId } from '@kindgi/types';
20
23
 
21
24
  import type { TeamBinding, TeamMembershipBinding } from '../src/team-binding.js';
25
+ import type { TeamSpec } from '../src/types.js';
22
26
 
23
27
  const T1 = 'tenant-1' as TenantId;
24
28
  const T2 = 'tenant-2' as TenantId;
25
29
  const U1 = 'user-1' as UserId;
26
30
  const U2 = 'user-2' as UserId;
27
31
  const ORG_A = 'org-a' as OrgId;
32
+ /** An org the binding doesn't know (T220), for the `checksOrgs` cases. */
33
+ export const CONFORMANCE_MISSING_ORG = 'org-missing' as OrgId;
34
+
35
+ /** Create a team the test expects to succeed; its id. */
36
+ async function createTeam(teams: TeamBinding, tenantId: TenantId, spec: TeamSpec): Promise<TeamId> {
37
+ const outcome = await teams.create(tenantId, spec);
38
+ if (outcome.kind !== 'ok')
39
+ throw new Error(`expected the team to be created, got ${outcome.kind}`);
40
+ return outcome.teamId;
41
+ }
28
42
 
29
43
  export function runTeamBindingConformance(
30
44
  makeBinding: () => {
@@ -32,11 +46,15 @@ export function runTeamBindingConformance(
32
46
  readonly memberships: TeamMembershipBinding;
33
47
  },
34
48
  label = 'TeamBinding',
49
+ options: {
50
+ /** The binding checks that an org exists (`CONFORMANCE_MISSING_ORG` doesn't). */
51
+ readonly checksOrgs?: boolean;
52
+ } = {},
35
53
  ): void {
36
54
  describe(`${label} — team CRUD`, () => {
37
55
  it('creates + gets a team with orgId', async () => {
38
56
  const { teams } = makeBinding();
39
- const id = await teams.create(T1, {
57
+ const id = await createTeam(teams, T1, {
40
58
  name: 'Litigation',
41
59
  slug: 'lit',
42
60
  orgId: ORG_A,
@@ -49,27 +67,27 @@ export function runTeamBindingConformance(
49
67
 
50
68
  it('creates a cross-org team (no orgId)', async () => {
51
69
  const { teams } = makeBinding();
52
- const id = await teams.create(T1, { name: 'Cross', slug: 'cross' });
70
+ const id = await createTeam(teams, T1, { name: 'Cross', slug: 'cross' });
53
71
  const got = await teams.get(T1, id);
54
72
  expect(got?.orgId).toBeUndefined();
55
73
  });
56
74
 
57
75
  it('updates a team; orgId=null clears the FK', async () => {
58
76
  const { teams } = makeBinding();
59
- const id = await teams.create(T1, {
77
+ const id = await createTeam(teams, T1, {
60
78
  name: 'Litigation',
61
79
  slug: 'lit',
62
80
  orgId: ORG_A,
63
81
  });
64
- await teams.update(T1, id, { orgId: null });
82
+ expect(await teams.update(T1, id, { orgId: null })).toEqual({ kind: 'ok' });
65
83
  const got = await teams.get(T1, id);
66
84
  expect(got?.orgId).toBeUndefined();
67
85
  });
68
86
 
69
87
  it('lists teams filtered by orgId', async () => {
70
88
  const { teams } = makeBinding();
71
- await teams.create(T1, { name: 'A', slug: 'a', orgId: ORG_A });
72
- await teams.create(T1, { name: 'B', slug: 'b' });
89
+ await createTeam(teams, T1, { name: 'A', slug: 'a', orgId: ORG_A });
90
+ await createTeam(teams, T1, { name: 'B', slug: 'b' });
73
91
  const inOrg = await teams.list(T1, { orgId: ORG_A });
74
92
  expect(inOrg.items).toHaveLength(1);
75
93
  expect(inOrg.items[0]?.name).toBe('A');
@@ -77,7 +95,7 @@ export function runTeamBindingConformance(
77
95
 
78
96
  it('deletes a team + cascades memberships', async () => {
79
97
  const { teams, memberships } = makeBinding();
80
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
98
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
81
99
  await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
82
100
  await teams.delete(T1, id);
83
101
  const page = await memberships.listForUser(T1, U1, {});
@@ -88,29 +106,37 @@ export function runTeamBindingConformance(
88
106
  describe(`${label} — cross-tenant isolation`, () => {
89
107
  it('team get across tenants returns undefined', async () => {
90
108
  const { teams } = makeBinding();
91
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
109
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
92
110
  expect(await teams.get(T2, id)).toBeUndefined();
93
111
  });
94
112
 
95
113
  it('team list across tenants does not leak', async () => {
96
114
  const { teams } = makeBinding();
97
- await teams.create(T1, { name: 'X', slug: 'x' });
115
+ await createTeam(teams, T1, { name: 'X', slug: 'x' });
98
116
  const page = await teams.list(T2, {});
99
117
  expect(page.items).toEqual([]);
100
118
  });
101
119
 
102
- it('adding a membership under wrong tenant errors', async () => {
120
+ it('adding a membership under wrong tenant → team-not-found', async () => {
103
121
  const { teams, memberships } = makeBinding();
104
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
105
- await expect(
106
- memberships.add(T2, { teamId: id, userId: U1, role: 'member' }),
107
- ).rejects.toThrow();
122
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
123
+ expect(await memberships.add(T2, { teamId: id, userId: U1, role: 'member' })).toEqual({
124
+ kind: 'team-not-found',
125
+ });
126
+ expect((await memberships.list(T1, id, {})).items).toEqual([]);
127
+ });
128
+
129
+ it('update under wrong tenant → team-not-found', async () => {
130
+ const { teams } = makeBinding();
131
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
132
+ expect(await teams.update(T2, id, { name: 'Y' })).toEqual({ kind: 'team-not-found' });
133
+ expect((await teams.get(T1, id))?.name).toBe('X');
108
134
  });
109
135
 
110
136
  it('listForUser across tenants only returns tenant-scoped memberships', async () => {
111
137
  const { teams, memberships } = makeBinding();
112
- const t1Team = await teams.create(T1, { name: 'A', slug: 'a' });
113
- const t2Team = await teams.create(T2, { name: 'B', slug: 'b' });
138
+ const t1Team = await createTeam(teams, T1, { name: 'A', slug: 'a' });
139
+ const t2Team = await createTeam(teams, T2, { name: 'B', slug: 'b' });
114
140
  await memberships.add(T1, { teamId: t1Team, userId: U1, role: 'member' });
115
141
  await memberships.add(T2, { teamId: t2Team, userId: U1, role: 'member' });
116
142
  const t1Page = await memberships.listForUser(T1, U1, {});
@@ -125,10 +151,14 @@ export function runTeamBindingConformance(
125
151
  describe(`${label} — membership idempotency`, () => {
126
152
  it('add on existing membership is a no-op (does NOT overwrite role)', async () => {
127
153
  const { teams, memberships } = makeBinding();
128
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
129
- await memberships.add(T1, { teamId: id, userId: U1, role: 'admin' });
154
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
155
+ expect(await memberships.add(T1, { teamId: id, userId: U1, role: 'admin' })).toEqual({
156
+ kind: 'ok',
157
+ });
130
158
  // Re-add with a different role — should be a no-op, not a mutation.
131
- await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
159
+ expect(await memberships.add(T1, { teamId: id, userId: U1, role: 'member' })).toEqual({
160
+ kind: 'ok',
161
+ });
132
162
  const page = await memberships.list(T1, id, {});
133
163
  expect(page.items).toHaveLength(1);
134
164
  expect(page.items[0]?.role).toBe('admin');
@@ -136,21 +166,32 @@ export function runTeamBindingConformance(
136
166
 
137
167
  it('remove on missing membership is a no-op', async () => {
138
168
  const { teams, memberships } = makeBinding();
139
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
169
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
140
170
  await expect(memberships.remove(T1, id, U1)).resolves.toBeUndefined();
141
171
  });
142
172
 
143
- it('updateRole on missing membership errors', async () => {
173
+ it('updateRole on missing membership → team-membership-not-found', async () => {
144
174
  const { teams, memberships } = makeBinding();
145
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
146
- await expect(memberships.updateRole(T1, id, U1, 'admin')).rejects.toThrow();
175
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
176
+ expect(await memberships.updateRole(T1, id, U1, 'admin')).toEqual({
177
+ kind: 'team-membership-not-found',
178
+ });
179
+ });
180
+
181
+ it('updateRole on a team in another tenant → team-not-found', async () => {
182
+ const { teams, memberships } = makeBinding();
183
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
184
+ await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
185
+ expect(await memberships.updateRole(T2, id, U1, 'admin')).toEqual({
186
+ kind: 'team-not-found',
187
+ });
147
188
  });
148
189
 
149
190
  it('updateRole on existing membership mutates the role', async () => {
150
191
  const { teams, memberships } = makeBinding();
151
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
192
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
152
193
  await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
153
- await memberships.updateRole(T1, id, U1, 'admin');
194
+ expect(await memberships.updateRole(T1, id, U1, 'admin')).toEqual({ kind: 'ok' });
154
195
  const page = await memberships.list(T1, id, {});
155
196
  expect(page.items[0]?.role).toBe('admin');
156
197
  });
@@ -159,9 +200,9 @@ export function runTeamBindingConformance(
159
200
  describe(`${label} — listForUser across teams`, () => {
160
201
  it('returns every team the user belongs to in the tenant', async () => {
161
202
  const { teams, memberships } = makeBinding();
162
- const a = await teams.create(T1, { name: 'A', slug: 'a' });
163
- const b = await teams.create(T1, { name: 'B', slug: 'b' });
164
- const c = await teams.create(T1, { name: 'C', slug: 'c' });
203
+ const a = await createTeam(teams, T1, { name: 'A', slug: 'a' });
204
+ const b = await createTeam(teams, T1, { name: 'B', slug: 'b' });
205
+ const c = await createTeam(teams, T1, { name: 'C', slug: 'c' });
165
206
  await memberships.add(T1, { teamId: a, userId: U1, role: 'member' });
166
207
  await memberships.add(T1, { teamId: b, userId: U1, role: 'admin' });
167
208
  await memberships.add(T1, { teamId: c, userId: U2, role: 'member' });
@@ -172,4 +213,55 @@ export function runTeamBindingConformance(
172
213
  expect(teamIds.has(b)).toBe(true);
173
214
  });
174
215
  });
216
+
217
+ describe(`${label} — slug uniqueness`, () => {
218
+ it('create with a slug the tenant already has → slug-conflict', async () => {
219
+ const { teams } = makeBinding();
220
+ await createTeam(teams, T1, { name: 'Litigation', slug: 'lit' });
221
+ expect(await teams.create(T1, { name: 'Litigation 2', slug: 'lit' })).toEqual({
222
+ kind: 'slug-conflict',
223
+ slug: 'lit',
224
+ });
225
+ expect((await teams.list(T1, {})).items).toHaveLength(1);
226
+ });
227
+
228
+ it('another tenant may use the same slug', async () => {
229
+ const { teams } = makeBinding();
230
+ await createTeam(teams, T1, { name: 'Litigation', slug: 'lit' });
231
+ expect((await teams.create(T2, { name: 'Litigation', slug: 'lit' })).kind).toBe('ok');
232
+ });
233
+
234
+ it("update to another team's slug → slug-conflict; the team is unchanged", async () => {
235
+ const { teams } = makeBinding();
236
+ await createTeam(teams, T1, { name: 'A', slug: 'a' });
237
+ const id = await createTeam(teams, T1, { name: 'B', slug: 'b' });
238
+ expect(await teams.update(T1, id, { slug: 'a' })).toEqual({
239
+ kind: 'slug-conflict',
240
+ slug: 'a',
241
+ });
242
+ expect((await teams.get(T1, id))?.slug).toBe('b');
243
+ });
244
+ });
245
+
246
+ if (options.checksOrgs === true) {
247
+ describe(`${label} — an org that isn't the tenant's (T220)`, () => {
248
+ it('creating a team in it: org-not-found, and nothing is created', async () => {
249
+ const { teams } = makeBinding();
250
+ expect(
251
+ await teams.create(T1, { name: 'T', slug: 't', orgId: CONFORMANCE_MISSING_ORG }),
252
+ ).toEqual({ kind: 'org-not-found', orgId: CONFORMANCE_MISSING_ORG });
253
+ expect((await teams.list(T1, { orgId: CONFORMANCE_MISSING_ORG })).items).toEqual([]);
254
+ });
255
+
256
+ it('moving a team to it: org-not-found, and the team stays where it was', async () => {
257
+ const { teams } = makeBinding();
258
+ const id = await createTeam(teams, T1, { name: 'T', slug: 't', orgId: ORG_A });
259
+ expect(await teams.update(T1, id, { orgId: CONFORMANCE_MISSING_ORG })).toEqual({
260
+ kind: 'org-not-found',
261
+ orgId: CONFORMANCE_MISSING_ORG,
262
+ });
263
+ expect((await teams.get(T1, id))?.orgId).toBe(ORG_A);
264
+ });
265
+ });
266
+ }
175
267
  }