@cosmicdrift/kumiko-bundled-features 0.159.1 → 0.161.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/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +529 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +134 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/login-screen.tsx +5 -2
- package/src/auth-foundation/__tests__/feature.test.ts +16 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
- package/src/auth-foundation/boot-checks.ts +54 -0
- package/src/auth-foundation/feature.ts +77 -23
- package/src/auth-foundation/index.ts +14 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
- package/src/auth-foundation/types.ts +63 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
- package/src/auth-mfa/constants.ts +1 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/feature.ts +8 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
|
@@ -5,31 +5,65 @@ import {
|
|
|
5
5
|
PrimitivesProvider,
|
|
6
6
|
} from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
8
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsHandlers, TagsQueries } from "../../constants";
|
|
10
11
|
import { defaultTranslations } from "../i18n";
|
|
11
12
|
import { TagManager } from "../tag-manager";
|
|
12
13
|
|
|
13
14
|
type TagRow = { id: string; name: string; color?: string; scope?: string; version: number };
|
|
15
|
+
type AssignmentRow = { tagId: string };
|
|
14
16
|
|
|
15
17
|
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
let catalogLoading = false;
|
|
20
|
+
let catalogData: { rows: readonly TagRow[] } | null = { rows: catalogRows };
|
|
21
|
+
let catalogError: { i18nKey: string; i18nParams?: Record<string, unknown> } | null = null;
|
|
22
|
+
let assignmentError: { i18nKey: string; i18nParams?: Record<string, unknown> } | null = null;
|
|
16
23
|
|
|
17
24
|
beforeEach(() => {
|
|
18
25
|
catalogRows = [{ id: "t1", name: "urgent", color: "#ef4444", version: 1 }];
|
|
26
|
+
assignmentRows = [{ tagId: "t1" }, { tagId: "t1" }];
|
|
27
|
+
catalogLoading = false;
|
|
28
|
+
catalogData = { rows: catalogRows };
|
|
29
|
+
catalogError = null;
|
|
30
|
+
assignmentError = null;
|
|
31
|
+
dispatchSpy.mockClear();
|
|
32
|
+
catalogRefetch.mockClear();
|
|
33
|
+
assignmentsRefetch.mockClear();
|
|
19
34
|
});
|
|
20
35
|
|
|
21
|
-
const dispatchSpy = mock(async () =>
|
|
36
|
+
const dispatchSpy = mock(async (type: string) =>
|
|
37
|
+
type === TagsHandlers.createTag
|
|
38
|
+
? { isSuccess: true, data: { id: "tag-new" } }
|
|
39
|
+
: { isSuccess: true, data: undefined },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const catalogRefetch = mock(async () => {});
|
|
43
|
+
const assignmentsRefetch = mock(async () => {});
|
|
44
|
+
|
|
45
|
+
const useQuerySpy = mock((type: string) => {
|
|
46
|
+
if (type === TagsQueries.tagList) {
|
|
47
|
+
return {
|
|
48
|
+
data: catalogData,
|
|
49
|
+
loading: catalogLoading,
|
|
50
|
+
error: catalogError,
|
|
51
|
+
refetch: catalogRefetch,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
data: { rows: assignmentRows },
|
|
56
|
+
loading: false,
|
|
57
|
+
error: assignmentError,
|
|
58
|
+
refetch: assignmentsRefetch,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
22
61
|
|
|
23
62
|
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
24
63
|
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
25
64
|
...actual_renderer,
|
|
26
65
|
useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
|
|
27
|
-
useQuery:
|
|
28
|
-
data: { rows: catalogRows },
|
|
29
|
-
loading: false,
|
|
30
|
-
error: null,
|
|
31
|
-
refetch: mock(),
|
|
32
|
-
})),
|
|
66
|
+
useQuery: useQuerySpy,
|
|
33
67
|
}));
|
|
34
68
|
|
|
35
69
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
@@ -40,6 +74,174 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
40
74
|
);
|
|
41
75
|
}
|
|
42
76
|
|
|
77
|
+
describe("TagManager — loading and errors", () => {
|
|
78
|
+
test("shows loading banner while catalog is loading with no data yet", () => {
|
|
79
|
+
catalogLoading = true;
|
|
80
|
+
catalogData = null;
|
|
81
|
+
render(
|
|
82
|
+
<Wrapper>
|
|
83
|
+
<TagManager />
|
|
84
|
+
</Wrapper>,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByTestId("tag-manager-loading")).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("shows error banner when catalog query fails", () => {
|
|
90
|
+
catalogError = { i18nKey: "tags.section.empty" };
|
|
91
|
+
render(
|
|
92
|
+
<Wrapper>
|
|
93
|
+
<TagManager />
|
|
94
|
+
</Wrapper>,
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByTestId("tag-manager-error")).toBeTruthy();
|
|
97
|
+
expect(screen.getByText("No tags found.")).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("shows empty banner when catalog has no rows", () => {
|
|
101
|
+
catalogRows = [];
|
|
102
|
+
catalogData = { rows: catalogRows };
|
|
103
|
+
render(
|
|
104
|
+
<Wrapper>
|
|
105
|
+
<TagManager />
|
|
106
|
+
</Wrapper>,
|
|
107
|
+
);
|
|
108
|
+
expect(screen.getByTestId("tag-manager-empty")).toBeTruthy();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("TagManager — catalog CRUD", () => {
|
|
113
|
+
test("renders tag row with scope label and usage count", () => {
|
|
114
|
+
catalogRows = [{ id: "t2", name: "note-tag", color: "#22c55e", scope: "note", version: 1 }];
|
|
115
|
+
catalogData = { rows: catalogRows };
|
|
116
|
+
assignmentRows = [{ tagId: "t2" }];
|
|
117
|
+
render(
|
|
118
|
+
<Wrapper>
|
|
119
|
+
<TagManager />
|
|
120
|
+
</Wrapper>,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByTestId("tag-manager-row-t2")).toBeTruthy();
|
|
123
|
+
expect(screen.getByText("@note")).toBeTruthy();
|
|
124
|
+
expect(screen.getByText("1×")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("create dispatches write with trimmed name/color/scope and refetches", async () => {
|
|
128
|
+
render(
|
|
129
|
+
<Wrapper>
|
|
130
|
+
<TagManager />
|
|
131
|
+
</Wrapper>,
|
|
132
|
+
);
|
|
133
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
134
|
+
target: { value: " new-tag " },
|
|
135
|
+
});
|
|
136
|
+
fireEvent.click(screen.getByTestId("tag-manager-new-color-swatch-#22c55e"));
|
|
137
|
+
fireEvent.change(document.getElementById("tag-manager-new-scope") as HTMLInputElement, {
|
|
138
|
+
target: { value: " note " },
|
|
139
|
+
});
|
|
140
|
+
fireEvent.click(screen.getByTestId("tag-manager-create"));
|
|
141
|
+
|
|
142
|
+
await waitFor(() =>
|
|
143
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.createTag, {
|
|
144
|
+
name: "new-tag",
|
|
145
|
+
color: "#22c55e",
|
|
146
|
+
scope: "note",
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
await waitFor(() => expect(catalogRefetch).toHaveBeenCalled());
|
|
150
|
+
await waitFor(() => expect(assignmentsRefetch).toHaveBeenCalled());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("create button stays disabled for empty trimmed name", () => {
|
|
154
|
+
render(
|
|
155
|
+
<Wrapper>
|
|
156
|
+
<TagManager />
|
|
157
|
+
</Wrapper>,
|
|
158
|
+
);
|
|
159
|
+
const create = screen.getByTestId("tag-manager-create") as HTMLButtonElement;
|
|
160
|
+
expect(create.disabled).toBe(true);
|
|
161
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
162
|
+
target: { value: " " },
|
|
163
|
+
});
|
|
164
|
+
expect(create.disabled).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("save edit dispatches update and closes edit form", async () => {
|
|
168
|
+
render(
|
|
169
|
+
<Wrapper>
|
|
170
|
+
<TagManager />
|
|
171
|
+
</Wrapper>,
|
|
172
|
+
);
|
|
173
|
+
fireEvent.click(screen.getByTestId("tag-manager-edit-btn-t1"));
|
|
174
|
+
expect(screen.getByTestId("tag-manager-edit-t1")).toBeTruthy();
|
|
175
|
+
|
|
176
|
+
fireEvent.change(document.getElementById("tag-edit-name-t1") as HTMLInputElement, {
|
|
177
|
+
target: { value: "renamed" },
|
|
178
|
+
});
|
|
179
|
+
fireEvent.click(screen.getByTestId("tag-edit-color-t1-swatch-#3b82f6"));
|
|
180
|
+
fireEvent.click(screen.getByTestId("tag-manager-save-t1"));
|
|
181
|
+
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.updateTag, {
|
|
184
|
+
id: "t1",
|
|
185
|
+
version: 1,
|
|
186
|
+
name: "renamed",
|
|
187
|
+
color: "#3b82f6",
|
|
188
|
+
scope: "",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
await waitFor(() => expect(screen.queryByTestId("tag-manager-edit-t1")).toBeNull());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("cancel edit closes edit form without write", () => {
|
|
195
|
+
render(
|
|
196
|
+
<Wrapper>
|
|
197
|
+
<TagManager />
|
|
198
|
+
</Wrapper>,
|
|
199
|
+
);
|
|
200
|
+
fireEvent.click(screen.getByTestId("tag-manager-edit-btn-t1"));
|
|
201
|
+
fireEvent.click(screen.getByText("Cancel"));
|
|
202
|
+
expect(screen.queryByTestId("tag-manager-edit-t1")).toBeNull();
|
|
203
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("delete confirm dispatches deleteTag and refetches", async () => {
|
|
207
|
+
render(
|
|
208
|
+
<Wrapper>
|
|
209
|
+
<TagManager />
|
|
210
|
+
</Wrapper>,
|
|
211
|
+
);
|
|
212
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-btn-t1"));
|
|
213
|
+
expect(screen.getByTestId("tag-manager-delete-dialog")).toBeTruthy();
|
|
214
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-dialog-confirm"));
|
|
215
|
+
|
|
216
|
+
await waitFor(() =>
|
|
217
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.deleteTag, { id: "t1" }),
|
|
218
|
+
);
|
|
219
|
+
await waitFor(() => expect(catalogRefetch).toHaveBeenCalled());
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("failed write shows action error banner", async () => {
|
|
223
|
+
dispatchSpy.mockImplementationOnce(
|
|
224
|
+
async () =>
|
|
225
|
+
({ isSuccess: false, error: { i18nKey: "tags.section.empty" } }) as {
|
|
226
|
+
isSuccess: boolean;
|
|
227
|
+
data?: { id: string } | undefined;
|
|
228
|
+
error?: { i18nKey: string };
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
render(
|
|
232
|
+
<Wrapper>
|
|
233
|
+
<TagManager />
|
|
234
|
+
</Wrapper>,
|
|
235
|
+
);
|
|
236
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
237
|
+
target: { value: "fail-tag" },
|
|
238
|
+
});
|
|
239
|
+
fireEvent.click(screen.getByTestId("tag-manager-create"));
|
|
240
|
+
|
|
241
|
+
await waitFor(() => expect(screen.getByTestId("tag-manager-action-error")).toBeTruthy());
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
43
245
|
// 695/4: saveEdit silently no-ops on an empty trimmed name — the Save button
|
|
44
246
|
// must reflect that in its disabled state, not just in the click handler.
|
|
45
247
|
describe("TagManager — edit Save button", () => {
|
|
@@ -62,3 +264,70 @@ describe("TagManager — edit Save button", () => {
|
|
|
62
264
|
expect(save.disabled).toBe(false);
|
|
63
265
|
});
|
|
64
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("TagManager — selection mode", () => {
|
|
269
|
+
test("filters tags by entityType scope and toggles selection", () => {
|
|
270
|
+
catalogRows = [
|
|
271
|
+
{ id: "t1", name: "global", version: 1 },
|
|
272
|
+
{ id: "t2", name: "note-only", scope: "note", version: 1 },
|
|
273
|
+
{ id: "t3", name: "other", scope: "task", version: 1 },
|
|
274
|
+
];
|
|
275
|
+
catalogData = { rows: catalogRows };
|
|
276
|
+
const onChange = mock((next: readonly string[]) => next);
|
|
277
|
+
let selected: readonly string[] = ["t1"];
|
|
278
|
+
|
|
279
|
+
render(
|
|
280
|
+
<Wrapper>
|
|
281
|
+
<TagManager
|
|
282
|
+
entityType="note"
|
|
283
|
+
selection={{
|
|
284
|
+
value: selected,
|
|
285
|
+
onChange: (next) => {
|
|
286
|
+
selected = next;
|
|
287
|
+
onChange(next);
|
|
288
|
+
},
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
</Wrapper>,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(screen.getByTestId("tag-manager-row-t1")).toBeTruthy();
|
|
295
|
+
expect(screen.getByTestId("tag-manager-row-t2")).toBeTruthy();
|
|
296
|
+
expect(screen.queryByTestId("tag-manager-row-t3")).toBeNull();
|
|
297
|
+
expect(screen.queryByTestId("tag-manager-new-scope")).toBeNull();
|
|
298
|
+
|
|
299
|
+
fireEvent.click(screen.getByTestId("tag-manager-toggle-t2"));
|
|
300
|
+
expect(onChange).toHaveBeenCalledWith(["t1", "t2"]);
|
|
301
|
+
|
|
302
|
+
fireEvent.click(screen.getByTestId("tag-manager-toggle-t1"));
|
|
303
|
+
expect(onChange).toHaveBeenCalledWith([]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("create in selection mode adds new tag id to selection", async () => {
|
|
307
|
+
const onChange = mock((next: readonly string[]) => next);
|
|
308
|
+
render(
|
|
309
|
+
<Wrapper>
|
|
310
|
+
<TagManager entityType="note" selection={{ value: [], onChange }} />
|
|
311
|
+
</Wrapper>,
|
|
312
|
+
);
|
|
313
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
314
|
+
target: { value: "picked" },
|
|
315
|
+
});
|
|
316
|
+
fireEvent.click(screen.getByTestId("tag-manager-create"));
|
|
317
|
+
|
|
318
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith(["tag-new"]));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("delete removes tag from selection when it was selected", async () => {
|
|
322
|
+
const onChange = mock((next: readonly string[]) => next);
|
|
323
|
+
render(
|
|
324
|
+
<Wrapper>
|
|
325
|
+
<TagManager selection={{ value: ["t1"], onChange }} />
|
|
326
|
+
</Wrapper>,
|
|
327
|
+
);
|
|
328
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-btn-t1"));
|
|
329
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-dialog-confirm"));
|
|
330
|
+
|
|
331
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith([]));
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -387,6 +387,47 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
387
387
|
expect(result.cleanedJobIds).not.toContain(jobId);
|
|
388
388
|
expect(await provider.exists(storageKey)).toBe(true);
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
test("storage-delete Throw → Job bleibt uncleaned (retry naechster Pass)", async () => {
|
|
392
|
+
const jobId = await seedPendingJob();
|
|
393
|
+
const T = getTemporal();
|
|
394
|
+
const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
|
395
|
+
const storageKey = `${tenantA}/exports/${jobId}.zip`;
|
|
396
|
+
const provider = await buildProvider(tenantA);
|
|
397
|
+
await provider.write(storageKey, new Uint8Array([7, 8, 9]));
|
|
398
|
+
|
|
399
|
+
await updateRows(
|
|
400
|
+
stack.db,
|
|
401
|
+
exportJobsTable,
|
|
402
|
+
{
|
|
403
|
+
status: EXPORT_JOB_STATUS.Done,
|
|
404
|
+
startedAt: longAgo,
|
|
405
|
+
completedAt: longAgo,
|
|
406
|
+
downloadStorageKey: storageKey,
|
|
407
|
+
expiresAt: longAgo,
|
|
408
|
+
},
|
|
409
|
+
{ id: jobId },
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const result = await runExportJobs({
|
|
413
|
+
db: stack.db,
|
|
414
|
+
registry: stack.registry,
|
|
415
|
+
buildStorageProvider: async () => ({
|
|
416
|
+
...provider,
|
|
417
|
+
delete: async () => {
|
|
418
|
+
throw new Error("synthetic storage delete failure");
|
|
419
|
+
},
|
|
420
|
+
}),
|
|
421
|
+
now: NOW(),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
expect(result.cleanedJobIds).not.toContain(jobId);
|
|
425
|
+
expect(await provider.exists(storageKey)).toBe(true);
|
|
426
|
+
const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{
|
|
427
|
+
downloadStorageKey: string | null;
|
|
428
|
+
}>;
|
|
429
|
+
expect(row?.downloadStorageKey).toBe(storageKey);
|
|
430
|
+
});
|
|
390
431
|
});
|
|
391
432
|
|
|
392
433
|
describe("runExportJobs :: idempotency", () => {
|
|
@@ -708,6 +749,145 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
708
749
|
expect(row?.errorMessage).toMatch(/Token-Creation failed/);
|
|
709
750
|
});
|
|
710
751
|
|
|
752
|
+
test("failed-Job: sendExportFailedEmail wird mit errorMessage + userEmail gerufen", async () => {
|
|
753
|
+
const jobId = await seedPendingJob();
|
|
754
|
+
|
|
755
|
+
// Force a deterministic failure the same way as the UNIQUE-violation
|
|
756
|
+
// test above — pre-seed a token row for this jobId so the worker's
|
|
757
|
+
// tokenCrud.create hits the unique index and the job flips to failed.
|
|
758
|
+
await asRawClient(stack.db).unsafe(
|
|
759
|
+
`
|
|
760
|
+
INSERT INTO read_export_download_tokens
|
|
761
|
+
(id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
|
|
762
|
+
VALUES (
|
|
763
|
+
gen_random_uuid(),
|
|
764
|
+
$1,
|
|
765
|
+
$2,
|
|
766
|
+
$3,
|
|
767
|
+
now(),
|
|
768
|
+
now() + interval '7 days',
|
|
769
|
+
1,
|
|
770
|
+
now(),
|
|
771
|
+
now()
|
|
772
|
+
)
|
|
773
|
+
`,
|
|
774
|
+
[tenantA, jobId, "existing-hash"],
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
type SentArgs = {
|
|
778
|
+
userId: string;
|
|
779
|
+
userEmail: string;
|
|
780
|
+
jobId: string;
|
|
781
|
+
errorMessage: string;
|
|
782
|
+
tenantId: string;
|
|
783
|
+
};
|
|
784
|
+
const sentEmails: SentArgs[] = [];
|
|
785
|
+
|
|
786
|
+
const result = await runExportJobs({
|
|
787
|
+
db: stack.db,
|
|
788
|
+
registry: stack.registry,
|
|
789
|
+
buildStorageProvider: buildProvider,
|
|
790
|
+
now: NOW(),
|
|
791
|
+
sendExportFailedEmail: async (args) => {
|
|
792
|
+
sentEmails.push(args);
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
expect(result.failedJobIds).toContain(jobId);
|
|
797
|
+
expect(sentEmails).toHaveLength(1);
|
|
798
|
+
const sent = sentEmails[0];
|
|
799
|
+
if (!sent) throw new Error("expected 1 sent email");
|
|
800
|
+
expect(sent.userId).toBe(String(aliceUser.id));
|
|
801
|
+
expect(sent.userEmail).toBe("alice@example.com");
|
|
802
|
+
expect(sent.jobId).toBe(jobId);
|
|
803
|
+
expect(sent.errorMessage).toMatch(/Token-Creation failed/);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("failed-Job ohne Callback: kein Email, Worker-Run succeeded", async () => {
|
|
807
|
+
const jobId = await seedPendingJob();
|
|
808
|
+
await asRawClient(stack.db).unsafe(
|
|
809
|
+
`
|
|
810
|
+
INSERT INTO read_export_download_tokens
|
|
811
|
+
(id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
|
|
812
|
+
VALUES (gen_random_uuid(), $1, $2, $3, now(), now() + interval '7 days', 1, now(), now())
|
|
813
|
+
`,
|
|
814
|
+
[tenantA, jobId, "existing-hash"],
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
const result = await runExportJobs({
|
|
818
|
+
db: stack.db,
|
|
819
|
+
registry: stack.registry,
|
|
820
|
+
buildStorageProvider: buildProvider,
|
|
821
|
+
now: NOW(),
|
|
822
|
+
// Kein sendExportFailedEmail
|
|
823
|
+
});
|
|
824
|
+
expect(result.failedJobIds).toContain(jobId);
|
|
825
|
+
// Kein Email, kein Throw — Worker laeuft normal durch
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("failed-Job, sendExportFailedEmail-Throw wird abgefangen (best-effort, kein Batch-Abbruch)", async () => {
|
|
829
|
+
const jobId = await seedPendingJob();
|
|
830
|
+
await asRawClient(stack.db).unsafe(
|
|
831
|
+
`
|
|
832
|
+
INSERT INTO read_export_download_tokens
|
|
833
|
+
(id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
|
|
834
|
+
VALUES (gen_random_uuid(), $1, $2, $3, now(), now() + interval '7 days', 1, now(), now())
|
|
835
|
+
`,
|
|
836
|
+
[tenantA, jobId, "existing-hash"],
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
// Should not throw despite the callback throwing — the try/catch
|
|
840
|
+
// around fireExportFailedCallback swallows it (operator-visibility
|
|
841
|
+
// via console.warn only).
|
|
842
|
+
const result = await runExportJobs({
|
|
843
|
+
db: stack.db,
|
|
844
|
+
registry: stack.registry,
|
|
845
|
+
buildStorageProvider: buildProvider,
|
|
846
|
+
now: NOW(),
|
|
847
|
+
sendExportFailedEmail: async () => {
|
|
848
|
+
throw new Error("synthetic email transport failure");
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
expect(result.failedJobIds).toContain(jobId);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("failed-Job, user ohne email → sendExportFailedEmail skipped (kein Throw)", async () => {
|
|
855
|
+
await resetTestTables(stack.db, [userTable]);
|
|
856
|
+
await seedRow(stack.db, userTable, {
|
|
857
|
+
id: String(aliceUser.id),
|
|
858
|
+
tenantId: tenantA,
|
|
859
|
+
email: "" as string,
|
|
860
|
+
passwordHash: "hashed",
|
|
861
|
+
displayName: "Alice",
|
|
862
|
+
locale: "de",
|
|
863
|
+
emailVerified: true,
|
|
864
|
+
roles: '["Member"]',
|
|
865
|
+
status: USER_STATUS.Active,
|
|
866
|
+
});
|
|
867
|
+
const jobId = await seedPendingJob();
|
|
868
|
+
await asRawClient(stack.db).unsafe(
|
|
869
|
+
`
|
|
870
|
+
INSERT INTO read_export_download_tokens
|
|
871
|
+
(id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
|
|
872
|
+
VALUES (gen_random_uuid(), $1, $2, $3, now(), now() + interval '7 days', 1, now(), now())
|
|
873
|
+
`,
|
|
874
|
+
[tenantA, jobId, "existing-hash"],
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
let callbackInvoked = false;
|
|
878
|
+
const result = await runExportJobs({
|
|
879
|
+
db: stack.db,
|
|
880
|
+
registry: stack.registry,
|
|
881
|
+
buildStorageProvider: buildProvider,
|
|
882
|
+
now: NOW(),
|
|
883
|
+
sendExportFailedEmail: async () => {
|
|
884
|
+
callbackInvoked = true;
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
expect(result.failedJobIds).toContain(jobId);
|
|
888
|
+
expect(callbackInvoked).toBe(false);
|
|
889
|
+
});
|
|
890
|
+
|
|
711
891
|
test("failed-Job mit downloadStorageKey → ZIP wird im Cleanup-Pass entfernt (orphan-fix)", async () => {
|
|
712
892
|
// Atom 4a.fix orphan-cleanup: failed-Jobs haben downloadStorageKey
|
|
713
893
|
// gesetzt (path-pre-claim) — der ZIP-Pfad ist persistiert ab claim.
|
|
@@ -71,6 +71,7 @@ function makeDispatcher(
|
|
|
71
71
|
query,
|
|
72
72
|
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
73
73
|
statusStore,
|
|
74
|
+
async *stream() {},
|
|
74
75
|
pendingWrites: () => [],
|
|
75
76
|
pendingFiles: () => [],
|
|
76
77
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
@@ -37,6 +37,7 @@ function makeDispatcher(me: Record<string, unknown>): Dispatcher {
|
|
|
37
37
|
query: (async () => ({ isSuccess: true, data: me })) as unknown as Dispatcher["query"],
|
|
38
38
|
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
39
39
|
statusStore,
|
|
40
|
+
async *stream() {},
|
|
40
41
|
pendingWrites: () => [],
|
|
41
42
|
pendingFiles: () => [],
|
|
42
43
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|