@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.
@@ -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,6 +22,7 @@ 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;
@@ -26,6 +30,14 @@ const U1 = 'user-1' as UserId;
26
30
  const U2 = 'user-2' as UserId;
27
31
  const ORG_A = 'org-a' as OrgId;
28
32
 
33
+ /** Create a team the test expects to succeed; its id. */
34
+ async function createTeam(teams: TeamBinding, tenantId: TenantId, spec: TeamSpec): Promise<TeamId> {
35
+ const outcome = await teams.create(tenantId, spec);
36
+ if (outcome.kind !== 'ok')
37
+ throw new Error(`expected the team to be created, got ${outcome.kind}`);
38
+ return outcome.teamId;
39
+ }
40
+
29
41
  export function runTeamBindingConformance(
30
42
  makeBinding: () => {
31
43
  readonly teams: TeamBinding;
@@ -36,7 +48,7 @@ export function runTeamBindingConformance(
36
48
  describe(`${label} — team CRUD`, () => {
37
49
  it('creates + gets a team with orgId', async () => {
38
50
  const { teams } = makeBinding();
39
- const id = await teams.create(T1, {
51
+ const id = await createTeam(teams, T1, {
40
52
  name: 'Litigation',
41
53
  slug: 'lit',
42
54
  orgId: ORG_A,
@@ -49,27 +61,27 @@ export function runTeamBindingConformance(
49
61
 
50
62
  it('creates a cross-org team (no orgId)', async () => {
51
63
  const { teams } = makeBinding();
52
- const id = await teams.create(T1, { name: 'Cross', slug: 'cross' });
64
+ const id = await createTeam(teams, T1, { name: 'Cross', slug: 'cross' });
53
65
  const got = await teams.get(T1, id);
54
66
  expect(got?.orgId).toBeUndefined();
55
67
  });
56
68
 
57
69
  it('updates a team; orgId=null clears the FK', async () => {
58
70
  const { teams } = makeBinding();
59
- const id = await teams.create(T1, {
71
+ const id = await createTeam(teams, T1, {
60
72
  name: 'Litigation',
61
73
  slug: 'lit',
62
74
  orgId: ORG_A,
63
75
  });
64
- await teams.update(T1, id, { orgId: null });
76
+ expect(await teams.update(T1, id, { orgId: null })).toEqual({ kind: 'ok' });
65
77
  const got = await teams.get(T1, id);
66
78
  expect(got?.orgId).toBeUndefined();
67
79
  });
68
80
 
69
81
  it('lists teams filtered by orgId', async () => {
70
82
  const { teams } = makeBinding();
71
- await teams.create(T1, { name: 'A', slug: 'a', orgId: ORG_A });
72
- await teams.create(T1, { name: 'B', slug: 'b' });
83
+ await createTeam(teams, T1, { name: 'A', slug: 'a', orgId: ORG_A });
84
+ await createTeam(teams, T1, { name: 'B', slug: 'b' });
73
85
  const inOrg = await teams.list(T1, { orgId: ORG_A });
74
86
  expect(inOrg.items).toHaveLength(1);
75
87
  expect(inOrg.items[0]?.name).toBe('A');
@@ -77,7 +89,7 @@ export function runTeamBindingConformance(
77
89
 
78
90
  it('deletes a team + cascades memberships', async () => {
79
91
  const { teams, memberships } = makeBinding();
80
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
92
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
81
93
  await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
82
94
  await teams.delete(T1, id);
83
95
  const page = await memberships.listForUser(T1, U1, {});
@@ -88,29 +100,37 @@ export function runTeamBindingConformance(
88
100
  describe(`${label} — cross-tenant isolation`, () => {
89
101
  it('team get across tenants returns undefined', async () => {
90
102
  const { teams } = makeBinding();
91
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
103
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
92
104
  expect(await teams.get(T2, id)).toBeUndefined();
93
105
  });
94
106
 
95
107
  it('team list across tenants does not leak', async () => {
96
108
  const { teams } = makeBinding();
97
- await teams.create(T1, { name: 'X', slug: 'x' });
109
+ await createTeam(teams, T1, { name: 'X', slug: 'x' });
98
110
  const page = await teams.list(T2, {});
99
111
  expect(page.items).toEqual([]);
100
112
  });
101
113
 
102
- it('adding a membership under wrong tenant errors', async () => {
114
+ it('adding a membership under wrong tenant → team-not-found', async () => {
103
115
  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();
116
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
117
+ expect(await memberships.add(T2, { teamId: id, userId: U1, role: 'member' })).toEqual({
118
+ kind: 'team-not-found',
119
+ });
120
+ expect((await memberships.list(T1, id, {})).items).toEqual([]);
121
+ });
122
+
123
+ it('update under wrong tenant → team-not-found', async () => {
124
+ const { teams } = makeBinding();
125
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
126
+ expect(await teams.update(T2, id, { name: 'Y' })).toEqual({ kind: 'team-not-found' });
127
+ expect((await teams.get(T1, id))?.name).toBe('X');
108
128
  });
109
129
 
110
130
  it('listForUser across tenants only returns tenant-scoped memberships', async () => {
111
131
  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' });
132
+ const t1Team = await createTeam(teams, T1, { name: 'A', slug: 'a' });
133
+ const t2Team = await createTeam(teams, T2, { name: 'B', slug: 'b' });
114
134
  await memberships.add(T1, { teamId: t1Team, userId: U1, role: 'member' });
115
135
  await memberships.add(T2, { teamId: t2Team, userId: U1, role: 'member' });
116
136
  const t1Page = await memberships.listForUser(T1, U1, {});
@@ -125,10 +145,14 @@ export function runTeamBindingConformance(
125
145
  describe(`${label} — membership idempotency`, () => {
126
146
  it('add on existing membership is a no-op (does NOT overwrite role)', async () => {
127
147
  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' });
148
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
149
+ expect(await memberships.add(T1, { teamId: id, userId: U1, role: 'admin' })).toEqual({
150
+ kind: 'ok',
151
+ });
130
152
  // 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' });
153
+ expect(await memberships.add(T1, { teamId: id, userId: U1, role: 'member' })).toEqual({
154
+ kind: 'ok',
155
+ });
132
156
  const page = await memberships.list(T1, id, {});
133
157
  expect(page.items).toHaveLength(1);
134
158
  expect(page.items[0]?.role).toBe('admin');
@@ -136,21 +160,32 @@ export function runTeamBindingConformance(
136
160
 
137
161
  it('remove on missing membership is a no-op', async () => {
138
162
  const { teams, memberships } = makeBinding();
139
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
163
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
140
164
  await expect(memberships.remove(T1, id, U1)).resolves.toBeUndefined();
141
165
  });
142
166
 
143
- it('updateRole on missing membership errors', async () => {
167
+ it('updateRole on missing membership → team-membership-not-found', async () => {
144
168
  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();
169
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
170
+ expect(await memberships.updateRole(T1, id, U1, 'admin')).toEqual({
171
+ kind: 'team-membership-not-found',
172
+ });
173
+ });
174
+
175
+ it('updateRole on a team in another tenant → team-not-found', async () => {
176
+ const { teams, memberships } = makeBinding();
177
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
178
+ await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
179
+ expect(await memberships.updateRole(T2, id, U1, 'admin')).toEqual({
180
+ kind: 'team-not-found',
181
+ });
147
182
  });
148
183
 
149
184
  it('updateRole on existing membership mutates the role', async () => {
150
185
  const { teams, memberships } = makeBinding();
151
- const id = await teams.create(T1, { name: 'X', slug: 'x' });
186
+ const id = await createTeam(teams, T1, { name: 'X', slug: 'x' });
152
187
  await memberships.add(T1, { teamId: id, userId: U1, role: 'member' });
153
- await memberships.updateRole(T1, id, U1, 'admin');
188
+ expect(await memberships.updateRole(T1, id, U1, 'admin')).toEqual({ kind: 'ok' });
154
189
  const page = await memberships.list(T1, id, {});
155
190
  expect(page.items[0]?.role).toBe('admin');
156
191
  });
@@ -159,9 +194,9 @@ export function runTeamBindingConformance(
159
194
  describe(`${label} — listForUser across teams`, () => {
160
195
  it('returns every team the user belongs to in the tenant', async () => {
161
196
  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' });
197
+ const a = await createTeam(teams, T1, { name: 'A', slug: 'a' });
198
+ const b = await createTeam(teams, T1, { name: 'B', slug: 'b' });
199
+ const c = await createTeam(teams, T1, { name: 'C', slug: 'c' });
165
200
  await memberships.add(T1, { teamId: a, userId: U1, role: 'member' });
166
201
  await memberships.add(T1, { teamId: b, userId: U1, role: 'admin' });
167
202
  await memberships.add(T1, { teamId: c, userId: U2, role: 'member' });
@@ -172,4 +207,33 @@ export function runTeamBindingConformance(
172
207
  expect(teamIds.has(b)).toBe(true);
173
208
  });
174
209
  });
210
+
211
+ describe(`${label} — slug uniqueness`, () => {
212
+ it('create with a slug the tenant already has → slug-conflict', async () => {
213
+ const { teams } = makeBinding();
214
+ await createTeam(teams, T1, { name: 'Litigation', slug: 'lit' });
215
+ expect(await teams.create(T1, { name: 'Litigation 2', slug: 'lit' })).toEqual({
216
+ kind: 'slug-conflict',
217
+ slug: 'lit',
218
+ });
219
+ expect((await teams.list(T1, {})).items).toHaveLength(1);
220
+ });
221
+
222
+ it('another tenant may use the same slug', async () => {
223
+ const { teams } = makeBinding();
224
+ await createTeam(teams, T1, { name: 'Litigation', slug: 'lit' });
225
+ expect((await teams.create(T2, { name: 'Litigation', slug: 'lit' })).kind).toBe('ok');
226
+ });
227
+
228
+ it("update to another team's slug → slug-conflict; the team is unchanged", async () => {
229
+ const { teams } = makeBinding();
230
+ await createTeam(teams, T1, { name: 'A', slug: 'a' });
231
+ const id = await createTeam(teams, T1, { name: 'B', slug: 'b' });
232
+ expect(await teams.update(T1, id, { slug: 'a' })).toEqual({
233
+ kind: 'slug-conflict',
234
+ slug: 'a',
235
+ });
236
+ expect((await teams.get(T1, id))?.slug).toBe('b');
237
+ });
238
+ });
175
239
  }