@cosmicdrift/kumiko-bundled-features 0.18.0 → 0.19.1
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/package.json +1 -1
- package/src/__tests__/boot-seed-contract.integration.test.ts +119 -0
- package/src/compliance-profiles/__tests__/seeding.integration.test.ts +35 -6
- package/src/compliance-profiles/seeding.ts +40 -35
- package/src/legal-pages/README.md +2 -2
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +1 -0
- package/src/tenant/seeding.ts +3 -7
- package/src/text-content/README.md +9 -6
- package/src/text-content/__tests__/text-content.integration.test.ts +38 -4
- package/src/text-content/seeding.ts +44 -40
- package/src/user/seeding.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Pins the shared boot-seed contract (DEFAULT_SEED_IF_EXISTS="skip") across
|
|
2
|
+
// event-sourced seed helpers. Feature-specific behaviour stays in each
|
|
3
|
+
// helper's own test file; here we assert cross-cutting invariants only.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
|
+
import {
|
|
9
|
+
setupTestStack,
|
|
10
|
+
type TestStack,
|
|
11
|
+
testTenantId,
|
|
12
|
+
unsafeCreateEntityTable,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { createComplianceProfilesFeature } from "../compliance-profiles/feature";
|
|
15
|
+
import {
|
|
16
|
+
tenantComplianceProfileEntity,
|
|
17
|
+
tenantComplianceProfileTable,
|
|
18
|
+
} from "../compliance-profiles/schema/profile-selection";
|
|
19
|
+
import { seedComplianceProfile } from "../compliance-profiles/seeding";
|
|
20
|
+
import { createTextContentFeature } from "../text-content/feature";
|
|
21
|
+
import { seedTextBlock } from "../text-content/seeding";
|
|
22
|
+
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../text-content/table";
|
|
23
|
+
|
|
24
|
+
let stack: TestStack;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
stack = await setupTestStack({
|
|
28
|
+
features: [createTextContentFeature(), createComplianceProfilesFeature()],
|
|
29
|
+
});
|
|
30
|
+
await unsafeCreateEntityTable(stack.db, textBlockEntity);
|
|
31
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
32
|
+
await createEventsTable(stack.db);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await stack.cleanup();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("boot-seed contract", () => {
|
|
40
|
+
test("seedTextBlock: re-boot skip preserves user edit + event count", async () => {
|
|
41
|
+
const tenantId = testTenantId(301);
|
|
42
|
+
|
|
43
|
+
await seedTextBlock(stack.db, {
|
|
44
|
+
tenantId,
|
|
45
|
+
slug: "imprint",
|
|
46
|
+
lang: "de",
|
|
47
|
+
title: "Impressum",
|
|
48
|
+
body: "seed body",
|
|
49
|
+
});
|
|
50
|
+
await seedTextBlock(stack.db, {
|
|
51
|
+
tenantId,
|
|
52
|
+
slug: "imprint",
|
|
53
|
+
lang: "de",
|
|
54
|
+
title: "Impressum (edited)",
|
|
55
|
+
body: "admin body",
|
|
56
|
+
ifExists: "update",
|
|
57
|
+
});
|
|
58
|
+
await seedTextBlock(stack.db, {
|
|
59
|
+
tenantId,
|
|
60
|
+
slug: "imprint",
|
|
61
|
+
lang: "de",
|
|
62
|
+
title: "Impressum",
|
|
63
|
+
body: "seed body",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const row = await fetchOne<TextBlockRow>(stack.db, textBlocksTable, {
|
|
67
|
+
tenantId,
|
|
68
|
+
slug: "imprint",
|
|
69
|
+
lang: "de",
|
|
70
|
+
});
|
|
71
|
+
expect(row).toMatchObject({ title: "Impressum (edited)", body: "admin body", version: 2 });
|
|
72
|
+
|
|
73
|
+
const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
|
|
74
|
+
expect(events).toHaveLength(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("seedComplianceProfile: re-boot skip preserves profile + event count", async () => {
|
|
78
|
+
const tenantId = testTenantId(302);
|
|
79
|
+
|
|
80
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
81
|
+
await seedComplianceProfile(stack.db, {
|
|
82
|
+
tenantId,
|
|
83
|
+
profileKey: "swiss-dsg",
|
|
84
|
+
ifExists: "update",
|
|
85
|
+
});
|
|
86
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
87
|
+
|
|
88
|
+
const profileRow = (await fetchOne(stack.db, tenantComplianceProfileTable, {
|
|
89
|
+
tenantId,
|
|
90
|
+
})) as { id: string; profileKey: string; version: number };
|
|
91
|
+
expect(profileRow.profileKey).toBe("swiss-dsg");
|
|
92
|
+
expect(profileRow.version).toBe(2);
|
|
93
|
+
|
|
94
|
+
const events = await selectMany(stack.db, eventsTable, {
|
|
95
|
+
aggregateId: profileRow.id,
|
|
96
|
+
});
|
|
97
|
+
expect(events).toHaveLength(2);
|
|
98
|
+
expect(events.map((e) => e.type)).toEqual([
|
|
99
|
+
"tenant-compliance-profile.created",
|
|
100
|
+
"tenant-compliance-profile.updated",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('seedComplianceProfile ifExists="update" overwrites existing profile', async () => {
|
|
105
|
+
const tenantId = testTenantId(303);
|
|
106
|
+
|
|
107
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
108
|
+
await seedComplianceProfile(stack.db, {
|
|
109
|
+
tenantId,
|
|
110
|
+
profileKey: "swiss-dsg",
|
|
111
|
+
ifExists: "update",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const row = (await fetchOne(stack.db, tenantComplianceProfileTable, {
|
|
115
|
+
tenantId,
|
|
116
|
+
})) as { profileKey: string };
|
|
117
|
+
expect(row.profileKey).toBe("swiss-dsg");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// Beweist:
|
|
4
4
|
// 1. Helper umgeht set-profile-Zod-Engung (kann minimal-no-region
|
|
5
5
|
// setzen für Migration-Edge-Case-Tests in Sprint 2+)
|
|
6
|
-
// 2.
|
|
7
|
-
// bestehenden Eintrag
|
|
6
|
+
// 2. Default skip: zweiter Boot-Call ohne ifExists="update" ändert nichts
|
|
8
7
|
// 3. Override wird als JSON-String persistiert + via for-tenant
|
|
9
8
|
// korrekt zurueckgelesen
|
|
10
9
|
|
|
11
10
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
-
import {
|
|
11
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
13
13
|
import {
|
|
14
14
|
createTestUser,
|
|
15
15
|
setupTestStack,
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
testTenantId,
|
|
18
18
|
unsafeCreateEntityTable,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
-
import { createComplianceProfilesFeature
|
|
20
|
+
import { createComplianceProfilesFeature } from "../feature";
|
|
21
|
+
import {
|
|
22
|
+
tenantComplianceProfileEntity,
|
|
23
|
+
tenantComplianceProfileTable,
|
|
24
|
+
} from "../schema/profile-selection";
|
|
21
25
|
import { seedComplianceProfile } from "../seeding";
|
|
22
26
|
|
|
23
27
|
const FOR_TENANT = "compliance-profiles:query:for-tenant";
|
|
@@ -47,17 +51,42 @@ describe("seedComplianceProfile", () => {
|
|
|
47
51
|
expect(result.profile.key).toBe("eu-dsgvo");
|
|
48
52
|
});
|
|
49
53
|
|
|
50
|
-
test("
|
|
54
|
+
test('ifExists="update" overwrites bestehenden Eintrag', async () => {
|
|
51
55
|
const tenantId = testTenantId(201);
|
|
52
56
|
const user = createTestUser({ id: 201, tenantId, roles: ["TenantAdmin"] });
|
|
53
57
|
|
|
54
58
|
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
55
|
-
await seedComplianceProfile(stack.db, {
|
|
59
|
+
await seedComplianceProfile(stack.db, {
|
|
60
|
+
tenantId,
|
|
61
|
+
profileKey: "swiss-dsg",
|
|
62
|
+
ifExists: "update",
|
|
63
|
+
});
|
|
56
64
|
|
|
57
65
|
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
58
66
|
expect(result.profile.key).toBe("swiss-dsg");
|
|
59
67
|
});
|
|
60
68
|
|
|
69
|
+
test("default skip: zweiter Boot-Call ohne update überschreibt nicht", async () => {
|
|
70
|
+
const tenantId = testTenantId(204);
|
|
71
|
+
|
|
72
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
73
|
+
await seedComplianceProfile(stack.db, {
|
|
74
|
+
tenantId,
|
|
75
|
+
profileKey: "swiss-dsg",
|
|
76
|
+
ifExists: "update",
|
|
77
|
+
});
|
|
78
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "de-hr-dsgvo-hgb" });
|
|
79
|
+
|
|
80
|
+
const row = (await fetchOne(stack.db, tenantComplianceProfileTable, {
|
|
81
|
+
tenantId,
|
|
82
|
+
})) as { id: string; profileKey: string; version: number };
|
|
83
|
+
expect(row.profileKey).toBe("swiss-dsg");
|
|
84
|
+
expect(row.version).toBe(2);
|
|
85
|
+
|
|
86
|
+
const events = await selectMany(stack.db, eventsTable, { aggregateId: row.id });
|
|
87
|
+
expect(events).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
61
90
|
test("kann minimal-no-region direkt seeden (Migration-Edge-Case, ohne set-profile-Zod-Engung)", async () => {
|
|
62
91
|
const tenantId = testTenantId(202);
|
|
63
92
|
const user = createTestUser({ id: 202, tenantId, roles: ["TenantAdmin"] });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// über den Event-Store-Executor an — gleicher Pfad wie der echte
|
|
3
3
|
// set-profile-Handler, aber ohne Zod-Schema-Engung (akzeptiert
|
|
4
4
|
// minimal-no-region für Migration-Edge-Case-Tests) und ohne Access-
|
|
5
|
-
// Check.
|
|
5
|
+
// Check. Default ifExists="skip": nur fehlende Profile anlegen.
|
|
6
6
|
//
|
|
7
7
|
// Sprint 2 user-data-rights nutzt das fuer Test-Setup ("user kann
|
|
8
8
|
// Daten exportieren mit profile X" — pro Test ein frischer Tenant +
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type DbConnection,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
23
23
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
24
25
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
25
26
|
import {
|
|
26
27
|
tenantComplianceProfileEntity,
|
|
@@ -38,6 +39,7 @@ export type SeedComplianceProfileOptions = {
|
|
|
38
39
|
readonly profileKey: ComplianceProfileKey;
|
|
39
40
|
readonly override?: ComplianceProfileOverride;
|
|
40
41
|
readonly by?: SessionUser;
|
|
42
|
+
readonly ifExists?: SeedIfExists;
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
export async function seedComplianceProfile(
|
|
@@ -55,39 +57,42 @@ export async function seedComplianceProfile(
|
|
|
55
57
|
tenantId: opts.tenantId,
|
|
56
58
|
})) as { id: string; version: number } | null; // @cast-boundary db-runner
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
60
|
+
return runEventStoreSeed({
|
|
61
|
+
existing,
|
|
62
|
+
ifExists: opts.ifExists,
|
|
63
|
+
create: async () => {
|
|
64
|
+
const result = await executor.create(
|
|
65
|
+
{
|
|
66
|
+
profileKey: opts.profileKey,
|
|
67
|
+
override: overrideJson,
|
|
68
|
+
tenantId: opts.tenantId,
|
|
69
|
+
},
|
|
70
|
+
by,
|
|
71
|
+
tdb,
|
|
72
|
+
);
|
|
73
|
+
if (!result.isSuccess) {
|
|
74
|
+
throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
|
|
75
|
+
}
|
|
76
|
+
const data = result.data as { id?: string };
|
|
77
|
+
if (data.id === undefined) {
|
|
78
|
+
throw new Error("seedComplianceProfile: executor.create did not return an id");
|
|
79
|
+
}
|
|
80
|
+
return { id: data.id };
|
|
81
|
+
},
|
|
82
|
+
update: async (row) => {
|
|
83
|
+
const result = await executor.update(
|
|
84
|
+
{
|
|
85
|
+
id: row.id,
|
|
86
|
+
version: row.version,
|
|
87
|
+
changes: { profileKey: opts.profileKey, override: overrideJson },
|
|
88
|
+
},
|
|
89
|
+
by,
|
|
90
|
+
tdb,
|
|
91
|
+
);
|
|
92
|
+
if (!result.isSuccess) {
|
|
93
|
+
throw new Error(`seedComplianceProfile update failed: ${JSON.stringify(result)}`);
|
|
94
|
+
}
|
|
95
|
+
return { id: row.id };
|
|
79
96
|
},
|
|
80
|
-
|
|
81
|
-
tdb,
|
|
82
|
-
);
|
|
83
|
-
if (!result.isSuccess) {
|
|
84
|
-
throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
|
|
85
|
-
}
|
|
86
|
-
// @cast-boundary db-row: executor.create-result enthält die inserted
|
|
87
|
-
// Row als Record<string, unknown>; id ist nach INSERT garantiert.
|
|
88
|
-
const data = result.data as { id?: string };
|
|
89
|
-
if (data.id === undefined) {
|
|
90
|
-
throw new Error("seedComplianceProfile: executor.create did not return an id");
|
|
91
|
-
}
|
|
92
|
-
return { id: data.id };
|
|
97
|
+
});
|
|
93
98
|
}
|
|
@@ -48,8 +48,8 @@ legal-pages doesn't have its own table — it uses text-content's
|
|
|
48
48
|
`read_text_blocks`. Table setup therefore goes through text-content:
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
bun kumiko migrate generate # text-block entity is detected
|
|
52
|
+
bun kumiko migrate apply
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
See [text-content/README.md](../text-content/README.md#production-table-setup).
|
|
@@ -153,6 +153,7 @@ describe("legal-pages :: edge-cases", () => {
|
|
|
153
153
|
lang: "de",
|
|
154
154
|
title: "Impressum",
|
|
155
155
|
body: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
|
|
156
|
+
ifExists: "update",
|
|
156
157
|
});
|
|
157
158
|
const res = await stack.app.request("/legal/impressum");
|
|
158
159
|
expect(res.status).toBe(200);
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -25,11 +25,7 @@
|
|
|
25
25
|
// correct event + projection.
|
|
26
26
|
//
|
|
27
27
|
// Idempotent: calling twice for the same (userId, tenantId) is a no-op on
|
|
28
|
-
// the second call
|
|
29
|
-
// `beforeEach` runs don't need explicit cleanup. A real `addMember` handler
|
|
30
|
-
// returns ConflictError on duplicates — that's the user-facing contract.
|
|
31
|
-
// Fixture-seeding prioritises "make the state exist" over "detect duplicate
|
|
32
|
-
// seeding", which is usually a test-author bug we don't need to surface.
|
|
28
|
+
// the second call (ifExists="skip", siehe @cosmicdrift/kumiko-framework/seeding).
|
|
33
29
|
|
|
34
30
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
35
31
|
import {
|
|
@@ -77,8 +73,8 @@ export type SeedTenantOptions = {
|
|
|
77
73
|
};
|
|
78
74
|
|
|
79
75
|
/**
|
|
80
|
-
* Seed a tenant through the event-store executor. Idempotent:
|
|
81
|
-
* call for the same `id` is a no-op. Same TX-semantics as the real
|
|
76
|
+
* Seed a tenant through the event-store executor. Idempotent (ifExists="skip"):
|
|
77
|
+
* a second call for the same `id` is a no-op. Same TX-semantics as the real
|
|
82
78
|
* `TenantHandlers.create`, minus the SystemAdmin-access-check and minus
|
|
83
79
|
* ConflictError-on-duplicate.
|
|
84
80
|
*/
|
|
@@ -22,20 +22,23 @@ runProdApp({
|
|
|
22
22
|
|
|
23
23
|
### Production table setup
|
|
24
24
|
|
|
25
|
-
Each app creates the `read_text_blocks` table via a
|
|
25
|
+
Each app creates the `read_text_blocks` table via a schema migration:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
# In the app workspace (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
# In the app workspace (legacy drizzle.config.ts apps):
|
|
29
|
+
bun kumiko migrate generate # detects the new r.entity("text-block")
|
|
30
|
+
bun kumiko migrate apply # pre-deploy step in prod
|
|
31
|
+
|
|
32
|
+
# New apps (kumiko/schema.ts):
|
|
33
|
+
bun kumiko schema generate text-content
|
|
34
|
+
bun kumiko schema apply
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
The boot gate (`runProdApp`) checks hard: missing table = `SchemaDriftError`,
|
|
35
38
|
container exits. No auto-heal in production. See
|
|
36
39
|
[docs/plans/architecture/migrations.md](../../../../docs/plans/architecture/migrations.md).
|
|
37
40
|
|
|
38
|
-
In integration tests (
|
|
41
|
+
In integration tests (`bun test`) it's enough to do:
|
|
39
42
|
|
|
40
43
|
```typescript
|
|
41
44
|
import { unsafeCreateEntityTable } from "@cosmicdrift/kumiko-framework/stack";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import type
|
|
3
|
-
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
2
|
+
import { type DbConnection, fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import {
|
|
5
5
|
createTestUser,
|
|
6
6
|
setupTestStack,
|
|
@@ -12,7 +12,7 @@ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
|
12
12
|
import { TextContentHandlers, TextContentQueries } from "../constants";
|
|
13
13
|
import { createTextContentFeature } from "../feature";
|
|
14
14
|
import { seedTextBlock } from "../seeding";
|
|
15
|
-
import { textBlockEntity } from "../table";
|
|
15
|
+
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
|
|
16
16
|
|
|
17
17
|
let stack: TestStack;
|
|
18
18
|
let db: DbConnection;
|
|
@@ -395,7 +395,7 @@ describe("text-content :: edge-cases", () => {
|
|
|
395
395
|
});
|
|
396
396
|
|
|
397
397
|
describe("text-content :: seedTextBlock", () => {
|
|
398
|
-
test("
|
|
398
|
+
test('ifExists="update" overwrites existing row (same aggregate id)', async () => {
|
|
399
399
|
const a = await seedTextBlock(db, {
|
|
400
400
|
tenantId: tenantAdmin.tenantId,
|
|
401
401
|
slug: "seed-test",
|
|
@@ -409,10 +409,44 @@ describe("text-content :: seedTextBlock", () => {
|
|
|
409
409
|
lang: "de",
|
|
410
410
|
title: "v2",
|
|
411
411
|
body: "neu",
|
|
412
|
+
ifExists: "update",
|
|
412
413
|
});
|
|
413
414
|
expect(a.id).toBe(b.id);
|
|
414
415
|
});
|
|
415
416
|
|
|
417
|
+
test('default ifExists="skip" does not overwrite on re-boot', async () => {
|
|
418
|
+
const base = {
|
|
419
|
+
tenantId: tenantAdmin.tenantId,
|
|
420
|
+
slug: "seed-skip",
|
|
421
|
+
lang: "de",
|
|
422
|
+
};
|
|
423
|
+
await seedTextBlock(db, {
|
|
424
|
+
...base,
|
|
425
|
+
title: "Initial",
|
|
426
|
+
body: "from seed",
|
|
427
|
+
});
|
|
428
|
+
await seedTextBlock(db, {
|
|
429
|
+
...base,
|
|
430
|
+
title: "User edit",
|
|
431
|
+
body: "from admin",
|
|
432
|
+
ifExists: "update",
|
|
433
|
+
});
|
|
434
|
+
await seedTextBlock(db, {
|
|
435
|
+
...base,
|
|
436
|
+
title: "Seed again",
|
|
437
|
+
body: "would overwrite",
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const row = await fetchOne<TextBlockRow>(db, textBlocksTable, base);
|
|
441
|
+
expect(row).toMatchObject({ title: "User edit", body: "from admin", version: 2 });
|
|
442
|
+
|
|
443
|
+
const events = await selectMany(db, eventsTable, {
|
|
444
|
+
aggregateId: String(row!.id),
|
|
445
|
+
});
|
|
446
|
+
expect(events).toHaveLength(2);
|
|
447
|
+
expect(events.map((e) => e.type)).toEqual(["text-block.created", "text-block.updated"]);
|
|
448
|
+
});
|
|
449
|
+
|
|
416
450
|
// Drift-Documentation: seedTextBlock geht direkt durch den Executor
|
|
417
451
|
// OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
|
|
418
452
|
// Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Test-Helper für text-content. Legt einen TextBlock direkt über den
|
|
2
2
|
// Event-Store-Executor an — gleicher Pfad wie der echte set-Handler,
|
|
3
|
-
// aber ohne Access-Check.
|
|
4
|
-
//
|
|
3
|
+
// aber ohne Access-Check. Default ifExists="skip": nur fehlende Blocks
|
|
4
|
+
// anlegen; opt-in update für Demo-Fixtures.
|
|
5
5
|
|
|
6
6
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
7
|
import {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type DbConnection,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
12
12
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
13
14
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
14
15
|
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
|
|
15
16
|
|
|
@@ -29,6 +30,7 @@ export type SeedTextBlockOptions = {
|
|
|
29
30
|
* set.write die geseedete Row später überschreiben kann. */
|
|
30
31
|
readonly folder?: string | null;
|
|
31
32
|
readonly by?: SessionUser;
|
|
33
|
+
readonly ifExists?: SeedIfExists;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
export async function seedTextBlock(
|
|
@@ -54,43 +56,45 @@ export async function seedTextBlock(
|
|
|
54
56
|
|
|
55
57
|
const folder = opts.folder ?? null;
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
59
|
+
return runEventStoreSeed({
|
|
60
|
+
existing,
|
|
61
|
+
ifExists: opts.ifExists,
|
|
62
|
+
create: async () => {
|
|
63
|
+
const result = await executor.create(
|
|
64
|
+
{
|
|
65
|
+
slug: opts.slug,
|
|
66
|
+
lang: opts.lang,
|
|
67
|
+
title: opts.title,
|
|
68
|
+
body: opts.body ?? null,
|
|
69
|
+
folder,
|
|
70
|
+
tenantId: opts.tenantId,
|
|
71
|
+
},
|
|
72
|
+
by,
|
|
73
|
+
tdb,
|
|
74
|
+
);
|
|
75
|
+
if (!result.isSuccess) {
|
|
76
|
+
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
77
|
+
}
|
|
78
|
+
const data = result.data as Partial<TextBlockRow>;
|
|
79
|
+
if (data.id === undefined) {
|
|
80
|
+
throw new Error("seedTextBlock: executor.create did not return an id");
|
|
81
|
+
}
|
|
82
|
+
return { id: data.id };
|
|
83
|
+
},
|
|
84
|
+
update: async (row) => {
|
|
85
|
+
const result = await executor.update(
|
|
86
|
+
{
|
|
87
|
+
id: row.id,
|
|
88
|
+
version: row.version,
|
|
89
|
+
changes: { title: opts.title, body: opts.body ?? null, folder },
|
|
90
|
+
},
|
|
91
|
+
by,
|
|
92
|
+
tdb,
|
|
93
|
+
);
|
|
94
|
+
if (!result.isSuccess) {
|
|
95
|
+
throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
|
|
96
|
+
}
|
|
97
|
+
return { id: row.id };
|
|
81
98
|
},
|
|
82
|
-
|
|
83
|
-
tdb,
|
|
84
|
-
);
|
|
85
|
-
if (!result.isSuccess) {
|
|
86
|
-
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
87
|
-
}
|
|
88
|
-
// @cast-boundary db-row executor.create result.data ist Drizzle-row
|
|
89
|
-
// (Record<string, unknown>), projected nach INSERT/RETURNING auf
|
|
90
|
-
// TextBlockRow. Runtime-narrowing in der nächsten Zeile.
|
|
91
|
-
const data = result.data as Partial<TextBlockRow>;
|
|
92
|
-
if (data.id === undefined) {
|
|
93
|
-
throw new Error("seedTextBlock: executor.create did not return an id");
|
|
94
|
-
}
|
|
95
|
-
return { id: data.id };
|
|
99
|
+
});
|
|
96
100
|
}
|
package/src/user/seeding.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Testing-Helper fürs user-Feature. `seedUser` legt einen User direkt
|
|
2
2
|
// über den Event-Store-Executor an — gleicher Pfad wie der echte
|
|
3
3
|
// `UserHandlers.create`, aber ohne Access-Check und ohne ConflictError
|
|
4
|
-
// bei Duplikaten
|
|
5
|
-
//
|
|
4
|
+
// bei Duplikaten. Verhält sich wie ifExists="skip" (siehe
|
|
5
|
+
// @cosmicdrift/kumiko-framework/seeding): existierende Email → return
|
|
6
|
+
// ohne Event.
|
|
6
7
|
//
|
|
7
8
|
// Warum nicht direkt `db.insert(userTable)`: das würde den Event-Store
|
|
8
9
|
// umgehen, also kein `user.created`-Event und keine MSP-Konsumenten
|