Files
familienarchiv/frontend/src/lib/document/transcription/saveBlockWithConflictRetry.spec.ts
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

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/shared/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();
});
});