@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.
- package/README.md +31 -0
- 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.map +1 -1
- package/dist/in-memory/project-binding.js +38 -7
- package/dist/in-memory/project-binding.js.map +1 -1
- package/dist/in-memory/team-binding.d.ts.map +1 -1
- package/dist/in-memory/team-binding.js +22 -6
- package/dist/in-memory/team-binding.js.map +1 -1
- package/dist/index.d.ts +4 -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 +62 -12
- package/dist/project-binding.d.ts.map +1 -1
- package/dist/team-binding.d.ts +56 -12
- package/dist/team-binding.d.ts.map +1 -1
- package/dist/tenant-hierarchy-binding.d.ts +19 -5
- package/dist/tenant-hierarchy-binding.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/in-memory/org-binding.ts +41 -19
- package/src/in-memory/project-binding.ts +64 -14
- package/src/in-memory/team-binding.ts +36 -11
- package/src/index.ts +13 -1
- package/src/org-binding.ts +60 -12
- package/src/project-binding.ts +72 -12
- package/src/team-binding.ts +66 -12
- package/src/tenant-hierarchy-binding.ts +17 -6
- package/tests/org-binding.conformance.ts +93 -18
- package/tests/project-binding.conformance.ts +186 -37
- package/tests/team-binding.conformance.ts +92 -28
|
@@ -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
|
}
|
|
@@ -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
|
|
14
|
-
* time; Postgres impl enforces
|
|
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
|
|
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
|
|
54
|
-
await projects
|
|
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
|
|
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
|
|
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
|
|
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
|
|
122
|
+
it('isDefault uniqueness — a second Default in the same tenant → project-default-already-exists', async () => {
|
|
101
123
|
const { projects } = makeBinding();
|
|
102
|
-
await projects
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
)
|
|
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
|
|
111
|
-
const d2 = await projects
|
|
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
|
|
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
|
|
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
|
|
167
|
+
it('project-membership add under wrong tenant → project-not-found', async () => {
|
|
136
168
|
const { projects, memberships } = makeBinding();
|
|
137
|
-
const id = await projects
|
|
138
|
-
await
|
|
139
|
-
|
|
140
|
-
)
|
|
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
|
|
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
|
|
154
|
-
const p2 = await projects
|
|
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
|
|
167
|
-
await memberships.add(T1, { projectId: id, userId: U1, role: 'owner' })
|
|
168
|
-
|
|
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
|
|
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
|
|
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
|
|
198
|
-
const b = await projects
|
|
199
|
-
const c = await projects
|
|
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
|
|
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
|
|
224
|
-
const b = await projects
|
|
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
|
}
|