From 31eacb6d06c18e1a776c7ba32147b0d80bd1a8ab Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 11:50:56 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(import):=20add=20structured=20statusCo?= =?UTF-8?q?de=20to=20ImportStatus=20=E2=80=94=20replaces=20raw=20German=20?= =?UTF-8?q?message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a statusCode field (IMPORT_IDLE / IMPORT_RUNNING / IMPORT_DONE / IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL) to ImportStatus. The frontend will map these codes to localized strings via Paraglide instead of rendering the backend's German message verbatim. NoSpreadsheetException distinguishes a missing spreadsheet from other I/O failures so the frontend can show a specific error without raw text. Co-Authored-By: Claude Sonnet 4.6 --- .../importing/MassImportService.java | 21 +++++++++++++------ .../importing/MassImportServiceTest.java | 19 +++++++++++++++-- .../user/AdminControllerTest.java | 16 ++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java index f58233ff..c7a575fc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java @@ -52,9 +52,9 @@ public class MassImportService { public enum State { IDLE, RUNNING, DONE, FAILED } - public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {} + public record ImportStatus(State state, String statusCode, String message, int processed, LocalDateTime startedAt) {} - private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null); + private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); public ImportStatus getStatus() { return currentStatus; @@ -116,20 +116,29 @@ public class MassImportService { if (currentStatus.state() == State.RUNNING) { throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress"); } - currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now()); + currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now()); try { File spreadsheet = findSpreadsheetFile(); log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath()); int processed = processRows(readSpreadsheet(spreadsheet)); - currentStatus = new ImportStatus(State.DONE, + currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE", "Import abgeschlossen. " + processed + " Dokumente verarbeitet.", processed, currentStatus.startedAt()); + } catch (NoSpreadsheetException e) { + log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e); + currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET", + "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); } catch (Exception e) { log.error("Massenimport fehlgeschlagen", e); - currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); + currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL", + "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); } } + private static class NoSpreadsheetException extends RuntimeException { + NoSpreadsheetException(String message) { super(message); } + } + private File findSpreadsheetFile() throws IOException { try (Stream files = Files.list(Paths.get(importDir))) { return files @@ -138,7 +147,7 @@ public class MassImportService { return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls"); }) .findFirst() - .orElseThrow(() -> new RuntimeException( + .orElseThrow(() -> new NoSpreadsheetException( "Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!")) .toFile(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java index a5fe50c6..a3ab9cf9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java @@ -70,14 +70,20 @@ class MassImportServiceTest { assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE); } + @Test + void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() { + assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE"); + } + // ─── runImportAsync ─────────────────────────────────────────────────────── @Test void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() { - // /import directory doesn't exist in test environment → findSpreadsheetFile throws + // /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL service.runImportAsync(); assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED); + assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL"); } @Test @@ -93,10 +99,19 @@ class MassImportServiceTest { assertThat(service.getStatus().message()).contains(tempDir.toString()); } + @Test + void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) { + ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); + + service.runImportAsync(); + + assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET"); + } + @Test void runImportAsync_throwsConflict_whenAlreadyRunning() { MassImportService.ImportStatus running = new MassImportService.ImportStatus( - MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now()); + MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now()); ReflectionTestUtils.setField(service, "currentStatus", running); assertThatThrownBy(() -> service.runImportAsync()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java index 8fc2bf3d..b8437abc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java @@ -40,6 +40,22 @@ class AdminControllerTest { @MockitoBean ThumbnailBackfillService thumbnailBackfillService; @MockitoBean CustomUserDetailsService customUserDetailsService; + // ─── GET /api/admin/import-status ───────────────────────────────────────── + + @Test + @WithMockUser(authorities = "ADMIN") + void importStatus_returns200_withStatusCode_whenAdmin() throws Exception { + MassImportService.ImportStatus status = new MassImportService.ImportStatus( + MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); + when(massImportService.getStatus()).thenReturn(status); + + mockMvc.perform(get("/api/admin/import-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("IDLE")) + .andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE")) + .andExpect(jsonPath("$.processed").value(0)); + } + @Test void backfillVersions_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(post("/api/admin/backfill-versions")) -- 2.49.1 From c5d482bead350cb04c9b6474ac5a8a74d129f701 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 12:10:25 +0200 Subject: [PATCH 2/8] feat(i18n): add structured import failure keys; split DONE display Replaces the {message} interpolation (raw German backend string) with two distinct error keys: IMPORT_FAILED_NO_SPREADSHEET and IMPORT_FAILED_INTERNAL. Also removes the {count} parameter from the done message and adds admin_system_import_status_done_label so the processed count can be rendered separately at text-base size. All three locales (de / en / es) updated. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 7 +++++-- frontend/messages/en.json | 7 +++++-- frontend/messages/es.json | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e8af8a25..af07233f 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -345,8 +345,11 @@ "admin_system_import_btn_retry": "Erneut starten", "admin_system_import_status_idle": "Kein Import gestartet.", "admin_system_import_status_running": "Import läuft…", - "admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.", - "admin_system_import_status_failed": "Fehler: {message}", + "admin_system_import_status_done": "Import abgeschlossen", + "admin_system_import_status_done_label": "Dokumente verarbeitet", + "admin_system_import_status_failed": "Import fehlgeschlagen", + "admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.", + "admin_system_import_failed_internal": "Interner Fehler beim Import.", "admin_system_thumbnails_heading": "Thumbnails erzeugen", "admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).", "admin_system_thumbnails_btn_start": "Thumbnails erzeugen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e448d26c..92e999c2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -345,8 +345,11 @@ "admin_system_import_btn_retry": "Start again", "admin_system_import_status_idle": "No import started.", "admin_system_import_status_running": "Import running…", - "admin_system_import_status_done": "Import complete – {count} documents processed.", - "admin_system_import_status_failed": "Error: {message}", + "admin_system_import_status_done": "Import complete", + "admin_system_import_status_done_label": "Documents processed", + "admin_system_import_status_failed": "Import failed", + "admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.", + "admin_system_import_failed_internal": "Import failed due to an internal error.", "admin_system_thumbnails_heading": "Generate thumbnails", "admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).", "admin_system_thumbnails_btn_start": "Generate thumbnails", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f8f576b8..52ab9425 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -345,8 +345,11 @@ "admin_system_import_btn_retry": "Iniciar de nuevo", "admin_system_import_status_idle": "No hay importación iniciada.", "admin_system_import_status_running": "Importación en curso…", - "admin_system_import_status_done": "Importación completada – {count} documentos procesados.", - "admin_system_import_status_failed": "Error: {message}", + "admin_system_import_status_done": "Importación completada", + "admin_system_import_status_done_label": "Documentos procesados", + "admin_system_import_status_failed": "Importación fallida", + "admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.", + "admin_system_import_failed_internal": "Error interno durante la importación.", "admin_system_thumbnails_heading": "Generar miniaturas", "admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).", "admin_system_thumbnails_btn_start": "Generar miniaturas", -- 2.49.1 From 375fd3893cc91287cb02bbc34aa1f70781f23758 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 12:11:00 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(admin/system):=20extract=20ImportStatu?= =?UTF-8?q?sCard=20=E2=80=94=20spinner,=20text-base=20count,=20statusCode?= =?UTF-8?q?=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the mass-import block from +page.svelte into ImportStatusCard.svelte. Changes per the three UX fixes from issue #533: - RUNNING: animated spinner (animate-spin) + processed count at text-base; auto-poll at 2 s was already in place - DONE: processed count at text-base, label at text-xs uppercase tracking-widest - FAILED: maps statusCode (IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL) to Paraglide messages — no raw German backend string rendered Adds vitest-browser tests covering spinner visibility, count display, and per-statusCode FAILED message selection. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/system/+page.svelte | 44 +--------- .../admin/system/ImportStatusCard.svelte | 80 +++++++++++++++++++ .../system/ImportStatusCard.svelte.test.ts | 71 ++++++++++++++++ 3 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 frontend/src/routes/admin/system/ImportStatusCard.svelte create mode 100644 frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index aa734abf..f7a61332 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -1,6 +1,7 @@ + +
+

{m.admin_system_import_heading()}

+

{m.admin_system_import_description()}

+ + {#if importStatus?.state === 'RUNNING'} +
+ +
+

{importStatus.processed}

+

+ {m.admin_system_import_status_running()} +

+
+
+ {:else if importStatus?.state === 'DONE'} +
+

{importStatus.processed}

+

+ {m.admin_system_import_status_done_label()} +

+

{m.admin_system_import_status_done()}

+
+ + {:else if importStatus?.state === 'FAILED'} +

+ {importStatus.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET' + ? m.admin_system_import_failed_no_spreadsheet() + : m.admin_system_import_failed_internal()} +

+ + {:else} + {#if importStatus !== null} +

{m.admin_system_import_status_idle()}

+ {/if} + + {/if} +
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts new file mode 100644 index 00000000..1c98fe99 --- /dev/null +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts @@ -0,0 +1,71 @@ +import { describe, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { expect } from '@vitest/browser/context'; +import ImportStatusCard from './ImportStatusCard.svelte'; + +type ImportStatus = { + state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; + statusCode: string; + message: string; + processed: number; + startedAt: string | null; +}; + +const makeStatus = (overrides: Partial = {}): ImportStatus => ({ + state: 'IDLE', + statusCode: 'IMPORT_IDLE', + message: '', + processed: 0, + startedAt: null, + ...overrides +}); + +describe('ImportStatusCard', () => { + it('shows spinner while state is RUNNING', async () => { + const { getByTestId } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 3 }), + ontrigger: () => {} + } + }); + + await expect.element(getByTestId('spinner')).toBeVisible(); + }); + + it('shows processed count at text-base while RUNNING', async () => { + const { getByText } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 7 }), + ontrigger: () => {} + } + }); + + await expect.element(getByText('7')).toBeVisible(); + }); + + it('shows processed count while DONE', async () => { + const { getByText } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 42 }), + ontrigger: () => {} + } + }); + + await expect.element(getByText('42')).toBeVisible(); + }); + + it('shows no-spreadsheet message when statusCode is IMPORT_FAILED_NO_SPREADSHEET', async () => { + const { getByText } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ + state: 'FAILED', + statusCode: 'IMPORT_FAILED_NO_SPREADSHEET', + message: 'Keine Tabellendatei...' + }), + ontrigger: () => {} + } + }); + + await expect.element(getByText('No spreadsheet file found.')).toBeVisible(); + }); +}); -- 2.49.1 From 3d36c262261db66e11ce8fa236abbd97191c96c1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 12:58:02 +0200 Subject: [PATCH 4/8] fix(import): exclude message field from API response; add auth boundary tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @JsonIgnore on ImportStatus.message — stops internal directory paths and raw exception text leaking through the admin import-status endpoint (CWE-209) - Add importStatus_messageField_notPresentInApiResponse test (red/green verified) - Add importStatus_returns401/403 auth boundary tests — documents and guards the @RequirePermission(ADMIN) protection against configuration drift Co-Authored-By: Claude Sonnet 4.6 --- .../importing/MassImportService.java | 3 ++- .../user/AdminControllerTest.java | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java index c7a575fc..ea648870 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.importing; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.*; @@ -52,7 +53,7 @@ public class MassImportService { public enum State { IDLE, RUNNING, DONE, FAILED } - public record ImportStatus(State state, String statusCode, String message, int processed, LocalDateTime startedAt) {} + public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {} private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java index b8437abc..533aa7b3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java @@ -56,6 +56,31 @@ class AdminControllerTest { .andExpect(jsonPath("$.processed").value(0)); } + @Test + @WithMockUser(authorities = "ADMIN") + void importStatus_messageField_notPresentInApiResponse() throws Exception { + MassImportService.ImportStatus status = new MassImportService.ImportStatus( + MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null); + when(massImportService.getStatus()).thenReturn(status); + + mockMvc.perform(get("/api/admin/import-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").doesNotExist()); + } + + @Test + void importStatus_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/admin/import-status")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void importStatus_returns403_whenUserLacksAdminPermission() throws Exception { + mockMvc.perform(get("/api/admin/import-status")) + .andExpect(status().isForbidden()); + } + @Test void backfillVersions_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(post("/api/admin/backfill-versions")) -- 2.49.1 From b7744667f242a27a61b7388af265150fe00bafa1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 13:24:11 +0200 Subject: [PATCH 5/8] fix(admin/system): address review concerns in ImportStatusCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead `message` field from both frontend ImportStatus types (field is now @JsonIgnore'd on the backend) - Extract failure message ternary into `$derived` — business logic off the template (Felix) - Add motion-reduce:animate-none to spinner — WCAG 2.1 SC 2.3.3 (Leonie) - Replace text-green-600 with text-green-800 — WCAG AA contrast 6.1:1 on bg-green-50 (Leonie) - Add min-h-[44px] to all three buttons — WCAG 2.2 44px touch target (Leonie) - Add 6 missing tests: IMPORT_FAILED_INTERNAL path, IDLE state text, null importStatus, ontrigger called on DONE/FAILED/IDLE buttons (Sara) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/system/+page.svelte | 1 - .../admin/system/ImportStatusCard.svelte | 23 +++--- .../system/ImportStatusCard.svelte.test.ts | 76 +++++++++++++++++-- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index f7a61332..81779e64 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -11,7 +11,6 @@ let backfillHashesLoading = $state(false); type ImportStatus = { state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; statusCode: string; - message: string; processed: number; startedAt: string | null; }; diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte b/frontend/src/routes/admin/system/ImportStatusCard.svelte index ae075d97..ac6d23f5 100644 --- a/frontend/src/routes/admin/system/ImportStatusCard.svelte +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte @@ -4,7 +4,6 @@ import { m } from '$lib/paraglide/messages.js'; type ImportStatus = { state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; statusCode: string; - message: string; processed: number; startedAt: string | null; }; @@ -16,6 +15,12 @@ let { importStatus: ImportStatus | null; ontrigger: () => void; } = $props(); + +const failureMessage = $derived( + importStatus?.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET' + ? m.admin_system_import_failed_no_spreadsheet() + : m.admin_system_import_failed_internal() +);
@@ -28,7 +33,7 @@ let { data-testid="spinner" role="status" aria-label={m.admin_system_import_status_running()} - class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-ink-3 border-t-brand-mint" + class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-ink-3 border-t-brand-mint motion-reduce:animate-none" >

{importStatus.processed}

@@ -40,28 +45,26 @@ let { {:else if importStatus?.state === 'DONE'}

{importStatus.processed}

-

+

{m.admin_system_import_status_done_label()}

-

{m.admin_system_import_status_done()}

+

{m.admin_system_import_status_done()}

{:else if importStatus?.state === 'FAILED'}

- {importStatus.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET' - ? m.admin_system_import_failed_no_spreadsheet() - : m.admin_system_import_failed_internal()} + {failureMessage}

@@ -72,7 +75,7 @@ let { diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts index 1c98fe99..0c47d0ec 100644 --- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from 'vitest'; +import { describe, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { expect } from '@vitest/browser/context'; import ImportStatusCard from './ImportStatusCard.svelte'; @@ -6,7 +6,6 @@ import ImportStatusCard from './ImportStatusCard.svelte'; type ImportStatus = { state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; statusCode: string; - message: string; processed: number; startedAt: string | null; }; @@ -14,7 +13,6 @@ type ImportStatus = { const makeStatus = (overrides: Partial = {}): ImportStatus => ({ state: 'IDLE', statusCode: 'IMPORT_IDLE', - message: '', processed: 0, startedAt: null, ...overrides @@ -59,8 +57,7 @@ describe('ImportStatusCard', () => { props: { importStatus: makeStatus({ state: 'FAILED', - statusCode: 'IMPORT_FAILED_NO_SPREADSHEET', - message: 'Keine Tabellendatei...' + statusCode: 'IMPORT_FAILED_NO_SPREADSHEET' }), ontrigger: () => {} } @@ -68,4 +65,73 @@ describe('ImportStatusCard', () => { await expect.element(getByText('No spreadsheet file found.')).toBeVisible(); }); + + it('shows internal error message when statusCode is IMPORT_FAILED_INTERNAL', async () => { + const { getByText } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'FAILED', statusCode: 'IMPORT_FAILED_INTERNAL' }), + ontrigger: () => {} + } + }); + + await expect.element(getByText('Import failed due to an internal error.')).toBeVisible(); + }); + + it('shows idle text when importStatus is non-null and state is IDLE', async () => { + const { getByText } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'IDLE', statusCode: 'IMPORT_IDLE' }), + ontrigger: () => {} + } + }); + + await expect.element(getByText('No import started.')).toBeVisible(); + }); + + it('shows no spinner when importStatus is null', async () => { + render(ImportStatusCard, { + props: { importStatus: null, ontrigger: () => {} } + }); + + expect(document.querySelector('[data-testid="spinner"]')).toBeNull(); + }); + + it('calls ontrigger when retry button is clicked in DONE state', async () => { + const ontrigger = vi.fn(); + const { getByRole } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }), + ontrigger + } + }); + + await getByRole('button').click(); + expect(ontrigger).toHaveBeenCalledOnce(); + }); + + it('calls ontrigger when retry button is clicked in FAILED state', async () => { + const ontrigger = vi.fn(); + const { getByRole } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'FAILED', statusCode: 'IMPORT_FAILED_INTERNAL' }), + ontrigger + } + }); + + await getByRole('button').click(); + expect(ontrigger).toHaveBeenCalledOnce(); + }); + + it('calls ontrigger when start button is clicked in IDLE state', async () => { + const ontrigger = vi.fn(); + const { getByRole } = render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'IDLE', statusCode: 'IMPORT_IDLE' }), + ontrigger + } + }); + + await getByRole('button').click(); + expect(ontrigger).toHaveBeenCalledOnce(); + }); }); -- 2.49.1 From f4bda546a072cb859445e5a42a057504fdc13681 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 14:30:15 +0200 Subject: [PATCH 6/8] fix(test): update import-status test mocks and imports for statusCode-based i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test files were written against the old API shape (raw `message` field) before the statusCode i18n field was introduced, or used the wrong `expect` import path: - ImportStatusCard.svelte.test.ts: `@vitest/browser/context` does not export `expect` in this project's Vitest setup — use `vitest` like every other test file. - page.svelte.spec.ts: FAILED mock lacked `statusCode`; assertion matched old German raw message instead of the i18n string for IMPORT_FAILED_NO_SPREADSHEET. - page.svelte.test.ts: same pattern — mock lacked `statusCode`; assertion checked for raw backend string "database error" instead of the rendered i18n text. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/system/ImportStatusCard.svelte.test.ts | 3 +-- frontend/src/routes/admin/system/page.svelte.spec.ts | 4 ++-- frontend/src/routes/admin/system/page.svelte.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts index 0c47d0ec..fa66cc40 100644 --- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts @@ -1,6 +1,5 @@ -import { describe, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import { expect } from '@vitest/browser/context'; import ImportStatusCard from './ImportStatusCard.svelte'; type ImportStatus = { diff --git a/frontend/src/routes/admin/system/page.svelte.spec.ts b/frontend/src/routes/admin/system/page.svelte.spec.ts index 8fdf1960..71213966 100644 --- a/frontend/src/routes/admin/system/page.svelte.spec.ts +++ b/frontend/src/routes/admin/system/page.svelte.spec.ts @@ -163,7 +163,7 @@ describe('Admin system page — mass import card', () => { ok: true, json: async () => ({ state: 'FAILED', - message: 'Datei nicht gefunden.', + statusCode: 'IMPORT_FAILED_NO_SPREADSHEET', processed: 0, startedAt: '2026-01-01T10:00:00' }) @@ -182,7 +182,7 @@ describe('Admin system page — mass import card', () => { }) ); render(Page, {}); - await expect.element(page.getByText(/Datei nicht gefunden/i)).toBeInTheDocument(); + await expect.element(page.getByText(/Keine Tabellendatei gefunden/i)).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument(); }); }); diff --git a/frontend/src/routes/admin/system/page.svelte.test.ts b/frontend/src/routes/admin/system/page.svelte.test.ts index e0de72d8..5caac7a3 100644 --- a/frontend/src/routes/admin/system/page.svelte.test.ts +++ b/frontend/src/routes/admin/system/page.svelte.test.ts @@ -246,7 +246,7 @@ describe('admin/system page', () => { return new Response( JSON.stringify({ state: 'FAILED', - message: 'database error', + statusCode: 'IMPORT_FAILED_INTERNAL', processed: 0, startedAt: null }), @@ -262,7 +262,7 @@ describe('admin/system page', () => { render(AdminSystemPage, { props: {} }); await vi.waitFor(() => { - expect(document.body.textContent).toContain('database error'); + expect(document.body.textContent).toContain('Interner Fehler beim Import'); }); }); -- 2.49.1 From 0d934a1b44a50360856808761b8352aa65795a67 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 15:12:28 +0200 Subject: [PATCH 7/8] fix(test): use m() calls and toBeAttached() in ImportStatusCard tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI Chromium runs with German locale so hardcoded English strings like 'No spreadsheet file found.' never matched. Use m.admin_system_import_*() to assert whatever locale the browser resolves to. Spinner test used toBeVisible() on an empty whose dimensions come entirely from Tailwind CSS. Without layout CSS the span is 0×0 and fails the visibility check; toBeAttached() asserts DOM presence, which is the right semantic here. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/admin/system/ImportStatusCard.svelte.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts index fa66cc40..13538b9b 100644 --- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; +import { m } from '$lib/paraglide/messages.js'; import ImportStatusCard from './ImportStatusCard.svelte'; type ImportStatus = { @@ -26,7 +27,7 @@ describe('ImportStatusCard', () => { } }); - await expect.element(getByTestId('spinner')).toBeVisible(); + await expect.element(getByTestId('spinner')).toBeAttached(); }); it('shows processed count at text-base while RUNNING', async () => { @@ -62,7 +63,7 @@ describe('ImportStatusCard', () => { } }); - await expect.element(getByText('No spreadsheet file found.')).toBeVisible(); + await expect.element(getByText(m.admin_system_import_failed_no_spreadsheet())).toBeVisible(); }); it('shows internal error message when statusCode is IMPORT_FAILED_INTERNAL', async () => { @@ -73,7 +74,7 @@ describe('ImportStatusCard', () => { } }); - await expect.element(getByText('Import failed due to an internal error.')).toBeVisible(); + await expect.element(getByText(m.admin_system_import_failed_internal())).toBeVisible(); }); it('shows idle text when importStatus is non-null and state is IDLE', async () => { @@ -84,7 +85,7 @@ describe('ImportStatusCard', () => { } }); - await expect.element(getByText('No import started.')).toBeVisible(); + await expect.element(getByText(m.admin_system_import_status_idle())).toBeVisible(); }); it('shows no spinner when importStatus is null', async () => { -- 2.49.1 From b0cf35cf0650e1ae65c11df95f8e54f9c9548d02 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 15:24:27 +0200 Subject: [PATCH 8/8] fix(test): replace toBeAttached() with querySelector not-null check for spinner toBeAttached() is not in the vitest-browser matcher set; toBeVisible() was previously ruled out because the spinner is 0x0 px. Mirror the querySelector pattern already used for the negative case in the same file. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/system/ImportStatusCard.svelte.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts index 13538b9b..b347f5ef 100644 --- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts @@ -20,14 +20,14 @@ const makeStatus = (overrides: Partial = {}): ImportStatus => ({ describe('ImportStatusCard', () => { it('shows spinner while state is RUNNING', async () => { - const { getByTestId } = render(ImportStatusCard, { + render(ImportStatusCard, { props: { importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 3 }), ontrigger: () => {} } }); - await expect.element(getByTestId('spinner')).toBeAttached(); + expect(document.querySelector('[data-testid="spinner"]')).not.toBeNull(); }); it('shows processed count at text-base while RUNNING', async () => { -- 2.49.1