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..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,9 +53,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, @JsonIgnore 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 +117,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 +148,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..533aa7b3 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,47 @@ 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 + @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")) 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", diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index aa734abf..81779e64 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'} +

+ {failureMessage} +

+ + {: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..b347f5ef --- /dev/null +++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts @@ -0,0 +1,137 @@ +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 = { + state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; + statusCode: string; + processed: number; + startedAt: string | null; +}; + +const makeStatus = (overrides: Partial = {}): ImportStatus => ({ + state: 'IDLE', + statusCode: 'IMPORT_IDLE', + processed: 0, + startedAt: null, + ...overrides +}); + +describe('ImportStatusCard', () => { + it('shows spinner while state is RUNNING', async () => { + render(ImportStatusCard, { + props: { + importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 3 }), + ontrigger: () => {} + } + }); + + expect(document.querySelector('[data-testid="spinner"]')).not.toBeNull(); + }); + + 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' + }), + ontrigger: () => {} + } + }); + + await expect.element(getByText(m.admin_system_import_failed_no_spreadsheet())).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(m.admin_system_import_failed_internal())).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(m.admin_system_import_status_idle())).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(); + }); +}); 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'); }); });