@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.
Files changed (38) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
  3. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
  4. package/src/auth-email-password/handlers/login.write.ts +6 -3
  5. package/src/auth-email-password/i18n.ts +4 -0
  6. package/src/auth-email-password/web/__tests__/auth-client.test.ts +529 -0
  7. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  8. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  9. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +134 -0
  10. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  11. package/src/auth-email-password/web/auth-client.ts +4 -3
  12. package/src/auth-email-password/web/login-screen.tsx +5 -2
  13. package/src/auth-foundation/__tests__/feature.test.ts +16 -3
  14. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  15. package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
  16. package/src/auth-foundation/boot-checks.ts +54 -0
  17. package/src/auth-foundation/feature.ts +77 -23
  18. package/src/auth-foundation/index.ts +14 -0
  19. package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
  20. package/src/auth-foundation/types.ts +63 -0
  21. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
  22. package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
  23. package/src/auth-mfa/constants.ts +1 -0
  24. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  25. package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
  26. package/src/auth-mfa/mfa-status-checker.ts +13 -4
  27. package/src/config/__tests__/app-overrides.test.ts +11 -0
  28. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  29. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  30. package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
  31. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  32. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  33. package/src/personal-access-tokens/feature.ts +1 -1
  34. package/src/sessions/feature.ts +8 -1
  35. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  36. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
  37. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  38. 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 () => ({ isSuccess: true, data: undefined }));
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: mock(() => ({
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