import { describe, it, expect, vi, afterEach } from 'vitest'; import { goto } from '$app/navigation'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); function makeFile(name: string): File { return new File(['content'], name, { type: 'application/pdf' }); } async function addFilesViaInput(container: HTMLElement, files: File[]): Promise { const input = container.querySelector('input[type="file"]') as HTMLInputElement; if (!input) throw new Error('No file input found — is BulkDropZone visible?'); await userEvent.upload(input, files); } describe('BulkDocumentEditLayout', () => { it('N=0: shows BulkDropZone', async () => { render(BulkDocumentEditLayout, {}); await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument(); }); it('N=1: file-switcher-strip and per-file scope card are absent', async () => { const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('doc.pdf')]); expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull(); expect(container.querySelector('[data-variant="per-file"]')).toBeNull(); }); it('N=5: file-switcher-strip and per-file scope card are both present', async () => { const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [ makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf'), makeFile('d.pdf'), makeFile('e.pdf') ]); expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(); expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull(); }); it('removing middle file preserves order of remaining files', async () => { const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [ makeFile('file0.pdf'), makeFile('file1.pdf'), makeFile('file2.pdf') ]); // Remove the chip for file1 via its remove button (identified by data-remove-id) const removeButtons = container.querySelectorAll( '[data-testid="file-switcher-strip"] button[data-remove-id]' ); expect(removeButtons.length).toBe(3); removeButtons[1].click(); // remove file1 // Wait for Svelte to flush the DOM update await vi.waitFor( () => { const chips = container.querySelectorAll( '[data-testid="file-switcher-strip"] [data-chip-id]' ); expect(chips.length).toBe(2); expect(chips[0].textContent?.trim()).toContain('file0'); expect(chips[1].textContent?.trim()).toContain('file2'); }, { timeout: 1000 } ); }); it('save calls fetch twice for 12 files (2 chunks of 10)', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ created: [], updated: [], errors: [] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`)); await addFilesViaInput(container, files); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; expect(saveBtn).not.toBeNull(); saveBtn.click(); // Wait for async save to complete await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 }); }); it('save marks file as error when server returns non-ok response', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('f0.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); }); it('save() includes tagNames in metadata payload', async () => { let capturedFormData: FormData | undefined; const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => { capturedFormData = init?.body as FormData; return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) }; }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('doc.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); expect(capturedFormData).toBeDefined(); const metadataBlob = capturedFormData!.get('metadata') as Blob; const metadataJson = JSON.parse(await metadataBlob.text()); expect(metadataJson).toHaveProperty('tagNames'); }); it('save() navigates to /documents when all chunks succeed', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ created: [], updated: [], errors: [] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('doc.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 }); }); it('save() does not navigate when chunk returns non-ok response', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('f0.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); expect(goto).not.toHaveBeenCalled(); }); it('save marks only the file whose filename matches the backend error, not adjacent files', async () => { // backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error" const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); await vi.waitFor( () => { const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]'); expect(errorChips.length).toBe(1); expect(errorChips[0].textContent).toContain('b'); }, { timeout: 1000 } ); }); it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => { // Backend can return 200 OK while reporting individual file failures const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ created: [{ id: '1' }], updated: [], errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); await vi.waitFor( () => { const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]'); expect(errorChips.length).toBe(1); expect(errorChips[0].textContent).toContain('b'); }, { timeout: 1000 } ); // Navigation should be suppressed because hadErrors is true expect(goto).not.toHaveBeenCalled(); }); it('save() marks all chunk files as errored when fetch throws a network error', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor( () => { const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]'); expect(errorChips.length).toBe(2); }, { timeout: 3000 } ); expect(goto).not.toHaveBeenCalled(); }); it('save() does not call fetch a second time when already saving', async () => { let resolveFirst: (() => void) | undefined; const mockFetch = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveFirst = () => resolve({ ok: true, json: async () => ({ created: [], updated: [], errors: [] }) } as Response); }) ); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('a.pdf')]); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); // first click — fetch is in-flight saveBtn.click(); // second click — should be a no-op resolveFirst?.(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); expect(mockFetch).toHaveBeenCalledTimes(1); }); it('discard-all resets to N=0 state and shows drop zone', async () => { const { container } = render(BulkDocumentEditLayout, {}); await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]); // Confirm N=2 state — switcher is visible expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(); // Click the topbar discard-all button (only visible in isMulti state) const discardBtn = container.querySelector( 'button[data-testid="discard-all-btn"]' ) as HTMLButtonElement; expect(discardBtn).not.toBeNull(); discardBtn.click(); await vi.waitFor( () => { expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull(); expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull(); }, { timeout: 1000 } ); }); }); // ─── mode="edit" ───────────────────────────────────────────────────────────── describe('BulkDocumentEditLayout — mode="edit" discard', () => { it('discard in edit mode clears the selection store and navigates back to /documents', async () => { const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte'); bulkSelectionStore.setAll(['doc-1']); const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [ { id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' }, { id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' } ] }); const discardBtn = container.querySelector( 'button[data-testid="discard-all-btn"]' ) as HTMLButtonElement; expect(discardBtn).not.toBeNull(); discardBtn.click(); await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 }); expect(bulkSelectionStore.size).toBe(0); }); }); describe('BulkDocumentEditLayout — mode="edit"', () => { const editEntry = (i: number) => ({ id: `doc-${i}`, title: `Brief ${i}`, pdfUrl: `/api/documents/doc-${i}/file` }); it('does not render the BulkDropZone in edit mode', async () => { const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull(); }); it('renders the onboarding callout with role=note in edit mode', async () => { render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); const callout = page.getByTestId('bulk-edit-callout'); await expect.element(callout).toBeInTheDocument(); await expect.element(callout).toHaveAttribute('role', 'note'); }); it('renders read-only title display (no input) in edit mode', async () => { const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull(); // Per-file ScopeCard absent at N=1 — title rendered in the single card const titleInput = container.querySelector('input[type="text"][value="Brief 1"]'); expect(titleInput).toBeNull(); }); it('hides the date field via WhoWhenSection hideDate prop', async () => { const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull(); }); it('shows additive badge next to tags label', async () => { const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull(); }); it('shows replace badges next to sender and archive fields', async () => { const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]'); // sender + archiveBox + archiveFolder = 3 expect(replaceBadges.length).toBeGreaterThanOrEqual(3); }); it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => { // Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" / // "werden erstellt" copy must not appear when mode === 'edit'. const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1), editEntry(2)] }); // Topbar title slot const topbar = container.querySelector('span.font-bold.text-ink'); expect(topbar?.textContent).toContain('Massenbearbeitung'); // Count pill const pill = container.querySelector('span.bg-accent'); expect(pill?.textContent).toContain('werden bearbeitet'); // Negative: must NOT show upload-flavoured copy expect(topbar?.textContent ?? '').not.toContain('hochladen'); expect(pill?.textContent ?? '').not.toContain('werden erstellt'); }); it('shows the archiveBox and archiveFolder bulk-only inputs', async () => { const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1)] }); expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull(); expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull(); }); it('save calls PATCH /api/documents/bulk in edit mode', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ updated: 2, errors: [] }) }); vi.stubGlobal('fetch', mockFetch); const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1), editEntry(2)] }); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; expect(saveBtn).not.toBeNull(); saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); const [url, init] = mockFetch.mock.calls[0]; expect(url).toBe('/api/documents/bulk'); expect(init.method).toBe('PATCH'); const body = JSON.parse(init.body); expect(body.documentIds).toEqual(['doc-1', 'doc-2']); }); it('chunks IDs into 500-sized PATCH requests', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ updated: 500, errors: [] }) }); vi.stubGlobal('fetch', mockFetch); const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i)); const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: entries }); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 }); expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500); expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500); expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100); }); it('stops on chunk failure and shows the partial-failure alert with retry', async () => { const mockFetch = vi .fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) }) .mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) }); vi.stubGlobal('fetch', mockFetch); const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i)); const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: entries }); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor( () => { const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]'); expect(alert).not.toBeNull(); }, { timeout: 5000 } ); // Should have called twice — chunks 0 and 1 — but not the third. expect(mockFetch).toHaveBeenCalledTimes(2); expect(vi.mocked(goto)).not.toHaveBeenCalled(); }); it('marks per-document error chips when service returns errors[]', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ updated: 1, errors: [{ id: 'doc-2', message: 'Sender not found' }] }) }) ); const { container } = render(BulkDocumentEditLayout, { mode: 'edit', initialEditEntries: [editEntry(1), editEntry(2)] }); const saveBtn = container.querySelector( 'button[data-testid="bulk-save-btn"]' ) as HTMLButtonElement; saveBtn.click(); await vi.waitFor( () => { const errorChip = container.querySelector( '[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]' ); expect(errorChip).not.toBeNull(); }, { timeout: 3000 } ); }); });