From ebfa20dde57ef4817fdc797a4995404465226c23 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 11 May 2026 17:29:07 +0200 Subject: [PATCH] test(admin): rewrite admin/system page test with vi.waitFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 15 setTimeout sleeps with vi.waitFor on the actual signal (fetch URL recorded, banner appears, status text rendered) and switches the default fetch mock from mockResolvedValue to mockImplementation so each call yields a fresh Response — no more "body stream already read" unhandled rejections. Co-Authored-By: Claude Opus 4.7 --- .../routes/admin/system/page.svelte.test.ts | 136 +++++++++--------- 1 file changed, 72 insertions(+), 64 deletions(-) diff --git a/frontend/src/routes/admin/system/page.svelte.test.ts b/frontend/src/routes/admin/system/page.svelte.test.ts index 9a028cee..e0de72d8 100644 --- a/frontend/src/routes/admin/system/page.svelte.test.ts +++ b/frontend/src/routes/admin/system/page.svelte.test.ts @@ -9,18 +9,21 @@ describe('admin/system page', () => { let fetchSpy: ReturnType; beforeEach(() => { - fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - JSON.stringify({ - state: 'IDLE', - message: '', - total: 0, - processed: 0, - skipped: 0, - failed: 0 - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) + // mockImplementation (not mockResolvedValue) returns a fresh Response per call so the + // body stream isn't already-consumed after the first read. + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async () => + new Response( + JSON.stringify({ + state: 'IDLE', + message: '', + total: 0, + processed: 0, + skipped: 0, + failed: 0 + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) ); }); @@ -79,36 +82,33 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - const btn = (await page - .getByRole('button', { name: /jetzt auffüllen/i }) - .element()) as HTMLButtonElement; - btn.click(); + await page.getByRole('button', { name: /jetzt auffüllen/i }).click(); - await new Promise((r) => setTimeout(r, 50)); - const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); - expect(calls.some((c) => c.includes('backfill-versions'))).toBe(true); + await vi.waitFor(() => { + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.includes('backfill-versions'))).toBe(true); + }); }); it('triggers file-hashes backfill when its button is clicked', async () => { render(AdminSystemPage, { props: {} }); - const btn = (await page - .getByRole('button', { name: /datei-hashes berechnen/i }) - .element()) as HTMLButtonElement; - btn.click(); + await page.getByRole('button', { name: /datei-hashes berechnen/i }).click(); - await new Promise((r) => setTimeout(r, 50)); - const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); - expect(calls.some((c) => c.includes('backfill-file-hashes'))).toBe(true); + await vi.waitFor(() => { + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.includes('backfill-file-hashes'))).toBe(true); + }); }); it('initial fetch loads import-status and thumbnail-status', async () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 50)); - const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); - expect(calls.some((c) => c.includes('import-status'))).toBe(true); - expect(calls.some((c) => c.includes('thumbnail-status'))).toBe(true); + await vi.waitFor(() => { + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.includes('import-status'))).toBe(true); + expect(calls.some((c) => c.includes('thumbnail-status'))).toBe(true); + }); }); it('shows the success banner after backfill versions completes', async () => { @@ -128,14 +128,11 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - const btn = (await page - .getByRole('button', { name: /jetzt auffüllen/i }) - .element()) as HTMLButtonElement; - btn.click(); + await page.getByRole('button', { name: /jetzt auffüllen/i }).click(); - await new Promise((r) => setTimeout(r, 100)); - const banner = document.querySelector('.bg-green-50'); - expect(banner).not.toBeNull(); + await vi.waitFor(() => { + expect(document.querySelector('.bg-green-50')).not.toBeNull(); + }); }); it('renders the running state for import-status', async () => { @@ -155,8 +152,9 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - expect(document.body.textContent).toMatch(/läuft|wird ausgeführt/i); + await vi.waitFor(() => { + expect(document.body.textContent).toMatch(/läuft|wird ausgeführt/i); + }); }); it('renders the DONE state with processed count for import-status', async () => { @@ -176,8 +174,9 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - expect(document.body.textContent).toContain('99'); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('99'); + }); }); it('renders the FAILED state with the error message for thumbnail-status', async () => { @@ -205,8 +204,9 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - expect(document.body.textContent).toContain('connection refused'); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('connection refused'); + }); }); it('renders the DONE state for thumbnail-status with retry button', async () => { @@ -234,9 +234,9 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - const banner = document.querySelector('[data-testid="thumbnails-status-done"]'); - expect(banner).not.toBeNull(); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thumbnails-status-done"]')).not.toBeNull(); + }); }); it('renders the FAILED state for import-status with retry button', async () => { @@ -261,8 +261,9 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - expect(document.body.textContent).toContain('database error'); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('database error'); + }); }); it('renders the running thumbnail status with progress count', async () => { @@ -290,33 +291,40 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - // Total 100, processed+skipped+failed = 36 - expect(document.body.textContent).toMatch(/36|100/); + await vi.waitFor(() => { + // Total 100, processed+skipped+failed = 36 — at least one of these surfaces. + expect(document.body.textContent).toMatch(/36|100/); + }); }); it('triggers thumbnail backfill when its button is clicked', async () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 50)); + await vi.waitFor(() => { + expect(document.querySelector('[data-thumbnails-trigger]')).not.toBeNull(); + }); const btns = Array.from(document.querySelectorAll('[data-thumbnails-trigger]')); - (btns[0] as HTMLButtonElement)?.click(); + (btns[0] as HTMLButtonElement).click(); - await new Promise((r) => setTimeout(r, 50)); - const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); - expect(calls.some((c) => c.includes('generate-thumbnails'))).toBe(true); + await vi.waitFor(() => { + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.includes('generate-thumbnails'))).toBe(true); + }); }); it('triggers import when import button is clicked from idle state', async () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 50)); + await vi.waitFor(() => { + expect(document.querySelector('[data-import-trigger]')).not.toBeNull(); + }); const btns = Array.from(document.querySelectorAll('[data-import-trigger]')); - (btns[0] as HTMLButtonElement)?.click(); + (btns[0] as HTMLButtonElement).click(); - await new Promise((r) => setTimeout(r, 50)); - const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); - expect(calls.some((c) => c.includes('trigger-import'))).toBe(true); + await vi.waitFor(() => { + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.includes('trigger-import'))).toBe(true); + }); }); it('renders the running thumbnail status without progress when total is 0', async () => { @@ -344,8 +352,8 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); - await new Promise((r) => setTimeout(r, 100)); - // running state shown but no progress count when total=0 - expect(document.body.textContent).toMatch(/läuft|wird|generier/i); + await vi.waitFor(() => { + expect(document.body.textContent).toMatch(/läuft|wird|generier/i); + }); }); });