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