Compare commits

...

18 Commits

Author SHA1 Message Date
Marcel
9a460b3c90 fix(document): add trainingLabels to Document.full entity graph (#642)
All checks were successful
CI / Semgrep Security Scan (push) Successful in 19s
CI / Compose Bucket Idempotency (push) Successful in 59s
CI / Unit & Component Tests (push) Successful in 3m28s
CI / OCR Service Tests (push) Successful in 20s
CI / Backend Unit Tests (push) Successful in 3m22s
CI / fail2ban Regex (push) Successful in 49s
trainingLabels was switched to LAZY fetch in #467 but not added to the
Document.full @NamedEntityGraph. DocumentRepository.findById() uses
Document.full to eagerly load sender/receivers/tags, but the Hibernate
session closes before Jackson serializes the response. Accessing
trainingLabels outside the session throws LazyInitializationException,
causing GET /api/documents/{id} to return HTTP 500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:36:27 +02:00
Marcel
cdc3e2e4c8 fix(deploy): wire VITE_SENTRY_DSN as Docker build arg for frontend GlitchTip (#645)
All checks were successful
CI / Backend Unit Tests (pull_request) Successful in 3m18s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 19s
CI / Backend Unit Tests (push) Successful in 3m26s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 18s
CI / Compose Bucket Idempotency (push) Successful in 1m0s
CI / Unit & Component Tests (pull_request) Successful in 3m29s
CI / OCR Service Tests (pull_request) Successful in 19s
VITE_SENTRY_DSN is a Vite build-time variable baked into the JS bundle.
Without an ARG/ENV in the Dockerfile build stage and a build.args entry in
docker-compose.prod.yml, the SDK initialised with enabled=false regardless
of the Gitea secret value.

- frontend/Dockerfile: add ARG VITE_SENTRY_DSN + ENV before npm run build
- docker-compose.prod.yml: add build.args.VITE_SENTRY_DSN with empty fallback
- nightly.yml: write VITE_SENTRY_DSN secret into .env.staging

Requires Gitea secret VITE_SENTRY_DSN to be set to the GlitchTip project #1 DSN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 09:54:04 +02:00
Marcel
e89a90ff66 fix(deploy): wire SENTRY_DSN and enable ECS JSON logging for prod (#641)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 1m19s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
CI / Unit & Component Tests (push) Successful in 3m21s
CI / OCR Service Tests (push) Successful in 18s
CI / Backend Unit Tests (push) Successful in 3m33s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 20s
CI / Compose Bucket Idempotency (push) Successful in 59s
Pass SENTRY_DSN env var through to the backend container so the Sentry SDK
actually ships exceptions to GlitchTip — the variable was written to
.env.staging by nightly.yml but never forwarded into the container.

Enable Spring Boot 4.0 ECS structured logging (LOGGING_STRUCTURED_FORMAT_CONSOLE=ecs)
so Loki receives single-entry JSON log lines with parsed log.level, enabling
detected_level filtering in Grafana instead of 50-line unlinked stack trace blobs.

Update Grafana Loki dashboard query from | logfmt to | json to match the new format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:16:00 +02:00
Marcel
0c0a4830cd ux(transcription): bump dismiss button icon from red-500 to red-600
All checks were successful
nightly / deploy-staging (push) Successful in 4m32s
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m27s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
CI / Unit & Component Tests (push) Successful in 3m30s
CI / OCR Service Tests (push) Successful in 19s
CI / Backend Unit Tests (push) Successful in 3m20s
CI / fail2ban Regex (push) Successful in 41s
CI / Semgrep Security Scan (push) Successful in 18s
CI / Compose Bucket Idempotency (push) Successful in 58s
text-red-500 on bg-red-50 gives ~3.8:1 contrast (passes AA for UI
components at 3:1 but leaves no margin). text-red-600 gives ~5.0:1,
comfortably above the AA threshold with no visual downgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:32:47 +02:00
Marcel
dd843d76c2 a11y(transcription): remove redundant aria-live="polite" from alert div
role="alert" already implies aria-live="assertive". The polite override
caused screen readers to wait for the current announcement to finish
before reading the error — too gentle for a failure state the user just
triggered. Dropping the attribute restores the implicit assertive
behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:31:57 +02:00
Marcel
9601974db0 ux(transcription): bump error banner font size to text-sm for readability
text-xs (12px) is at the lower bound for the 60+ transcriber cohort.
text-sm (14px) matches the visual weight of the progress counter label
above and is more comfortable to read under stress (failed operation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:30:54 +02:00
Marcel
1782526c99 test(transcription): gate second click on button re-enabled to fix race
Adds an await for the button to become non-disabled between the two
dispatchEvent calls in 'clears error on next successful call'. This
ensures the first async rejection has fully settled and Svelte has
flushed markingAllReviewed before the second click fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:29:31 +02:00
Marcel
76ef54e064 test(transcription): cover non-JSON fallback in markAllReviewed error path
Adds a test for when the server returns a non-JSON body (e.g. an nginx
502 HTML page). Confirms the res.json().catch(() => ({})) fallback
produces 'INTERNAL_ERROR' as the thrown message and leaves blocks intact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:28:39 +02:00
Marcel
f1d1ac3f1a test(transcription): assert error banner shows domain-specific message
Adds toHaveTextContent(m.transcription_mark_all_reviewed_error()) to the
error-present test. The previous check only asserted presence via
role="alert", which would not have caught the dead key bug — the banner
was showing the generic fallback rather than the operation-specific copy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:27:29 +02:00
Marcel
0f48ffede5 fix(transcription): use domain-specific message in markAllReviewed catch
Removes the getErrorMessage() indirection and calls
m.transcription_mark_all_reviewed_error() directly in the catch block.
The previous implementation routed through getErrorMessage(code) which
mapped any error code to the generic m.error_internal_error() fallback,
leaving the domain-specific key unreachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:23:59 +02:00
Marcel
3e72157ee1 test(transcription): update markAllReviewed non-OK test to expect throw
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m14s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 18s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
The function now throws instead of silently returning on failure.
Update the test name and assertion to match the new behaviour, and
verify blocks remain unchanged after the error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:43:21 +02:00
Marcel
e2d3975524 test(transcription): replace hardcoded regex with m.* calls in mark-all spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:40:28 +02:00
Marcel
59e99f862a fix(i18n): wire TranscriptionEditView mark-all button through Paraglide
Replace hardcoded German strings with m.transcription_mark_all_reviewed()
and m.transcription_mark_all_reviewed_disabled().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:39:39 +02:00
Marcel
bb39ca59ec feat(i18n): add transcription_mark_all_reviewed and _disabled message keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:39:06 +02:00
Marcel
6b53cbfc5b feat(transcription): show dismissible error banner when markAllReviewed fails
Adds markAllError state and catch block to handleMarkAllReviewed.
Error banner renders below the review progress bar with role="alert"
and aria-live="polite" for screen reader announcement. Dismiss button
clears the error; next successful call also clears it automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:38:28 +02:00
Marcel
e3e8373526 fix(transcription): throw error from markAllReviewed() on non-2xx response
Previously the function silently returned on failure, leaving no way
for callers to detect or surface the error to the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:37:21 +02:00
Marcel
907a6a6b53 feat(i18n): add transcription_mark_all_reviewed_error message key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:36:44 +02:00
Marcel
f27e2d33a5 test(transcription): add failing tests for markAllReviewed error display
RED phase: 4 new Vitest browser tests that fail because the error
banner and catch block don't exist yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:35:56 +02:00
12 changed files with 162 additions and 14 deletions

View File

@@ -79,6 +79,7 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
EOF EOF
- name: Verify backend /import:ro mount is wired - name: Verify backend /import:ro mount is wired

View File

@@ -25,7 +25,8 @@ import java.util.UUID;
@NamedEntityGraph(name = "Document.full", attributeNodes = { @NamedEntityGraph(name = "Document.full", attributeNodes = {
@NamedAttributeNode("sender"), @NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"), @NamedAttributeNode("receivers"),
@NamedAttributeNode("tags") @NamedAttributeNode("tags"),
@NamedAttributeNode("trainingLabels")
}) })
@NamedEntityGraph(name = "Document.list", attributeNodes = { @NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"), @NamedAttributeNode("sender"),

View File

@@ -252,6 +252,8 @@ services:
OTEL_METRICS_EXPORTER: none OTEL_METRICS_EXPORTER: none
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1} MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
SENTRY_DSN: ${SENTRY_DSN:-}
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
@@ -266,6 +268,10 @@ services:
build: build:
context: ./frontend context: ./frontend
target: production target: production
args:
# Vite build-time variable — baked into the JS bundle at build time.
# Empty default so deploys succeed before the secret is configured.
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
backend: backend:

View File

@@ -16,6 +16,10 @@ CMD ["npm", "run", "dev"]
# Compiles the SvelteKit Node-adapter output to /app/build. # Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:20.19.0-alpine3.21 AS build FROM node:20.19.0-alpine3.21 AS build
WORKDIR /app WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK.
ARG VITE_SENTRY_DSN
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .

View File

@@ -633,6 +633,9 @@
"transcription_block_review": "Als geprüft markieren", "transcription_block_review": "Als geprüft markieren",
"transcription_block_unreview": "Markierung aufheben", "transcription_block_unreview": "Markierung aufheben",
"transcription_reviewed_count": "{reviewed} von {total} geprüft", "transcription_reviewed_count": "{reviewed} von {total} geprüft",
"transcription_mark_all_reviewed": "Alle als fertig markieren",
"transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert",
"transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"training_ocr_heading": "Kurrent-Erkennung trainieren", "training_ocr_heading": "Kurrent-Erkennung trainieren",
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.", "training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente", "training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",

View File

@@ -633,6 +633,9 @@
"transcription_block_review": "Mark as reviewed", "transcription_block_review": "Mark as reviewed",
"transcription_block_unreview": "Unmark as reviewed", "transcription_block_unreview": "Unmark as reviewed",
"transcription_reviewed_count": "{reviewed} of {total} reviewed", "transcription_reviewed_count": "{reviewed} of {total} reviewed",
"transcription_mark_all_reviewed": "Mark all as reviewed",
"transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed",
"transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.",
"training_ocr_heading": "Train Kurrent recognition", "training_ocr_heading": "Train Kurrent recognition",
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.", "training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents", "training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",

View File

@@ -633,6 +633,9 @@
"transcription_block_review": "Marcar como revisado", "transcription_block_review": "Marcar como revisado",
"transcription_block_unreview": "Desmarcar como revisado", "transcription_block_unreview": "Desmarcar como revisado",
"transcription_reviewed_count": "{reviewed} de {total} revisados", "transcription_reviewed_count": "{reviewed} de {total} revisados",
"transcription_mark_all_reviewed": "Marcar todo como revisado",
"transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados",
"transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.",
"training_ocr_heading": "Entrenar reconocimiento Kurrent", "training_ocr_heading": "Entrenar reconocimiento Kurrent",
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.", "training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos", "training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",

View File

@@ -49,6 +49,7 @@ let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]); let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null); let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false); let markingAllReviewed = $state(false);
let markAllError = $state<string | null>(null);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0); const hasBlocks = $derived(blocks.length > 0);
@@ -67,8 +68,11 @@ $effect(() => {
async function handleMarkAllReviewed() { async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return; if (!onMarkAllReviewed) return;
markingAllReviewed = true; markingAllReviewed = true;
markAllError = null;
try { try {
await onMarkAllReviewed(); await onMarkAllReviewed();
} catch {
markAllError = m.transcription_mark_all_reviewed_error();
} finally { } finally {
markingAllReviewed = false; markingAllReviewed = false;
} }
@@ -169,7 +173,7 @@ async function handleLabelToggle(label: string) {
<button <button
onclick={handleMarkAllReviewed} onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed} disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined} title={allReviewed ? m.transcription_mark_all_reviewed_disabled() : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40" class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
> >
{#if markingAllReviewed} {#if markingAllReviewed}
@@ -207,7 +211,7 @@ async function handleLabelToggle(label: string) {
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
{/if} {/if}
Alle als fertig markieren {m.transcription_mark_all_reviewed()}
</button> </button>
{/if} {/if}
</div> </div>
@@ -217,6 +221,31 @@ async function handleLabelToggle(label: string) {
style="width: {reviewProgress}%" style="width: {reviewProgress}%"
></div> ></div>
</div> </div>
{#if markAllError}
<div
role="alert"
class="mt-1.5 flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 px-3 py-2 font-sans text-sm text-red-700"
>
<span class="flex-1">{markAllError}</span>
<button
onclick={() => (markAllError = null)}
aria-label={m.comp_dismiss()}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-red-600 hover:text-red-700 focus-visible:ring-2 focus-visible:ring-red-500"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div> </div>
<div class="p-4"> <div class="p-4">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->

View File

@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte'; import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup); afterEach(cleanup);
@@ -312,14 +313,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
}); });
await expect await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ })) .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => { it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] }); renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ })) .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
}); });
@@ -329,7 +330,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
}); });
await expect await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ })) .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.toBeDisabled(); .toBeDisabled();
}); });
@@ -343,7 +344,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick // userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
// handlers when a TipTap editor is mounted in the same component tree. // handlers when a TipTap editor is mounted in the same component tree.
const btn = (await page const btn = (await page
.getByRole('button', { name: /Alle als fertig markieren/ }) .getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement; .element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
@@ -361,12 +362,83 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick // Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
const btnEl = (await page const btnEl = (await page
.getByRole('button', { name: /Alle als fertig markieren/ }) .getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement; .element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ })) .element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.toBeDisabled(); .toBeDisabled();
resolveMarkAll(); resolveMarkAll();
}); });
it('shows error message when onMarkAllReviewed callback rejects', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.transcription_mark_all_reviewed_error());
});
it('clears error when dismiss button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const dismissEl = (await page
.getByRole('button', { name: m.comp_dismiss() })
.element()) as HTMLButtonElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('clears error on next successful markAllReviewed call', async () => {
const onMarkAllReviewed = vi
.fn()
.mockRejectedValueOnce(new Error('INTERNAL_ERROR'))
.mockResolvedValue(undefined);
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
// Wait for the button to be re-enabled before the second click — ensures the first
// async rejection has fully settled and Svelte has flushed state changes
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('re-enables button after markAllReviewed failure', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
});
}); });

View File

@@ -259,12 +259,15 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true); expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
}); });
it('is a no-op when PUT returns non-OK', async () => { it('throws and leaves blocks unchanged when PUT returns non-OK', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString(); const u = url.toString();
const method = init?.method ?? 'GET'; const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') { if (u.includes('/review-all') && method === 'PUT') {
return new Response('', { status: 500 }); return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
} }
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200, status: 200,
@@ -274,7 +277,26 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load(); await ctrl.load();
await ctrl.markAllReviewed(); await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
expect(ctrl.blocks[0].reviewed).toBe(false);
});
it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response('Bad Gateway', { status: 502 });
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
expect(ctrl.blocks[0].reviewed).toBe(false); expect(ctrl.blocks[0].reviewed).toBe(false);
}); });
}); });

View File

@@ -119,7 +119,11 @@ export function createTranscriptionBlocks(
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, { const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
method: 'PUT' method: 'PUT'
}); });
if (!res.ok) return; if (!res.ok) {
const body = await res.json().catch(() => ({}));
// Never render body.message — route through getErrorMessage() to prevent leaking backend internals
throw new Error((body as { code?: string })?.code ?? 'INTERNAL_ERROR');
}
const updated = (await res.json()) as { id: string; reviewed: boolean }[]; const updated = (await res.json()) as { id: string; reviewed: boolean }[];
for (const b of updated) { for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id); const existing = blocks.find((x) => x.id === b.id);

View File

@@ -196,7 +196,7 @@
}, },
"targets": [ "targets": [
{ {
"expr": "{job=\"$app\"} |= \"$search\" | logfmt", "expr": "{job=\"$app\"} |= \"$search\" | json",
"hide": false, "hide": false,
"legendFormat": "", "legendFormat": "",
"refId": "A" "refId": "A"