@flowdular/sdk 0.3.1 → 0.3.2
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/.ai/platform-capabilities.md +2 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/.ai/references/catalog/module.json +11 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +25 -4
- package/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/.ai/references/catalog/src/client/api.ts +124 -36
- package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/.ai/references/catalog/src/client/state.ts +169 -3
- package/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/.ai/references/catalog/src/domain/types.ts +20 -0
- package/.ai/references/catalog/src/platform.ts +20 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/.ai/references/catalog/src/services/migration.ts +27 -1
- package/.ai/references/catalog/src/services/repository.ts +31 -2
- package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/.ai/references/catalog/tests/export.test.ts +134 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/.ai/references/catalog/tests/list.test.ts +217 -0
- package/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/.ai/references/catalog/tests/module.test.ts +2 -1
- package/.ai/references/catalog/tests/support/database.ts +14 -0
- package/.ai/references/catalog/translations/en.json +35 -4
- package/.ai/references/catalog/translations/pl.json +35 -4
- package/.ai/references/catalog.provenance.json +34 -26
- package/README.md +1 -1
- package/assets/flowdular-banner.webp +0 -0
- package/modules/access/module.json +1 -1
- package/modules/access/spec/module.yaml +1 -1
- package/modules/agents/module.json +1 -1
- package/modules/agents/spec/module.yaml +1 -1
- package/modules/approvals/module.json +1 -1
- package/modules/approvals/spec/module.yaml +1 -1
- package/modules/audit/module.json +1 -1
- package/modules/audit/spec/module.yaml +1 -1
- package/modules/auth/module.json +2 -2
- package/modules/auth/package.json +1 -1
- package/modules/auth/spec/module.yaml +2 -2
- package/modules/auth/src/middleware/authentication.ts +5 -1
- package/modules/auth/src/services/auth-service.ts +26 -0
- package/modules/auth/src/services/database-repository.ts +9 -0
- package/modules/auth/src/services/repository.ts +2 -0
- package/modules/automations/module.json +1 -1
- package/modules/automations/spec/module.yaml +1 -1
- package/modules/connectors/module.json +1 -1
- package/modules/connectors/spec/module.yaml +1 -1
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/module.json +1 -1
- package/modules/documents/spec/module.yaml +1 -1
- package/modules/exports/module.json +1 -1
- package/modules/exports/spec/module.yaml +1 -1
- package/modules/import/module.json +1 -1
- package/modules/import/spec/module.yaml +1 -1
- package/modules/metering/module.json +1 -1
- package/modules/metering/spec/module.yaml +1 -1
- package/modules/notifications/module.json +1 -1
- package/modules/notifications/spec/module.yaml +1 -1
- package/modules/reports/module.json +1 -1
- package/modules/reports/spec/module.yaml +1 -1
- package/modules/sandbox/module.json +1 -1
- package/modules/sandbox/spec/module.yaml +1 -1
- package/modules/search/module.json +1 -1
- package/modules/search/spec/module.yaml +1 -1
- package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
- package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
- package/modules/system/module.json +3 -2
- package/modules/system/package.json +2 -2
- package/modules/system/spec/module.yaml +98 -1
- package/modules/system/src/client/ModulesView.tsrx +101 -8
- package/modules/system/src/client/api.ts +52 -11
- package/modules/system/src/client/module-columns.tsrx +32 -6
- package/modules/system/src/client/state.ts +26 -0
- package/modules/system/src/domain/modules.ts +93 -0
- package/modules/system/src/index.ts +13 -0
- package/modules/system/src/platform.ts +41 -1
- package/modules/system/src/server/capability.ts +6 -0
- package/modules/system/src/server/endpoints.ts +125 -5
- package/modules/system/src/server/index.ts +26 -0
- package/modules/system/src/server/module-catalog.ts +19 -0
- package/modules/system/src/server/runtime.ts +102 -0
- package/modules/system/src/services/database-repository.ts +91 -0
- package/modules/system/src/services/migration.ts +39 -0
- package/modules/system/src/services/module-activation-service.ts +245 -0
- package/modules/system/src/services/repository.ts +17 -0
- package/modules/system/translations/en.json +17 -1
- package/modules/system/translations/pl.json +17 -1
- package/modules/users/module.json +2 -2
- package/modules/users/package.json +1 -1
- package/modules/users/spec/module.yaml +6 -9
- package/modules/users/src/client/UsersView.tsrx +27 -13
- package/modules/users/src/client/member-columns.tsrx +13 -4
- package/modules/users/src/client/state.ts +16 -0
- package/modules/users/src/services/users-service.ts +3 -0
- package/modules/users/translations/en.json +2 -1
- package/modules/users/translations/pl.json +2 -1
- package/modules/workflows/module.json +2 -2
- package/modules/workflows/package.json +1 -1
- package/modules/workflows/spec/module.yaml +2 -2
- package/modules/workflows/src/services/database-repository.ts +93 -53
- package/package.json +1 -1
- package/packages/client/package.json +1 -0
- package/packages/client/src/ApplicationShell.tsrx +46 -5
- package/packages/client/src/index.ts +5 -0
- package/packages/client/src/shell/modules.ts +49 -0
- package/packages/client/src/shell/types.ts +3 -0
- package/packages/contracts/src/index.ts +11 -1
- package/packages/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- package/assets/flowdular-banner.png +0 -0
|
@@ -317,4 +317,273 @@ describe('catalog lifecycle and history endpoints', () => {
|
|
|
317
317
|
(await (await runtime.service()).get('tenant-a', item.id))?.status,
|
|
318
318
|
).toBe('active');
|
|
319
319
|
});
|
|
320
|
+
|
|
321
|
+
it('rejects a bulk lifecycle mutation without a valid CSRF token', async () => {
|
|
322
|
+
const actor = principal([CATALOG_PERMISSIONS.manage]);
|
|
323
|
+
const runtime = catalogRuntime(await catalogTestProvider());
|
|
324
|
+
const item = await (
|
|
325
|
+
await runtime.service()
|
|
326
|
+
).create('tenant-a', seedInput('A-1'), OWNER);
|
|
327
|
+
const response = await route(
|
|
328
|
+
createCatalogRoutes(authRuntime(actor), runtime),
|
|
329
|
+
'/api/catalog/items/archive-many',
|
|
330
|
+
'POST',
|
|
331
|
+
).handler(
|
|
332
|
+
context(
|
|
333
|
+
mutation('/api/catalog/items/archive-many', { ids: [item.id] }, 'bad'),
|
|
334
|
+
actor,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
expect(response.status).toBe(403);
|
|
338
|
+
expect(
|
|
339
|
+
(await (await runtime.service()).get('tenant-a', item.id))?.status,
|
|
340
|
+
).toBe('active');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('bounds the ids of a bulk lifecycle mutation', async () => {
|
|
344
|
+
const actor = principal([CATALOG_PERMISSIONS.manage]);
|
|
345
|
+
const routes = createCatalogRoutes(
|
|
346
|
+
authRuntime(actor),
|
|
347
|
+
catalogRuntime(await catalogTestProvider()),
|
|
348
|
+
);
|
|
349
|
+
for (const body of [
|
|
350
|
+
{},
|
|
351
|
+
{ ids: [] },
|
|
352
|
+
{ ids: Array.from({ length: 101 }, (_, index) => `id-${index}`) },
|
|
353
|
+
{ ids: ['same', 'same'] },
|
|
354
|
+
{ ids: [''] },
|
|
355
|
+
{ ids: ['x'.repeat(129)] },
|
|
356
|
+
]) {
|
|
357
|
+
for (const path of [
|
|
358
|
+
'/api/catalog/items/archive-many',
|
|
359
|
+
'/api/catalog/items/restore-many',
|
|
360
|
+
]) {
|
|
361
|
+
const response = await route(routes, path, 'POST').handler(
|
|
362
|
+
context(mutation(path, body), actor),
|
|
363
|
+
);
|
|
364
|
+
expect(response.status, `${path} ${JSON.stringify(body)}`).toBe(400);
|
|
365
|
+
expect(await response.json()).toMatchObject({
|
|
366
|
+
error: { code: 'INVALID_INPUT' },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('answers one outcome per id and one history row per accepted transition', async () => {
|
|
373
|
+
const actor = principal([
|
|
374
|
+
CATALOG_PERMISSIONS.read,
|
|
375
|
+
CATALOG_PERMISSIONS.manage,
|
|
376
|
+
]);
|
|
377
|
+
const runtime = catalogRuntime(await catalogTestProvider());
|
|
378
|
+
const service = await runtime.service();
|
|
379
|
+
const first = await service.create('tenant-a', seedInput('A-1'), OWNER);
|
|
380
|
+
const second = await service.create('tenant-a', seedInput('A-2'), OWNER);
|
|
381
|
+
const foreign = await service.create('tenant-b', seedInput('B-1'), OWNER);
|
|
382
|
+
const routes = createCatalogRoutes(authRuntime(actor), runtime);
|
|
383
|
+
|
|
384
|
+
const archived = await route(
|
|
385
|
+
routes,
|
|
386
|
+
'/api/catalog/items/archive-many',
|
|
387
|
+
'POST',
|
|
388
|
+
).handler(
|
|
389
|
+
context(
|
|
390
|
+
mutation('/api/catalog/items/archive-many', {
|
|
391
|
+
ids: [first.id, 'missing', foreign.id, second.id],
|
|
392
|
+
}),
|
|
393
|
+
actor,
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
expect(archived.status).toBe(200);
|
|
397
|
+
expect(await archived.json()).toEqual({
|
|
398
|
+
outcomes: [
|
|
399
|
+
{ id: first.id, outcome: 'updated' },
|
|
400
|
+
{ id: 'missing', outcome: 'not-found' },
|
|
401
|
+
{ id: foreign.id, outcome: 'not-found' },
|
|
402
|
+
{ id: second.id, outcome: 'updated' },
|
|
403
|
+
],
|
|
404
|
+
});
|
|
405
|
+
expect((await service.get('tenant-b', foreign.id))?.status).toBe('active');
|
|
406
|
+
|
|
407
|
+
const restored = await route(
|
|
408
|
+
routes,
|
|
409
|
+
'/api/catalog/items/restore-many',
|
|
410
|
+
'POST',
|
|
411
|
+
).handler(
|
|
412
|
+
context(
|
|
413
|
+
mutation('/api/catalog/items/restore-many', { ids: [first.id] }),
|
|
414
|
+
actor,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
expect(await restored.json()).toEqual({
|
|
418
|
+
outcomes: [{ id: first.id, outcome: 'updated' }],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
for (const [id, actions] of [
|
|
422
|
+
[first.id, ['restored', 'archived', 'created']],
|
|
423
|
+
[second.id, ['archived', 'created']],
|
|
424
|
+
] as const) {
|
|
425
|
+
const history = await service.history('tenant-a', {
|
|
426
|
+
recordId: id,
|
|
427
|
+
limit: 10,
|
|
428
|
+
cursor: null,
|
|
429
|
+
});
|
|
430
|
+
expect(history.entries.map((entry) => entry.action)).toEqual(actions);
|
|
431
|
+
expect(history.entries[0]?.actor).toEqual({
|
|
432
|
+
kind: 'user',
|
|
433
|
+
id: 'account-a',
|
|
434
|
+
label: 'Owner',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
function seedInput(sku: string) {
|
|
441
|
+
return {
|
|
442
|
+
sku,
|
|
443
|
+
name: 'Item ' + sku,
|
|
444
|
+
kind: 'product' as const,
|
|
445
|
+
unit: 'each',
|
|
446
|
+
basePriceMinor: 100,
|
|
447
|
+
currency: 'EUR',
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const OWNER = { kind: 'user', id: 'account-a', label: 'Owner' } as const;
|
|
452
|
+
|
|
453
|
+
function listRequest(query: Record<string, string>): Request {
|
|
454
|
+
return new Request(
|
|
455
|
+
'https://erp.example/api/catalog/items?' +
|
|
456
|
+
new URLSearchParams(query).toString(),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/* Flips the first character of one dot-separated segment of a signed cursor. */
|
|
461
|
+
function tamper(cursor: string, segment: number): string {
|
|
462
|
+
const parts = cursor.split('.');
|
|
463
|
+
const target = parts[segment]!;
|
|
464
|
+
const first = target[0] === 'A' ? 'B' : 'A';
|
|
465
|
+
parts[segment] = first + target.slice(1);
|
|
466
|
+
return parts.join('.');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
interface ListBody {
|
|
470
|
+
readonly items: readonly { readonly id: string; readonly sku: string }[];
|
|
471
|
+
readonly page: { readonly nextCursor: string | null; readonly limit: number };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
describe('catalog items list endpoint', () => {
|
|
475
|
+
async function listing(count: number) {
|
|
476
|
+
const actor = principal([CATALOG_PERMISSIONS.read]);
|
|
477
|
+
const runtime = catalogRuntime(await catalogTestProvider());
|
|
478
|
+
const service = await runtime.service();
|
|
479
|
+
for (let index = 0; index < count; index += 1) {
|
|
480
|
+
await service.create(
|
|
481
|
+
'tenant-a',
|
|
482
|
+
seedInput(`SKU-${String(index).padStart(3, '0')}`),
|
|
483
|
+
OWNER,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
await service.create('tenant-b', seedInput('OTHER-1'), OWNER);
|
|
487
|
+
const list = route(
|
|
488
|
+
createCatalogRoutes(authRuntime(actor), runtime),
|
|
489
|
+
'/api/catalog/items',
|
|
490
|
+
'GET',
|
|
491
|
+
);
|
|
492
|
+
const read = async (
|
|
493
|
+
query: Record<string, string>,
|
|
494
|
+
as = actor,
|
|
495
|
+
): Promise<{ status: number; body: ListBody }> => {
|
|
496
|
+
const response = await list.handler(context(listRequest(query), as));
|
|
497
|
+
return {
|
|
498
|
+
status: response.status,
|
|
499
|
+
body: (await response.json()) as ListBody,
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
return { read, runtime };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
it('answers consecutive pages without overlap or gap, a cursor only on a full page', async () => {
|
|
506
|
+
const { read } = await listing(5);
|
|
507
|
+
const first = await read({ limit: '2', sort: 'sku' });
|
|
508
|
+
expect(first.status).toBe(200);
|
|
509
|
+
expect(first.body.items.map((item) => item.sku)).toEqual([
|
|
510
|
+
'SKU-000',
|
|
511
|
+
'SKU-001',
|
|
512
|
+
]);
|
|
513
|
+
expect(first.body.page).toEqual({
|
|
514
|
+
nextCursor: expect.any(String),
|
|
515
|
+
limit: 2,
|
|
516
|
+
});
|
|
517
|
+
const second = await read({
|
|
518
|
+
limit: '2',
|
|
519
|
+
sort: 'sku',
|
|
520
|
+
cursor: first.body.page.nextCursor!,
|
|
521
|
+
});
|
|
522
|
+
expect(second.body.items.map((item) => item.sku)).toEqual([
|
|
523
|
+
'SKU-002',
|
|
524
|
+
'SKU-003',
|
|
525
|
+
]);
|
|
526
|
+
const third = await read({
|
|
527
|
+
limit: '2',
|
|
528
|
+
sort: 'sku',
|
|
529
|
+
cursor: second.body.page.nextCursor!,
|
|
530
|
+
});
|
|
531
|
+
expect(third.body.items.map((item) => item.sku)).toEqual(['SKU-004']);
|
|
532
|
+
expect(third.body.page.nextCursor).toBeNull();
|
|
533
|
+
|
|
534
|
+
const whole = await read({});
|
|
535
|
+
expect(whole.body.items).toHaveLength(5);
|
|
536
|
+
expect(whole.body.page).toEqual({ nextCursor: null, limit: 50 });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('refuses a tampered, foreign, refiltered or re-sorted cursor and an unknown sort', async () => {
|
|
540
|
+
const { read } = await listing(3);
|
|
541
|
+
const query = { limit: '2', sort: 'name', direction: 'asc', q: 'sku' };
|
|
542
|
+
const cursor = (await read(query)).body.page.nextCursor!;
|
|
543
|
+
expect((await read({ ...query, cursor })).status).toBe(200);
|
|
544
|
+
|
|
545
|
+
const refused = async (
|
|
546
|
+
changes: Record<string, string>,
|
|
547
|
+
as?: AuthPrincipal,
|
|
548
|
+
) => {
|
|
549
|
+
const result = await read({ ...query, ...changes }, as);
|
|
550
|
+
expect(result.status, JSON.stringify(changes)).toBe(400);
|
|
551
|
+
return (result.body as unknown as { error: { code: string } }).error.code;
|
|
552
|
+
};
|
|
553
|
+
for (const segment of [0, 1, 2]) {
|
|
554
|
+
expect(await refused({ cursor: tamper(cursor, segment) })).toBe(
|
|
555
|
+
'CURSOR_INVALID',
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
expect(
|
|
559
|
+
await refused(
|
|
560
|
+
{ cursor },
|
|
561
|
+
principal([CATALOG_PERMISSIONS.read], 'tenant-b'),
|
|
562
|
+
),
|
|
563
|
+
).toBe('CURSOR_INVALID');
|
|
564
|
+
expect(await refused({ cursor, q: 'other' })).toBe('CURSOR_INVALID');
|
|
565
|
+
expect(await refused({ cursor, kind: 'product' })).toBe('CURSOR_INVALID');
|
|
566
|
+
expect(await refused({ cursor, sort: 'sku' })).toBe('CURSOR_INVALID');
|
|
567
|
+
expect(await refused({ cursor, direction: 'desc' })).toBe('CURSOR_INVALID');
|
|
568
|
+
expect(await refused({ cursor: 'not-a-cursor' })).toBe('CURSOR_INVALID');
|
|
569
|
+
expect(await refused({ sort: 'price' })).toBe('INVALID_INPUT');
|
|
570
|
+
expect(await refused({ direction: 'up' })).toBe('INVALID_INPUT');
|
|
571
|
+
expect(await refused({ status: 'gone' })).toBe('INVALID_INPUT');
|
|
572
|
+
expect(await refused({ limit: '201' })).toBe('INVALID_INPUT');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('pushes the filters into the read and never crosses the tenant', async () => {
|
|
576
|
+
const { read, runtime } = await listing(2);
|
|
577
|
+
const service = await runtime.service();
|
|
578
|
+
const [archived] = (await read({ sort: 'sku' })).body.items;
|
|
579
|
+
await service.archive('tenant-a', archived!.id, OWNER);
|
|
580
|
+
expect(
|
|
581
|
+
(await read({ status: 'archived' })).body.items.map((item) => item.sku),
|
|
582
|
+
).toEqual(['SKU-000']);
|
|
583
|
+
expect(
|
|
584
|
+
(await read({ status: 'active' })).body.items.map((item) => item.sku),
|
|
585
|
+
).toEqual(['SKU-001']);
|
|
586
|
+
expect((await read({ kind: 'service' })).body.items).toEqual([]);
|
|
587
|
+
expect((await read({ q: 'other' })).body.items).toEqual([]);
|
|
588
|
+
});
|
|
320
589
|
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { DefinedListExport } from '@flowdular/sdk/server';
|
|
2
|
+
import { EXPORT_LISTS_CAPABILITY } from '@flowdular/sdk/modules/exports';
|
|
3
|
+
import type { PlatformServerContext } from '@flowdular/sdk/modules/auth/server';
|
|
4
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { CATALOG_ITEMS_EXPORT_LIST_ID } from '../src/domain/lists.ts';
|
|
6
|
+
import { createServerComposition } from '../src/platform.ts';
|
|
7
|
+
import {
|
|
8
|
+
createCatalogRuntime,
|
|
9
|
+
type CatalogRuntime,
|
|
10
|
+
} from '../src/server/runtime.ts';
|
|
11
|
+
import {
|
|
12
|
+
catalogTestProvider,
|
|
13
|
+
closeCatalogTestDatabases,
|
|
14
|
+
} from './support/database.ts';
|
|
15
|
+
|
|
16
|
+
const ACTOR = { kind: 'user', id: 'account-a', label: 'Owner' } as const;
|
|
17
|
+
|
|
18
|
+
const disposals = new Set<() => Promise<void>>();
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all([...disposals].map((dispose) => dispose()));
|
|
22
|
+
disposals.clear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(closeCatalogTestDatabases);
|
|
26
|
+
|
|
27
|
+
/* The composition context a test needs: leased databases, a capability map
|
|
28
|
+
and inert registries for what this test does not observe. */
|
|
29
|
+
async function compose(registered: Map<string, readonly DefinedListExport[]>) {
|
|
30
|
+
const capabilities = new Map<string, unknown>();
|
|
31
|
+
capabilities.set(EXPORT_LISTS_CAPABILITY, {
|
|
32
|
+
register: (moduleId: string, lists: readonly DefinedListExport[]) =>
|
|
33
|
+
registered.set(moduleId, lists),
|
|
34
|
+
});
|
|
35
|
+
const context = {
|
|
36
|
+
environment: { NODE_ENV: 'test' },
|
|
37
|
+
databases: await catalogTestProvider(),
|
|
38
|
+
auth: {},
|
|
39
|
+
agentTools: { register: () => undefined },
|
|
40
|
+
dataClasses: { declare: () => undefined },
|
|
41
|
+
capabilities: {
|
|
42
|
+
register: (id: string, value: unknown) => capabilities.set(id, value),
|
|
43
|
+
get: (id: string) => capabilities.get(id) ?? null,
|
|
44
|
+
has: (id: string) => capabilities.has(id),
|
|
45
|
+
},
|
|
46
|
+
} as unknown as PlatformServerContext;
|
|
47
|
+
const composition = createServerComposition(context);
|
|
48
|
+
disposals.add(() => composition.dispose?.() ?? Promise.resolve());
|
|
49
|
+
return composition;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function seed(runtime: CatalogRuntime, tenantId: string, skus: string[]) {
|
|
53
|
+
const service = await runtime.service();
|
|
54
|
+
for (const sku of skus) {
|
|
55
|
+
await service.create(
|
|
56
|
+
tenantId,
|
|
57
|
+
{
|
|
58
|
+
sku,
|
|
59
|
+
name: 'Item ' + sku,
|
|
60
|
+
kind: 'product',
|
|
61
|
+
unit: 'pcs',
|
|
62
|
+
basePriceMinor: 1_000,
|
|
63
|
+
currency: 'PLN',
|
|
64
|
+
},
|
|
65
|
+
ACTOR,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('catalog items list export', () => {
|
|
71
|
+
it('registers catalog.core.items with its columns while composing', async () => {
|
|
72
|
+
const registered = new Map<string, readonly DefinedListExport[]>();
|
|
73
|
+
const composition = await compose(registered);
|
|
74
|
+
composition.start?.();
|
|
75
|
+
const lists = registered.get('catalog.core');
|
|
76
|
+
expect(lists?.map((list) => list.id)).toEqual([
|
|
77
|
+
CATALOG_ITEMS_EXPORT_LIST_ID,
|
|
78
|
+
]);
|
|
79
|
+
expect(lists?.[0]).toMatchObject({
|
|
80
|
+
permission: 'catalog.items.read',
|
|
81
|
+
columns: [
|
|
82
|
+
{ key: 'sku' },
|
|
83
|
+
{ key: 'name' },
|
|
84
|
+
{ key: 'kind' },
|
|
85
|
+
{ key: 'unit' },
|
|
86
|
+
{ key: 'basePriceMinor' },
|
|
87
|
+
{ key: 'currency' },
|
|
88
|
+
{ key: 'status' },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
expect(lists?.[0]?.header.trim()).toBe(
|
|
92
|
+
'SKU,Name,Kind,Unit,Base price (minor units),Currency,Status',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('streams one workspace in SKU order, page by page, and none of another', async () => {
|
|
97
|
+
const registered = new Map<string, readonly DefinedListExport[]>();
|
|
98
|
+
const composition = await compose(registered);
|
|
99
|
+
const list = registered.get('catalog.core')![0]!;
|
|
100
|
+
/* The composition owns its runtime; the rows are seeded through a second
|
|
101
|
+
one over the same cluster. */
|
|
102
|
+
const runtime = createCatalogRuntime({
|
|
103
|
+
databases: await catalogTestProvider(),
|
|
104
|
+
purpose: 'test',
|
|
105
|
+
});
|
|
106
|
+
disposals.add(() => runtime.dispose());
|
|
107
|
+
await seed(runtime, 'tenant-a', ['C-3', 'A-1', 'B-2', 'D-4', 'E-5']);
|
|
108
|
+
await seed(runtime, 'tenant-b', ['Z-9']);
|
|
109
|
+
const principal = {
|
|
110
|
+
accountId: 'account-a',
|
|
111
|
+
tenantId: 'tenant-a',
|
|
112
|
+
scopes: ['catalog.items.read'],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const first = await list.page(principal, null, 2);
|
|
116
|
+
expect(first.rows).toBe(2);
|
|
117
|
+
expect(first.records.map((record) => record.split(',')[0])).toEqual([
|
|
118
|
+
'A-1',
|
|
119
|
+
'B-2',
|
|
120
|
+
]);
|
|
121
|
+
expect(first.nextCursor).not.toBeNull();
|
|
122
|
+
const second = await list.page(principal, first.nextCursor, 2);
|
|
123
|
+
expect(second.records.map((record) => record.split(',')[0])).toEqual([
|
|
124
|
+
'C-3',
|
|
125
|
+
'D-4',
|
|
126
|
+
]);
|
|
127
|
+
const third = await list.page(principal, second.nextCursor, 2);
|
|
128
|
+
expect(third.records.map((record) => record.trim())).toEqual([
|
|
129
|
+
'E-5,Item E-5,product,pcs,1000,PLN,active',
|
|
130
|
+
]);
|
|
131
|
+
expect(third.nextCursor).toBeNull();
|
|
132
|
+
expect(composition.routes.length).toBeGreaterThan(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type CatalogRuntime,
|
|
12
12
|
} from '../src/server/runtime.ts';
|
|
13
13
|
import {
|
|
14
|
+
allItems,
|
|
14
15
|
catalogTestProvider,
|
|
15
16
|
closeCatalogTestDatabases,
|
|
16
17
|
} from './support/database.ts';
|
|
@@ -117,7 +118,7 @@ describe('catalog target idempotency', () => {
|
|
|
117
118
|
)) as CatalogItem;
|
|
118
119
|
expect(replay).toEqual(first);
|
|
119
120
|
await expect(
|
|
120
|
-
(await recoveredRuntime.service()
|
|
121
|
+
allItems(await recoveredRuntime.service(), 'tenant-a'),
|
|
121
122
|
).resolves.toEqual([first]);
|
|
122
123
|
expect(
|
|
123
124
|
(
|
|
@@ -155,7 +156,7 @@ describe('catalog target idempotency', () => {
|
|
|
155
156
|
)) as CatalogItem;
|
|
156
157
|
expect(tenantB.tenantId).toBe('tenant-b');
|
|
157
158
|
await expect(
|
|
158
|
-
(await recoveredRuntime.service()
|
|
159
|
+
allItems(await recoveredRuntime.service(), 'tenant-b'),
|
|
159
160
|
).resolves.toHaveLength(1);
|
|
160
161
|
});
|
|
161
162
|
|
|
@@ -170,16 +171,16 @@ describe('catalog target idempotency', () => {
|
|
|
170
171
|
const actor = { kind: 'user', id: 'owner-1', label: 'Owner' } as const;
|
|
171
172
|
await (await runtime.service()).archive('tenant-a', created.id, actor);
|
|
172
173
|
await (await runtime.service()).delete('tenant-a', created.id, actor);
|
|
173
|
-
await expect(
|
|
174
|
-
|
|
175
|
-
);
|
|
174
|
+
await expect(
|
|
175
|
+
allItems(await runtime.service(), 'tenant-a'),
|
|
176
|
+
).resolves.toEqual([]);
|
|
176
177
|
|
|
177
178
|
expect(
|
|
178
179
|
await create.execute(input, context('catalog-delete-key-1')),
|
|
179
180
|
).toEqual(created);
|
|
180
|
-
await expect(
|
|
181
|
-
|
|
182
|
-
);
|
|
181
|
+
await expect(
|
|
182
|
+
allItems(await runtime.service(), 'tenant-a'),
|
|
183
|
+
).resolves.toEqual([]);
|
|
183
184
|
expect(
|
|
184
185
|
(
|
|
185
186
|
await (
|
|
@@ -234,9 +235,9 @@ describe('catalog target idempotency', () => {
|
|
|
234
235
|
context('catalog-rollback-key-1'),
|
|
235
236
|
),
|
|
236
237
|
).rejects.toThrow(/forced_ledger_failure/);
|
|
237
|
-
await expect(
|
|
238
|
-
|
|
239
|
-
);
|
|
238
|
+
await expect(
|
|
239
|
+
allItems(await runtime.service(), 'tenant-a'),
|
|
240
|
+
).resolves.toEqual([]);
|
|
240
241
|
expect(await rowCounts(databases)).toEqual({
|
|
241
242
|
items: 0,
|
|
242
243
|
history: 0,
|
|
@@ -284,9 +285,9 @@ describe('catalog target idempotency', () => {
|
|
|
284
285
|
context('catalog-corrupt-key-1'),
|
|
285
286
|
),
|
|
286
287
|
).rejects.toMatchObject({ code: 'CATALOG_IDEMPOTENCY_LEDGER_CORRUPT' });
|
|
287
|
-
await expect(
|
|
288
|
-
|
|
289
|
-
);
|
|
288
|
+
await expect(
|
|
289
|
+
allItems(await recovered.service(), 'tenant-a'),
|
|
290
|
+
).resolves.toEqual([]);
|
|
290
291
|
expect(
|
|
291
292
|
await catalogAgentTools(recovered)[1]!.execute(
|
|
292
293
|
input,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CatalogItem, CatalogListSort } from '../src/domain/types.ts';
|
|
3
|
+
import {
|
|
4
|
+
CatalogService,
|
|
5
|
+
FIRST_LIST_PAGE,
|
|
6
|
+
type CatalogListInput,
|
|
7
|
+
} from '../src/services/catalog-service.ts';
|
|
8
|
+
import { listStatement } from '../src/services/database-repository.ts';
|
|
9
|
+
import {
|
|
10
|
+
closeCatalogTestDatabases,
|
|
11
|
+
createCatalogTestDatabase,
|
|
12
|
+
type CatalogTestDatabase,
|
|
13
|
+
} from './support/database.ts';
|
|
14
|
+
|
|
15
|
+
const ACTOR = { kind: 'user', id: 'account-1', label: 'Test user' } as const;
|
|
16
|
+
|
|
17
|
+
const databases = new Set<CatalogTestDatabase>();
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await Promise.all([...databases].map((database) => database.dispose()));
|
|
21
|
+
databases.clear();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(closeCatalogTestDatabases);
|
|
25
|
+
|
|
26
|
+
async function fixture(): Promise<CatalogTestDatabase> {
|
|
27
|
+
const database = await createCatalogTestDatabase();
|
|
28
|
+
databases.add(database);
|
|
29
|
+
return database;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Names collide in case and prefix, SKUs sort differently from names, and the
|
|
33
|
+
update times are spread by the mutations below, so each order is its own. */
|
|
34
|
+
const SEED = [
|
|
35
|
+
['B-2', 'bolt', 'product'],
|
|
36
|
+
['A-1', 'Bolt', 'product'],
|
|
37
|
+
['C-3', 'Anchor', 'service'],
|
|
38
|
+
['D-4', 'anchor plate', 'product'],
|
|
39
|
+
['E-5', 'Washer', 'service'],
|
|
40
|
+
['F-6', 'washer_wide', 'product'],
|
|
41
|
+
['G-7', '50% nut', 'product'],
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
async function seed(
|
|
45
|
+
service: CatalogService,
|
|
46
|
+
tenantId = 'tenant-a',
|
|
47
|
+
): Promise<readonly CatalogItem[]> {
|
|
48
|
+
const items: CatalogItem[] = [];
|
|
49
|
+
for (const [sku, name, kind] of SEED) {
|
|
50
|
+
items.push(
|
|
51
|
+
await service.create(
|
|
52
|
+
tenantId,
|
|
53
|
+
{ sku, name, kind, unit: 'pcs', basePriceMinor: 100, currency: 'EUR' },
|
|
54
|
+
ACTOR,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return items;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The unbounded order, read once by the same ORDER BY the page uses. */
|
|
62
|
+
async function unbounded(
|
|
63
|
+
database: CatalogTestDatabase,
|
|
64
|
+
sort: CatalogListSort,
|
|
65
|
+
direction: 'asc' | 'desc',
|
|
66
|
+
tenantId = 'tenant-a',
|
|
67
|
+
): Promise<readonly string[]> {
|
|
68
|
+
const expression = {
|
|
69
|
+
name: 'lower(name)',
|
|
70
|
+
sku: 'sku_normalized',
|
|
71
|
+
updatedAt: 'updated_at',
|
|
72
|
+
}[sort];
|
|
73
|
+
const order = direction === 'asc' ? 'ASC' : 'DESC';
|
|
74
|
+
const result = await database.runtime.transaction(
|
|
75
|
+
(transaction) =>
|
|
76
|
+
transaction.query<{ id: string }>({
|
|
77
|
+
text: `SELECT id FROM catalog_items WHERE tenant_id = $1
|
|
78
|
+
ORDER BY ${expression} ${order}, id ${order}`,
|
|
79
|
+
parameters: [tenantId],
|
|
80
|
+
}),
|
|
81
|
+
{ access: 'read', tenantId },
|
|
82
|
+
);
|
|
83
|
+
return result.rows.map((row) => row.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function walk(
|
|
87
|
+
service: CatalogService,
|
|
88
|
+
input: Omit<CatalogListInput, 'after'>,
|
|
89
|
+
): Promise<readonly string[]> {
|
|
90
|
+
const ids: string[] = [];
|
|
91
|
+
let after: CatalogListInput['after'] = null;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const page = await service.listPage('tenant-a', { ...input, after });
|
|
94
|
+
ids.push(...page.items.map((item) => item.id));
|
|
95
|
+
if (page.next === null) return ids;
|
|
96
|
+
after = page.next;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('catalog list pages', () => {
|
|
101
|
+
it('walks every order in pages of two exactly as the unbounded order reads', async () => {
|
|
102
|
+
const database = await fixture();
|
|
103
|
+
const service = new CatalogService(database.repository);
|
|
104
|
+
const items = await seed(service);
|
|
105
|
+
await seed(service, 'tenant-b');
|
|
106
|
+
/* Spread the update times so the recency order differs from creation. */
|
|
107
|
+
await service.archive('tenant-a', items[2]!.id, ACTOR);
|
|
108
|
+
await service.update(
|
|
109
|
+
'tenant-a',
|
|
110
|
+
{ ...items[5]!, name: 'washer_wide', basePriceMinor: 120 },
|
|
111
|
+
ACTOR,
|
|
112
|
+
);
|
|
113
|
+
for (const sort of ['name', 'sku', 'updatedAt'] as const) {
|
|
114
|
+
for (const direction of ['asc', 'desc'] as const) {
|
|
115
|
+
const expected = await unbounded(database, sort, direction);
|
|
116
|
+
expect(expected, `${sort} ${direction}`).toHaveLength(SEED.length);
|
|
117
|
+
expect(
|
|
118
|
+
await walk(service, {
|
|
119
|
+
...FIRST_LIST_PAGE,
|
|
120
|
+
sort,
|
|
121
|
+
direction,
|
|
122
|
+
limit: 2,
|
|
123
|
+
}),
|
|
124
|
+
`${sort} ${direction}`,
|
|
125
|
+
).toEqual(expected);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('hands a cursor back only on a full page', async () => {
|
|
131
|
+
const service = new CatalogService((await fixture()).repository);
|
|
132
|
+
await seed(service);
|
|
133
|
+
const full = await service.listPage('tenant-a', {
|
|
134
|
+
...FIRST_LIST_PAGE,
|
|
135
|
+
limit: SEED.length,
|
|
136
|
+
});
|
|
137
|
+
expect(full.items).toHaveLength(SEED.length);
|
|
138
|
+
expect(full.next).not.toBeNull();
|
|
139
|
+
const rest = await service.listPage('tenant-a', {
|
|
140
|
+
...FIRST_LIST_PAGE,
|
|
141
|
+
limit: SEED.length,
|
|
142
|
+
after: full.next,
|
|
143
|
+
});
|
|
144
|
+
expect(rest).toEqual({ items: [], next: null });
|
|
145
|
+
const short = await service.listPage('tenant-a', {
|
|
146
|
+
...FIRST_LIST_PAGE,
|
|
147
|
+
limit: SEED.length + 1,
|
|
148
|
+
});
|
|
149
|
+
expect(short.items).toHaveLength(SEED.length);
|
|
150
|
+
expect(short.next).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('narrows by kind, status and a literal search in SQL', async () => {
|
|
154
|
+
const service = new CatalogService((await fixture()).repository);
|
|
155
|
+
const items = await seed(service);
|
|
156
|
+
await service.archive('tenant-a', items[0]!.id, ACTOR);
|
|
157
|
+
const skus = async (input: Partial<CatalogListInput>) =>
|
|
158
|
+
(
|
|
159
|
+
await service.listPage('tenant-a', { ...FIRST_LIST_PAGE, ...input })
|
|
160
|
+
).items.map((item) => item.sku);
|
|
161
|
+
|
|
162
|
+
expect(await skus({ kind: 'service', sort: 'sku' })).toEqual([
|
|
163
|
+
'C-3',
|
|
164
|
+
'E-5',
|
|
165
|
+
]);
|
|
166
|
+
expect(await skus({ status: 'archived' })).toEqual(['B-2']);
|
|
167
|
+
expect(
|
|
168
|
+
await skus({ status: 'active', kind: 'product', sort: 'sku' }),
|
|
169
|
+
).toEqual(['A-1', 'D-4', 'F-6', 'G-7']);
|
|
170
|
+
expect(await skus({ search: 'BOLT', sort: 'sku' })).toEqual(['A-1', 'B-2']);
|
|
171
|
+
expect(await skus({ search: 'a-1' })).toEqual(['A-1']);
|
|
172
|
+
expect(await skus({ search: '%' })).toEqual(['G-7']);
|
|
173
|
+
expect(await skus({ search: '_wide' })).toEqual(['F-6']);
|
|
174
|
+
expect(await skus({ search: 'nothing-here' })).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('refuses an unknown sort, direction, limit or filter before reading', async () => {
|
|
178
|
+
const service = new CatalogService((await fixture()).repository);
|
|
179
|
+
await expect(
|
|
180
|
+
service.listPage('tenant-a', {
|
|
181
|
+
...FIRST_LIST_PAGE,
|
|
182
|
+
sort: 'price' as CatalogListSort,
|
|
183
|
+
}),
|
|
184
|
+
).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
|
185
|
+
await expect(
|
|
186
|
+
service.listPage('tenant-a', { ...FIRST_LIST_PAGE, limit: 201 }),
|
|
187
|
+
).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
|
188
|
+
await expect(
|
|
189
|
+
service.listPage('tenant-a', {
|
|
190
|
+
...FIRST_LIST_PAGE,
|
|
191
|
+
kind: 'bundle' as CatalogItem['kind'],
|
|
192
|
+
}),
|
|
193
|
+
).rejects.toMatchObject({ code: 'INVALID_ITEM_KIND' });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('orders by the indexed expression and binds every value', () => {
|
|
197
|
+
const statement = listStatement('tenant-a', {
|
|
198
|
+
sort: 'name',
|
|
199
|
+
direction: 'desc',
|
|
200
|
+
kind: 'product',
|
|
201
|
+
status: null,
|
|
202
|
+
term: 'bo%lt',
|
|
203
|
+
limit: 50,
|
|
204
|
+
after: { sortValue: 'bolt', id: 'item-1' },
|
|
205
|
+
});
|
|
206
|
+
expect(statement.text).toContain('ORDER BY lower(name) DESC, id DESC');
|
|
207
|
+
expect(statement.text).toContain('(lower(name), id) <');
|
|
208
|
+
expect(statement.parameters).toEqual([
|
|
209
|
+
'tenant-a',
|
|
210
|
+
'product',
|
|
211
|
+
'%bo\\%lt%',
|
|
212
|
+
'bolt',
|
|
213
|
+
'item-1',
|
|
214
|
+
50,
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
});
|