Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions frontend/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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' });

Expand All @@ -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: '' });

Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/components/AddBookModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' }); };
}
Expand Down
31 changes: 17 additions & 14 deletions frontend/src/lib/components/AutoSearchCoverModal.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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 }
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/lib/components/BookDetailDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadingProgressEntry[]> => []);
const mockProgressCreate = vi.fn(async (_bookId: number, _page: number): Promise<ReadingProgressEntry> => ({ 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<Book>) => ({ ...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<Book>) => mockBooksUpdate(id, data),
progress: {
list: (bookId: number) => mockProgressList(bookId),
create: (bookId: number, page: number) => mockProgressCreate(bookId, page),
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/lib/components/BookDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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: '' }); };
}
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/lib/components/CoverCandidateGrid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/DataExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/lib/components/DataImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/StarRating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>('input[type="radio"]');
expect(radios[0].name).toMatch(/^rating-\d+$/);
expect(radios[0].name).toBe(radios[1].name);
} finally {
Expand Down
50 changes: 30 additions & 20 deletions frontend/src/lib/components/Toaster.test.ts
Original file line number Diff line number Diff line change
@@ -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<Array<{ id: number; message: string; level: string }>>([]);
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<Array<{ id: number; message: string; level: string }>>([]);
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();
});
Expand All @@ -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' }
]);
Expand All @@ -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' });
Expand All @@ -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();
});
Expand Down
Loading