feat(admin): mass import card on system tab with live status polling

Adds a new card on the System tab that triggers the existing
POST /api/admin/trigger-import endpoint. Status is polled every 2 s
while RUNNING and stops automatically on DONE or FAILED.
IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 09:42:44 +02:00
parent 559b522507
commit 9996055cac
5 changed files with 219 additions and 1 deletions

View File

@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
afterEach(cleanup);
afterEach(() => vi.restoreAllMocks());
describe('Admin system page', () => {
it('renders the backfill versions heading', async () => {
@@ -32,3 +33,104 @@ describe('Admin system page', () => {
.toBeInTheDocument();
});
});
describe('Admin system page — mass import card', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
state: 'IDLE',
message: 'Kein Import gestartet.',
processed: 0,
startedAt: null
})
})
);
});
it('renders the mass import heading', async () => {
render(Page, {});
await expect.element(page.getByText(/Massenimport/i)).toBeInTheDocument();
});
it('renders the start import button when idle', async () => {
render(Page, {});
await expect.element(page.getByRole('button', { name: /Import starten/i })).toBeInTheDocument();
});
it('shows idle status text', async () => {
render(Page, {});
await expect.element(page.getByText(/Kein Import gestartet/i)).toBeInTheDocument();
});
it('disables the start button and shows running state after click', async () => {
const fetchMock = vi
.fn()
// initial status fetch → IDLE
.mockResolvedValueOnce({
ok: true,
json: async () => ({
state: 'IDLE',
message: 'Kein Import gestartet.',
processed: 0,
startedAt: null
})
})
// trigger POST → returns RUNNING immediately
.mockResolvedValueOnce({
ok: true,
json: async () => ({
state: 'RUNNING',
message: 'Import läuft...',
processed: 0,
startedAt: '2026-01-01T10:00:00'
})
});
vi.stubGlobal('fetch', fetchMock);
render(Page, {});
await expect.element(page.getByRole('button', { name: /Import starten/i })).toBeInTheDocument();
document.querySelector<HTMLButtonElement>('[data-import-trigger]')!.click();
await expect.element(page.getByText(/Import läuft/i)).toBeInTheDocument();
});
it('shows done status and retry button after successful import', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
state: 'DONE',
message: 'Import abgeschlossen.',
processed: 42,
startedAt: '2026-01-01T10:00:00'
})
})
);
render(Page, {});
await expect.element(page.getByText(/42 Dokumente/i)).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
});
it('shows failed status and retry button on error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
state: 'FAILED',
message: 'Datei nicht gefunden.',
processed: 0,
startedAt: '2026-01-01T10:00:00'
})
})
);
render(Page, {});
await expect.element(page.getByText(/Datei nicht gefunden/i)).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
});
});