refactor(transcription): extract saveBlockWithConflictRetry into a util

Tester #5506 §2 + Markus #5504 §2: the 409 orchestration was inline in
+page.svelte and untested. Extract into a pure module that takes the
fetch function as a dependency, so the full happy path / 409 path / 500
path / refetch-fails path / UUID-guard path can be unit-tested with
mock Responses. The route file now reads as 12 lines: call the helper,
on conflict apply the merged snapshot to local state, re-throw.

BlockConflictResolvedError now carries the merged block on its
`merged` property so callers don't have to redo the refetch.

6 new unit tests cover every branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 01:20:49 +02:00
parent d9c7abf2ab
commit ba73387d50
4 changed files with 234 additions and 35 deletions

View File

@@ -88,44 +88,28 @@ async function loadTranscriptionBlocks() {
}
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
async function saveBlock(
blockId: string,
text: string,
mentionedPersons: import('$lib/types').PersonMention[]
) {
// Path-injection defence in depth (Sina #5505): both ids are server-controlled
// today, but reject anything that isn't a UUID before interpolating it into
// the URL — a future feature accepting user-supplied ids must not silently
// bypass this check.
if (!UUID_RE.test(doc.id) || !UUID_RE.test(blockId)) {
throw new Error(`Invalid id for save: doc=${doc.id} block=${blockId}`);
}
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons })
});
if (res.status === 409) {
// Rename-mid-edit (B12b): refetch latest, merge so transcriber input survives.
const { mergeBlockOnConflict, BlockConflictResolvedError } =
await import('$lib/utils/blockConflictMerge');
const fresh = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`);
if (fresh.ok) {
const serverBlock = await fresh.json();
const merged = mergeBlockOnConflict({
serverBlock,
localText: text,
localMentions: mentionedPersons
});
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? merged : b));
const { saveBlockWithConflictRetry } = await import('$lib/utils/saveBlockWithConflictRetry');
const { BlockConflictResolvedError } = await import('$lib/utils/blockConflictMerge');
try {
const updated = await saveBlockWithConflictRetry({
fetchImpl: fetch,
documentId: doc.id,
blockId,
text,
mentionedPersons
});
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
} catch (err) {
if (err instanceof BlockConflictResolvedError && err.merged) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? err.merged! : b));
}
throw new BlockConflictResolvedError(blockId);
throw err;
}
if (!res.ok) throw new Error('Save failed');
const updated = await res.json();
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
}
async function deleteBlock(blockId: string) {