create-flowdular 0.2.4 → 0.2.6
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 +11 -0
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +203 -0
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +90 -0
- package/agent-template/.agents/skills/auto-review/SKILL.md +103 -0
- package/agent-template/.agents/skills/bug-hunt/SKILL.md +104 -0
- package/agent-template/.agents/skills/business-agent-design/SKILL.md +182 -0
- package/agent-template/.agents/skills/cli-extension/SKILL.md +108 -0
- package/agent-template/.agents/skills/core-extend/SKILL.md +99 -0
- package/agent-template/.agents/skills/database-adapter/SKILL.md +198 -0
- package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/agent-template/.agents/skills/migration-authoring/SKILL.md +161 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +171 -0
- package/agent-template/.agents/skills/module-update/SKILL.md +91 -0
- package/agent-template/.agents/skills/perf-audit/SKILL.md +98 -0
- package/agent-template/.agents/skills/release-eject-pr/SKILL.md +107 -0
- package/agent-template/.agents/skills/spec-approval/SKILL.md +106 -0
- package/agent-template/.agents/skills/test-hardening/SKILL.md +79 -0
- package/agent-template/.agents/skills/translations-i18n/SKILL.md +78 -0
- package/agent-template/.agents/skills/ux-design/SKILL.md +92 -0
- package/agent-template/.agents/skills/variables/SKILL.md +156 -0
- package/agent-template/.agents/skills/workflow-development/SKILL.md +192 -0
- package/agent-template/.ai/README.md +62 -0
- package/agent-template/.ai/agents/README.md +27 -0
- package/agent-template/.ai/agents/module-executor.md +36 -0
- package/agent-template/.ai/agents/reviewer.md +23 -0
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +31 -0
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +36 -0
- package/agent-template/.ai/agents/sandbox/business-manager.md +23 -0
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +27 -0
- package/agent-template/.ai/agents/sandbox/ux-designer.md +23 -0
- package/agent-template/.ai/agents/spec-author.md +29 -0
- package/agent-template/.ai/blueprints/add-migration/README.md +5 -0
- package/agent-template/.ai/blueprints/add-migration/allowed-paths.yaml +23 -0
- package/agent-template/.ai/blueprints/add-migration/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/add-migration/examples/invalid/input-destructive.json +6 -0
- package/agent-template/.ai/blueprints/add-migration/examples/invalid/plan-unnumbered-file.json +9 -0
- package/agent-template/.ai/blueprints/add-migration/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/add-migration/examples/valid/plan.json +9 -0
- package/agent-template/.ai/blueprints/add-migration/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/add-migration/input.schema.json +23 -0
- package/agent-template/.ai/blueprints/add-migration/plan.schema.json +54 -0
- package/agent-template/.ai/blueprints/add-migration/required-files.yaml +18 -0
- package/agent-template/.ai/blueprints/add-migration/spec-requirements.yaml +13 -0
- package/agent-template/.ai/blueprints/add-migration/steps.yaml +62 -0
- package/agent-template/.ai/blueprints/author-spec/README.md +5 -0
- package/agent-template/.ai/blueprints/author-spec/allowed-paths.yaml +7 -0
- package/agent-template/.ai/blueprints/author-spec/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/author-spec/examples/invalid/input-missing-outcome.json +5 -0
- package/agent-template/.ai/blueprints/author-spec/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/author-spec/gates.yaml +13 -0
- package/agent-template/.ai/blueprints/author-spec/input.schema.json +20 -0
- package/agent-template/.ai/blueprints/author-spec/plan.schema.json +14 -0
- package/agent-template/.ai/blueprints/author-spec/required-files.yaml +6 -0
- package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +35 -0
- package/agent-template/.ai/blueprints/author-spec/steps.yaml +28 -0
- package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +45 -0
- package/agent-template/.ai/blueprints/bug-fix/README.md +5 -0
- package/agent-template/.ai/blueprints/bug-fix/allowed-paths.yaml +29 -0
- package/agent-template/.ai/blueprints/bug-fix/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/invalid/input-no-symptom.json +4 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/invalid/plan-no-test.json +8 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/bug-fix/examples/valid/plan.json +8 -0
- package/agent-template/.ai/blueprints/bug-fix/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/bug-fix/input.schema.json +25 -0
- package/agent-template/.ai/blueprints/bug-fix/plan.schema.json +53 -0
- package/agent-template/.ai/blueprints/bug-fix/required-files.yaml +7 -0
- package/agent-template/.ai/blueprints/bug-fix/spec-requirements.yaml +7 -0
- package/agent-template/.ai/blueprints/bug-fix/steps.yaml +51 -0
- package/agent-template/.ai/blueprints/core-extend/README.md +5 -0
- package/agent-template/.ai/blueprints/core-extend/allowed-paths.yaml +49 -0
- package/agent-template/.ai/blueprints/core-extend/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/core-extend/examples/invalid/input-unknown-package.json +5 -0
- package/agent-template/.ai/blueprints/core-extend/examples/invalid/plan-missing-gates.json +7 -0
- package/agent-template/.ai/blueprints/core-extend/examples/valid/input.json +6 -0
- package/agent-template/.ai/blueprints/core-extend/examples/valid/plan.json +10 -0
- package/agent-template/.ai/blueprints/core-extend/gates.yaml +16 -0
- package/agent-template/.ai/blueprints/core-extend/input.schema.json +54 -0
- package/agent-template/.ai/blueprints/core-extend/plan.schema.json +39 -0
- package/agent-template/.ai/blueprints/core-extend/required-files.yaml +36 -0
- package/agent-template/.ai/blueprints/core-extend/spec-requirements.yaml +17 -0
- package/agent-template/.ai/blueprints/core-extend/steps.yaml +54 -0
- package/agent-template/.ai/blueprints/edit-module/README.md +9 -0
- package/agent-template/.ai/blueprints/edit-module/allowed-paths.yaml +27 -0
- package/agent-template/.ai/blueprints/edit-module/blueprint.json +20 -0
- package/agent-template/.ai/blueprints/edit-module/examples/invalid/input-unknown-change.json +5 -0
- package/agent-template/.ai/blueprints/edit-module/examples/invalid/plan-touches-platform.json +15 -0
- package/agent-template/.ai/blueprints/edit-module/examples/valid/input.json +5 -0
- package/agent-template/.ai/blueprints/edit-module/examples/valid/plan.json +25 -0
- package/agent-template/.ai/blueprints/edit-module/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/edit-module/input.schema.json +31 -0
- package/agent-template/.ai/blueprints/edit-module/plan.schema.json +65 -0
- package/agent-template/.ai/blueprints/edit-module/required-files.yaml +80 -0
- package/agent-template/.ai/blueprints/edit-module/spec-requirements.yaml +15 -0
- package/agent-template/.ai/blueprints/edit-module/steps.yaml +115 -0
- package/agent-template/.ai/blueprints/new-module/README.md +7 -0
- package/agent-template/.ai/blueprints/new-module/allowed-paths.yaml +27 -0
- package/agent-template/.ai/blueprints/new-module/blueprint.json +20 -0
- package/agent-template/.ai/blueprints/new-module/examples/invalid/input-spec-outside-modules.json +4 -0
- package/agent-template/.ai/blueprints/new-module/examples/invalid/plan-unknown-gate.json +8 -0
- package/agent-template/.ai/blueprints/new-module/examples/valid/input.json +5 -0
- package/agent-template/.ai/blueprints/new-module/examples/valid/plan.json +22 -0
- package/agent-template/.ai/blueprints/new-module/gates.yaml +30 -0
- package/agent-template/.ai/blueprints/new-module/input.schema.json +21 -0
- package/agent-template/.ai/blueprints/new-module/plan.schema.json +58 -0
- package/agent-template/.ai/blueprints/new-module/required-files.yaml +73 -0
- package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +30 -0
- package/agent-template/.ai/blueprints/new-module/steps.yaml +138 -0
- package/agent-template/.ai/blueprints/release/README.md +5 -0
- package/agent-template/.ai/blueprints/release/allowed-paths.yaml +19 -0
- package/agent-template/.ai/blueprints/release/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/release/examples/invalid/input-bad-version.json +4 -0
- package/agent-template/.ai/blueprints/release/examples/invalid/plan-bad-branch.json +7 -0
- package/agent-template/.ai/blueprints/release/examples/valid/input.json +5 -0
- package/agent-template/.ai/blueprints/release/examples/valid/plan.json +20 -0
- package/agent-template/.ai/blueprints/release/gates.yaml +20 -0
- package/agent-template/.ai/blueprints/release/input.schema.json +24 -0
- package/agent-template/.ai/blueprints/release/plan.schema.json +46 -0
- package/agent-template/.ai/blueprints/release/required-files.yaml +19 -0
- package/agent-template/.ai/blueprints/release/spec-requirements.yaml +8 -0
- package/agent-template/.ai/blueprints/release/steps.yaml +47 -0
- package/agent-template/.ai/blueprints/security-review/README.md +5 -0
- package/agent-template/.ai/blueprints/security-review/allowed-paths.yaml +6 -0
- package/agent-template/.ai/blueprints/security-review/blueprint.json +14 -0
- package/agent-template/.ai/blueprints/security-review/examples/invalid/input-unknown-kind.json +4 -0
- package/agent-template/.ai/blueprints/security-review/examples/invalid/plan-finding-without-scenario.json +14 -0
- package/agent-template/.ai/blueprints/security-review/examples/valid/input.json +4 -0
- package/agent-template/.ai/blueprints/security-review/examples/valid/plan.json +19 -0
- package/agent-template/.ai/blueprints/security-review/gates.yaml +22 -0
- package/agent-template/.ai/blueprints/security-review/input.schema.json +20 -0
- package/agent-template/.ai/blueprints/security-review/plan.schema.json +65 -0
- package/agent-template/.ai/blueprints/security-review/required-files.yaml +6 -0
- package/agent-template/.ai/blueprints/security-review/spec-requirements.yaml +9 -0
- package/agent-template/.ai/blueprints/security-review/steps.yaml +38 -0
- package/agent-template/.ai/examples/README.md +8 -0
- package/agent-template/.ai/examples/bad/client-imports-server/README.md +20 -0
- package/agent-template/.ai/examples/bad/client-imports-server/api.ts +12 -0
- package/agent-template/.ai/examples/bad/missing-acl/README.md +23 -0
- package/agent-template/.ai/examples/bad/missing-acl/endpoints.ts +12 -0
- package/agent-template/.ai/examples/bad/tenant-from-body/README.md +19 -0
- package/agent-template/.ai/examples/bad/tenant-from-body/endpoints.ts +33 -0
- package/agent-template/.ai/examples/client-contribution/CustomerListView.tsrx +34 -0
- package/agent-template/.ai/examples/client-contribution/README.md +11 -0
- package/agent-template/.ai/examples/client-contribution/contribution.tsrx +48 -0
- package/agent-template/.ai/examples/client-contribution/index.ts +20 -0
- package/agent-template/.ai/examples/client-contribution/permissions.ts +8 -0
- package/agent-template/.ai/examples/customer-cli-extension/README.md +14 -0
- package/agent-template/.ai/examples/customer-cli-extension/commands.json +17 -0
- package/agent-template/.ai/examples/customer-cli-extension/index.ts +36 -0
- package/agent-template/.ai/examples/module-create/task-packet.json +11 -0
- package/agent-template/.ai/guides/application-development.md +97 -0
- package/agent-template/.ai/policies/capabilities.yaml +164 -0
- package/agent-template/.ai/policies/model-routing.yaml +72 -0
- package/agent-template/.ai/policies/path-ownership.yaml +65 -0
- package/agent-template/.ai/policies/task-budgets.yaml +37 -0
- package/agent-template/.ai/references/catalog/LICENSE +21 -0
- package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.down.sql +2 -0
- package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.up.sql +21 -0
- package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.up.sql +20 -0
- package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +36 -0
- package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +19 -0
- package/agent-template/.ai/references/catalog/migrations/README.md +3 -0
- package/agent-template/.ai/references/catalog/module.json +27 -0
- package/agent-template/.ai/references/catalog/package.json +49 -0
- package/agent-template/.ai/references/catalog/spec/module.yaml +86 -0
- package/agent-template/.ai/references/catalog/src/acl/permissions.ts +6 -0
- package/agent-template/.ai/references/catalog/src/agent/tools.ts +164 -0
- package/agent-template/.ai/references/catalog/src/api/endpoints.ts +243 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogHistoryDrawer.tsrx +123 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +190 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogView.tsrx +473 -0
- package/agent-template/.ai/references/catalog/src/client/api.ts +111 -0
- package/agent-template/.ai/references/catalog/src/client/contribution.tsrx +61 -0
- package/agent-template/.ai/references/catalog/src/client/index.ts +18 -0
- package/agent-template/.ai/references/catalog/src/client/navigation-copy.ts +9 -0
- package/agent-template/.ai/references/catalog/src/client/state.ts +24 -0
- package/agent-template/.ai/references/catalog/src/domain/types.ts +32 -0
- package/agent-template/.ai/references/catalog/src/domain/variables.ts +111 -0
- package/agent-template/.ai/references/catalog/src/index.ts +31 -0
- package/agent-template/.ai/references/catalog/src/platform.ts +35 -0
- package/agent-template/.ai/references/catalog/src/server/index.ts +4 -0
- package/agent-template/.ai/references/catalog/src/server/runtime.ts +86 -0
- package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +306 -0
- package/agent-template/.ai/references/catalog/src/services/database-repository.ts +440 -0
- package/agent-template/.ai/references/catalog/src/services/index.ts +4 -0
- package/agent-template/.ai/references/catalog/src/services/migration.ts +171 -0
- package/agent-template/.ai/references/catalog/src/services/repository.ts +36 -0
- package/agent-template/.ai/references/catalog/src/services/target-idempotency.ts +59 -0
- package/agent-template/.ai/references/catalog/tests/agent-tools.test.ts +277 -0
- package/agent-template/.ai/references/catalog/tests/endpoints.test.ts +320 -0
- package/agent-template/.ai/references/catalog/tests/idempotency.test.ts +297 -0
- package/agent-template/.ai/references/catalog/tests/migrations.test.ts +149 -0
- package/agent-template/.ai/references/catalog/tests/module.test.ts +271 -0
- package/agent-template/.ai/references/catalog/tests/support/database.ts +76 -0
- package/agent-template/.ai/references/catalog/translations/en.json +101 -0
- package/agent-template/.ai/references/catalog/translations/pl.json +101 -0
- package/agent-template/.ai/references/catalog/tsconfig.json +15 -0
- package/agent-template/.ai/references/catalog/vitest.config.ts +16 -0
- package/agent-template/.ai/references/catalog.provenance.json +55 -0
- package/agent-template/.ai/rules/flowdular.md +86 -0
- package/agent-template/.ai/skills/README.md +36 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +209 -0
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +96 -0
- package/agent-template/.ai/skills/auto-review/SKILL.md +112 -0
- package/agent-template/.ai/skills/bug-hunt/SKILL.md +110 -0
- package/agent-template/.ai/skills/business-agent-design/SKILL.md +188 -0
- package/agent-template/.ai/skills/cli-extension/SKILL.md +114 -0
- package/agent-template/.ai/skills/core-extend/SKILL.md +104 -0
- package/agent-template/.ai/skills/database-adapter/SKILL.md +204 -0
- package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/agent-template/.ai/skills/migration-authoring/SKILL.md +167 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +180 -0
- package/agent-template/.ai/skills/module-update/SKILL.md +100 -0
- package/agent-template/.ai/skills/perf-audit/SKILL.md +105 -0
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +113 -0
- package/agent-template/.ai/skills/spec-approval/SKILL.md +112 -0
- package/agent-template/.ai/skills/test-hardening/SKILL.md +86 -0
- package/agent-template/.ai/skills/translations-i18n/SKILL.md +85 -0
- package/agent-template/.ai/skills/ux-design/SKILL.md +97 -0
- package/agent-template/.ai/skills/variables/SKILL.md +164 -0
- package/agent-template/.ai/skills/workflow-development/SKILL.md +199 -0
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +203 -0
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +90 -0
- package/agent-template/.claude/skills/auto-review/SKILL.md +103 -0
- package/agent-template/.claude/skills/bug-hunt/SKILL.md +104 -0
- package/agent-template/.claude/skills/business-agent-design/SKILL.md +182 -0
- package/agent-template/.claude/skills/cli-extension/SKILL.md +108 -0
- package/agent-template/.claude/skills/core-extend/SKILL.md +99 -0
- package/agent-template/.claude/skills/database-adapter/SKILL.md +198 -0
- package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/agent-template/.claude/skills/migration-authoring/SKILL.md +161 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +171 -0
- package/agent-template/.claude/skills/module-update/SKILL.md +91 -0
- package/agent-template/.claude/skills/perf-audit/SKILL.md +98 -0
- package/agent-template/.claude/skills/release-eject-pr/SKILL.md +107 -0
- package/agent-template/.claude/skills/spec-approval/SKILL.md +106 -0
- package/agent-template/.claude/skills/test-hardening/SKILL.md +79 -0
- package/agent-template/.claude/skills/translations-i18n/SKILL.md +78 -0
- package/agent-template/.claude/skills/ux-design/SKILL.md +92 -0
- package/agent-template/.claude/skills/variables/SKILL.md +156 -0
- package/agent-template/.claude/skills/workflow-development/SKILL.md +192 -0
- package/agent-template/AGENTS.md +77 -0
- package/agent-template/CLAUDE.md +77 -0
- package/agent-template/docs/adr/0001-development-reload.md +16 -0
- package/agent-template/docs/adr/0002-durable-agent-execution.md +21 -0
- package/agent-template/docs/adr/0003-module-settings.md +22 -0
- package/agent-template/docs/adr/0004-enterprise-access-and-audit.md +36 -0
- package/agent-template/docs/adr/0005-sandbox-runtime-and-coding-agents.md +81 -0
- package/agent-template/docs/adr/0006-agentic-workflows.md +1702 -0
- package/agent-template/docs/adr/0007-module-owned-agents.md +429 -0
- package/agent-template/docs/adr/0008-database-adapter-contract.md +90 -0
- package/agent-template/docs/agent-contract.md +45 -0
- package/agent-template/docs/configuration.md +122 -0
- package/agent-template/docs/database-adapters.md +346 -0
- package/agent-template/docs/design-system.md +217 -0
- package/agent-template/docs/modules.md +146 -0
- package/agent-template/platform/scripts/build.mjs +38 -0
- package/agent-template/rulesync.jsonc +11 -0
- package/dist/bin.js +40 -2
- package/package.json +3 -2
- package/template/default/.prettierignore +9 -0
- package/template/default/README.md +12 -0
- package/template/default/flowdular.json +3 -3
- package/template/default/modules/example/package.json +2 -2
- package/template/default/package.json +6 -2
- package/template/default/platform/octane.config.ts +17 -6
- package/template/default/platform/package.json +3 -2
- package/template/default/pnpm-workspace.yaml +1 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DatabaseSession,
|
|
3
|
+
DatabaseProvider,
|
|
4
|
+
} from '@flowdular/sdk/database';
|
|
5
|
+
import type { AgentToolContext } from '@flowdular/sdk/harness/runtime';
|
|
6
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { catalogAgentTools } from '../src/agent/tools.ts';
|
|
8
|
+
import type { CatalogItem } from '../src/domain/types.ts';
|
|
9
|
+
import {
|
|
10
|
+
createCatalogRuntime,
|
|
11
|
+
type CatalogRuntime,
|
|
12
|
+
} from '../src/server/runtime.ts';
|
|
13
|
+
import {
|
|
14
|
+
catalogTestProvider,
|
|
15
|
+
closeCatalogTestDatabases,
|
|
16
|
+
} from './support/database.ts';
|
|
17
|
+
|
|
18
|
+
const runtimes = new Set<CatalogRuntime>();
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all([...runtimes].map((runtime) => runtime.dispose()));
|
|
22
|
+
runtimes.clear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(closeCatalogTestDatabases);
|
|
26
|
+
|
|
27
|
+
/* A second runtime on the same cluster is what a restart looks like to the
|
|
28
|
+
module: the process is new, the durable ledger is not. */
|
|
29
|
+
function catalogRuntime(databases: DatabaseProvider): CatalogRuntime {
|
|
30
|
+
const runtime = createCatalogRuntime({ databases, purpose: 'test' });
|
|
31
|
+
runtimes.add(runtime);
|
|
32
|
+
return runtime;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* DDL needs the owner; tenant data still needs a tenant, even for its owner. */
|
|
36
|
+
async function withMigrationLease<T>(
|
|
37
|
+
databases: DatabaseProvider,
|
|
38
|
+
work: (database: DatabaseSession) => Promise<T>,
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
const lease = await databases.acquire({
|
|
41
|
+
namespace: 'catalog.core',
|
|
42
|
+
purpose: 'migration',
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
return await lease.database.transaction(work, {
|
|
46
|
+
tenantId: 'tenant-a',
|
|
47
|
+
access: 'write',
|
|
48
|
+
});
|
|
49
|
+
} finally {
|
|
50
|
+
await lease.release();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function rowCounts(
|
|
55
|
+
databases: DatabaseProvider,
|
|
56
|
+
): Promise<Record<string, number>> {
|
|
57
|
+
const row = await withMigrationLease(
|
|
58
|
+
databases,
|
|
59
|
+
async (database) =>
|
|
60
|
+
(
|
|
61
|
+
await database.query<Record<string, string>>({
|
|
62
|
+
text: `SELECT (SELECT count(*) FROM catalog_items) AS items,
|
|
63
|
+
(SELECT count(*) FROM catalog_items_history_v2) AS history,
|
|
64
|
+
(SELECT count(*) FROM catalog_idempotency_ledger) AS ledger`,
|
|
65
|
+
})
|
|
66
|
+
).rows[0],
|
|
67
|
+
);
|
|
68
|
+
return {
|
|
69
|
+
items: Number(row?.items),
|
|
70
|
+
history: Number(row?.history),
|
|
71
|
+
ledger: Number(row?.ledger),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function context(key: string, tenantId = 'tenant-a'): AgentToolContext {
|
|
76
|
+
return {
|
|
77
|
+
runId: 'run-1',
|
|
78
|
+
tenantId,
|
|
79
|
+
requestedBy: 'account-1',
|
|
80
|
+
idempotencyKey: key,
|
|
81
|
+
actor: {
|
|
82
|
+
kind: 'agent',
|
|
83
|
+
id: 'catalog-agent',
|
|
84
|
+
label: 'Catalog agent',
|
|
85
|
+
runId: 'run-1',
|
|
86
|
+
},
|
|
87
|
+
permissions: new Set(['catalog.items.manage']),
|
|
88
|
+
signal: new AbortController().signal,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const input = {
|
|
93
|
+
sku: 'SKU-100',
|
|
94
|
+
name: 'Steel bolt',
|
|
95
|
+
kind: 'product' as const,
|
|
96
|
+
unit: 'pcs',
|
|
97
|
+
basePriceMinor: 250,
|
|
98
|
+
currency: 'EUR',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
describe('catalog target idempotency', () => {
|
|
102
|
+
it('returns the first result after restart and refuses key reuse for another input or operation', async () => {
|
|
103
|
+
const databases = await catalogTestProvider();
|
|
104
|
+
const firstRuntime = catalogRuntime(databases);
|
|
105
|
+
const create = catalogAgentTools(firstRuntime)[1]!;
|
|
106
|
+
const first = (await create.execute(
|
|
107
|
+
{ ...input, sku: ' sku-100 ', name: ' Steel bolt ', currency: 'eur' },
|
|
108
|
+
context('catalog-create-key-1'),
|
|
109
|
+
)) as CatalogItem;
|
|
110
|
+
await firstRuntime.dispose();
|
|
111
|
+
|
|
112
|
+
const recoveredRuntime = catalogRuntime(databases);
|
|
113
|
+
const recoveredCreate = catalogAgentTools(recoveredRuntime)[1]!;
|
|
114
|
+
const replay = (await recoveredCreate.execute(
|
|
115
|
+
input,
|
|
116
|
+
context('catalog-create-key-1'),
|
|
117
|
+
)) as CatalogItem;
|
|
118
|
+
expect(replay).toEqual(first);
|
|
119
|
+
await expect(
|
|
120
|
+
(await recoveredRuntime.service()).list('tenant-a'),
|
|
121
|
+
).resolves.toEqual([first]);
|
|
122
|
+
expect(
|
|
123
|
+
(
|
|
124
|
+
await (
|
|
125
|
+
await recoveredRuntime.service()
|
|
126
|
+
).history('tenant-a', {
|
|
127
|
+
recordId: first.id,
|
|
128
|
+
limit: 10,
|
|
129
|
+
cursor: null,
|
|
130
|
+
})
|
|
131
|
+
).entries,
|
|
132
|
+
).toHaveLength(1);
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
recoveredCreate.execute(
|
|
136
|
+
{ ...input, name: 'Different item' },
|
|
137
|
+
context('catalog-create-key-1'),
|
|
138
|
+
),
|
|
139
|
+
).rejects.toMatchObject({ code: 'CATALOG_IDEMPOTENCY_CONFLICT' });
|
|
140
|
+
await expect(
|
|
141
|
+
(await recoveredRuntime.service()).createIdempotent(
|
|
142
|
+
'tenant-a',
|
|
143
|
+
input,
|
|
144
|
+
context('catalog-create-key-1').actor!,
|
|
145
|
+
{
|
|
146
|
+
key: 'catalog-create-key-1',
|
|
147
|
+
operationId: 'catalog.item.create@2',
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
).rejects.toMatchObject({ code: 'CATALOG_IDEMPOTENCY_CONFLICT' });
|
|
151
|
+
|
|
152
|
+
const tenantB = (await recoveredCreate.execute(
|
|
153
|
+
input,
|
|
154
|
+
context('catalog-create-key-1', 'tenant-b'),
|
|
155
|
+
)) as CatalogItem;
|
|
156
|
+
expect(tenantB.tenantId).toBe('tenant-b');
|
|
157
|
+
await expect(
|
|
158
|
+
(await recoveredRuntime.service()).list('tenant-b'),
|
|
159
|
+
).resolves.toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('keeps the ledger after deletion without adding history on replay', async () => {
|
|
163
|
+
const databases = await catalogTestProvider();
|
|
164
|
+
const runtime = catalogRuntime(databases);
|
|
165
|
+
const create = catalogAgentTools(runtime)[1]!;
|
|
166
|
+
const created = (await create.execute(
|
|
167
|
+
input,
|
|
168
|
+
context('catalog-delete-key-1'),
|
|
169
|
+
)) as CatalogItem;
|
|
170
|
+
const actor = { kind: 'user', id: 'owner-1', label: 'Owner' } as const;
|
|
171
|
+
await (await runtime.service()).archive('tenant-a', created.id, actor);
|
|
172
|
+
await (await runtime.service()).delete('tenant-a', created.id, actor);
|
|
173
|
+
await expect((await runtime.service()).list('tenant-a')).resolves.toEqual(
|
|
174
|
+
[],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
await create.execute(input, context('catalog-delete-key-1')),
|
|
179
|
+
).toEqual(created);
|
|
180
|
+
await expect((await runtime.service()).list('tenant-a')).resolves.toEqual(
|
|
181
|
+
[],
|
|
182
|
+
);
|
|
183
|
+
expect(
|
|
184
|
+
(
|
|
185
|
+
await (
|
|
186
|
+
await runtime.service()
|
|
187
|
+
).history('tenant-a', {
|
|
188
|
+
recordId: created.id,
|
|
189
|
+
limit: 10,
|
|
190
|
+
cursor: null,
|
|
191
|
+
})
|
|
192
|
+
).entries.map((entry) => entry.action),
|
|
193
|
+
).toEqual(['deleted', 'archived', 'created']);
|
|
194
|
+
|
|
195
|
+
const evidence = await withMigrationLease(
|
|
196
|
+
databases,
|
|
197
|
+
async (database) =>
|
|
198
|
+
(
|
|
199
|
+
await database.query<Record<string, unknown>>({
|
|
200
|
+
text: `SELECT tenant_id, idempotency_key, operation_id, input_digest,
|
|
201
|
+
outcome, result_digest
|
|
202
|
+
FROM catalog_idempotency_ledger`,
|
|
203
|
+
})
|
|
204
|
+
).rows[0],
|
|
205
|
+
);
|
|
206
|
+
expect(evidence).toMatchObject({
|
|
207
|
+
tenant_id: 'tenant-a',
|
|
208
|
+
idempotency_key: 'catalog-delete-key-1',
|
|
209
|
+
operation_id: 'catalog.item.create@1',
|
|
210
|
+
outcome: 'succeeded',
|
|
211
|
+
});
|
|
212
|
+
expect(evidence?.input_digest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
213
|
+
expect(evidence?.result_digest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
214
|
+
expect(JSON.stringify(evidence)).not.toContain('Steel bolt');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rolls back the item and history if the ledger cannot commit', async () => {
|
|
218
|
+
const databases = await catalogTestProvider();
|
|
219
|
+
const runtime = catalogRuntime(databases);
|
|
220
|
+
await runtime.service();
|
|
221
|
+
/* The ledger is empty here, so an always-false constraint only ever
|
|
222
|
+
rejects the write the tool is about to attempt. */
|
|
223
|
+
await withMigrationLease(databases, (database) =>
|
|
224
|
+
database.execute({
|
|
225
|
+
text: `ALTER TABLE catalog_idempotency_ledger
|
|
226
|
+
ADD CONSTRAINT forced_ledger_failure CHECK (false) NOT VALID`,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await expect(
|
|
232
|
+
catalogAgentTools(runtime)[1]!.execute(
|
|
233
|
+
input,
|
|
234
|
+
context('catalog-rollback-key-1'),
|
|
235
|
+
),
|
|
236
|
+
).rejects.toThrow(/forced_ledger_failure/);
|
|
237
|
+
await expect((await runtime.service()).list('tenant-a')).resolves.toEqual(
|
|
238
|
+
[],
|
|
239
|
+
);
|
|
240
|
+
expect(await rowCounts(databases)).toEqual({
|
|
241
|
+
items: 0,
|
|
242
|
+
history: 0,
|
|
243
|
+
ledger: 0,
|
|
244
|
+
});
|
|
245
|
+
} finally {
|
|
246
|
+
await withMigrationLease(databases, (database) =>
|
|
247
|
+
database.execute({
|
|
248
|
+
text: `ALTER TABLE catalog_idempotency_ledger
|
|
249
|
+
DROP CONSTRAINT forced_ledger_failure`,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('fails closed on a corrupted replay result without recreating the item', async () => {
|
|
256
|
+
const databases = await catalogTestProvider();
|
|
257
|
+
const first = catalogRuntime(databases);
|
|
258
|
+
const create = catalogAgentTools(first)[1]!;
|
|
259
|
+
const created = (await create.execute(
|
|
260
|
+
input,
|
|
261
|
+
context('catalog-corrupt-key-1'),
|
|
262
|
+
)) as CatalogItem;
|
|
263
|
+
const actor = { kind: 'user', id: 'owner-1', label: 'Owner' } as const;
|
|
264
|
+
await (await first.service()).archive('tenant-a', created.id, actor);
|
|
265
|
+
await (await first.service()).delete('tenant-a', created.id, actor);
|
|
266
|
+
await first.dispose();
|
|
267
|
+
|
|
268
|
+
await withMigrationLease(databases, (database) =>
|
|
269
|
+
database.execute({
|
|
270
|
+
text: `UPDATE catalog_idempotency_ledger SET result_json = $1
|
|
271
|
+
WHERE tenant_id = $2 AND idempotency_key = $3`,
|
|
272
|
+
parameters: [
|
|
273
|
+
JSON.stringify({ ...created, name: 'Tampered' }),
|
|
274
|
+
'tenant-a',
|
|
275
|
+
'catalog-corrupt-key-1',
|
|
276
|
+
],
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const recovered = catalogRuntime(databases);
|
|
281
|
+
await expect(
|
|
282
|
+
catalogAgentTools(recovered)[1]!.execute(
|
|
283
|
+
input,
|
|
284
|
+
context('catalog-corrupt-key-1'),
|
|
285
|
+
),
|
|
286
|
+
).rejects.toMatchObject({ code: 'CATALOG_IDEMPOTENCY_LEDGER_CORRUPT' });
|
|
287
|
+
await expect((await recovered.service()).list('tenant-a')).resolves.toEqual(
|
|
288
|
+
[],
|
|
289
|
+
);
|
|
290
|
+
expect(
|
|
291
|
+
await catalogAgentTools(recovered)[1]!.execute(
|
|
292
|
+
input,
|
|
293
|
+
context('catalog-corrupt-key-1', 'tenant-b'),
|
|
294
|
+
),
|
|
295
|
+
).toMatchObject({ tenantId: 'tenant-b' });
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import type {
|
|
3
|
+
DatabaseAdapterLease,
|
|
4
|
+
DatabaseProvider,
|
|
5
|
+
} from '@flowdular/sdk/database';
|
|
6
|
+
import {
|
|
7
|
+
DATABASE_MIGRATION_LEDGER,
|
|
8
|
+
databaseMigrationStatus,
|
|
9
|
+
runDatabaseMigrations,
|
|
10
|
+
} from '@flowdular/sdk/database';
|
|
11
|
+
import { createTestDatabaseProvider } from '@flowdular/sdk/database-testing';
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
13
|
+
import { databaseMigrations } from '../src/services/migration.ts';
|
|
14
|
+
|
|
15
|
+
const migrationDirectory = new URL('../migrations/', import.meta.url);
|
|
16
|
+
|
|
17
|
+
const MODULE_TABLES =
|
|
18
|
+
'catalog_items, catalog_items_history, catalog_items_history_v2, catalog_idempotency_ledger';
|
|
19
|
+
|
|
20
|
+
let provider: DatabaseProvider;
|
|
21
|
+
let lease: DatabaseAdapterLease;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
provider = createTestDatabaseProvider();
|
|
25
|
+
lease = await provider.acquire({
|
|
26
|
+
namespace: 'catalog.core',
|
|
27
|
+
purpose: 'migration',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/* Every case states its own starting point, so the shared cluster goes back to
|
|
32
|
+
an unmigrated, unrecorded schema first. */
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
await lease.database.execute({
|
|
35
|
+
text: `DROP TABLE IF EXISTS ${MODULE_TABLES} CASCADE`,
|
|
36
|
+
});
|
|
37
|
+
if (await lease.database.schema.hasTable(DATABASE_MIGRATION_LEDGER)) {
|
|
38
|
+
await lease.database.execute({
|
|
39
|
+
text: `DELETE FROM ${DATABASE_MIGRATION_LEDGER} WHERE namespace = 'catalog.core'`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await lease?.release();
|
|
46
|
+
await provider?.dispose();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function apply() {
|
|
50
|
+
return runDatabaseMigrations(
|
|
51
|
+
lease.database,
|
|
52
|
+
'catalog.core',
|
|
53
|
+
databaseMigrations,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function status() {
|
|
58
|
+
return databaseMigrationStatus(
|
|
59
|
+
lease.database,
|
|
60
|
+
'catalog.core',
|
|
61
|
+
databaseMigrations,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('catalog migrations', () => {
|
|
66
|
+
it('mirrors every PostgreSQL up file byte for byte', () => {
|
|
67
|
+
const files = readdirSync(migrationDirectory)
|
|
68
|
+
.filter((name) => name.endsWith('.up.sql'))
|
|
69
|
+
.sort();
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
databaseMigrations.map((migration) => `${migration.id}.up.sql`),
|
|
73
|
+
).toEqual(files);
|
|
74
|
+
for (const migration of databaseMigrations) {
|
|
75
|
+
expect(migration.sql.postgresql).toBe(
|
|
76
|
+
readFileSync(
|
|
77
|
+
new URL(`${migration.id}.up.sql`, migrationDirectory),
|
|
78
|
+
'utf8',
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
/* PostgreSQL and nothing else. A stray dialect key would ship SQL no
|
|
82
|
+
deployment runs and no test covers. */
|
|
83
|
+
expect(Object.keys(migration.sql)).toEqual(['postgresql']);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('declares forced PostgreSQL row security for every tenant table', () => {
|
|
88
|
+
for (const migration of databaseMigrations) {
|
|
89
|
+
const sql = migration.sql.postgresql ?? '';
|
|
90
|
+
expect(sql).toContain('ENABLE ROW LEVEL SECURITY');
|
|
91
|
+
expect(sql).toContain('FORCE ROW LEVEL SECURITY');
|
|
92
|
+
expect(sql).toContain("current_setting('coreloom.tenant_id', true)");
|
|
93
|
+
expect(sql).toContain('WITH CHECK');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('applies every migration on a fresh database', async () => {
|
|
98
|
+
expect((await apply()).map((entry) => entry.action)).toEqual(
|
|
99
|
+
databaseMigrations.map(() => 'applied'),
|
|
100
|
+
);
|
|
101
|
+
expect((await status()).map((entry) => entry.state)).toEqual(
|
|
102
|
+
databaseMigrations.map(() => 'applied'),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('adopts a schema that predates the ledger without changing its rows', async () => {
|
|
107
|
+
await apply();
|
|
108
|
+
await lease.database.transaction(
|
|
109
|
+
(transaction) =>
|
|
110
|
+
transaction.execute({
|
|
111
|
+
text: `INSERT INTO catalog_items
|
|
112
|
+
(id, tenant_id, sku, sku_normalized, name, kind, unit,
|
|
113
|
+
base_price_minor, currency, status, created_at)
|
|
114
|
+
VALUES ('item-1', 'tenant-a', 'SKU-1', 'sku-1', 'Bolt', 'product',
|
|
115
|
+
'pcs', 500, 'EUR', 'active', 1)`,
|
|
116
|
+
}),
|
|
117
|
+
{ tenantId: 'tenant-a', access: 'write' },
|
|
118
|
+
);
|
|
119
|
+
await lease.database.execute({
|
|
120
|
+
text: `DELETE FROM ${DATABASE_MIGRATION_LEDGER} WHERE namespace = 'catalog.core'`,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect((await status()).map((entry) => entry.state)).toEqual(
|
|
124
|
+
databaseMigrations.map(() => 'adopted'),
|
|
125
|
+
);
|
|
126
|
+
expect((await apply()).map((entry) => entry.action)).toEqual(
|
|
127
|
+
databaseMigrations.map(() => 'adopted'),
|
|
128
|
+
);
|
|
129
|
+
expect(
|
|
130
|
+
(
|
|
131
|
+
await lease.database.transaction(
|
|
132
|
+
(transaction) =>
|
|
133
|
+
transaction.query<{ sku: string }>({
|
|
134
|
+
text: 'SELECT sku FROM catalog_items',
|
|
135
|
+
}),
|
|
136
|
+
{ tenantId: 'tenant-a', access: 'read' },
|
|
137
|
+
)
|
|
138
|
+
).rows,
|
|
139
|
+
).toEqual([{ sku: 'SKU-1' }]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('runs clean on a second migration pass', async () => {
|
|
143
|
+
await apply();
|
|
144
|
+
|
|
145
|
+
expect((await apply()).map((entry) => entry.action)).toEqual(
|
|
146
|
+
databaseMigrations.map(() => 'unchanged'),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
registerModuleTranslations,
|
|
4
|
+
setActiveLocale,
|
|
5
|
+
t,
|
|
6
|
+
} from '@flowdular/sdk/client/i18n';
|
|
7
|
+
import translationsEn from '../translations/en.json';
|
|
8
|
+
import translationsPl from '../translations/pl.json';
|
|
9
|
+
import { catalogNavigationLabel } from '../src/client/navigation-copy.ts';
|
|
10
|
+
import { moduleDefinition } from '../src/index.ts';
|
|
11
|
+
import {
|
|
12
|
+
CatalogService,
|
|
13
|
+
CatalogServiceError,
|
|
14
|
+
} from '../src/services/catalog-service.ts';
|
|
15
|
+
import {
|
|
16
|
+
closeCatalogTestDatabases,
|
|
17
|
+
createCatalogTestDatabase,
|
|
18
|
+
type CatalogTestDatabase,
|
|
19
|
+
} from './support/database.ts';
|
|
20
|
+
|
|
21
|
+
const TEST_ACTOR = {
|
|
22
|
+
kind: 'user',
|
|
23
|
+
id: 'account-1',
|
|
24
|
+
label: 'Test user',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const databases = new Set<CatalogTestDatabase>();
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await Promise.all([...databases].map((database) => database.dispose()));
|
|
31
|
+
databases.clear();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(closeCatalogTestDatabases);
|
|
35
|
+
|
|
36
|
+
async function catalogFixture(): Promise<CatalogTestDatabase> {
|
|
37
|
+
const database = await createCatalogTestDatabase();
|
|
38
|
+
databases.add(database);
|
|
39
|
+
return database;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function serviceError(
|
|
43
|
+
action: () => unknown,
|
|
44
|
+
): Promise<CatalogServiceError> {
|
|
45
|
+
try {
|
|
46
|
+
await action();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof CatalogServiceError) return error;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
throw new Error('Expected a CatalogServiceError.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('catalog.core', () => {
|
|
55
|
+
it('ships matching English and Polish translation keys', async () => {
|
|
56
|
+
expect(Object.keys(translationsPl).sort()).toEqual(
|
|
57
|
+
Object.keys(translationsEn).sort(),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('resolves the navigation label from the registered module bundle', async () => {
|
|
62
|
+
registerModuleTranslations([
|
|
63
|
+
{
|
|
64
|
+
moduleId: 'catalog.core',
|
|
65
|
+
translations: { en: translationsEn, pl: translationsPl },
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
setActiveLocale('en');
|
|
69
|
+
expect(catalogNavigationLabel()).toBe('Catalog');
|
|
70
|
+
setActiveLocale('pl');
|
|
71
|
+
expect(catalogNavigationLabel()).toBe('Katalog');
|
|
72
|
+
setActiveLocale('en');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('translates every dynamic record and history value', async () => {
|
|
76
|
+
registerModuleTranslations([
|
|
77
|
+
{
|
|
78
|
+
moduleId: 'catalog.core',
|
|
79
|
+
translations: { en: translationsEn, pl: translationsPl },
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
for (const locale of ['en', 'pl']) {
|
|
83
|
+
setActiveLocale(locale);
|
|
84
|
+
for (const kind of ['product', 'service']) {
|
|
85
|
+
const key = 'catalog.kind.' + kind;
|
|
86
|
+
expect(t(key), `${locale}: ${key}`).not.toBe(key);
|
|
87
|
+
}
|
|
88
|
+
for (const status of ['active', 'archived']) {
|
|
89
|
+
const key = 'catalog.status.' + status;
|
|
90
|
+
expect(t(key), `${locale}: ${key}`).not.toBe(key);
|
|
91
|
+
}
|
|
92
|
+
for (const action of [
|
|
93
|
+
'created',
|
|
94
|
+
'updated',
|
|
95
|
+
'archived',
|
|
96
|
+
'restored',
|
|
97
|
+
'deleted',
|
|
98
|
+
]) {
|
|
99
|
+
const key = 'catalog.history.action.' + action;
|
|
100
|
+
expect(t(key), `${locale}: ${key}`).not.toBe(key);
|
|
101
|
+
}
|
|
102
|
+
for (const actor of ['user', 'agent']) {
|
|
103
|
+
const key = 'catalog.history.actor.' + actor;
|
|
104
|
+
expect(t(key), `${locale}: ${key}`).not.toBe(key);
|
|
105
|
+
}
|
|
106
|
+
const serviceKey = 'catalog.history.actor.serviceConfiguredBy';
|
|
107
|
+
expect(
|
|
108
|
+
t(serviceKey, { name: 'Ada' }),
|
|
109
|
+
`${locale}: ${serviceKey}`,
|
|
110
|
+
).not.toBe(serviceKey);
|
|
111
|
+
for (const field of [
|
|
112
|
+
'sku',
|
|
113
|
+
'name',
|
|
114
|
+
'kind',
|
|
115
|
+
'unit',
|
|
116
|
+
'basePriceMinor',
|
|
117
|
+
'currency',
|
|
118
|
+
'status',
|
|
119
|
+
]) {
|
|
120
|
+
const key = 'catalog.history.field.' + field;
|
|
121
|
+
expect(t(key), `${locale}: ${key}`).not.toBe(key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
setActiveLocale('en');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('exports its validated identity', async () => {
|
|
128
|
+
expect(moduleDefinition.manifest.id).toBe('catalog.core');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('enforces SKU uniqueness inside one tenant only', async () => {
|
|
132
|
+
const service = new CatalogService((await catalogFixture()).repository);
|
|
133
|
+
const input = {
|
|
134
|
+
sku: 'consulting',
|
|
135
|
+
name: 'Consulting hour',
|
|
136
|
+
kind: 'service' as const,
|
|
137
|
+
unit: 'hour',
|
|
138
|
+
basePriceMinor: 12_000,
|
|
139
|
+
currency: 'EUR',
|
|
140
|
+
};
|
|
141
|
+
await service.create('tenant-a', input, TEST_ACTOR);
|
|
142
|
+
await expect(
|
|
143
|
+
service.create('tenant-a', input, TEST_ACTOR),
|
|
144
|
+
).rejects.toThrowError(/active tenant/);
|
|
145
|
+
await expect(
|
|
146
|
+
service.create('tenant-b', input, TEST_ACTOR),
|
|
147
|
+
).resolves.toBeDefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('isolates item lists by trusted tenant id', async () => {
|
|
151
|
+
const service = new CatalogService((await catalogFixture()).repository);
|
|
152
|
+
await service.create(
|
|
153
|
+
'tenant-a',
|
|
154
|
+
{
|
|
155
|
+
sku: 'A-1',
|
|
156
|
+
name: 'Alpha',
|
|
157
|
+
kind: 'product',
|
|
158
|
+
unit: 'each',
|
|
159
|
+
basePriceMinor: 100,
|
|
160
|
+
currency: 'EUR',
|
|
161
|
+
},
|
|
162
|
+
TEST_ACTOR,
|
|
163
|
+
);
|
|
164
|
+
expect(await service.list('tenant-b')).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('keeps lifecycle revisions append-only and tenant-scoped', async () => {
|
|
168
|
+
const service = new CatalogService((await catalogFixture()).repository);
|
|
169
|
+
const item = await service.create(
|
|
170
|
+
'tenant-a',
|
|
171
|
+
{
|
|
172
|
+
sku: 'A-1',
|
|
173
|
+
name: 'Alpha',
|
|
174
|
+
kind: 'product',
|
|
175
|
+
unit: 'each',
|
|
176
|
+
basePriceMinor: 100,
|
|
177
|
+
currency: 'EUR',
|
|
178
|
+
},
|
|
179
|
+
TEST_ACTOR,
|
|
180
|
+
);
|
|
181
|
+
await service.update(
|
|
182
|
+
'tenant-a',
|
|
183
|
+
{ ...item, name: 'Alpha revised', basePriceMinor: 125 },
|
|
184
|
+
TEST_ACTOR,
|
|
185
|
+
);
|
|
186
|
+
expect(
|
|
187
|
+
await serviceError(() => service.delete('tenant-a', item.id, TEST_ACTOR)),
|
|
188
|
+
).toMatchObject({ code: 'CATALOG_ITEM_NOT_ARCHIVED', status: 409 });
|
|
189
|
+
expect((await service.get('tenant-a', item.id))?.status).toBe('active');
|
|
190
|
+
await service.archive('tenant-a', item.id, {
|
|
191
|
+
kind: 'agent',
|
|
192
|
+
id: 'catalog-agent',
|
|
193
|
+
label: 'Catalog curator',
|
|
194
|
+
runId: 'run-1',
|
|
195
|
+
});
|
|
196
|
+
await service.restore('tenant-a', item.id, TEST_ACTOR);
|
|
197
|
+
await service.archive('tenant-a', item.id, TEST_ACTOR);
|
|
198
|
+
await service.delete('tenant-a', item.id, TEST_ACTOR);
|
|
199
|
+
|
|
200
|
+
const history = await service.history('tenant-a', {
|
|
201
|
+
recordId: item.id,
|
|
202
|
+
limit: 20,
|
|
203
|
+
cursor: null,
|
|
204
|
+
});
|
|
205
|
+
expect(history.entries.map((entry) => entry.action)).toEqual([
|
|
206
|
+
'deleted',
|
|
207
|
+
'archived',
|
|
208
|
+
'restored',
|
|
209
|
+
'archived',
|
|
210
|
+
'updated',
|
|
211
|
+
'created',
|
|
212
|
+
]);
|
|
213
|
+
expect(history.entries[3]?.actor).toEqual({
|
|
214
|
+
kind: 'agent',
|
|
215
|
+
id: 'catalog-agent',
|
|
216
|
+
label: 'Catalog curator',
|
|
217
|
+
runId: 'run-1',
|
|
218
|
+
});
|
|
219
|
+
expect(history.entries[4]?.changes).toEqual({
|
|
220
|
+
name: { from: 'Alpha', to: 'Alpha revised' },
|
|
221
|
+
basePriceMinor: { from: 100, to: 125 },
|
|
222
|
+
});
|
|
223
|
+
expect(
|
|
224
|
+
(
|
|
225
|
+
await service.history('tenant-b', {
|
|
226
|
+
recordId: item.id,
|
|
227
|
+
limit: 20,
|
|
228
|
+
cursor: null,
|
|
229
|
+
})
|
|
230
|
+
).entries,
|
|
231
|
+
).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('refuses a write that forced row security assigns to another tenant', async () => {
|
|
235
|
+
const database = await catalogFixture();
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
database.runtime.transaction(
|
|
239
|
+
(transaction) =>
|
|
240
|
+
transaction.execute({
|
|
241
|
+
text: `INSERT INTO catalog_items
|
|
242
|
+
(id, tenant_id, sku, sku_normalized, name, kind, unit,
|
|
243
|
+
base_price_minor, currency, status, created_at)
|
|
244
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
245
|
+
parameters: [
|
|
246
|
+
'forged',
|
|
247
|
+
'tenant-b',
|
|
248
|
+
'F-1',
|
|
249
|
+
'f-1',
|
|
250
|
+
'Forged',
|
|
251
|
+
'product',
|
|
252
|
+
'each',
|
|
253
|
+
1,
|
|
254
|
+
'EUR',
|
|
255
|
+
'active',
|
|
256
|
+
Date.now(),
|
|
257
|
+
],
|
|
258
|
+
}),
|
|
259
|
+
{ access: 'write', tenantId: 'tenant-a' },
|
|
260
|
+
),
|
|
261
|
+
).rejects.toBeDefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('refuses any runtime statement without a tenant context', async () => {
|
|
265
|
+
const database = await catalogFixture();
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
database.runtime.transaction(async () => undefined, { access: 'read' }),
|
|
269
|
+
).rejects.toMatchObject({ code: 'TENANT_CONTEXT_REQUIRED' });
|
|
270
|
+
});
|
|
271
|
+
});
|