@kindgi/platform 0.1.3 → 0.1.4-rc.0

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.
@@ -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.create(T1, { name: 'Acme', slug: 'acme' });
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.create(T1, { name: 'Acme', slug: 'acme' });
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.create(T1, { name: 'Acme', slug: 'acme' });
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.create(T1, { name: 'Acme', slug: 'acme' });
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.create(T1, { name: 'Acme', slug: 'acme' });
80
- await b.create(T1, { name: 'Globex', slug: 'globex' });
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 errors', async () => {
124
+ it('update on tenant B for tenant-A orgId → org-not-found', async () => {
86
125
  const b = makeBinding();
87
- const id = await b.create(T1, { name: 'Acme', slug: 'acme' });
88
- await expect(b.update(T2, id, { name: 'X' })).rejects.toThrow();
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.create(T1, { name: 'Acme', slug: 'acme' });
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.create(T1, { name: `Org${i}`, slug: `org-${i}` }));
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.create(T1, { name: 'Litigation', slug: 'lit' });
137
- await b.create(T1, { name: 'Corporate', slug: 'corp' });
138
- await b.create(T1, { name: 'Litigation-Sub', slug: 'lit-sub' });
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 errors', async () => {
195
+ it('update on unknown orgId → org-not-found', async () => {
156
196
  const b = makeBinding();
157
- await expect(b.update(T1, unknown, { name: 'X' })).rejects.toThrow();
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
  }
@@ -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,20 @@ 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
+ /** Create a project the test expects to succeed; its id. */
44
+ async function createProject(
45
+ projects: ProjectBinding,
46
+ tenantId: TenantId,
47
+ spec: ProjectSpec,
48
+ ): Promise<ProjectId> {
49
+ const outcome = await projects.create(tenantId, spec);
50
+ if (outcome.kind !== 'ok') {
51
+ throw new Error(`expected the project to be created, got ${outcome.kind}`);
52
+ }
53
+ return outcome.projectId;
54
+ }
33
55
 
34
56
  export function runProjectBindingConformance(
35
57
  makeBinding: () => {
@@ -42,7 +64,7 @@ export function runProjectBindingConformance(
42
64
  describe(`${label} — project CRUD`, () => {
43
65
  it('creates + gets a project', async () => {
44
66
  const { projects } = makeBinding();
45
- const id = await projects.create(T1, { name: 'Matter #1', slug: 'm1' });
67
+ const id = await createProject(projects, T1, { name: 'Matter #1', slug: 'm1' });
46
68
  const got = await projects.get(T1, id);
47
69
  expect(got?.name).toBe('Matter #1');
48
70
  expect(got?.isDefault).toBe(false);
@@ -50,8 +72,8 @@ export function runProjectBindingConformance(
50
72
 
51
73
  it('lists projects filtered by orgId', async () => {
52
74
  const { projects } = makeBinding();
53
- await projects.create(T1, { name: 'A', slug: 'a', orgId: ORG_A });
54
- await projects.create(T1, { name: 'B', slug: 'b' });
75
+ await createProject(projects, T1, { name: 'A', slug: 'a', orgId: ORG_A });
76
+ await createProject(projects, T1, { name: 'B', slug: 'b' });
55
77
  const inOrg = await projects.list(T1, { orgId: ORG_A });
56
78
  expect(inOrg.items).toHaveLength(1);
57
79
  expect(inOrg.items[0]?.name).toBe('A');
@@ -59,15 +81,15 @@ export function runProjectBindingConformance(
59
81
 
60
82
  it('updates a project', async () => {
61
83
  const { projects } = makeBinding();
62
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
63
- await projects.update(T1, id, { name: 'A-updated' });
84
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
85
+ expect(await projects.update(T1, id, { name: 'A-updated' })).toEqual({ kind: 'ok' });
64
86
  const got = await projects.get(T1, id);
65
87
  expect(got?.name).toBe('A-updated');
66
88
  });
67
89
 
68
90
  it('deletes a project + cascades memberships + grants', async () => {
69
91
  const { projects, memberships, grants } = makeBinding();
70
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
92
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
71
93
  await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
72
94
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
73
95
  await projects.delete(T1, id);
@@ -86,7 +108,7 @@ export function runProjectBindingConformance(
86
108
 
87
109
  it('getDefault returns the isDefault=true row', async () => {
88
110
  const { projects } = makeBinding();
89
- const id = await projects.create(T1, {
111
+ const id = await createProject(projects, T1, {
90
112
  name: 'Default',
91
113
  slug: 'default',
92
114
  isDefault: true,
@@ -97,18 +119,28 @@ export function runProjectBindingConformance(
97
119
  expect(got?.isDefault).toBe(true);
98
120
  });
99
121
 
100
- it('isDefault uniqueness — a second Default in the same tenant errors', async () => {
122
+ it('isDefault uniqueness — a second Default in the same tenant → project-default-already-exists', async () => {
101
123
  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();
124
+ await createProject(projects, T1, { name: 'D1', slug: 'd1', isDefault: true });
125
+ expect(await projects.create(T1, { name: 'D2', slug: 'd2', isDefault: true })).toEqual({
126
+ kind: 'project-default-already-exists',
127
+ });
128
+ });
129
+
130
+ it('isDefault with only the slug taken → slug-conflict', async () => {
131
+ const { projects } = makeBinding();
132
+ await createProject(projects, T1, { name: 'P', slug: 'p' });
133
+ expect(await projects.create(T1, { name: 'D', slug: 'p', isDefault: true })).toEqual({
134
+ kind: 'slug-conflict',
135
+ slug: 'p',
136
+ });
137
+ expect(await projects.getDefault(T1)).toBeUndefined();
106
138
  });
107
139
 
108
140
  it('a Default in tenant A does NOT block a Default in tenant B', async () => {
109
141
  const { projects } = makeBinding();
110
- await projects.create(T1, { name: 'D1', slug: 'd1', isDefault: true });
111
- const d2 = await projects.create(T2, {
142
+ await createProject(projects, T1, { name: 'D1', slug: 'd1', isDefault: true });
143
+ const d2 = await createProject(projects, T2, {
112
144
  name: 'D2',
113
145
  slug: 'd2',
114
146
  isDefault: true,
@@ -121,28 +153,45 @@ export function runProjectBindingConformance(
121
153
  describe(`${label} — cross-tenant isolation`, () => {
122
154
  it('project get across tenants returns undefined', async () => {
123
155
  const { projects } = makeBinding();
124
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
156
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
125
157
  expect(await projects.get(T2, id)).toBeUndefined();
126
158
  });
127
159
 
128
160
  it('project list across tenants does not leak', async () => {
129
161
  const { projects } = makeBinding();
130
- await projects.create(T1, { name: 'A', slug: 'a' });
162
+ await createProject(projects, T1, { name: 'A', slug: 'a' });
131
163
  const page = await projects.list(T2, {});
132
164
  expect(page.items).toEqual([]);
133
165
  });
134
166
 
135
- it('project-membership add under wrong tenant errors', async () => {
167
+ it('project-membership add under wrong tenant → project-not-found', async () => {
136
168
  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();
169
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
170
+ expect(await memberships.add(T2, { projectId: id, userId: U1, role: 'editor' })).toEqual({
171
+ kind: 'project-not-found',
172
+ });
173
+ expect((await memberships.list(T1, id, {})).items).toEqual([]);
174
+ });
175
+
176
+ it('project update under wrong tenant → project-not-found', async () => {
177
+ const { projects } = makeBinding();
178
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
179
+ expect(await projects.update(T2, id, { name: 'B' })).toEqual({ kind: 'project-not-found' });
180
+ expect((await projects.get(T1, id))?.name).toBe('A');
181
+ });
182
+
183
+ it('membership updateRole under wrong tenant → project-not-found', async () => {
184
+ const { projects, memberships } = makeBinding();
185
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
186
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
187
+ expect(await memberships.updateRole(T2, id, U1, 'owner')).toEqual({
188
+ kind: 'project-not-found',
189
+ });
141
190
  });
142
191
 
143
192
  it('team-project grant add under wrong tenant errors', async () => {
144
193
  const { projects, grants } = makeBinding();
145
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
194
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
146
195
  await expect(
147
196
  grants.add(T2, { teamId: TEAM_A, projectId: id, role: 'editor' }),
148
197
  ).rejects.toThrow();
@@ -150,8 +199,8 @@ export function runProjectBindingConformance(
150
199
 
151
200
  it('listForUser across tenants only returns tenant-scoped rows', async () => {
152
201
  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' });
202
+ const p1 = await createProject(projects, T1, { name: 'A', slug: 'a' });
203
+ const p2 = await createProject(projects, T2, { name: 'B', slug: 'b' });
155
204
  await memberships.add(T1, { projectId: p1, userId: U1, role: 'editor' });
156
205
  await memberships.add(T2, { projectId: p2, userId: U1, role: 'editor' });
157
206
  const t1Page = await memberships.listForUser(T1, U1, {});
@@ -163,17 +212,37 @@ export function runProjectBindingConformance(
163
212
  describe(`${label} — membership + grant idempotency`, () => {
164
213
  it('project-membership add is idempotent (does not overwrite role)', async () => {
165
214
  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' });
215
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
216
+ expect(await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' })).toEqual({
217
+ kind: 'ok',
218
+ });
219
+ expect(await memberships.add(T1, { projectId: id, userId: U1, role: 'viewer' })).toEqual({
220
+ kind: 'ok',
221
+ });
169
222
  const page = await memberships.list(T1, id, {});
170
223
  expect(page.items).toHaveLength(1);
171
224
  expect(page.items[0]?.role).toBe('owner');
172
225
  });
173
226
 
227
+ it('project-membership updateRole mutates the role', async () => {
228
+ const { projects, memberships } = makeBinding();
229
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
230
+ await memberships.add(T1, { projectId: id, userId: U1, role: 'editor' });
231
+ expect(await memberships.updateRole(T1, id, U1, 'owner')).toEqual({ kind: 'ok' });
232
+ expect((await memberships.list(T1, id, {})).items[0]?.role).toBe('owner');
233
+ });
234
+
235
+ it('project-membership updateRole on a missing membership → project-membership-not-found', async () => {
236
+ const { projects, memberships } = makeBinding();
237
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
238
+ expect(await memberships.updateRole(T1, id, U1, 'owner')).toEqual({
239
+ kind: 'project-membership-not-found',
240
+ });
241
+ });
242
+
174
243
  it('grant add is idempotent', async () => {
175
244
  const { projects, grants } = makeBinding();
176
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
245
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
177
246
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'owner' });
178
247
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'viewer' });
179
248
  const page = await grants.listForProject(T1, id, {});
@@ -183,7 +252,7 @@ export function runProjectBindingConformance(
183
252
 
184
253
  it('grant updateRole mutates the role', async () => {
185
254
  const { projects, grants } = makeBinding();
186
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
255
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
187
256
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
188
257
  await grants.updateRole(T1, TEAM_A, id, 'owner');
189
258
  const page = await grants.listForTeam(T1, TEAM_A, {});
@@ -194,9 +263,9 @@ export function runProjectBindingConformance(
194
263
  describe(`${label} — listForUser aggregates across projects`, () => {
195
264
  it('returns every direct-grant project the user has', async () => {
196
265
  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' });
266
+ const a = await createProject(projects, T1, { name: 'A', slug: 'a' });
267
+ const b = await createProject(projects, T1, { name: 'B', slug: 'b' });
268
+ const c = await createProject(projects, T1, { name: 'C', slug: 'c' });
200
269
  await memberships.add(T1, { projectId: a, userId: U1, role: 'editor' });
201
270
  await memberships.add(T1, { projectId: b, userId: U1, role: 'viewer' });
202
271
  await memberships.add(T1, { projectId: c, userId: U2, role: 'editor' });
@@ -211,7 +280,7 @@ export function runProjectBindingConformance(
211
280
  describe(`${label} — grant listForProject + listForTeam`, () => {
212
281
  it('listForProject returns every team-grant on the project', async () => {
213
282
  const { projects, grants } = makeBinding();
214
- const id = await projects.create(T1, { name: 'A', slug: 'a' });
283
+ const id = await createProject(projects, T1, { name: 'A', slug: 'a' });
215
284
  await grants.add(T1, { teamId: TEAM_A, projectId: id, role: 'editor' });
216
285
  await grants.add(T1, { teamId: TEAM_B, projectId: id, role: 'viewer' });
217
286
  const page = await grants.listForProject(T1, id, {});
@@ -220,12 +289,92 @@ export function runProjectBindingConformance(
220
289
 
221
290
  it('listForTeam returns every project the team has a grant on', async () => {
222
291
  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' });
292
+ const a = await createProject(projects, T1, { name: 'A', slug: 'a' });
293
+ const b = await createProject(projects, T1, { name: 'B', slug: 'b' });
225
294
  await grants.add(T1, { teamId: TEAM_A, projectId: a, role: 'editor' });
226
295
  await grants.add(T1, { teamId: TEAM_A, projectId: b, role: 'viewer' });
227
296
  const page = await grants.listForTeam(T1, TEAM_A, {});
228
297
  expect(page.items).toHaveLength(2);
229
298
  });
230
299
  });
300
+
301
+ describe(`${label} — slug uniqueness`, () => {
302
+ it('create with a slug a project without an org already has → slug-conflict', async () => {
303
+ const { projects } = makeBinding();
304
+ await createProject(projects, T1, { name: 'Matter', slug: 'm1' });
305
+ expect(await projects.create(T1, { name: 'Matter again', slug: 'm1' })).toEqual({
306
+ kind: 'slug-conflict',
307
+ slug: 'm1',
308
+ });
309
+ expect((await projects.list(T1, {})).items).toHaveLength(1);
310
+ });
311
+
312
+ it('another tenant may use the same slug', async () => {
313
+ const { projects } = makeBinding();
314
+ await createProject(projects, T1, { name: 'Matter', slug: 'm1' });
315
+ expect((await projects.create(T2, { name: 'Matter', slug: 'm1' })).kind).toBe('ok');
316
+ });
317
+
318
+ it("update to another project's slug → slug-conflict; the project is unchanged", async () => {
319
+ const { projects } = makeBinding();
320
+ await createProject(projects, T1, { name: 'A', slug: 'a' });
321
+ const id = await createProject(projects, T1, { name: 'B', slug: 'b' });
322
+ expect(await projects.update(T1, id, { slug: 'a' })).toEqual({
323
+ kind: 'slug-conflict',
324
+ slug: 'a',
325
+ });
326
+ expect((await projects.get(T1, id))?.slug).toBe('b');
327
+ });
328
+
329
+ it('two orgs may each have a project with the same slug', async () => {
330
+ const { projects } = makeBinding();
331
+ await createProject(projects, T1, { name: 'A', slug: 'shared', orgId: ORG_A });
332
+ await createProject(projects, T1, { name: 'B', slug: 'shared', orgId: ORG_B });
333
+ // And a project without an org may have it too.
334
+ await createProject(projects, T1, { name: 'C', slug: 'shared' });
335
+ expect((await projects.list(T1, {})).items.map((p) => p.slug)).toEqual([
336
+ 'shared',
337
+ 'shared',
338
+ 'shared',
339
+ ]);
340
+ });
341
+
342
+ it("create with a slug the org's project already has → slug-conflict", async () => {
343
+ const { projects } = makeBinding();
344
+ await createProject(projects, T1, { name: 'A', slug: 'a', orgId: ORG_A });
345
+ expect(await projects.create(T1, { name: 'A again', slug: 'a', orgId: ORG_A })).toEqual({
346
+ kind: 'slug-conflict',
347
+ slug: 'a',
348
+ });
349
+ });
350
+
351
+ it('a move to an org that has the slug → slug-conflict; the project stays where it was', async () => {
352
+ const { projects } = makeBinding();
353
+ await createProject(projects, T1, { name: 'In A', slug: 's', orgId: ORG_A });
354
+ const id = await createProject(projects, T1, { name: 'In B', slug: 's', orgId: ORG_B });
355
+ expect(await projects.update(T1, id, { orgId: ORG_A })).toEqual({
356
+ kind: 'slug-conflict',
357
+ slug: 's',
358
+ });
359
+ expect((await projects.get(T1, id))?.orgId).toBe(ORG_B);
360
+ });
361
+
362
+ it('a move out of its org, where a project without one has the slug → slug-conflict', async () => {
363
+ const { projects } = makeBinding();
364
+ await createProject(projects, T1, { name: 'Org-less', slug: 's' });
365
+ const id = await createProject(projects, T1, { name: 'In A', slug: 's', orgId: ORG_A });
366
+ expect(await projects.update(T1, id, { orgId: null })).toEqual({
367
+ kind: 'slug-conflict',
368
+ slug: 's',
369
+ });
370
+ });
371
+
372
+ it('a move to an org without the slug, and a rename to a slug only another org has: ok', async () => {
373
+ const { projects } = makeBinding();
374
+ await createProject(projects, T1, { name: 'In A', slug: 'a', orgId: ORG_A });
375
+ const id = await createProject(projects, T1, { name: 'In B', slug: 'b', orgId: ORG_B });
376
+ expect(await projects.update(T1, id, { slug: 'a' })).toEqual({ kind: 'ok' });
377
+ expect(await projects.update(T1, id, { slug: 'c', orgId: ORG_A })).toEqual({ kind: 'ok' });
378
+ });
379
+ });
231
380
  }