From c4ff25b37ea15a95b0c8547e1bc9a5177f71e5ab Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 12 Jun 2026 23:29:59 +0200 Subject: [PATCH] Fix types in frontend tests --- frontend/src/lib/api.test.ts | 10 ++-- .../src/lib/components/AddBookModal.test.ts | 1 + .../components/AutoSearchCoverModal.test.ts | 31 ++++++------ .../lib/components/BookDetailDialog.test.ts | 13 ++--- .../src/lib/components/BookDrawer.test.ts | 11 ++-- .../lib/components/CoverCandidateGrid.test.ts | 16 +++--- .../src/lib/components/DataExport.test.ts | 2 +- .../src/lib/components/DataImport.test.ts | 8 +-- .../src/lib/components/StarRating.test.ts | 2 +- frontend/src/lib/components/Toaster.test.ts | 50 +++++++++++-------- frontend/src/lib/components/UserMenu.test.ts | 29 +++++++---- frontend/src/lib/errors.ts | 28 ++++++----- frontend/src/lib/test/setup.ts | 7 ++- frontend/src/lib/types.ts | 2 +- frontend/src/lib/utils/language.test.ts | 14 ++++-- frontend/src/vitest-globals.d.ts | 1 + frontend/tsconfig.json | 3 +- 17 files changed, 132 insertions(+), 96 deletions(-) create mode 100644 frontend/src/vitest-globals.d.ts diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 1261fd30..4d811e94 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -18,7 +18,7 @@ describe('api.covers.upload', () => { .mockResolvedValue({ ok: true, json: async () => ({ cover_url: '/api/covers/uploaded.jpg' }) - } as Response); + } as unknown as Response); const file = new File(['fake-image-bytes'], 'cover.jpg', { type: 'image/jpeg' }); const coverUrl = await api.covers.upload(file); @@ -59,7 +59,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => mockResponse, - } as Response); + } as unknown as Response); const result = await api.books.list(); @@ -76,7 +76,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ books: [], total: 0 }), - } as Response); + } as unknown as Response); const result = await api.books.list(); @@ -89,7 +89,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ books: [], total: 0 }), - } as Response); + } as unknown as Response); await api.books.list({ status: 'read', q: 'dune', sort: 'title', order: 'asc' }); @@ -105,7 +105,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ books: [], total: 0 }), - } as Response); + } as unknown as Response); await api.books.list({ q: '' }); diff --git a/frontend/src/lib/components/AddBookModal.test.ts b/frontend/src/lib/components/AddBookModal.test.ts index 70ec91b7..55212d8c 100644 --- a/frontend/src/lib/components/AddBookModal.test.ts +++ b/frontend/src/lib/components/AddBookModal.test.ts @@ -42,6 +42,7 @@ vi.mock('html5-qrcode/esm/core', () => { vi.mock('html5-qrcode/esm/code-decoder', () => { const Html5QrcodeShim = class { + decodeAsync: () => Promise<{ text: string }>; constructor() { this.decodeAsync = function () { return Promise.resolve({ text: '' }); }; } diff --git a/frontend/src/lib/components/AutoSearchCoverModal.test.ts b/frontend/src/lib/components/AutoSearchCoverModal.test.ts index df6e4e46..7bc84f49 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.test.ts +++ b/frontend/src/lib/components/AutoSearchCoverModal.test.ts @@ -1,15 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import AutoSearchCoverModal from './AutoSearchCoverModal.svelte'; +import type { CoverCandidate } from '$lib/types'; describe('AutoSearchCoverModal', () => { const onCancel = vi.fn(); const onSelect = vi.fn(); - const candidates = [ - { source: 'AbeBooks', url: 'https://example.com/1.jpg', available: true, filesize: 512, width: 200, height: 300 }, - { source: 'OpenLibrary', url: 'https://example.com/2.jpg', available: true, filesize: 1024 * 1024, width: 400, height: 600 }, - { source: 'Amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null } + const candidates: CoverCandidate[] = [ + { source: 'abebooks', url: 'https://example.com/1.jpg', available: true, filesize: 512, width: 200, height: 300, content_type: 'image/jpeg' }, + { source: 'openlibrary', url: 'https://example.com/2.jpg', available: true, filesize: 1024 * 1024, width: 400, height: 600, content_type: 'image/jpeg' }, + { source: 'amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null, content_type: null } ]; beforeEach(() => { @@ -76,7 +77,7 @@ describe('AutoSearchCoverModal', () => { await fireEvent.click(imgButtons[0]); expect(onSelect).toHaveBeenCalledOnce(); // First card is sorted by resolution descending: OpenLibrary (400x600) before AbeBooks (200x300) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'OpenLibrary' })); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'openlibrary' })); }); it('calls onCancel when close button clicked', async () => { @@ -103,13 +104,15 @@ describe('AutoSearchCoverModal', () => { render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: [], error: null, onCancel, onSelect } }); - await fireEvent.click(document.querySelector('.modal-backdrop')); + const backdrop = document.querySelector('.modal-backdrop'); + expect(backdrop).toBeTruthy(); + await fireEvent.click(backdrop as Element); expect(onCancel).toHaveBeenCalledOnce(); }); it('shows n/a for missing filesize and resolution', () => { - const candidateNoMeta = [ - { source: 'Test', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null } + const candidateNoMeta: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null, content_type: null } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateNoMeta, error: null, onCancel, onSelect } @@ -118,8 +121,8 @@ describe('AutoSearchCoverModal', () => { }); it('shows KB filesize label', () => { - const candidateKB = [ - { source: 'Test', url: 'https://example.com/kb.jpg', available: true, filesize: 5120, width: 100, height: 150 } + const candidateKB: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/kb.jpg', available: true, filesize: 5120, width: 100, height: 150, content_type: 'image/jpeg' } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateKB, error: null, onCancel, onSelect } @@ -128,8 +131,8 @@ describe('AutoSearchCoverModal', () => { }); it('updates resolution map when image loads', async () => { - const candidateWithLoad = [ - { source: 'Test', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null } + const candidateWithLoad: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null, content_type: null } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateWithLoad, error: null, onCancel, onSelect } @@ -146,8 +149,8 @@ describe('AutoSearchCoverModal', () => { }); it('skips resolution update when image has no natural dimensions', async () => { - const candidateWithLoad = [ - { source: 'Test', url: 'https://example.com/load2.jpg', available: true, filesize: 1000, width: null, height: null } + const candidateWithLoad: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/load2.jpg', available: true, filesize: 1000, width: null, height: null, content_type: null } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateWithLoad, error: null, onCancel, onSelect } diff --git a/frontend/src/lib/components/BookDetailDialog.test.ts b/frontend/src/lib/components/BookDetailDialog.test.ts index fae7e163..5856f650 100644 --- a/frontend/src/lib/components/BookDetailDialog.test.ts +++ b/frontend/src/lib/components/BookDetailDialog.test.ts @@ -2,22 +2,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; import { writable } from 'svelte/store'; import BookDetailDialog from './BookDetailDialog.svelte'; +import type { Book, ReadingProgressEntry } from '$lib/types'; vi.mock('svelte-chartjs', () => ({ Line: vi.fn().mockImplementation(() => ({ default: {} })), })); -const mockProgressList = vi.fn(async () => []); -const mockProgressCreate = vi.fn(async (_bookId: number, _page: number) => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); -const mockProgressDelete = vi.fn(async () => {}); -const mockBooksDelete = vi.fn(async () => {}); -const mockBooksUpdate = vi.fn(async (_id: number, _data: unknown) => ({ ..._data, id: _id })); +const mockProgressList = vi.fn(async (_bookId: number): Promise => []); +const mockProgressCreate = vi.fn(async (_bookId: number, _page: number): Promise => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); +const mockProgressDelete = vi.fn(async (_bookId: number, _entryId: number) => {}); +const mockBooksDelete = vi.fn(async (_id: number) => {}); +const mockBooksUpdate = vi.fn(async (_id: number, _data: Partial) => ({ ...mockBook, ..._data, id: _id })); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ api: { books: { - update: (id: number, data: unknown) => mockBooksUpdate(id, data), + update: (id: number, data: Partial) => mockBooksUpdate(id, data), progress: { list: (bookId: number) => mockProgressList(bookId), create: (bookId: number, page: number) => mockProgressCreate(bookId, page), diff --git a/frontend/src/lib/components/BookDrawer.test.ts b/frontend/src/lib/components/BookDrawer.test.ts index b551a521..16e70e93 100644 --- a/frontend/src/lib/components/BookDrawer.test.ts +++ b/frontend/src/lib/components/BookDrawer.test.ts @@ -3,11 +3,11 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/sv import { writable } from 'svelte/store'; import BookDrawer from './BookDrawer.svelte'; -const mockBooksUpdate = vi.fn(async () => ({ id: 1, title: 'Updated' })); -const mockTransitionStatus = vi.fn(async () => ({ book: { id: 1, title: 'Updated' }, date_conflict: null })); -const mockSuggestionsAuthors = vi.fn(async () => ['Author 1']); -const mockSuggestionsPublishers = vi.fn(async () => ['Publisher 1']); -const mockSuggestionsTags = vi.fn(async () => ['Tag 1']); +const mockBooksUpdate = vi.fn(async (_id: number, _data: unknown) => ({ id: 1, title: 'Updated' })); +const mockTransitionStatus = vi.fn(async (_id: number, _data: unknown) => ({ book: { id: 1, title: 'Updated' }, date_conflict: null })); +const mockSuggestionsAuthors = vi.fn(async (_q: string) => ['Author 1']); +const mockSuggestionsPublishers = vi.fn(async (_q: string) => ['Publisher 1']); +const mockSuggestionsTags = vi.fn(async (_q: string) => ['Tag 1']); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ @@ -33,6 +33,7 @@ vi.mock('html5-qrcode/esm/core', () => ({ vi.mock('html5-qrcode/esm/code-decoder', () => { const Html5QrcodeShim = class { + decodeAsync: () => Promise<{ text: string }>; constructor() { this.decodeAsync = function () { return Promise.resolve({ text: '' }); }; } diff --git a/frontend/src/lib/components/CoverCandidateGrid.test.ts b/frontend/src/lib/components/CoverCandidateGrid.test.ts index e2bffec3..64c9c620 100644 --- a/frontend/src/lib/components/CoverCandidateGrid.test.ts +++ b/frontend/src/lib/components/CoverCandidateGrid.test.ts @@ -4,10 +4,10 @@ import CoverCandidateGrid from './CoverCandidateGrid.svelte'; import type { CoverCandidate } from '$lib/types'; const candidates: CoverCandidate[] = [ - { source: 'abebooks', url: 'https://example.com/1.jpg', available: true, filesize: 20408, width: 200, height: 300 }, - { source: 'hardcover', url: 'https://example.com/2.jpg', available: true, filesize: 3706413, width: 500, height: 800 }, - { source: 'amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null }, - { source: 'thalia', url: 'https://example.com/4.jpg', available: false, filesize: 12036, width: null, height: null } + { source: 'abebooks', url: 'https://example.com/1.jpg', available: true, filesize: 20408, width: 200, height: 300, content_type: 'image/jpeg' }, + { source: 'hardcover', url: 'https://example.com/2.jpg', available: true, filesize: 3706413, width: 500, height: 800, content_type: 'image/jpeg' }, + { source: 'amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null, content_type: null }, + { source: 'thalia', url: 'https://example.com/4.jpg', available: false, filesize: 12036, width: null, height: null, content_type: null } ]; describe('CoverCandidateGrid', () => { @@ -69,16 +69,16 @@ describe('CoverCandidateGrid', () => { }); it('shows n/a for missing filesize and resolution when unloaded', () => { - const missing = [ - { source: 'amazon', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null } + const missing: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null, content_type: null } ]; render(CoverCandidateGrid, { props: { loading: false, candidates: missing, onSelect: vi.fn() } }); expect(document.body.textContent).toContain('n/a'); }); it('updates resolution from image onload event', async () => { - const single = [ - { source: 'abebooks', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null } + const single: CoverCandidate[] = [ + { source: 'abebooks', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null, content_type: null } ]; render(CoverCandidateGrid, { props: { loading: false, candidates: single, onSelect: vi.fn() } }); const img = document.querySelector('img'); diff --git a/frontend/src/lib/components/DataExport.test.ts b/frontend/src/lib/components/DataExport.test.ts index 98bbb8f7..08048f98 100644 --- a/frontend/src/lib/components/DataExport.test.ts +++ b/frontend/src/lib/components/DataExport.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; import DataExport from './DataExport.svelte'; -const mockExportData = vi.fn(async () => new Blob(['test'])); +const mockExportData = vi.fn(async (_params: unknown) => new Blob(['test'])); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ diff --git a/frontend/src/lib/components/DataImport.test.ts b/frontend/src/lib/components/DataImport.test.ts index 4c9e560e..8f62c411 100644 --- a/frontend/src/lib/components/DataImport.test.ts +++ b/frontend/src/lib/components/DataImport.test.ts @@ -3,25 +3,25 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/sv import { writable } from 'svelte/store'; import DataImport from './DataImport.svelte'; -const mockParseImportFile = vi.fn(async () => ({ +const mockParseImportFile = vi.fn(async (_file: File) => ({ file_id: 'test-file-123', format: 'csv' as const, source_fields: ['Book Title', 'Author Name', 'ISBN'], sample_rows: [{ 'Book Title': 'Dune', 'Author Name': 'Frank Herbert', 'ISBN': '978-3-16-148410-0' }], row_count: 1 })); -const mockSuggestMapping = vi.fn(async () => ({ +const mockSuggestMapping = vi.fn(async (_fileId: string) => ({ suggested_mapping: { title: 'Book Title', author: 'Author Name', isbn: 'ISBN' }, db_fields: ['title', 'author', 'isbn', 'publisher', 'page_count'] })); -const mockValidateImport = vi.fn(async () => ({ +const mockValidateImport = vi.fn(async (_params: unknown) => ({ valid: true, row_count: 1, warnings: [], errors: [] })); const mockListMappings = vi.fn(async () => []); -const mockExecuteImport = vi.fn(async function* () { +const mockExecuteImport = vi.fn(async function* (_params: unknown) { yield { event: 'start', total_rows: 1 }; yield { event: 'progress', processed: 1, total: 1, percent: 100 }; yield { event: 'complete', imported: 1, failed: 0, failures: [] }; diff --git a/frontend/src/lib/components/StarRating.test.ts b/frontend/src/lib/components/StarRating.test.ts index 063c3f6b..ed3df54b 100644 --- a/frontend/src/lib/components/StarRating.test.ts +++ b/frontend/src/lib/components/StarRating.test.ts @@ -56,7 +56,7 @@ describe('StarRating', () => { }); try { const { container } = render(StarRating, { props: { value: null } }); - const radios = container.querySelectorAll('input[type="radio"]'); + const radios = container.querySelectorAll('input[type="radio"]'); expect(radios[0].name).toMatch(/^rating-\d+$/); expect(radios[0].name).toBe(radios[1].name); } finally { diff --git a/frontend/src/lib/components/Toaster.test.ts b/frontend/src/lib/components/Toaster.test.ts index 8d09ce3f..5b139d3c 100644 --- a/frontend/src/lib/components/Toaster.test.ts +++ b/frontend/src/lib/components/Toaster.test.ts @@ -1,32 +1,42 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/svelte'; -import { writable } from 'svelte/store'; import Toaster from './Toaster.svelte'; -// Create mock store inside a helper that can be used by both mock and tests -const createMockToasts = () => writable>([]); +type ToastItem = { id: number; message: string; level: string }; + +function createStore(initial: ToastItem[]) { + let value = initial; + const subscribers = new Set<(next: ToastItem[]) => void>(); + return { + subscribe(run: (next: ToastItem[]) => void) { + run(value); + subscribers.add(run); + return () => subscribers.delete(run); + }, + set(next: ToastItem[]) { + value = next; + subscribers.forEach((run) => run(value)); + } + }; +} vi.mock('$lib/toasts', async () => { - const { writable } = await import('svelte/store'); - const store = writable>([]); + const store = createStore([]); return { toasts: { subscribe: store.subscribe, add: vi.fn(), - remove: vi.fn() - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ToastLevel: {} as any, - // Expose for tests - _mockStore: store + remove: vi.fn(), + __set: store.set + } }; }); -import { toasts, _mockStore } from '$lib/toasts'; +import { toasts } from '$lib/toasts'; describe('Toaster', () => { afterEach(() => { - _mockStore.set([]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([]); cleanup(); vi.clearAllMocks(); }); @@ -37,13 +47,13 @@ describe('Toaster', () => { }); it('renders a toast message', () => { - _mockStore.set([{ id: 1, message: 'Book saved', level: 'success' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Book saved', level: 'success' }]); render(Toaster); expect(screen.getByText('Book saved')).toBeInTheDocument(); }); it('renders multiple toasts', () => { - _mockStore.set([ + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([ { id: 1, message: 'First toast', level: 'info' }, { id: 2, message: 'Second toast', level: 'warning' } ]); @@ -53,7 +63,7 @@ describe('Toaster', () => { }); it('calls remove when dismiss button clicked', async () => { - _mockStore.set([{ id: 42, message: 'Dismiss me', level: 'error' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 42, message: 'Dismiss me', level: 'error' }]); render(Toaster); const dismissBtn = screen.getByRole('button', { name: 'Dismiss' }); @@ -63,25 +73,25 @@ describe('Toaster', () => { }); it('applies correct alert class for error level', () => { - _mockStore.set([{ id: 1, message: 'Error!', level: 'error' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Error!', level: 'error' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-error')).toBeInTheDocument(); }); it('applies correct alert class for success level', () => { - _mockStore.set([{ id: 1, message: 'Success!', level: 'success' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Success!', level: 'success' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-success')).toBeInTheDocument(); }); it('applies correct alert class for warning level', () => { - _mockStore.set([{ id: 1, message: 'Warning!', level: 'warning' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Warning!', level: 'warning' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-warning')).toBeInTheDocument(); }); it('applies correct alert class for info level', () => { - _mockStore.set([{ id: 1, message: 'Info!', level: 'info' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Info!', level: 'info' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-info')).toBeInTheDocument(); }); diff --git a/frontend/src/lib/components/UserMenu.test.ts b/frontend/src/lib/components/UserMenu.test.ts index 26b934b2..e38d3253 100644 --- a/frontend/src/lib/components/UserMenu.test.ts +++ b/frontend/src/lib/components/UserMenu.test.ts @@ -11,7 +11,7 @@ const mockBroadcastLogout = vi.fn(); vi.mock('$lib/stores/auth', async () => { const { writable } = await import('svelte/store'); return { - currentUser: writable<{ id: number; firstname: string; lastname: string; email: string; role: string } | null>(null), + currentUser: writable<{ id: number; firstname: string; lastname: string; email: string; role: 'admin' | 'user'; created_at: string } | null>(null), csrfToken: writable(null), broadcastLogout: () => mockBroadcastLogout() }; @@ -42,14 +42,23 @@ describe('UserMenu', () => { cleanup(); }); + const mockUser = { + id: 1, + firstname: 'John', + lastname: 'Doe', + email: 'john@example.com', + role: 'user' as const, + created_at: '2024-01-01T00:00:00Z' + }; + it('renders user avatar button', () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); expect(screen.getByRole('button', { name: 'User menu' })).toBeInTheDocument(); }); it('shows user avatar', () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); expect(screen.getByRole('button', { name: 'User menu' }).querySelector('svg')).toBeInTheDocument(); }); @@ -61,7 +70,7 @@ describe('UserMenu', () => { }); it('opens dropdown when clicked', async () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); const menuBtn = screen.getByRole('button', { name: 'User menu' }); @@ -72,7 +81,7 @@ describe('UserMenu', () => { }); it('has profile link', async () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); await fireEvent.click(screen.getByRole('button', { name: 'User menu' })); @@ -81,7 +90,7 @@ describe('UserMenu', () => { }); it('calls logout API and redirects on logout', async () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); csrfToken.set('test-csrf'); render(UserMenu); @@ -95,7 +104,7 @@ describe('UserMenu', () => { }); it('clears stores on logout', async () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); csrfToken.set('test-csrf'); render(UserMenu); @@ -113,7 +122,7 @@ describe('UserMenu', () => { it('handles logout API failure gracefully', async () => { mockLogout.mockRejectedValue(new Error('Network error')); - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); await fireEvent.click(screen.getByRole('button', { name: 'User menu' })); @@ -123,7 +132,7 @@ describe('UserMenu', () => { }); it('closes dropdown when logout clicked', async () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); await fireEvent.click(screen.getByRole('button', { name: 'User menu' })); @@ -133,7 +142,7 @@ describe('UserMenu', () => { }); it('closes dropdown when profile link clicked', async () => { - currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); + currentUser.set(mockUser); render(UserMenu); await fireEvent.click(screen.getByRole('button', { name: 'User menu' })); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 1ea65013..ad6bcc1a 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -21,37 +21,39 @@ const BACKEND_ERROR_REGEX: [RegExp, string, string[]][] = [ [/^At most (\d+) books can be updated at once$/, 'error.tooManyBooksSelected', ['max']], ]; -export function localizeBackendError(err: unknown): { key: string; values?: Record } { +type TranslationValue = string | number | boolean | Date | null | undefined; + +export function localizeBackendError(err: unknown): { id: string; values?: Record } { if (err instanceof Error) { if (err.message.startsWith('error.')) { - return { key: err.message }; + return { id: err.message }; } const exactKey = BACKEND_ERROR_MAP[err.message]; if (exactKey) { - return { key: exactKey }; + return { id: exactKey }; } for (const [pattern, key, names] of BACKEND_ERROR_REGEX) { const match = err.message.match(pattern); if (match) { - const values: Record = {}; + const values: Record = {}; for (let i = 0; i < names.length; i++) { values[names[i]] = match[i + 1]; } - return { key, values }; + return { id: key, values }; } } - return { key: err.message }; + return { id: err.message }; } - return { key: 'Unknown error' }; + return { id: 'Unknown error' }; } -export function localizeError(err: unknown, translate: (key: string, options?: { values?: Record }) => string, fallback: string): string { - const { key, values } = localizeBackendError(err); - if (key.startsWith('error.')) { - return translate(key, values ? { values } : undefined); +export function localizeError(err: unknown, translate: (key: string, options?: any) => string, fallback: string): string { + const { id, values } = localizeBackendError(err); + if (id.startsWith('error.')) { + return translate(id, values ? { values } : undefined); } - if (key !== 'Unknown error') { - return key; + if (id !== 'Unknown error') { + return id; } return fallback; } diff --git a/frontend/src/lib/test/setup.ts b/frontend/src/lib/test/setup.ts index b1c76458..1fb972d5 100644 --- a/frontend/src/lib/test/setup.ts +++ b/frontend/src/lib/test/setup.ts @@ -4,8 +4,11 @@ import '$lib/chartjs/register'; // --- Polyfill crypto.randomUUID for happy-dom --- if (typeof crypto !== 'undefined' && !crypto.randomUUID) { - // @ts-expect-error polyfill for testing environment - crypto.randomUUID = () => '00000000-0000-0000-0000-000000000000'; + Object.defineProperty(crypto, 'randomUUID', { + value: () => '00000000-0000-0000-0000-000000000000', + configurable: true, + writable: true + }); } // --- Mock svelte-i18n ($lib/i18n) --- diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 47496c68..5bd556a3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -42,7 +42,7 @@ export interface BookImportCandidate { } export interface CoverCandidate { - source: 'abebooks' | 'openlibrary' | 'amazon' | 'hardcover'; + source: 'abebooks' | 'openlibrary' | 'amazon' | 'hardcover' | 'thalia'; url: string; available: boolean; width: number | null; diff --git a/frontend/src/lib/utils/language.test.ts b/frontend/src/lib/utils/language.test.ts index 09a1f311..458949d8 100644 --- a/frontend/src/lib/utils/language.test.ts +++ b/frontend/src/lib/utils/language.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { formatLanguageCode } from '$lib/utils/language'; describe('formatLanguageCode', () => { @@ -33,15 +33,19 @@ describe('formatLanguageCode', () => { }); it('returns uppercase code when Intl.DisplayNames throws', () => { - const OriginalDisplayNames = Intl.DisplayNames; - // @ts-expect-error mock - Intl.DisplayNames = vi.fn(function () { + const originalDescriptor = Object.getOwnPropertyDescriptor(Intl, 'DisplayNames'); + Object.defineProperty(Intl, 'DisplayNames', { + value: vi.fn(function () { throw new TypeError('Intl not available'); + }), + configurable: true }); try { expect(formatLanguageCode('fr', 'en')).toBe('FR'); } finally { - Intl.DisplayNames = OriginalDisplayNames; + if (originalDescriptor) { + Object.defineProperty(Intl, 'DisplayNames', originalDescriptor); + } } }); }); diff --git a/frontend/src/vitest-globals.d.ts b/frontend/src/vitest-globals.d.ts new file mode 100644 index 00000000..9896c472 --- /dev/null +++ b/frontend/src/vitest-globals.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2c2ed3c4..44f95350 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,7 +10,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["vitest/globals"] } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files