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>
153 lines
4.7 KiB
TypeScript
153 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
|
import { BlockConflictResolvedError } from './blockConflictMerge';
|
|
import type { PersonMention } from '$lib/types';
|
|
|
|
const DOC = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
|
const BLK = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
|
|
|
const SERVER_BLOCK_AFTER_RENAME = {
|
|
id: BLK,
|
|
annotationId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
|
documentId: DOC,
|
|
text: 'old text from server',
|
|
label: null,
|
|
sortOrder: 0,
|
|
version: 7,
|
|
source: 'MANUAL' as const,
|
|
reviewed: false,
|
|
mentionedPersons: [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]
|
|
};
|
|
|
|
function mkResponse(status: number, body?: unknown): Response {
|
|
return new Response(body === undefined ? null : JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
describe('saveBlockWithConflictRetry', () => {
|
|
it('returns the server-saved block on a successful PUT', async () => {
|
|
const updated = { ...SERVER_BLOCK_AFTER_RENAME, text: 'persisted text' };
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(200, updated));
|
|
|
|
const result = await saveBlockWithConflictRetry({
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
documentId: DOC,
|
|
blockId: BLK,
|
|
text: 'persisted text',
|
|
mentionedPersons: []
|
|
});
|
|
|
|
expect(result).toEqual(updated);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
`/api/documents/${DOC}/transcription-blocks/${BLK}`,
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify({ text: 'persisted text', mentionedPersons: [] })
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws BlockConflictResolvedError carrying the merged block on 409', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(mkResponse(409))
|
|
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
|
|
|
|
const localMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
|
|
|
|
await expect(
|
|
saveBlockWithConflictRetry({
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
documentId: DOC,
|
|
blockId: BLK,
|
|
text: 'transcriber unsaved input',
|
|
mentionedPersons: localMentions
|
|
})
|
|
).rejects.toThrow(BlockConflictResolvedError);
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
// First call PUT, second is the GET refetch.
|
|
expect(fetchImpl.mock.calls[0]?.[1]?.method).toBe('PUT');
|
|
expect(fetchImpl.mock.calls[1]?.[1]).toBeUndefined();
|
|
});
|
|
|
|
it('attaches the merged block to err.merged so callers can update local state', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(mkResponse(409))
|
|
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
|
|
|
|
const localMentions: PersonMention[] = [
|
|
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale displayName
|
|
];
|
|
try {
|
|
await saveBlockWithConflictRetry({
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
documentId: DOC,
|
|
blockId: BLK,
|
|
text: 'transcriber unsaved input',
|
|
mentionedPersons: localMentions
|
|
});
|
|
throw new Error('expected throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(BlockConflictResolvedError);
|
|
const merged = (err as BlockConflictResolvedError).merged!;
|
|
// Local text wins.
|
|
expect(merged.text).toBe('transcriber unsaved input');
|
|
// Server displayName wins for shared personId.
|
|
expect(merged.mentionedPersons).toEqual([
|
|
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
|
|
]);
|
|
// Server version carried forward.
|
|
expect(merged.version).toBe(7);
|
|
}
|
|
});
|
|
|
|
it('throws BlockConflictResolvedError without merged when refetch fails', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(mkResponse(409))
|
|
.mockResolvedValueOnce(mkResponse(500));
|
|
|
|
await expect(
|
|
saveBlockWithConflictRetry({
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
documentId: DOC,
|
|
blockId: BLK,
|
|
text: 'x',
|
|
mentionedPersons: []
|
|
})
|
|
).rejects.toMatchObject({ code: 'CONFLICT_RESOLVED', merged: undefined });
|
|
});
|
|
|
|
it('throws Save failed for any other non-OK response', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(500));
|
|
await expect(
|
|
saveBlockWithConflictRetry({
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
documentId: DOC,
|
|
blockId: BLK,
|
|
text: 'x',
|
|
mentionedPersons: []
|
|
})
|
|
).rejects.toThrow('Save failed');
|
|
});
|
|
|
|
it('rejects ids that are not UUIDs (path-injection guard)', async () => {
|
|
const fetchImpl = vi.fn();
|
|
await expect(
|
|
saveBlockWithConflictRetry({
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
documentId: DOC,
|
|
blockId: '../../etc/passwd',
|
|
text: 'x',
|
|
mentionedPersons: []
|
|
})
|
|
).rejects.toThrow(/Invalid id/);
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
});
|