test(admin): rewrite admin/system page test with vi.waitFor

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-11 17:29:07 +02:00
committed by marcel
parent 6c7d696d56
commit ebfa20dde5

View File

@@ -9,18 +9,21 @@ describe('admin/system page', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>; let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( // mockImplementation (not mockResolvedValue) returns a fresh Response per call so the
new Response( // body stream isn't already-consumed after the first read.
JSON.stringify({ fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
state: 'IDLE', async () =>
message: '', new Response(
total: 0, JSON.stringify({
processed: 0, state: 'IDLE',
skipped: 0, message: '',
failed: 0 total: 0,
}), processed: 0,
{ status: 200, headers: { 'Content-Type': 'application/json' } } skipped: 0,
) failed: 0
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
); );
}); });
@@ -79,36 +82,33 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
const btn = (await page await page.getByRole('button', { name: /jetzt auffüllen/i }).click();
.getByRole('button', { name: /jetzt auffüllen/i })
.element()) as HTMLButtonElement;
btn.click();
await new Promise((r) => setTimeout(r, 50)); await vi.waitFor(() => {
const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); const calls = fetchSpy.mock.calls.map((c) => c[0].toString());
expect(calls.some((c) => c.includes('backfill-versions'))).toBe(true); expect(calls.some((c) => c.includes('backfill-versions'))).toBe(true);
});
}); });
it('triggers file-hashes backfill when its button is clicked', async () => { it('triggers file-hashes backfill when its button is clicked', async () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
const btn = (await page await page.getByRole('button', { name: /datei-hashes berechnen/i }).click();
.getByRole('button', { name: /datei-hashes berechnen/i })
.element()) as HTMLButtonElement;
btn.click();
await new Promise((r) => setTimeout(r, 50)); await vi.waitFor(() => {
const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); const calls = fetchSpy.mock.calls.map((c) => c[0].toString());
expect(calls.some((c) => c.includes('backfill-file-hashes'))).toBe(true); expect(calls.some((c) => c.includes('backfill-file-hashes'))).toBe(true);
});
}); });
it('initial fetch loads import-status and thumbnail-status', async () => { it('initial fetch loads import-status and thumbnail-status', async () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 50)); await vi.waitFor(() => {
const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); 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('import-status'))).toBe(true);
expect(calls.some((c) => c.includes('thumbnail-status'))).toBe(true); expect(calls.some((c) => c.includes('thumbnail-status'))).toBe(true);
});
}); });
it('shows the success banner after backfill versions completes', async () => { it('shows the success banner after backfill versions completes', async () => {
@@ -128,14 +128,11 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
const btn = (await page await page.getByRole('button', { name: /jetzt auffüllen/i }).click();
.getByRole('button', { name: /jetzt auffüllen/i })
.element()) as HTMLButtonElement;
btn.click();
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
const banner = document.querySelector('.bg-green-50'); expect(document.querySelector('.bg-green-50')).not.toBeNull();
expect(banner).not.toBeNull(); });
}); });
it('renders the running state for import-status', async () => { it('renders the running state for import-status', async () => {
@@ -155,8 +152,9 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
expect(document.body.textContent).toMatch(/läuft|wird ausgeführt/i); expect(document.body.textContent).toMatch(/läuft|wird ausgeführt/i);
});
}); });
it('renders the DONE state with processed count for import-status', async () => { it('renders the DONE state with processed count for import-status', async () => {
@@ -176,8 +174,9 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
expect(document.body.textContent).toContain('99'); expect(document.body.textContent).toContain('99');
});
}); });
it('renders the FAILED state with the error message for thumbnail-status', async () => { it('renders the FAILED state with the error message for thumbnail-status', async () => {
@@ -205,8 +204,9 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
expect(document.body.textContent).toContain('connection refused'); expect(document.body.textContent).toContain('connection refused');
});
}); });
it('renders the DONE state for thumbnail-status with retry button', async () => { it('renders the DONE state for thumbnail-status with retry button', async () => {
@@ -234,9 +234,9 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
const banner = document.querySelector('[data-testid="thumbnails-status-done"]'); expect(document.querySelector('[data-testid="thumbnails-status-done"]')).not.toBeNull();
expect(banner).not.toBeNull(); });
}); });
it('renders the FAILED state for import-status with retry button', async () => { it('renders the FAILED state for import-status with retry button', async () => {
@@ -261,8 +261,9 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
expect(document.body.textContent).toContain('database error'); expect(document.body.textContent).toContain('database error');
});
}); });
it('renders the running thumbnail status with progress count', async () => { it('renders the running thumbnail status with progress count', async () => {
@@ -290,33 +291,40 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
// Total 100, processed+skipped+failed = 36 // Total 100, processed+skipped+failed = 36 — at least one of these surfaces.
expect(document.body.textContent).toMatch(/36|100/); expect(document.body.textContent).toMatch(/36|100/);
});
}); });
it('triggers thumbnail backfill when its button is clicked', async () => { it('triggers thumbnail backfill when its button is clicked', async () => {
render(AdminSystemPage, { props: {} }); 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]')); 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)); await vi.waitFor(() => {
const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); const calls = fetchSpy.mock.calls.map((c) => c[0].toString());
expect(calls.some((c) => c.includes('generate-thumbnails'))).toBe(true); expect(calls.some((c) => c.includes('generate-thumbnails'))).toBe(true);
});
}); });
it('triggers import when import button is clicked from idle state', async () => { it('triggers import when import button is clicked from idle state', async () => {
render(AdminSystemPage, { props: {} }); 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]')); 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)); await vi.waitFor(() => {
const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); const calls = fetchSpy.mock.calls.map((c) => c[0].toString());
expect(calls.some((c) => c.includes('trigger-import'))).toBe(true); expect(calls.some((c) => c.includes('trigger-import'))).toBe(true);
});
}); });
it('renders the running thumbnail status without progress when total is 0', async () => { it('renders the running thumbnail status without progress when total is 0', async () => {
@@ -344,8 +352,8 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} }); render(AdminSystemPage, { props: {} });
await new Promise((r) => setTimeout(r, 100)); await vi.waitFor(() => {
// running state shown but no progress count when total=0 expect(document.body.textContent).toMatch(/läuft|wird|generier/i);
expect(document.body.textContent).toMatch(/läuft|wird|generier/i); });
}); });
}); });