Compare commits

..

27 Commits

Author SHA1 Message Date
Marcel
b0cf35cf06 fix(test): replace toBeAttached() with querySelector not-null check for spinner
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m10s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m25s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
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 <noreply@anthropic.com>
2026-05-14 15:24:27 +02:00
Marcel
0d934a1b44 fix(test): use m() calls and toBeAttached() in ImportStatusCard tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (pull_request) Successful in 4m21s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Successful in 56s
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 <span> 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 <noreply@anthropic.com>
2026-05-14 15:14:50 +02:00
Marcel
f4bda546a0 fix(test): update import-status test mocks and imports for statusCode-based i18n
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m6s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m23s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
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 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
b7744667f2 fix(admin/system): address review concerns in ImportStatusCard
- 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 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
3d36c26226 fix(import): exclude message field from API response; add auth boundary tests
- @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 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
375fd3893c feat(admin/system): extract ImportStatusCard — spinner, text-base count, statusCode i18n
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 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
c5d482bead 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 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
31eacb6d06 feat(import): add structured statusCode to ImportStatus — replaces raw German message
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 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
636900110a fix(ci): raise Surefire JVM ceiling 120→600 s — suite takes ~4 min
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m10s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m25s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Successful in 57s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:35:49 +02:00
Marcel
d78ee4397b devops(ci): add testTimeout + hookTimeout to browser vitest config
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
testTimeout: 30_000 causes Vitest to fail a hanging browser test
within 30 s when Chromium crashes mid-load instead of silently
occupying the CI slot for 14+ min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
ebdb36b7d0 devops(ci): upload surefire XML reports as CI artifact
Captures all 102 test results independent of log verbosity.
if: always() ensures reports are available on failure — exactly
when they're needed most.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
93ff6cfb67 devops(ci): add Surefire per-test timeout and JVM ceiling
forkedProcessTimeoutInSeconds=120 caps the JVM on catastrophic hangs.
junit.jupiter.execution.timeout.default=90s times out each hanging
JUnit 5 test individually, letting healthy tests continue — replaces
the deprecated <timeout> alias that conflicted with the JVM ceiling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
ed4c4a52eb devops(ci): silence Spring Boot INFO noise in test log
Set logging.level.root=WARN + logging.level.org.raddatz=INFO in
backend/src/test/resources/application.properties to keep the full
test run under Gitea's 1.4 MB log cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
2ca8428be4 refactor(test): hoist SubmitFn to file-level type in unsaved-guard specs
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 5m8s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
CI / Unit & Component Tests (push) Successful in 3m24s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m24s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:12:08 +02:00
Marcel
6fffc06c28 fix(test): allow extra result properties in enhance callback type
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m8s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
CI / Backend Unit Tests (pull_request) Failing after 17m19s
Use [key: string]: unknown index signature so TS does not reject the
extra fields (location, status) passed to the redirect/failure result
in the spec helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:09:14 +02:00
Marcel
ffcb901376 fix(admin): clear unsaved-changes guard before redirect on users/new
Mirror the groups/new fix: replace inline beforeNavigate/isDirty with
createUnsavedWarning() + UnsavedWarningBanner and add an enhance callback
that calls clearOnSuccess() before update() on redirect results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:09:14 +02:00
Marcel
30469e74c9 fix(admin): clear unsaved-changes guard before redirect on groups/new
Use createUnsavedWarning() + UnsavedWarningBanner to replace the inline
beforeNavigate/isDirty pattern, and add an enhance callback that calls
clearOnSuccess() before update() so the guard is disarmed before
SvelteKit's internal goto() fires on a redirect result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:09:14 +02:00
Marcel
5646e739c2 fix(ci): run svelte-kit sync before lint to fix cache-hit tsconfig miss
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m8s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m25s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
CI / Unit & Component Tests (push) Successful in 3m7s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m15s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Successful in 58s
When the node_modules cache hits, npm ci is skipped and the prepare
lifecycle (svelte-kit sync) never runs. frontend/tsconfig.json extends
.svelte-kit/tsconfig.json which only exists after svelte-kit sync —
so ESLint fails at tsconfig resolution on every cache-warm run.

Adding an unconditional svelte-kit sync step after Paraglide compile
and before Lint ensures .svelte-kit/tsconfig.json is always present
regardless of cache state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:07:15 +02:00
Marcel
bbbdf8cd09 ci: restrict push trigger to main — eliminate duplicate runs on feature branches
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m5s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m27s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:12:24 +02:00
Marcel
f727429699 fix(ci): run client coverage even when server coverage fails
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
Replace && with ; in test:coverage so the client vitest run is not
short-circuited when the server run exits non-zero (e.g. threshold
violation or test failure). Without this the upload-artifact step
only ever sees coverage/server.

Also updates the stale CLAUDE.md comment that said server-only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:34 +02:00
Marcel
e268e2dbca fix(tests): use native element clicks in layout dropdown spec
Some checks failed
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CDP-based Playwright clicks (locator.click()) do not reliably trigger
Svelte 5 onclick handlers — documented in commit 0c765d81 which fixed
13 other specs. The layout dropdown tests were missed in that pass.

Applies the same pattern: ((await locator.element()) as HTMLElement).click()
for button interactions, and native KeyboardEvent dispatch for the Escape
test (dispatched on the button so it bubbles to the parent div's onkeydown).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:22 +02:00
Marcel
3de0d2f0fe fix(ci): add IMPORT_HOST_DIR stub to compose-idempotency env file
Some checks failed
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Docker Compose interpolates all variables in the full file even when
only a subset of services is requested. The backend service uses
IMPORT_HOST_DIR with :? (hard-required), causing the idempotency job
to abort before any container starts. A dummy path satisfies the parser;
the backend service is never started in this job so the path need not exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:58:38 +02:00
Marcel
0abbc147e2 ci(unit-tests): add negative self-test case to upload-artifact guard
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
The previous self-test proved the regex catches @v5 (positive case).
This adds a negative case proving @v3 is NOT flagged — guards against
a false-positive that would break every CI run permanently.

Suggested by Sara Holt in review of PR #558.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:58:19 +02:00
Marcel
6210480952 docs(ci-gitea): replace '← upgraded from v3' with ADR-014 pin comment
Lines 203, 230, and 332 carried comments that actively encouraged
the regression (they read as if v4 is the canonical target). Replaced
with the correct pinned-at-v3 comment referencing ADR-014.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:58:19 +02:00
Marcel
e17f4110f1 docs(adr-014): record upload-artifact v3 pin and Gitea act_runner v4 limitation
Documents the three-incident history, the enforcement layers (inline
comments + grep guard + ADR), how to spot the symptom, and the explicit
upgrade trigger (act_runner v4 protocol support OR v3 CVE).

Cross-references ADR-011 (single-tenant Gitea runner) and #557.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:58:19 +02:00
Marcel
fa46492759 ci(workflows): downgrade upload-artifact v4 → v3 — Gitea act_runner limitation (ADR-014)
Reverts the re-regression introduced in 410b91e2. Gitea Actions
(act_runner) does not implement the v4 artifact protocol — jobs report
failure even when all tests pass. Pins all three call sites back to @v3
and adds load-bearing inline comments pointing to ADR-014 / #557.

This commit makes the grep guard added in the previous commit GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:58:19 +02:00
Marcel
3965541879 ci(unit-tests): add grep guard for (upload|download)-artifact@v4+
Adds a repo-invariant check in the same 'Assert' block as the ADR-012
birpc guard. Anchored to YAML `uses:` lines so the inline self-test
fixture does not false-positive. Fails with an actionable error
referencing ADR-014 / #557.

Guard is intentionally RED at this commit — the three v4 call sites
are downgraded in the next commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:58:19 +02:00
25 changed files with 765 additions and 143 deletions

View File

@@ -2,6 +2,7 @@ name: CI
on:
push:
branches: [main]
pull_request:
jobs:
@@ -32,6 +33,10 @@ jobs:
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend
- name: Sync SvelteKit
run: npx svelte-kit sync
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
@@ -56,6 +61,26 @@ jobs:
exit 1
fi
- name: Assert no (upload|download)-artifact past v3
shell: bash
run: |
# Self-test: verify the regex catches v4+ and does not catch v3.
tmp=$(mktemp)
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
rm "$tmp"
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
# Both upload-artifact and download-artifact share the same incompatibility.
# Pin to @v3. See ADR-014 / #557.
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
exit 1
fi
- name: Run unit and component tests with coverage
shell: bash
run: |
@@ -77,9 +102,10 @@ jobs:
exit 1
fi
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage-reports
path: |
@@ -113,9 +139,10 @@ jobs:
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
echo "PASS: only /hilfe/transkription.html prerendered."
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: unit-test-screenshots
path: frontend/test-results/screenshots/
@@ -170,6 +197,14 @@ jobs:
./mvnw clean test
working-directory: backend
- name: Upload surefire reports
if: always()
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
uses: actions/upload-artifact@v3
with:
name: surefire-reports
path: backend/target/surefire-reports/
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
# the JSON keys would silently break it (fail2ban-regex would return

View File

@@ -56,9 +56,10 @@ jobs:
exit 1
fi
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- name: Upload coverage log on failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage-log-run-${{ matrix.run }}
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log

View File

@@ -273,6 +273,16 @@
</profiles>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
<systemPropertyVariables>
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>

View File

@@ -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<Path> 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();
}

View File

@@ -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())

View File

@@ -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"))

View File

@@ -0,0 +1,2 @@
logging.level.root=WARN
logging.level.org.raddatz=INFO

View File

@@ -0,0 +1,122 @@
# ADR 014 — Pin actions/upload-artifact to v3 (Gitea act_runner v4 protocol incompatibility)
**Status:** Accepted
**Date:** 2026-05-14
**Issues:** [#557 — re-regression](https://git.raddatz.cloud/marcel/familienarchiv/issues/557) · [#14 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/14)
---
## Context
`actions/upload-artifact` is available in two incompatible major versions. The v4 client
uploads via a GitHub-specific artifact API that is **not implemented** in Gitea's
`act_runner` (the self-hosted CI substrate established by ADR-011). When a workflow step
uses `actions/upload-artifact@v4` on this runner, `act_runner` returns a non-zero exit
code from the v4 client even when all tests pass, producing:
> green test suite — red job status — no artifact uploaded
The failure lands in the upload step, _after_ the test output, making it hard to diagnose
from the build log.
### Incident history
| Date | Commit | Event |
|---|---|---|
| 2026-03-19 | `9f3f022e` | Original downgrade: `upload-artifact@v4 → v3` |
| 2026-03-19 | `4142c7cd` | Rationale committed; closes #14 |
| 2026-05-05 | `410b91e2` | Re-regression: upgraded back to v4 without referencing #14 |
| 2026-05-14 | this PR | Second downgrade + ADR + grep guard |
The root cause of the re-regression was institutional-memory failure: the original
rationale was captured only in a commit body, invisible at the point of change (the
`uses:` line). This ADR, the inline comments, and the grep guard are the three
defence layers that replace that missing breadcrumb.
---
## Decision
**Pin all `actions/upload-artifact` and `actions/download-artifact` call sites to `@v3`.**
Both action families share the same v4 protocol incompatibility with `act_runner`.
Pinning to the major tag (`@v3`) keeps us on the latest v3 patch without Renovate noise.
Three call sites are pinned:
- `.gitea/workflows/ci.yml` — "Upload coverage reports" step
- `.gitea/workflows/ci.yml` — "Upload screenshots" step
- `.gitea/workflows/coverage-flake-probe.yml` — "Upload coverage log on failure" step
Each pinned `uses:` line carries a load-bearing inline comment:
```yaml
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- uses: actions/upload-artifact@v3
```
A CI grep guard enforces the constraint automatically (see below).
---
## Consequences
### Enforcement layers (defence in depth)
1. **Inline comments** on every `uses:` line — visible at the point of change.
2. **CI grep guard** in `.gitea/workflows/ci.yml` ("Assert no (upload|download)-artifact
past v3") — fails the build if a future commit re-introduces `@v4` or higher on any
workflow file. Anchored to YAML `uses:` lines to avoid false positives on embedded
shell strings. Includes a self-test that proves the regex catches v4+ before scanning
the repo.
3. **This ADR** — canonical rationale; cross-referenced by comments and guard message.
### How to spot the symptom
- Test suite output shows green (vitest, surefire, pytest all exit 0)
- CI job status shows red
- Artifacts section of the run is empty
- Build log shows a non-zero exit from the `Upload …` step immediately after green tests
### `@v3` maintenance-mode status
GitHub placed `actions/upload-artifact@v3` in maintenance mode (no new features) but it
has not been removed and carries no known unpatched CVE as of this writing. If GitHub
publishes a v3-specific security advisory, that is an additional trigger to re-evaluate
(see upgrade conditions below).
### When to remove this pin
Re-evaluate pinning **when either condition is met:**
1. `gitea/act_runner` ships a release with v4 artifact protocol support. Track upstream:
<https://gitea.com/gitea/act_runner>
2. `actions/upload-artifact@v3` acquires an unpatched CVE that cannot be mitigated
at the runner level.
When upgrading: remove the grep guard step, update all three `uses:` lines, remove the
inline comments, and update this ADR's status to Superseded.
---
## Alternatives
### SHA pinning (`uses: actions/upload-artifact@<sha>`)
More secure against action repository compromise, but adds Renovate update friction
and is disproportionate for a self-hosted, single-tenant Gitea instance with one
trusted contributor (ADR-011). Rejected.
### Minor/patch pinning (`@v3.4.0`)
Avoids Renovate PRs but freezes us on a specific patch. The v3 major track is in
maintenance mode — minor pinning has no benefit and would require manual updates
for any v3 security patches. Rejected.
### Renovate `packageRules` bypass
Would prevent automated PRs from proposing v4. Not needed while Renovate is not
configured for this repository. Revisit if Renovate is introduced.
### Migrating the runner to a v4-compatible Gitea release
Out of scope for this issue. A separate decision; tracked in #557's non-goals.

View File

@@ -200,7 +200,7 @@ jobs:
working-directory: frontend
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4 # ← upgraded from v3
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
with:
name: unit-test-screenshots
path: frontend/test-results/screenshots/
@@ -227,7 +227,7 @@ jobs:
working-directory: backend
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4 # ← upgraded from v3
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
with:
name: backend-test-results
path: backend/target/surefire-reports/
@@ -329,7 +329,7 @@ jobs:
E2E_BACKEND_URL: http://localhost:8080
- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v4 # ← upgraded from v3
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
with:
name: e2e-results
path: frontend/test-results/e2e/

View File

@@ -165,7 +165,7 @@ npm run check # svelte-check (type checking)
```bash
npm run test # Vitest unit + server tests (headless)
npm run test:coverage # Coverage report (server project only)
npm run test:coverage # Coverage report (server + client)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:headed # Playwright E2E with visible browser
npm run test:e2e:ui # Playwright UI mode

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -16,7 +16,7 @@
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
const availableStandard = $derived([
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
@@ -18,17 +19,7 @@ const availableAdmin = $derived([
let { form } = $props();
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
const unsaved = createUnsavedWarning();
</script>
<div class="flex flex-1 flex-col overflow-hidden">
@@ -58,23 +49,8 @@ beforeNavigate(({ cancel, to }) => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<div
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
@@ -85,11 +61,11 @@ beforeNavigate(({ cancel, to }) => {
<form
id="new-group-form"
method="POST"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
use:enhance={() => async ({ result, update }) => {
if (result.type === 'redirect') unsaved.clearOnSuccess();
await update();
}}
oninput={unsaved.markDirty}
class="space-y-5"
>
<!-- Name card -->

View File

@@ -0,0 +1,125 @@
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';
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
vi.mock('$app/forms', () => ({
enhance: (_el: HTMLFormElement, fn?: unknown) => {
enhanceCaptureRef.submitFn = fn;
return { destroy: vi.fn() };
}
}));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
afterEach(cleanup);
type SubmitFn = () => Promise<
(opts: {
result: { type: string; [key: string]: unknown };
update: () => Promise<void>;
}) => Promise<void>
>;
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
describe('Admin new group page unsaved-changes guard', () => {
beforeEach(() => {
vi.clearAllMocks();
enhanceCaptureRef.submitFn = undefined;
});
it('does not show unsaved warning initially', async () => {
render(Page, { props: { form: null } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
});
it('cancels navigation and shows banner when form is dirty', async () => {
render(Page, { props: { form: null } });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
});
it('does not cancel navigation when form is clean', async () => {
render(Page, { props: { form: null } });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).not.toHaveBeenCalled();
});
it('discard button calls goto with the target URL', async () => {
render(Page, { props: { form: null } });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
await page.getByRole('button', { name: /verwerfen/i }).click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/groups');
});
it('clears banner when enhance callback receives a redirect result', async () => {
render(Page, { props: { form: null } });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'redirect', location: '/admin/groups', status: 303 },
update: vi.fn().mockResolvedValue(undefined)
});
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).not.toHaveBeenCalled();
});
it('keeps banner when enhance callback receives a failure result', async () => {
render(Page, { props: { form: null } });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'failure', status: 400, data: { error: 'Name bereits vergeben' } },
update: vi.fn().mockResolvedValue(undefined)
});
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import ImportStatusCard from './ImportStatusCard.svelte';
let backfillResult: number | null = $state(null);
let backfillLoading = $state(false);
@@ -9,7 +10,7 @@ let backfillHashesLoading = $state(false);
type ImportStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
message: string;
statusCode: string;
processed: number;
startedAt: string | null;
};
@@ -177,47 +178,7 @@ async function backfillFileHashes() {
</div>
<!-- Mass import -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
{#if importStatus?.state === 'RUNNING'}
<p class="text-sm text-ink-2">{m.admin_system_import_status_running()}</p>
{:else if importStatus?.state === 'DONE'}
<p class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.admin_system_import_status_done({ count: importStatus.processed })}
</p>
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else if importStatus?.state === 'FAILED'}
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{m.admin_system_import_status_failed({ message: importStatus.message })}
</p>
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else}
{#if importStatus !== null}
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
{/if}
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_start()}
</button>
{/if}
</div>
<ImportStatusCard importStatus={importStatus} ontrigger={triggerImport} />
<!-- Thumbnail backfill -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type ImportStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
statusCode: string;
processed: number;
startedAt: string | null;
};
let {
importStatus,
ontrigger
}: {
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()
);
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
{#if importStatus?.state === 'RUNNING'}
<div class="mb-4 flex items-center gap-3">
<span
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 motion-reduce:animate-none"
></span>
<div>
<p class="text-base font-bold text-ink">{importStatus.processed}</p>
<p class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_system_import_status_running()}
</p>
</div>
</div>
{:else if importStatus?.state === 'DONE'}
<div class="mb-4 rounded-sm border border-green-200 bg-green-50 p-4 text-green-700">
<p class="text-base font-bold">{importStatus.processed}</p>
<p class="text-xs font-bold tracking-widest text-green-800 uppercase">
{m.admin_system_import_status_done_label()}
</p>
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
</div>
<button
data-import-trigger
onclick={ontrigger}
class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else if importStatus?.state === 'FAILED'}
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{failureMessage}
</p>
<button
data-import-trigger
onclick={ontrigger}
class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else}
{#if importStatus !== null}
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
{/if}
<button
data-import-trigger
onclick={ontrigger}
class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_start()}
</button>
{/if}
</div>

View File

@@ -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> = {}): 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();
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -1,24 +1,15 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import UserProfileSection from '$lib/user/UserProfileSection.svelte';
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
import AccountSection from './AccountSection.svelte';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
let { data, form } = $props();
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
const unsaved = createUnsavedWarning();
</script>
<div class="flex flex-1 flex-col overflow-hidden">
@@ -44,23 +35,8 @@ beforeNavigate(({ cancel, to }) => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<div
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
@@ -71,11 +47,11 @@ beforeNavigate(({ cancel, to }) => {
<form
id="new-user-form"
method="POST"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
use:enhance={() => async ({ result, update }) => {
if (result.type === 'redirect') unsaved.clearOnSuccess();
await update();
}}
oninput={unsaved.markDirty}
class="space-y-5"
>
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">

View File

@@ -1,9 +1,19 @@
import { afterEach, describe, expect, it, vi } 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';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
vi.mock('$app/forms', () => ({
enhance: (_el: HTMLFormElement, fn?: unknown) => {
enhanceCaptureRef.submitFn = fn;
return { destroy: vi.fn() };
}
}));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
const groups = [
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
@@ -20,6 +30,13 @@ const baseData = {
afterEach(cleanup);
type SubmitFn = () => Promise<
(opts: {
result: { type: string; [key: string]: unknown };
update: () => Promise<void>;
}) => Promise<void>
>;
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin new user page rendering', () => {
@@ -66,3 +83,103 @@ describe('Admin new user page error display', () => {
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
});
});
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
describe('Admin new user page unsaved-changes guard', () => {
beforeEach(() => {
vi.clearAllMocks();
enhanceCaptureRef.submitFn = undefined;
});
it('does not show unsaved warning initially', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
});
it('cancels navigation and shows banner when form is dirty', async () => {
render(Page, { data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
});
it('does not cancel navigation when form is clean', async () => {
render(Page, { data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).not.toHaveBeenCalled();
});
it('discard button calls goto with the target URL', async () => {
render(Page, { data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
await page.getByRole('button', { name: /verwerfen/i }).click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users');
});
it('clears banner when enhance callback receives a redirect result', async () => {
render(Page, { data: baseData, form: null });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'redirect', location: '/admin/users', status: 303 },
update: vi.fn().mockResolvedValue(undefined)
});
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).not.toHaveBeenCalled();
});
it('keeps banner when enhance callback receives a failure result', async () => {
render(Page, { data: baseData, form: null });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'failure', status: 400, data: { error: 'E-Mail bereits vergeben' } },
update: vi.fn().mockResolvedValue(undefined)
});
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { page } from 'vitest/browser';
import { createRawSnippet } from 'svelte';
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
@@ -96,13 +96,13 @@ describe('Layout user dropdown', () => {
it('opens dropdown on button click', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await page.getByRole('button', { name: /MM/ }).click();
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
});
it('profile link points to /profile', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await page.getByRole('button', { name: /MM/ }).click();
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
await expect
.element(page.getByRole('link', { name: /Profil/i }))
.toHaveAttribute('href', '/profile');
@@ -110,16 +110,16 @@ describe('Layout user dropdown', () => {
it('logout button is in the dropdown', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await page.getByRole('button', { name: /MM/ }).click();
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
});
it('closes dropdown when Escape is pressed', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const btn = page.getByRole('button', { name: /MM/ });
await btn.click();
const btnEl = (await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement;
btnEl.click();
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
btnEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await tick();
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
});

View File

@@ -24,6 +24,8 @@ export default defineConfig({
})
],
test: {
testTimeout: 30_000,
hookTimeout: 15_000,
expect: { requireAssertions: true },
browser: {
enabled: true,