fix(transcription): replace sendBeacon with fetch keepalive; add catch-all API proxy

sendBeacon always sends POST, but the backend expects PUT for block updates, so
saves were silently dropped on page unload.  Replace with fetch({ keepalive: true,
method: 'PUT' }) which survives navigation and uses the correct HTTP method.

Add a catch-all SvelteKit server route at /api/[...path] so all client-side API
calls work in production (without the Vite dev proxy).  More-specific routes
(/api/persons, /api/tags, /api/documents/[id]/file) keep precedence.

Closes #204

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 15:56:53 +02:00
committed by marcel
parent b6bfb9148e
commit e1304b6512
3 changed files with 107 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
@@ -82,3 +82,70 @@ describe('createBlockAutoSave', () => {
expect(mockSaveFn).not.toHaveBeenCalled();
});
});
describe('flushViaBeacon', () => {
let mockFetch: Mock;
beforeEach(() => {
vi.useFakeTimers();
mockSaveFn.mockClear();
mockSaveFn.mockResolvedValue(undefined);
mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('sends a PUT request with keepalive:true for each pending block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
as.flushViaBeacon();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'hello' })
})
);
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-2',
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'world' })
})
);
});
it('does not call navigator.sendBeacon', () => {
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.flushViaBeacon();
expect(sendBeaconSpy).not.toHaveBeenCalled();
});
it('does nothing when there are no pending edits', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.flushViaBeacon();
expect(mockFetch).not.toHaveBeenCalled();
});
it('cancels the debounce timer so saveFn is not also called', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.flushViaBeacon();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
});