@teamblind/axeda-testing 0.0.0-stage → 0.1.0-alpha.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/CHANGELOG.md +29 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +12 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/memory-persistence.d.ts +11 -0
- package/dist/memory-persistence.js +231 -0
- package/dist/memory-tenants.d.ts +24 -0
- package/dist/memory-tenants.js +248 -0
- package/dist/memory-users.d.ts +17 -0
- package/dist/memory-users.js +111 -0
- package/dist/persistence-contract.d.ts +13 -0
- package/dist/persistence-contract.js +426 -0
- package/dist/tenant-contract.d.ts +3 -0
- package/dist/tenant-contract.js +388 -0
- package/package.json +51 -4
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { GLOBAL_SCOPE, isAxedaError } from '@teamblind/axeda-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
const expectCode = async (promise, code) => {
|
|
4
|
+
const error = await promise.then(() => null, (caught) => caught);
|
|
5
|
+
expect(isAxedaError(error, code), `expected ${code}, got ${String(error)}`).toBe(true);
|
|
6
|
+
};
|
|
7
|
+
const FUTURE = new Date('2031-01-01T00:00:00.000Z');
|
|
8
|
+
const PAST = new Date('2020-01-01T00:00:00.000Z');
|
|
9
|
+
/** Pruebas de contrato de compañías, negocios e invitaciones (parte de `runPersistenceContract`). */
|
|
10
|
+
export function defineTenantContract(database) {
|
|
11
|
+
const company = (slug = 'acme') => database().companies.create({ slug, name: slug.toUpperCase() });
|
|
12
|
+
describe('companies', () => {
|
|
13
|
+
it('creates a company with defaults and reads it by id and slug', async () => {
|
|
14
|
+
const created = await database().companies.create({
|
|
15
|
+
slug: 'acme',
|
|
16
|
+
name: 'ACME',
|
|
17
|
+
legalName: 'ACME S.A. de C.V.',
|
|
18
|
+
logoUrl: 'https://cdn.example/acme.png',
|
|
19
|
+
metadata: { tier: 'gold', nested: { ok: true } },
|
|
20
|
+
});
|
|
21
|
+
expect(created).toMatchObject({
|
|
22
|
+
slug: 'acme',
|
|
23
|
+
name: 'ACME',
|
|
24
|
+
legalName: 'ACME S.A. de C.V.',
|
|
25
|
+
logoUrl: 'https://cdn.example/acme.png',
|
|
26
|
+
faviconUrl: null,
|
|
27
|
+
status: 'active',
|
|
28
|
+
version: 1,
|
|
29
|
+
deletedAt: null,
|
|
30
|
+
metadata: { tier: 'gold', nested: { ok: true } },
|
|
31
|
+
});
|
|
32
|
+
expect(await database().companies.get(created.id)).toEqual(created);
|
|
33
|
+
expect(await database().companies.getBySlug('acme')).toEqual(created);
|
|
34
|
+
expect(await database().companies.get('nope')).toBeNull();
|
|
35
|
+
expect(await database().companies.getBySlug('nope')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('rejects a duplicate slug, even while the first company is deleted', async () => {
|
|
38
|
+
const first = await company();
|
|
39
|
+
await expectCode(company(), 'AXEDA_SLUG_TAKEN');
|
|
40
|
+
await database().companies.setDeleted(first.id, 1, new Date());
|
|
41
|
+
await expectCode(company(), 'AXEDA_SLUG_TAKEN');
|
|
42
|
+
});
|
|
43
|
+
it('lets exactly one of two concurrent creations of the same slug win', async () => {
|
|
44
|
+
const results = await Promise.allSettled([company('race'), company('race')]);
|
|
45
|
+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
46
|
+
const rejected = results.find((result) => result.status === 'rejected');
|
|
47
|
+
expect(isAxedaError(rejected?.status === 'rejected' ? rejected.reason : null, 'AXEDA_SLUG_TAKEN')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('updates with the expected version and bumps it, keeping the rest', async () => {
|
|
50
|
+
const created = await company();
|
|
51
|
+
const updated = await database().companies.update(created.id, 1, {
|
|
52
|
+
name: 'ACME 2',
|
|
53
|
+
legalName: 'Legal',
|
|
54
|
+
metadata: { a: 1 },
|
|
55
|
+
});
|
|
56
|
+
expect(updated).toMatchObject({
|
|
57
|
+
name: 'ACME 2',
|
|
58
|
+
legalName: 'Legal',
|
|
59
|
+
version: 2,
|
|
60
|
+
slug: 'acme',
|
|
61
|
+
});
|
|
62
|
+
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
|
63
|
+
const cleared = await database().companies.update(created.id, 2, { legalName: null });
|
|
64
|
+
expect(cleared.legalName).toBeNull();
|
|
65
|
+
expect(cleared.name).toBe('ACME 2');
|
|
66
|
+
expect(cleared.metadata).toEqual({ a: 1 });
|
|
67
|
+
});
|
|
68
|
+
it('rejects a stale version and an unknown company', async () => {
|
|
69
|
+
const created = await company();
|
|
70
|
+
await database().companies.update(created.id, 1, { name: 'first' });
|
|
71
|
+
await expectCode(database().companies.update(created.id, 1, { name: 'second' }), 'AXEDA_VERSION_CONFLICT');
|
|
72
|
+
await expectCode(database().companies.update('nope', 1, { name: 'x' }), 'AXEDA_COMPANY_NOT_FOUND');
|
|
73
|
+
await expectCode(database().companies.setStatus('nope', 1, 'inactive'), 'AXEDA_COMPANY_NOT_FOUND');
|
|
74
|
+
expect((await database().companies.get(created.id))?.name).toBe('first');
|
|
75
|
+
});
|
|
76
|
+
it('changes the status and soft-deletes and restores, bumping the version each time', async () => {
|
|
77
|
+
const created = await company();
|
|
78
|
+
const off = await database().companies.setStatus(created.id, 1, 'inactive');
|
|
79
|
+
expect(off).toMatchObject({ status: 'inactive', version: 2 });
|
|
80
|
+
const at = new Date('2030-03-03T03:03:03.000Z');
|
|
81
|
+
const deleted = await database().companies.setDeleted(created.id, 2, at);
|
|
82
|
+
expect(deleted.deletedAt?.getTime()).toBe(at.getTime());
|
|
83
|
+
expect(deleted.version).toBe(3);
|
|
84
|
+
const restored = await database().companies.setDeleted(created.id, 3, null);
|
|
85
|
+
expect(restored).toMatchObject({ deletedAt: null, version: 4, status: 'inactive' });
|
|
86
|
+
});
|
|
87
|
+
it('lists by slug with cursor pagination and the deleted filter', async () => {
|
|
88
|
+
for (const slug of ['d', 'a', 'c', 'b'])
|
|
89
|
+
await company(slug);
|
|
90
|
+
const b = await database().companies.getBySlug('b');
|
|
91
|
+
await database().companies.setDeleted(b?.id ?? '', 1, new Date());
|
|
92
|
+
const first = await database().companies.list({}, { limit: 2 });
|
|
93
|
+
expect(first.items.map((item) => item.slug)).toEqual(['a', 'c']);
|
|
94
|
+
const second = await database().companies.list({}, { limit: 2, cursor: first.nextCursor ?? '' });
|
|
95
|
+
expect(second.items.map((item) => item.slug)).toEqual(['d']);
|
|
96
|
+
expect(second.nextCursor).toBeNull();
|
|
97
|
+
const slugs = async (deleted) => (await database().companies.list({ deleted }, { limit: 10 })).items.map((item) => item.slug);
|
|
98
|
+
expect(await slugs('include')).toEqual(['a', 'b', 'c', 'd']);
|
|
99
|
+
expect(await slugs('only')).toEqual(['b']);
|
|
100
|
+
});
|
|
101
|
+
it('lists only the requested ids, and nothing for an empty list', async () => {
|
|
102
|
+
const a = await company('a');
|
|
103
|
+
await company('b');
|
|
104
|
+
const ids = async (list) => (await database().companies.list({ ids: list }, { limit: 10 })).items.map((item) => item.id);
|
|
105
|
+
expect(await ids([a.id, 'missing'])).toEqual([a.id]);
|
|
106
|
+
expect(await ids([])).toEqual([]);
|
|
107
|
+
expect((await database().companies.getMany([a.id, 'missing'])).map((item) => item.id)).toEqual([a.id]);
|
|
108
|
+
expect(await database().companies.getMany([])).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
it('finds the companies deleted before a date', async () => {
|
|
111
|
+
const old = await company('old');
|
|
112
|
+
const recent = await company('recent');
|
|
113
|
+
await company('alive');
|
|
114
|
+
await database().companies.setDeleted(old.id, 1, new Date('2025-01-01T00:00:00.000Z'));
|
|
115
|
+
await database().companies.setDeleted(recent.id, 1, new Date('2029-01-01T00:00:00.000Z'));
|
|
116
|
+
const found = await database().companies.listDeletedBefore(new Date('2027-01-01T00:00:00.000Z'), 10);
|
|
117
|
+
expect(found.map((item) => item.slug)).toEqual(['old']);
|
|
118
|
+
});
|
|
119
|
+
it('purges a company with everything that belongs to it, and only that', async () => {
|
|
120
|
+
const db = database();
|
|
121
|
+
const doomed = await db.companies.create({ slug: 'doomed', name: 'D' });
|
|
122
|
+
const kept = await db.companies.create({ slug: 'kept', name: 'K' });
|
|
123
|
+
const shop = await db.businesses.create({ companyId: doomed.id, slug: 'shop', name: 'Shop' });
|
|
124
|
+
const other = await db.businesses.create({ companyId: kept.id, slug: 'shop', name: 'Shop' });
|
|
125
|
+
const role = await db.roles.create({ key: 'r', name: 'R', permissions: [] });
|
|
126
|
+
const grant = (scope) => db.assignments.create({ userId: 'u1', roleId: role.id, scope });
|
|
127
|
+
await grant({ type: 'company', id: doomed.id });
|
|
128
|
+
await grant({ type: 'business', id: shop.id });
|
|
129
|
+
await grant({ type: 'company', id: kept.id });
|
|
130
|
+
await grant({ type: 'business', id: other.id });
|
|
131
|
+
await grant(GLOBAL_SCOPE);
|
|
132
|
+
for (const scope of [
|
|
133
|
+
{ type: 'company', id: doomed.id },
|
|
134
|
+
{ type: 'company', id: kept.id },
|
|
135
|
+
]) {
|
|
136
|
+
await db.invitations.create({
|
|
137
|
+
email: 'x@test.dev',
|
|
138
|
+
scope,
|
|
139
|
+
roleIds: [role.id],
|
|
140
|
+
invitedBy: 'u1',
|
|
141
|
+
expiresAt: FUTURE,
|
|
142
|
+
secretHash: 'h',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
expect(await db.companies.purge([doomed.id, 'missing'])).toBe(1);
|
|
146
|
+
expect(await db.companies.get(doomed.id)).toBeNull();
|
|
147
|
+
expect(await db.businesses.get(shop.id)).toBeNull();
|
|
148
|
+
expect(await db.companies.get(kept.id)).not.toBeNull();
|
|
149
|
+
expect(await db.businesses.get(other.id)).not.toBeNull();
|
|
150
|
+
const left = await db.assignments.listForUser('u1');
|
|
151
|
+
expect(left
|
|
152
|
+
.map((item) => item.scope)
|
|
153
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))).toEqual([{ type: 'business', id: other.id }, { type: 'company', id: kept.id }, GLOBAL_SCOPE].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
|
|
154
|
+
expect(await db.invitations.findPending('x@test.dev', { type: 'company', id: doomed.id })).toBeNull();
|
|
155
|
+
expect(await db.invitations.findPending('x@test.dev', { type: 'company', id: kept.id })).not.toBeNull();
|
|
156
|
+
expect(await db.companies.purge([])).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
it('is rolled back with its transaction', async () => {
|
|
159
|
+
await expect(database().transaction(async (tx) => {
|
|
160
|
+
await tx.companies.create({ slug: 'ghost', name: 'G' });
|
|
161
|
+
throw new Error('boom');
|
|
162
|
+
})).rejects.toThrow('boom');
|
|
163
|
+
expect(await database().companies.getBySlug('ghost')).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('businesses', () => {
|
|
167
|
+
it('creates a business inside a company and reads it back', async () => {
|
|
168
|
+
const parent = await company();
|
|
169
|
+
const created = await database().businesses.create({
|
|
170
|
+
companyId: parent.id,
|
|
171
|
+
slug: 'main',
|
|
172
|
+
name: 'Main',
|
|
173
|
+
faviconUrl: 'https://cdn.example/f.ico',
|
|
174
|
+
metadata: { city: 'CDMX' },
|
|
175
|
+
});
|
|
176
|
+
expect(created).toMatchObject({
|
|
177
|
+
companyId: parent.id,
|
|
178
|
+
slug: 'main',
|
|
179
|
+
status: 'active',
|
|
180
|
+
version: 1,
|
|
181
|
+
deletedAt: null,
|
|
182
|
+
faviconUrl: 'https://cdn.example/f.ico',
|
|
183
|
+
metadata: { city: 'CDMX' },
|
|
184
|
+
});
|
|
185
|
+
expect(await database().businesses.get(created.id)).toEqual(created);
|
|
186
|
+
expect(await database().businesses.getBySlug(parent.id, 'main')).toEqual(created);
|
|
187
|
+
expect(await database().businesses.getBySlug(parent.id, 'nope')).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
it('requires an existing company', async () => {
|
|
190
|
+
await expectCode(database().businesses.create({ companyId: 'missing', slug: 's', name: 'S' }), 'AXEDA_COMPANY_NOT_FOUND');
|
|
191
|
+
});
|
|
192
|
+
it('keeps slugs unique per company, not globally', async () => {
|
|
193
|
+
const a = await company('a');
|
|
194
|
+
const b = await company('b');
|
|
195
|
+
await database().businesses.create({ companyId: a.id, slug: 'main', name: 'A main' });
|
|
196
|
+
await expectCode(database().businesses.create({ companyId: a.id, slug: 'main', name: 'again' }), 'AXEDA_SLUG_TAKEN');
|
|
197
|
+
await database().businesses.create({ companyId: b.id, slug: 'main', name: 'B main' });
|
|
198
|
+
});
|
|
199
|
+
it('lets exactly one of two concurrent creations of the same slug win', async () => {
|
|
200
|
+
const parent = await company();
|
|
201
|
+
const make = () => database().businesses.create({ companyId: parent.id, slug: 'race', name: 'R' });
|
|
202
|
+
const results = await Promise.allSettled([make(), make()]);
|
|
203
|
+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
it('updates, changes status, soft-deletes and restores with versions; the company never changes', async () => {
|
|
206
|
+
const parent = await company();
|
|
207
|
+
const created = await database().businesses.create({
|
|
208
|
+
companyId: parent.id,
|
|
209
|
+
slug: 'main',
|
|
210
|
+
name: 'Main',
|
|
211
|
+
});
|
|
212
|
+
const renamed = await database().businesses.update(created.id, 1, {
|
|
213
|
+
name: 'Renamed',
|
|
214
|
+
logoUrl: 'https://x/y.png',
|
|
215
|
+
});
|
|
216
|
+
expect(renamed).toMatchObject({
|
|
217
|
+
name: 'Renamed',
|
|
218
|
+
logoUrl: 'https://x/y.png',
|
|
219
|
+
version: 2,
|
|
220
|
+
companyId: parent.id,
|
|
221
|
+
});
|
|
222
|
+
const off = await database().businesses.setStatus(created.id, 2, 'inactive');
|
|
223
|
+
expect(off.status).toBe('inactive');
|
|
224
|
+
const deleted = await database().businesses.setDeleted(created.id, 3, new Date());
|
|
225
|
+
expect(deleted.deletedAt).not.toBeNull();
|
|
226
|
+
const back = await database().businesses.setDeleted(created.id, 4, null);
|
|
227
|
+
expect(back).toMatchObject({ deletedAt: null, version: 5, companyId: parent.id });
|
|
228
|
+
await expectCode(database().businesses.update(created.id, 1, { name: 'old' }), 'AXEDA_VERSION_CONFLICT');
|
|
229
|
+
await expectCode(database().businesses.update('nope', 1, { name: 'x' }), 'AXEDA_BUSINESS_NOT_FOUND');
|
|
230
|
+
});
|
|
231
|
+
it('lists the businesses of one company with filters and pagination', async () => {
|
|
232
|
+
const a = await company('a');
|
|
233
|
+
const b = await company('b');
|
|
234
|
+
for (const slug of ['z', 'x', 'y'])
|
|
235
|
+
await database().businesses.create({ companyId: a.id, slug, name: slug });
|
|
236
|
+
await database().businesses.create({ companyId: b.id, slug: 'other', name: 'o' });
|
|
237
|
+
const y = await database().businesses.getBySlug(a.id, 'y');
|
|
238
|
+
await database().businesses.setDeleted(y?.id ?? '', 1, new Date());
|
|
239
|
+
const first = await database().businesses.listForCompany(a.id, {}, { limit: 1 });
|
|
240
|
+
expect(first.items.map((item) => item.slug)).toEqual(['x']);
|
|
241
|
+
const rest = await database().businesses.listForCompany(a.id, {}, { limit: 5, cursor: first.nextCursor ?? '' });
|
|
242
|
+
expect(rest.items.map((item) => item.slug)).toEqual(['z']);
|
|
243
|
+
const only = await database().businesses.listForCompany(a.id, { deleted: 'only' }, { limit: 5 });
|
|
244
|
+
expect(only.items.map((item) => item.slug)).toEqual(['y']);
|
|
245
|
+
const ids = await database().businesses.listForCompany(a.id, { ids: [y?.id ?? ''], deleted: 'include' }, { limit: 5 });
|
|
246
|
+
expect(ids.items).toHaveLength(1);
|
|
247
|
+
expect((await database().businesses.getMany([y?.id ?? '', 'missing'])).map((item) => item.slug)).toEqual(['y']);
|
|
248
|
+
});
|
|
249
|
+
it('finds the businesses deleted before a date', async () => {
|
|
250
|
+
const parent = await company();
|
|
251
|
+
const old = await database().businesses.create({
|
|
252
|
+
companyId: parent.id,
|
|
253
|
+
slug: 'old',
|
|
254
|
+
name: 'o',
|
|
255
|
+
});
|
|
256
|
+
await database().businesses.setDeleted(old.id, 1, PAST);
|
|
257
|
+
const found = await database().businesses.listDeletedBefore(new Date('2027-01-01T00:00:00.000Z'), 10);
|
|
258
|
+
expect(found.map((item) => item.id)).toEqual([old.id]);
|
|
259
|
+
});
|
|
260
|
+
it('purges a business with its assignments and invitations only', async () => {
|
|
261
|
+
const db = database();
|
|
262
|
+
const parent = await db.companies.create({ slug: 'p', name: 'P' });
|
|
263
|
+
const gone = await db.businesses.create({ companyId: parent.id, slug: 'gone', name: 'G' });
|
|
264
|
+
const stays = await db.businesses.create({ companyId: parent.id, slug: 'stays', name: 'S' });
|
|
265
|
+
const role = await db.roles.create({ key: 'r', name: 'R', permissions: [] });
|
|
266
|
+
await db.assignments.create({
|
|
267
|
+
userId: 'u1',
|
|
268
|
+
roleId: role.id,
|
|
269
|
+
scope: { type: 'business', id: gone.id },
|
|
270
|
+
});
|
|
271
|
+
await db.assignments.create({
|
|
272
|
+
userId: 'u1',
|
|
273
|
+
roleId: role.id,
|
|
274
|
+
scope: { type: 'business', id: stays.id },
|
|
275
|
+
});
|
|
276
|
+
await db.invitations.create({
|
|
277
|
+
email: 'a@test.dev',
|
|
278
|
+
scope: { type: 'business', id: gone.id },
|
|
279
|
+
roleIds: [role.id],
|
|
280
|
+
invitedBy: 'u1',
|
|
281
|
+
expiresAt: FUTURE,
|
|
282
|
+
secretHash: 'h',
|
|
283
|
+
});
|
|
284
|
+
expect(await db.businesses.purge([gone.id, 'missing'])).toBe(1);
|
|
285
|
+
expect(await db.businesses.get(gone.id)).toBeNull();
|
|
286
|
+
expect(await db.businesses.get(stays.id)).not.toBeNull();
|
|
287
|
+
expect(await db.companies.get(parent.id)).not.toBeNull();
|
|
288
|
+
expect((await db.assignments.listForUser('u1')).map((item) => item.scope)).toEqual([
|
|
289
|
+
{ type: 'business', id: stays.id },
|
|
290
|
+
]);
|
|
291
|
+
expect(await db.invitations.findPending('a@test.dev', { type: 'business', id: gone.id })).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
describe('invitations', () => {
|
|
295
|
+
const scope = { type: 'company', id: 'c1' };
|
|
296
|
+
const input = (overrides = {}) => ({
|
|
297
|
+
email: 'new@test.dev',
|
|
298
|
+
scope,
|
|
299
|
+
roleIds: ['r1', 'r2'],
|
|
300
|
+
invitedBy: 'u1',
|
|
301
|
+
expiresAt: FUTURE,
|
|
302
|
+
secretHash: 'hash-1',
|
|
303
|
+
...overrides,
|
|
304
|
+
});
|
|
305
|
+
it('creates an invitation and reads it back, with its secret hash', async () => {
|
|
306
|
+
const created = await database().invitations.create(input());
|
|
307
|
+
expect(created).toMatchObject({
|
|
308
|
+
email: 'new@test.dev',
|
|
309
|
+
scope,
|
|
310
|
+
roleIds: ['r1', 'r2'],
|
|
311
|
+
invitedBy: 'u1',
|
|
312
|
+
secretHash: 'hash-1',
|
|
313
|
+
acceptedAt: null,
|
|
314
|
+
revokedAt: null,
|
|
315
|
+
});
|
|
316
|
+
expect(created.expiresAt.getTime()).toBe(FUTURE.getTime());
|
|
317
|
+
expect(await database().invitations.get(created.id)).toEqual(created);
|
|
318
|
+
expect(await database().invitations.get('nope')).toBeNull();
|
|
319
|
+
expect(await database().invitations.findPending('new@test.dev', scope)).toEqual(created);
|
|
320
|
+
});
|
|
321
|
+
it('allows one pending invitation per email and scope, but not per email alone', async () => {
|
|
322
|
+
await database().invitations.create(input());
|
|
323
|
+
await expectCode(database().invitations.create(input({ secretHash: 'other' })), 'AXEDA_INVITATION_EXISTS');
|
|
324
|
+
await database().invitations.create(input({ scope: { type: 'company', id: 'c2' } }));
|
|
325
|
+
await database().invitations.create(input({ scope: GLOBAL_SCOPE }));
|
|
326
|
+
await database().invitations.create(input({ email: 'someone@test.dev' }));
|
|
327
|
+
});
|
|
328
|
+
it('lets exactly one of two concurrent identical invitations win', async () => {
|
|
329
|
+
const results = await Promise.allSettled([
|
|
330
|
+
database().invitations.create(input()),
|
|
331
|
+
database().invitations.create(input({ secretHash: 'h2' })),
|
|
332
|
+
]);
|
|
333
|
+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
334
|
+
});
|
|
335
|
+
it('closes an invitation once: accepted or revoked, never both', async () => {
|
|
336
|
+
const created = await database().invitations.create(input());
|
|
337
|
+
const at = new Date('2030-01-01T00:00:00.000Z');
|
|
338
|
+
expect(await database().invitations.close(created.id, 'accepted', at)).toBe(true);
|
|
339
|
+
expect(await database().invitations.close(created.id, 'accepted', at)).toBe(false);
|
|
340
|
+
expect(await database().invitations.close(created.id, 'revoked', at)).toBe(false);
|
|
341
|
+
const read = await database().invitations.get(created.id);
|
|
342
|
+
expect(read?.acceptedAt?.getTime()).toBe(at.getTime());
|
|
343
|
+
expect(read?.revokedAt).toBeNull();
|
|
344
|
+
expect(await database().invitations.findPending('new@test.dev', scope)).toBeNull();
|
|
345
|
+
expect(await database().invitations.close('missing', 'revoked', at)).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
it('lets exactly one of several concurrent closes win', async () => {
|
|
348
|
+
const created = await database().invitations.create(input());
|
|
349
|
+
const at = new Date();
|
|
350
|
+
const results = await Promise.all([
|
|
351
|
+
database().invitations.close(created.id, 'accepted', at),
|
|
352
|
+
database().invitations.close(created.id, 'accepted', at),
|
|
353
|
+
database().invitations.close(created.id, 'revoked', at),
|
|
354
|
+
database().invitations.close(created.id, 'accepted', at),
|
|
355
|
+
]);
|
|
356
|
+
expect(results.filter(Boolean)).toHaveLength(1);
|
|
357
|
+
});
|
|
358
|
+
it('frees the slot for a new pending invitation once the old one is closed', async () => {
|
|
359
|
+
const first = await database().invitations.create(input());
|
|
360
|
+
await database().invitations.close(first.id, 'revoked', new Date());
|
|
361
|
+
const second = await database().invitations.create(input({ secretHash: 'hash-2' }));
|
|
362
|
+
expect(second.id).not.toBe(first.id);
|
|
363
|
+
expect((await database().invitations.findPending('new@test.dev', scope))?.id).toBe(second.id);
|
|
364
|
+
});
|
|
365
|
+
it('lists the invitations of a scope without ever returning the secret hash', async () => {
|
|
366
|
+
await database().invitations.create(input());
|
|
367
|
+
const closed = await database().invitations.create(input({ email: 'closed@test.dev' }));
|
|
368
|
+
await database().invitations.close(closed.id, 'accepted', new Date());
|
|
369
|
+
await database().invitations.create(input({ email: 'expired@test.dev', expiresAt: PAST }));
|
|
370
|
+
await database().invitations.create(input({ scope: { type: 'company', id: 'other' } }));
|
|
371
|
+
const now = new Date('2026-06-01T00:00:00.000Z');
|
|
372
|
+
const all = await database().invitations.list(scope, { pendingOnly: false, now }, { limit: 10 });
|
|
373
|
+
expect(all.items.map((item) => item.email).sort()).toEqual([
|
|
374
|
+
'closed@test.dev',
|
|
375
|
+
'expired@test.dev',
|
|
376
|
+
'new@test.dev',
|
|
377
|
+
]);
|
|
378
|
+
for (const item of all.items)
|
|
379
|
+
expect(item).not.toHaveProperty('secretHash');
|
|
380
|
+
const pending = await database().invitations.list(scope, { pendingOnly: true, now }, { limit: 10 });
|
|
381
|
+
expect(pending.items.map((item) => item.email)).toEqual(['new@test.dev']);
|
|
382
|
+
const first = await database().invitations.list(scope, { pendingOnly: false, now }, { limit: 2 });
|
|
383
|
+
const second = await database().invitations.list(scope, { pendingOnly: false, now }, { limit: 2, cursor: first.nextCursor ?? '' });
|
|
384
|
+
expect([...first.items, ...second.items]).toHaveLength(3);
|
|
385
|
+
expect(second.nextCursor).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamblind/axeda-testing",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Testing utilities for Axeda: in-memory stores and port contract suites.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "TeamBlind",
|
|
7
|
+
"homepage": "https://github.com/teamblindmx/axeda#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/teamblindmx/axeda/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/teamblindmx/axeda.git",
|
|
14
|
+
"directory": "packages/testing"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"authorization",
|
|
18
|
+
"rbac",
|
|
19
|
+
"multi-tenant",
|
|
20
|
+
"better-auth",
|
|
21
|
+
"access-control"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"NOTICE",
|
|
38
|
+
"CHANGELOG.md"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public",
|
|
42
|
+
"provenance": true
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@teamblind/axeda-core": "^0.1.0-alpha.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"vitest": "^5.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|