@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/src/config/__tests__/write-helpers.test.ts +152 -0
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/read-redaction.ts +0 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -4
- package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/entity.ts +10 -3
- package/src/custom-fields/events.ts +4 -1
- package/src/custom-fields/feature.ts +1 -5
- package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
- package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
- package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
- package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
- package/src/custom-fields/wire-for-entity.ts +7 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
- package/src/files-provider-s3/s3-provider.ts +2 -4
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
- package/src/legal-pages/web/client-plugin.ts +9 -10
- package/src/managed-pages/handlers/set.write.ts +4 -11
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
- package/src/sessions/feature.ts +16 -3
- package/src/tags/__tests__/tags.integration.test.ts +30 -1
- package/src/tags/entity.ts +8 -0
- package/src/tags/handlers/assign-tag.write.ts +20 -5
- package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
- package/src/tags/web/i18n.ts +6 -2
- package/src/tags/web/tag-section.tsx +87 -76
- package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
- package/src/text-content/web/client-plugin.tsx +16 -13
- package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
- package/src/tier-engine/__tests__/trial.test.ts +27 -0
- package/src/tier-engine/entity.ts +8 -0
- package/src/tier-engine/feature.ts +49 -9
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
- package/src/tier-engine/index.ts +1 -0
- package/src/tier-engine/trial.ts +26 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
- package/src/user-data-rights/constants.ts +48 -0
- package/src/user-data-rights/feature.ts +15 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
- package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
- package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
- package/src/user-data-rights/index.ts +3 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
- package/src/user-data-rights/run-forget-cleanup.ts +3 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
- package/src/user-data-rights/web/client-plugin.tsx +30 -0
- package/src/user-data-rights/web/i18n.ts +95 -0
- package/src/user-data-rights/web/index.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
|
@@ -9,7 +9,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { TagsHandlers, TagsQueries } from "../../constants";
|
|
11
11
|
import { defaultTranslations } from "../i18n";
|
|
12
|
-
import { TagSection } from "../tag-section";
|
|
12
|
+
import { TagSection, tagSelectionDelta } from "../tag-section";
|
|
13
13
|
|
|
14
14
|
type TagRow = { id: string; name: string };
|
|
15
15
|
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
@@ -17,14 +17,12 @@ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
|
17
17
|
let catalogRows: readonly TagRow[] = [];
|
|
18
18
|
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
19
|
|
|
20
|
-
// createTag returns the new id; assign/remove return data-less success.
|
|
21
20
|
const dispatchSpy = mock(async (type: string) =>
|
|
22
21
|
type === TagsHandlers.createTag
|
|
23
22
|
? { isSuccess: true, data: { id: "tag-new" } }
|
|
24
23
|
: { isSuccess: true, data: undefined },
|
|
25
24
|
);
|
|
26
25
|
|
|
27
|
-
// useQuery is called twice (catalog + assignments) — branch on the QN.
|
|
28
26
|
const useQuerySpy = mock((type: string) => ({
|
|
29
27
|
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
30
28
|
loading: false,
|
|
@@ -47,14 +45,33 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
47
45
|
);
|
|
48
46
|
}
|
|
49
47
|
|
|
48
|
+
// The combobox's assign/remove toggle drives onChange with the full new
|
|
49
|
+
// selection; the component diffs it against the current tags via this helper.
|
|
50
|
+
// Popover interaction itself (cmdk + Radix in jsdom) is covered by the
|
|
51
|
+
// combobox primitive's own tests + e2e — here we pin the diff that turns a
|
|
52
|
+
// selection into assign/remove calls.
|
|
53
|
+
describe("tagSelectionDelta", () => {
|
|
54
|
+
test("addition only", () => {
|
|
55
|
+
expect(tagSelectionDelta(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] });
|
|
56
|
+
});
|
|
57
|
+
test("removal only", () => {
|
|
58
|
+
expect(tagSelectionDelta(["a", "b"], ["a"])).toEqual({ added: [], removed: ["b"] });
|
|
59
|
+
});
|
|
60
|
+
test("simultaneous add + remove", () => {
|
|
61
|
+
expect(tagSelectionDelta(["a"], ["b"])).toEqual({ added: ["b"], removed: ["a"] });
|
|
62
|
+
});
|
|
63
|
+
test("no change", () => {
|
|
64
|
+
expect(tagSelectionDelta(["a", "b"], ["b", "a"])).toEqual({ added: [], removed: [] });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
50
68
|
describe("TagSection", () => {
|
|
51
|
-
test("
|
|
69
|
+
test("renders assigned tags as combobox chips", () => {
|
|
52
70
|
catalogRows = [
|
|
53
71
|
{ id: "t1", name: "important" },
|
|
54
72
|
{ id: "t2", name: "project-x" },
|
|
55
73
|
];
|
|
56
74
|
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
|
|
57
|
-
dispatchSpy.mockClear();
|
|
58
75
|
|
|
59
76
|
render(
|
|
60
77
|
<Wrapper>
|
|
@@ -62,28 +79,10 @@ describe("TagSection", () => {
|
|
|
62
79
|
</Wrapper>,
|
|
63
80
|
);
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
expect(screen.
|
|
68
|
-
expect(screen.
|
|
69
|
-
|
|
70
|
-
fireEvent.click(screen.getByTestId("tags-section-assign-t2"));
|
|
71
|
-
await waitFor(() =>
|
|
72
|
-
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
73
|
-
tagId: "t2",
|
|
74
|
-
entityType: "note",
|
|
75
|
-
entityId: "note-1",
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
fireEvent.click(screen.getByTestId("tags-section-remove-t1"));
|
|
80
|
-
await waitFor(() =>
|
|
81
|
-
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
|
|
82
|
-
tagId: "t1",
|
|
83
|
-
entityType: "note",
|
|
84
|
-
entityId: "note-1",
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
82
|
+
expect(screen.getByTestId("combobox-tags-section-select")).toBeTruthy();
|
|
83
|
+
// assigned → chip shown in the trigger; unassigned t2 lives in the (closed) dropdown
|
|
84
|
+
expect(screen.getByText("important")).toBeTruthy();
|
|
85
|
+
expect(screen.queryByText("project-x")).toBeNull();
|
|
87
86
|
});
|
|
88
87
|
|
|
89
88
|
test("create-and-attach dispatches create-tag, then assign-tag with the new id", async () => {
|
package/src/tags/web/i18n.ts
CHANGED
|
@@ -9,7 +9,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
9
9
|
de: {
|
|
10
10
|
"tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
|
|
11
11
|
"tags.section.loading": "Lädt…",
|
|
12
|
-
"tags.section.
|
|
12
|
+
"tags.section.label": "Tags",
|
|
13
|
+
"tags.section.placeholder": "Tags auswählen…",
|
|
14
|
+
"tags.section.empty": "Keine Tags gefunden.",
|
|
13
15
|
"tags.section.newLabel": "Neuer Tag",
|
|
14
16
|
"tags.section.create": "Tag anlegen & zuweisen",
|
|
15
17
|
"tags.section.working": "Speichert…",
|
|
@@ -17,7 +19,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
17
19
|
en: {
|
|
18
20
|
"tags.section.createMode": "Save the entity first to add tags.",
|
|
19
21
|
"tags.section.loading": "Loading…",
|
|
20
|
-
"tags.section.
|
|
22
|
+
"tags.section.label": "Tags",
|
|
23
|
+
"tags.section.placeholder": "Select tags…",
|
|
24
|
+
"tags.section.empty": "No tags found.",
|
|
21
25
|
"tags.section.newLabel": "New tag",
|
|
22
26
|
"tags.section.create": "Create & attach tag",
|
|
23
27
|
"tags.section.working": "Saving…",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// TagSection — drop-in tag manager for ANY entity
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// TagSection — drop-in tag manager for ANY entity, GitLab-labels style: one
|
|
3
|
+
// searchable multi-combobox showing the entity's tags as chips, with a compact
|
|
4
|
+
// row below to create-and-attach a brand-new tag. Tag writes are immediate
|
|
5
|
+
// (assign/remove are idempotent), so the section owns its state and refetches
|
|
6
|
+
// after each action — it is NOT part of a host form's save.
|
|
7
7
|
//
|
|
8
8
|
// Two ways to mount (both need tagsClient() registered once, for i18n):
|
|
9
9
|
// - standalone: <TagSection entityName="note" entityId={noteId} />
|
|
@@ -29,12 +29,20 @@ type AssignmentRow = {
|
|
|
29
29
|
type TagListResponse = { readonly rows: readonly TagRow[] };
|
|
30
30
|
type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
// What changed between the entity's current tags and the combobox's new
|
|
33
|
+
// selection. A single combobox toggle yields one add or one remove; the diff
|
|
34
|
+
// stays correct for a batch selection too.
|
|
35
|
+
export function tagSelectionDelta(
|
|
36
|
+
prev: readonly string[],
|
|
37
|
+
next: readonly string[],
|
|
38
|
+
): { readonly added: readonly string[]; readonly removed: readonly string[] } {
|
|
39
|
+
const prevSet = new Set(prev);
|
|
40
|
+
const nextSet = new Set(next);
|
|
41
|
+
return {
|
|
42
|
+
added: next.filter((id) => !prevSet.has(id)),
|
|
43
|
+
removed: prev.filter((id) => !nextSet.has(id)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
38
46
|
|
|
39
47
|
export function TagSection({
|
|
40
48
|
entityName,
|
|
@@ -84,98 +92,101 @@ export function TagSection({
|
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
const catalogTags = catalog.data?.rows ?? [];
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
const assignedIds = (assignments.data?.rows ?? [])
|
|
96
|
+
.filter((r) => r.entityType === entityName)
|
|
97
|
+
.map((r) => r.tagId);
|
|
98
|
+
// Catalog drives the options; an assigned tag missing from the catalog (none
|
|
99
|
+
// in v1 — no delete-tag yet) is appended so it stays removable.
|
|
100
|
+
const nameById = new Map(catalogTags.map((tg) => [tg.id, tg.name]));
|
|
101
|
+
const options = [...new Set([...catalogTags.map((tg) => tg.id), ...assignedIds])].map((id) => ({
|
|
102
|
+
value: id,
|
|
103
|
+
label: nameById.get(id) ?? id,
|
|
104
|
+
}));
|
|
92
105
|
|
|
93
106
|
const refetch = async (): Promise<void> => {
|
|
94
107
|
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
95
108
|
};
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
// Runs a write-sequence (each step returns false + sets errorKey on failure,
|
|
111
|
+
// stopping the sequence) and refetches to server-truth when it completes.
|
|
112
|
+
const apply = async (writes: () => Promise<boolean>): Promise<void> => {
|
|
98
113
|
setBusy(true);
|
|
99
114
|
setErrorKey(null);
|
|
100
115
|
try {
|
|
101
|
-
|
|
102
|
-
if (!result.isSuccess) {
|
|
103
|
-
setErrorKey(result.error.i18nKey);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
await refetch();
|
|
116
|
+
if (await writes()) await refetch();
|
|
107
117
|
} finally {
|
|
108
118
|
setBusy(false);
|
|
109
119
|
}
|
|
110
120
|
};
|
|
111
121
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
123
|
+
const result = await dispatcher.write(type, payload);
|
|
124
|
+
if (!result.isSuccess) {
|
|
125
|
+
setErrorKey(result.error.i18nKey);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
116
130
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
131
|
+
const onSelectionChange = (next: readonly string[]): void => {
|
|
132
|
+
const { added, removed } = tagSelectionDelta(assignedIds, next);
|
|
133
|
+
if (added.length === 0 && removed.length === 0) return;
|
|
134
|
+
void apply(async () => {
|
|
135
|
+
for (const tagId of added) {
|
|
136
|
+
if (!(await writeOk(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId })))
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
for (const tagId of removed) {
|
|
140
|
+
if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
121
146
|
|
|
122
|
-
const createAndAssign =
|
|
147
|
+
const createAndAssign = (): void => {
|
|
123
148
|
const name = newName.trim();
|
|
124
149
|
if (name === "") return;
|
|
125
|
-
|
|
126
|
-
setErrorKey(null);
|
|
127
|
-
try {
|
|
150
|
+
void apply(async () => {
|
|
128
151
|
const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, { name });
|
|
129
152
|
if (!created.isSuccess) {
|
|
130
153
|
setErrorKey(created.error.i18nKey);
|
|
131
|
-
return;
|
|
154
|
+
return false;
|
|
132
155
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return;
|
|
156
|
+
if (
|
|
157
|
+
!(await writeOk(TagsHandlers.assignTag, {
|
|
158
|
+
tagId: created.data.id,
|
|
159
|
+
entityType: entityName,
|
|
160
|
+
entityId,
|
|
161
|
+
}))
|
|
162
|
+
) {
|
|
163
|
+
return false;
|
|
141
164
|
}
|
|
142
165
|
setNewName("");
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
setBusy(false);
|
|
146
|
-
}
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
147
168
|
};
|
|
148
169
|
|
|
149
170
|
return (
|
|
150
171
|
<div data-testid="tags-section">
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
testId={`tags-section-remove-${tg.id}`}
|
|
161
|
-
>
|
|
162
|
-
{`${tg.name} ✕`}
|
|
163
|
-
</Button>
|
|
164
|
-
))
|
|
165
|
-
)}
|
|
166
|
-
|
|
167
|
-
{available.map((tg) => (
|
|
168
|
-
<Button
|
|
169
|
-
key={tg.id}
|
|
170
|
-
variant="secondary"
|
|
172
|
+
<Field id="tags-section-select" label={t("tags.section.label")}>
|
|
173
|
+
<Input
|
|
174
|
+
kind="combobox"
|
|
175
|
+
multiple
|
|
176
|
+
id="tags-section-select"
|
|
177
|
+
name="tags"
|
|
178
|
+
options={options}
|
|
179
|
+
value={assignedIds}
|
|
180
|
+
onChange={onSelectionChange}
|
|
171
181
|
disabled={busy}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
</Button>
|
|
177
|
-
))}
|
|
182
|
+
placeholder={t("tags.section.placeholder")}
|
|
183
|
+
emptyText={t("tags.section.empty")}
|
|
184
|
+
/>
|
|
185
|
+
</Field>
|
|
178
186
|
|
|
187
|
+
{/* ponytail: separate create row — the shared combobox has no create-on-type
|
|
188
|
+
affordance. Fold create into the dropdown's Command.Empty if/when the
|
|
189
|
+
renderer-web combobox grows a freeSolo/onCreate prop. */}
|
|
179
190
|
<Field id="tags-section-new" label={t("tags.section.newLabel")}>
|
|
180
191
|
<Input
|
|
181
192
|
kind="text"
|
|
@@ -186,9 +197,9 @@ export function TagSection({
|
|
|
186
197
|
/>
|
|
187
198
|
</Field>
|
|
188
199
|
<Button
|
|
189
|
-
variant="
|
|
200
|
+
variant="secondary"
|
|
190
201
|
disabled={busy || newName.trim() === ""}
|
|
191
|
-
onClick={() =>
|
|
202
|
+
onClick={() => createAndAssign()}
|
|
192
203
|
testId="tags-section-create"
|
|
193
204
|
>
|
|
194
205
|
{busy ? t("tags.section.working") : t("tags.section.create")}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { textContentClient } from "../client-plugin";
|
|
4
|
+
|
|
5
|
+
// Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach + SSE-
|
|
6
|
+
// Entities, no-leak ohne navId (conditional-spread), und der Unwrap (Provider
|
|
7
|
+
// emittiert die Folder/Leaves direkt, NICHT unter dem "Content"-Wrapper).
|
|
8
|
+
// Der Provider fetcht → fetch wird gemockt.
|
|
9
|
+
|
|
10
|
+
describe("textContentClient — shape", () => {
|
|
11
|
+
test("ohne navId: kein navProvider/navEntities (no-leak), aber Resolver bleibt", () => {
|
|
12
|
+
const def = textContentClient();
|
|
13
|
+
expect(def.name).toBe("text-content");
|
|
14
|
+
expect(def.navProviders).toBeUndefined();
|
|
15
|
+
expect(def.navEntities).toBeUndefined();
|
|
16
|
+
expect(def.resolvers?.["text-content:edit"]).toBeDefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("mit navId: Provider + SSE-Entities unter exakt dieser QN", () => {
|
|
20
|
+
const navId = "publicstatus:nav:content";
|
|
21
|
+
const def = textContentClient({ navId });
|
|
22
|
+
expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
|
|
23
|
+
expect(def.navEntities?.[navId]).toEqual(["text-block"]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("textContentClient — Provider unwrappt den Content-Container", () => {
|
|
28
|
+
const origFetch = globalThis.fetch;
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = origFetch;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("emittiert Folder/Leaves direkt, kein 'Content'-Wrapper-Knoten", async () => {
|
|
34
|
+
const blocks = [
|
|
35
|
+
{ slug: "imprint", lang: "de", title: "Imprint", body: "x", folder: null, updatedAt: "" },
|
|
36
|
+
{ slug: "hero", lang: "de", title: "Hero", body: null, folder: "page", updatedAt: "" },
|
|
37
|
+
];
|
|
38
|
+
// Test-Mock-Grenze: bun-Mock deckt nicht die volle fetch-Signatur
|
|
39
|
+
// (preconnect etc.) — Double-Cast bewusst, nur dieser Test ruft fetch.
|
|
40
|
+
globalThis.fetch = mock(
|
|
41
|
+
async () =>
|
|
42
|
+
new Response(JSON.stringify({ data: { blocks } }), {
|
|
43
|
+
status: 200,
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
}),
|
|
46
|
+
) as unknown as typeof fetch;
|
|
47
|
+
|
|
48
|
+
const navId = "x:nav:content";
|
|
49
|
+
const provider = textContentClient({ navId }).navProviders?.[navId];
|
|
50
|
+
if (provider === undefined) throw new Error("provider missing");
|
|
51
|
+
|
|
52
|
+
let emitted: readonly TreeNode[] | undefined;
|
|
53
|
+
provider()((nodes) => {
|
|
54
|
+
emitted = nodes;
|
|
55
|
+
});
|
|
56
|
+
// fetch().then(...) ist async → eine Makrotask abwarten bis emit lief.
|
|
57
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
58
|
+
|
|
59
|
+
expect(emitted).toBeDefined();
|
|
60
|
+
const labels = (emitted ?? []).map((n) => n.label).sort();
|
|
61
|
+
// Kein "Content"-Wrapper — root-leaf "Imprint" + folder "page" direkt.
|
|
62
|
+
expect(labels).not.toContain("Content");
|
|
63
|
+
expect(labels).toEqual(["Imprint", "page"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -157,8 +157,10 @@ const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
|
|
|
157
157
|
return r.json();
|
|
158
158
|
})
|
|
159
159
|
.then((data: ByTenantResponse) => {
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
// Der App-seitige r.nav-Knoten IST der "Content"-Container → die
|
|
161
|
+
// Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
|
|
162
|
+
const content = groupBlocksByFolder(data.data.blocks)[0];
|
|
163
|
+
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
162
164
|
})
|
|
163
165
|
.catch((e) => {
|
|
164
166
|
// V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
|
|
@@ -346,19 +348,20 @@ function TextContentEditor({
|
|
|
346
348
|
);
|
|
347
349
|
}
|
|
348
350
|
|
|
349
|
-
|
|
351
|
+
// `navId` = die QN des r.nav({ provider: true })-Knotens, den die App für den
|
|
352
|
+
// Content-Tree registriert (z.B. "publicstatus:nav:content"). Die App besitzt
|
|
353
|
+
// Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
|
|
354
|
+
// liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
|
|
355
|
+
// Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
|
|
356
|
+
export function textContentClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
|
|
357
|
+
const navId = opts?.navId;
|
|
350
358
|
return {
|
|
351
359
|
name: "text-content",
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
treeActions: {
|
|
358
|
-
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
359
|
-
list: {},
|
|
360
|
-
create: { args: { folder: "" as string } },
|
|
361
|
-
},
|
|
360
|
+
...(navId !== undefined && {
|
|
361
|
+
navProviders: { [navId]: treeProvider },
|
|
362
|
+
// SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
|
|
363
|
+
navEntities: { [navId]: ["text-block"] },
|
|
364
|
+
}),
|
|
362
365
|
resolvers: {
|
|
363
366
|
"text-content:edit": TextContentEditor,
|
|
364
367
|
},
|
|
@@ -71,6 +71,20 @@ const features = composeFeatures(
|
|
|
71
71
|
{ includeBundled: true },
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
// Zweite Komposition MIT Trial-Option: jeder Tenant bekommt 30 Tage ab
|
|
75
|
+
// inserted_at die "pro"-Features (feat-pro), unabhängig vom gespeicherten Tier.
|
|
76
|
+
const TRIAL_HOURS = 30 * 24;
|
|
77
|
+
const featuresWithTrial = composeFeatures(
|
|
78
|
+
[
|
|
79
|
+
createTierEngineFeature({
|
|
80
|
+
tierMap: TEST_TIER_MAP,
|
|
81
|
+
trial: { tier: "pro", durationHours: TRIAL_HOURS },
|
|
82
|
+
}),
|
|
83
|
+
featProFeature,
|
|
84
|
+
],
|
|
85
|
+
{ includeBundled: true },
|
|
86
|
+
);
|
|
87
|
+
|
|
74
88
|
let stack: TestStack;
|
|
75
89
|
const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
|
|
76
90
|
const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
|
|
@@ -212,3 +226,56 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
|
|
|
212
226
|
expect(systemSet.size).toBeGreaterThanOrEqual(2);
|
|
213
227
|
});
|
|
214
228
|
});
|
|
229
|
+
|
|
230
|
+
describe("createTierEngineFeature — Trial-Phase (zeit-abgeleitet)", () => {
|
|
231
|
+
function sysUser(tenantId: TenantId, id: string) {
|
|
232
|
+
return createTestUser({ id, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test("neuer 'free'-Tenant sieht im Fenster die Trial-Features (feat-pro)", async () => {
|
|
236
|
+
const usage = findTierResolverUsage(featuresWithTrial);
|
|
237
|
+
if (!usage) throw new Error("setup failure: no trial resolver");
|
|
238
|
+
const plugin = usage.options as TierResolverPlugin;
|
|
239
|
+
|
|
240
|
+
// Gespeicherter Tier ist free (keine feat-pro), inserted_at = jetzt → Trial aktiv.
|
|
241
|
+
await stack.http.writeOk(
|
|
242
|
+
"tier-engine:write:tier-assignment:create",
|
|
243
|
+
{ tier: "free" },
|
|
244
|
+
sysUser(tenantA, "trial-sys-1"),
|
|
245
|
+
);
|
|
246
|
+
const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
|
|
247
|
+
expect(resolver(tenantA).has("feat-pro")).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("Tenant außerhalb des Fensters (inserted_at > 30 Tage) fällt auf free zurück", async () => {
|
|
251
|
+
const usage = findTierResolverUsage(featuresWithTrial);
|
|
252
|
+
if (!usage) throw new Error("setup failure: no trial resolver");
|
|
253
|
+
const plugin = usage.options as TierResolverPlugin;
|
|
254
|
+
|
|
255
|
+
await stack.http.writeOk(
|
|
256
|
+
"tier-engine:write:tier-assignment:create",
|
|
257
|
+
{ tier: "free" },
|
|
258
|
+
sysUser(tenantB, "trial-sys-2"),
|
|
259
|
+
);
|
|
260
|
+
// Anlage-Datum künstlich 31 Tage zurückdrehen → Trial abgelaufen. tenantB ist
|
|
261
|
+
// eine fixe Test-UUID (kein User-Input) → inline-Interpolation unkritisch.
|
|
262
|
+
await asRawClient(stack.db).unsafe(
|
|
263
|
+
`UPDATE read_tier_assignments SET inserted_at = now() - interval '31 days' WHERE tenant_id = '${tenantB}'::uuid`,
|
|
264
|
+
);
|
|
265
|
+
const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
|
|
266
|
+
expect(resolver(tenantB).has("feat-pro")).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("ohne Trial-Option ist der Resolver unverändert (free = keine feat-pro)", async () => {
|
|
270
|
+
const usage = findTierResolverUsage(features);
|
|
271
|
+
if (!usage) throw new Error("setup failure");
|
|
272
|
+
const plugin = usage.options as TierResolverPlugin;
|
|
273
|
+
await stack.http.writeOk(
|
|
274
|
+
"tier-engine:write:tier-assignment:create",
|
|
275
|
+
{ tier: "free" },
|
|
276
|
+
sysUser(tenantA, "no-trial-sys"),
|
|
277
|
+
);
|
|
278
|
+
const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
|
|
279
|
+
expect(resolver(tenantA).has("feat-pro")).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Trial-Fenster: reine epochMs-Arithmetik, Rand inklusive.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { isTrialActive } from "../trial";
|
|
5
|
+
|
|
6
|
+
const HOUR_MS = 3_600_000;
|
|
7
|
+
const start = 1_700_000_000_000;
|
|
8
|
+
|
|
9
|
+
describe("isTrialActive", () => {
|
|
10
|
+
test("innerhalb des Fensters → aktiv", () => {
|
|
11
|
+
expect(isTrialActive(start, start + 10 * 24 * HOUR_MS, 720)).toBe(true);
|
|
12
|
+
expect(isTrialActive(start, start, 720)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("exakt am Fenster-Ende → nicht mehr aktiv (halb-offen)", () => {
|
|
16
|
+
expect(isTrialActive(start, start + 720 * HOUR_MS, 720)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("nach dem Fenster → inaktiv", () => {
|
|
20
|
+
expect(isTrialActive(start, start + 721 * HOUR_MS, 720)).toBe(false);
|
|
21
|
+
expect(isTrialActive(start, start + 31 * 24 * HOUR_MS, 720)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("Dauer 0 → nie aktiv", () => {
|
|
25
|
+
expect(isTrialActive(start, start, 0)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -33,3 +33,11 @@ export const tierAssignmentEntity = createEntity({
|
|
|
33
33
|
source: createTextField({ required: false, maxLength: 20 }),
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
export type TierAssignmentRow = {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly version: number;
|
|
40
|
+
readonly tier: string;
|
|
41
|
+
readonly source: string | null;
|
|
42
|
+
readonly tenantId: string;
|
|
43
|
+
};
|