Compare commits
18 Commits
feat/issue
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a460b3c90 | ||
|
|
cdc3e2e4c8 | ||
|
|
e89a90ff66 | ||
|
|
0c0a4830cd | ||
|
|
dd843d76c2 | ||
|
|
9601974db0 | ||
|
|
1782526c99 | ||
|
|
76ef54e064 | ||
|
|
f1d1ac3f1a | ||
|
|
0f48ffede5 | ||
|
|
3e72157ee1 | ||
|
|
e2d3975524 | ||
|
|
59e99f862a | ||
|
|
bb39ca59ec | ||
|
|
6b53cbfc5b | ||
|
|
e3e8373526 | ||
|
|
907a6a6b53 | ||
|
|
f27e2d33a5 |
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 . .
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user