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
parent b26ae52fbb
commit c89b9a2d60

View File

@@ -9,18 +9,21 @@ describe('admin/system page', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
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);
});
});
});