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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user