diff --git a/frontend/src/routes/DropZone.svelte b/frontend/src/routes/DropZone.svelte index dd9c5bb6..6f43bee7 100644 --- a/frontend/src/routes/DropZone.svelte +++ b/frontend/src/routes/DropZone.svelte @@ -5,6 +5,12 @@ import { getErrorMessage } from '$lib/errors'; const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']; +interface Props { + onUploadComplete?: (count: number) => void; +} + +let { onUploadComplete }: Props = $props(); + let isDragging = $state(false); let windowDragging = $state(false); let dragCounter = 0; @@ -80,6 +86,7 @@ async function uploadFiles(files: File[]) { const result = JSON.parse(body); if (result.created?.length > 0) { messages.push({ text: m.upload_success({ count: result.created.length }), isError: false }); + onUploadComplete?.(result.created.length); } for (const doc of result.updated ?? []) { messages.push({ diff --git a/frontend/src/routes/DropZone.svelte.spec.ts b/frontend/src/routes/DropZone.svelte.spec.ts new file mode 100644 index 00000000..514f1b74 --- /dev/null +++ b/frontend/src/routes/DropZone.svelte.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import DropZone from './DropZone.svelte'; + +vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn(async () => {}) })); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function stubXhrWith(responseBody: string) { + class FakeXhr { + upload = { addEventListener: vi.fn() }; + status = 200; + responseText = responseBody; + private loadHandler: (() => void) | null = null; + open = vi.fn(); + addEventListener = vi.fn((event: string, handler: () => void) => { + if (event === 'load') this.loadHandler = handler; + }); + send = vi.fn(() => { + queueMicrotask(() => this.loadHandler?.()); + }); + } + vi.stubGlobal('XMLHttpRequest', FakeXhr); +} + +describe('DropZone onUploadComplete', () => { + it('invokes callback with created.length after a successful upload', async () => { + stubXhrWith(JSON.stringify({ created: [{ id: 'd1' }, { id: 'd2' }], updated: [], errors: [] })); + + const onUploadComplete = vi.fn(); + render(DropZone, { onUploadComplete }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + const file = new File(['%PDF-1.4'], 'test.pdf', { type: 'application/pdf' }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + input!.files = dataTransfer.files; + input!.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + expect(onUploadComplete).toHaveBeenCalledTimes(1); + }); + expect(onUploadComplete).toHaveBeenCalledWith(2); + }); + + it('does not invoke callback when no files were created', async () => { + stubXhrWith(JSON.stringify({ created: [], updated: [], errors: [] })); + + const onUploadComplete = vi.fn(); + render(DropZone, { onUploadComplete }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF-1.4'], 'dupe.pdf', { type: 'application/pdf' }); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + + // Wait a tick to let the microtask flush + await new Promise((r) => setTimeout(r, 50)); + expect(onUploadComplete).not.toHaveBeenCalled(); + }); + + it('works when the onUploadComplete prop is not supplied', async () => { + stubXhrWith(JSON.stringify({ created: [{ id: 'x' }], updated: [], errors: [] })); + + render(DropZone, {}); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF-1.4'], 'x.pdf', { type: 'application/pdf' }); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + // Should not throw + input.dispatchEvent(new Event('change', { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + await expect.element(page.getByText(/1 Dokument/)).toBeInTheDocument(); + }); +});