Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
48e6161
spec: add non-Calibre books specification with clarifications (spec-003)
masonfox Feb 5, 2026
fe9d1fe
enhance spec
masonfox Feb 5, 2026
99d2114
feat: Add implementation plan for multi-source book tracking (spec-003)
masonfox Feb 5, 2026
33d95af
feat(spec-003): Phase 1 - Database schema and provider foundation
masonfox Feb 5, 2026
1bfb3e1
feat(spec-003): Phase 2 Part 1 - Repository layer extensions
masonfox Feb 5, 2026
53ecac2
feat(spec-003): Phase 2 Part 2 - Circuit breaker and manual provider
masonfox Feb 5, 2026
ed408bb
feat(spec-003): Phase 2 complete - Service layer and provider stubs
masonfox Feb 5, 2026
4b6d9c1
feat(spec-003): Phase 3 Backend - Manual book creation (T021-T027)
masonfox Feb 5, 2026
9e9d4c5
feat(spec-003): Phase 3 Frontend - ManualBookForm and ProviderBadge c…
masonfox Feb 5, 2026
dc27d59
feat(spec-003): Phase 3 UI Integration - Add Manual Book button and P…
masonfox Feb 5, 2026
a874163
feat(spec-003): Phase 4 - Calibre Sync Isolation (T039-T047)
masonfox Feb 5, 2026
7c35d1a
fix(tests): Add source field to createTestBook helper
masonfox Feb 5, 2026
6e401fa
feat(spec-003): Phase 5 Backend - Source filtering API (T048-T049)
masonfox Feb 6, 2026
b85e373
feat(library): add source filter for multi-provider book tracking
masonfox Feb 6, 2026
9c6a5b8
docs: mark Phase 5 source filtering tasks as complete
masonfox Feb 6, 2026
64bd2c0
feat(providers): implement Hardcover and OpenLibrary search
masonfox Feb 6, 2026
97a7b2e
feat(search): implement federated search service and API
masonfox Feb 6, 2026
dfa4149
feat(search): add federated search UI with provider selection
masonfox Feb 6, 2026
396e726
fix: consolidate Providers directory and update task tracking
masonfox Feb 6, 2026
84d0040
feat(spec-003): Phase 8 - Provider Configuration UI and API
Feb 6, 2026
6249c3c
docs(spec-003): Mark Phase 3 complete - all 18 tasks verified
Feb 6, 2026
4aba8f4
docs(spec-003): Mark Phase 4 code complete
Feb 6, 2026
0171941
fix(providers): Set Hardcover requiresAuth to true
Feb 6, 2026
a1425d5
fix(search): correctly parse API response in FederatedSearchModal
Feb 6, 2026
701376b
fix(hardcover): add API key authentication and graceful error handling
Feb 6, 2026
3952d41
refactor(hardcover): use database-backed credentials with runtime reload
Feb 6, 2026
5539cf4
fix: Hardcover search results parsing for Typesense API response
masonfox Feb 13, 2026
e222634
feat(providers): add page count to search results
masonfox Feb 13, 2026
b72216d
Enhance provider date metadata to capture full publication dates
masonfox Feb 13, 2026
e1e9853
fix(hardcover): correct release_date field type and simplify date par…
masonfox Feb 13, 2026
00159b1
refactor: separate Sources from Metadata Providers in spec 003
masonfox Feb 13, 2026
cee5830
feat(covers): add cover image support for manual books (Phase C1)
masonfox Feb 13, 2026
b7a8599
refactor(spec-003): implement many-to-many book sources architecture …
masonfox Feb 13, 2026
1bbf5cb
refactor(spec-003): update repositories for book sources (Phase R1.3)
masonfox Feb 13, 2026
158ef6b
fix(spec-003): regenerate migration 0022 to include provider_configs …
masonfox Feb 13, 2026
66de8d5
refactor(spec-003): update services and APIs for book sources (Phase …
masonfox Feb 13, 2026
d1bd196
fix(spec-003): export provider-config repository from index
masonfox Feb 13, 2026
733c9de
refactor(spec-003): update type system and remove manual provider (Ph…
masonfox Feb 13, 2026
6478364
refactor(spec-003): update UI to display multiple book sources (Phase…
masonfox Feb 14, 2026
d04bf71
test(spec-003): fix test fixtures for book sources architecture (Phas…
masonfox Feb 14, 2026
5ded79d
feat: implement fetchMetadata for Hardcover and OpenLibrary providers
masonfox Feb 14, 2026
a9be9a9
fix(hardcover): correct GraphQL query structure for fetchMetadata
masonfox Feb 14, 2026
25bfb09
feat: add metadata fetch endpoint and integrate with search modal
masonfox Feb 14, 2026
a3590a7
refactor: remove ProviderRegistry and simplify provider architecture
masonfox Feb 14, 2026
0841224
feat: add web URL support for cover images in manual book form
masonfox Feb 14, 2026
a5a6c12
fix: replace hardcoded dark mode classes with CSS variables in Add Bo…
masonfox Feb 14, 2026
801ee0e
fix: make SearchResultCard dark/light mode aware and remove ISBN display
masonfox Feb 14, 2026
fe7cc3e
feat: add edit and delete functionality for manual books
masonfox Feb 14, 2026
7513241
Fix mobile pull-to-refresh in modals and migrate EditBookModal to res…
masonfox Feb 14, 2026
961b0f7
Migrate FederatedSearchModal to responsive pattern for mobile pull-to…
masonfox Feb 14, 2026
4364b38
Remove metadata edit banner from FederatedSearchModal
masonfox Feb 14, 2026
5fafeb5
Increase description textarea height in FederatedSearchModal
masonfox Feb 14, 2026
ad0e05f
Increase description textarea to 6 rows in FederatedSearchModal
masonfox Feb 14, 2026
8654794
Migrate ManualBookForm to responsive pattern for mobile pull-to-refre…
masonfox Feb 14, 2026
1f56a0d
Remove CSS properties interfering with iOS Safari scrolling in Bottom…
masonfox Feb 14, 2026
ff27f9d
Remove body scroll lock from BottomSheet for iOS Safari compatibility
masonfox Feb 14, 2026
03dada9
Fix provider disable validation to accept enabled: false
masonfox Feb 15, 2026
1eedfa7
fix: replace booksToUpsert with calibreBooks in book_sources sync
masonfox Feb 17, 2026
00af0ba
refactor: remove provider circuit breaker and health check infrastruc…
masonfox Feb 17, 2026
9c7a9c7
fix: standardize session initialization and date format across book c…
masonfox Feb 18, 2026
3a39764
rollback: remove books.path column - unused by application
masonfox Feb 18, 2026
3342f09
feat: add series support to Hardcover provider
masonfox Feb 18, 2026
475babf
feat: add series fields to provider search modal
masonfox Feb 18, 2026
97c0f8f
fix: show validation errors to user in provider search modal
masonfox Feb 18, 2026
5834dd0
don't allow modals to close on scrim
masonfox Feb 18, 2026
76ffae7
fix: limit Hardcover tags to 50 max, truncate tag strings to 50 chars
masonfox Feb 18, 2026
5429952
revert: remove tag validation error UI (handled at provider level)
masonfox Feb 18, 2026
3b845a7
fix: deduplicate tags and fix React key warning
masonfox Feb 18, 2026
9a7bb03
fix: use index-based keys in TagEditor to prevent React warnings
masonfox Feb 18, 2026
3c9e2c8
feat: deduplicate tags in TagEditor modal on open and save
masonfox Feb 18, 2026
f2868b4
refactor: move tag deduplication upstream to Hardcover provider
masonfox Feb 18, 2026
443ed63
make tags smaller in the edit modal
masonfox Feb 18, 2026
4009b36
fix: update book timestamp on cover upload for cache invalidation
masonfox Feb 18, 2026
bd2526f
fix: manual source filter now correctly identifies books without exte…
masonfox Feb 18, 2026
8189511
refactor: consolidate library filters - sources and shelves in 50/50 …
masonfox Feb 18, 2026
d37b4fa
docs: mark library filter consolidation as done
masonfox Feb 18, 2026
e2d1002
refactor: remove source provider badges from book cards
masonfox Feb 18, 2026
4ef5cc5
feat: improve tag UI in Add Book modals with autocomplete and visual …
masonfox Feb 18, 2026
0e3f2f2
docs: mark tag UI improvements as complete in TODOS
masonfox Feb 18, 2026
a6a9e9a
feat: add context-aware dropdown behavior to TagSelector
masonfox Feb 18, 2026
168f8a4
fix: invalidate all caches when deleting local/manual books
masonfox Feb 18, 2026
9d8efb5
docs: mark cache invalidation fix as complete
masonfox Feb 18, 2026
11b7252
fix: ensure covers display immediately after book creation
masonfox Feb 18, 2026
94cea8d
docs: mark cover display issue as resolved in TODOS
masonfox Feb 18, 2026
8196656
refactor: change terminology from 'Manual' to 'Local' throughout code…
masonfox Feb 18, 2026
2bdfbf7
fix: restore rating check triggers after books table recreation
masonfox Feb 18, 2026
96d1182
refactor: update source filter icons for better visual clarity
masonfox Feb 18, 2026
0e039ef
feat: add tag truncation/deduplication for OpenLibrary and improve va…
masonfox Feb 18, 2026
ad86d35
fix: make cover downloads synchronous to prevent cache race condition
masonfox Feb 18, 2026
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
3 changes: 2 additions & 1 deletion __tests__/components/BookHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ afterEach(() => {

describe("BookHeader", () => {
const mockBook = {
id: 1,
calibreId: 1,
totalPages: 300,
};
Expand Down Expand Up @@ -368,7 +369,7 @@ describe("BookHeader", () => {
});

test("should handle book without totalPages", () => {
const bookWithoutPages = { calibreId: 1 };
const bookWithoutPages = { id: 1, calibreId: 1 };

render(<BookHeader {...defaultProps} book={bookWithoutPages} />);

Expand Down
317 changes: 317 additions & 0 deletions __tests__/e2e/api/providers/metadata-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
/**
* Tests for GET /api/providers/[providerId]/metadata/[externalId]
*
* Verifies that the metadata fetch endpoint correctly retrieves
* complete book metadata including description, tags, and publisher.
*/

import { describe, it, expect, beforeEach, vi } from "vitest";
import { GET } from "@/app/api/providers/[providerId]/metadata/[externalId]/route";
import { createMockRequest } from "@/__tests__/fixtures/test-data";
import { providerService } from "@/lib/services/provider.service";
import type { BookMetadata } from "@/lib/providers/base/IMetadataProvider";
import type { NextRequest } from "next/server";

// Mock the provider service
vi.mock("@/lib/services/provider.service", () => ({
providerService: {
fetchMetadata: vi.fn(),
},
}));

describe("GET /api/providers/[providerId]/metadata/[externalId]", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("Success Cases", () => {
it("should fetch complete metadata from Hardcover", async () => {
// Arrange
const mockMetadata: BookMetadata = {
title: "Dune",
authors: ["Frank Herbert"],
isbn: "9780441172719",
description: "Set on the desert planet Arrakis, Dune is the story of the boy Paul Atreides...",
tags: ["Science Fiction", "Fantasy", "Space Opera"],
publisher: "Ace Books",
pubDate: new Date("1965-06-01"),
totalPages: 688,
coverImageUrl: "https://example.com/cover.jpg",
};

vi.mocked(providerService.fetchMetadata).mockResolvedValue(mockMetadata);

const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/312460"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "312460" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.data.title).toBe("Dune");
expect(data.data.authors).toEqual(["Frank Herbert"]);
expect(data.data.description).toBe("Set on the desert planet Arrakis, Dune is the story of the boy Paul Atreides...");
expect(data.data.tags).toEqual(["Science Fiction", "Fantasy", "Space Opera"]);
expect(data.data.publisher).toBe("Ace Books");
expect(data.data.pubDate).toBe("1965-06-01T00:00:00.000Z"); // JSON serializes Date to string
expect(providerService.fetchMetadata).toHaveBeenCalledWith("hardcover", "312460");
});

it("should fetch complete metadata from OpenLibrary", async () => {
// Arrange
const mockMetadata: BookMetadata = {
title: "Fantastic Mr Fox",
authors: ["Roald Dahl"],
isbn: "9780142410349",
description: "Three farmers plot to kill Mr Fox, but he outsmarts them...",
tags: ["Children's Literature", "Fiction", "Animals"],
publisher: "Puffin Books",
pubDate: new Date("1970-01-01"),
totalPages: 96,
coverImageUrl: "https://covers.openlibrary.org/b/id/123.jpg",
};

vi.mocked(providerService.fetchMetadata).mockResolvedValue(mockMetadata);

const request = createMockRequest(
"GET",
"/api/providers/openlibrary/metadata/OL45804W"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "openlibrary", externalId: "OL45804W" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.data.title).toBe("Fantastic Mr Fox");
expect(data.data.authors).toEqual(["Roald Dahl"]);
expect(data.data.description).toBe("Three farmers plot to kill Mr Fox, but he outsmarts them...");
expect(data.data.tags).toEqual(["Children's Literature", "Fiction", "Animals"]);
expect(data.data.publisher).toBe("Puffin Books");
expect(data.data.pubDate).toBe("1970-01-01T00:00:00.000Z"); // JSON serializes Date to string
expect(providerService.fetchMetadata).toHaveBeenCalledWith("openlibrary", "OL45804W");
});

it("should handle metadata with missing optional fields", async () => {
// Arrange
const mockMetadata: BookMetadata = {
title: "Some Book",
authors: ["Author Name"],
isbn: "1234567890",
description: undefined,
tags: undefined,
publisher: undefined,
pubDate: undefined,
totalPages: undefined,
coverImageUrl: undefined,
};

vi.mocked(providerService.fetchMetadata).mockResolvedValue(mockMetadata);

const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/999"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "999" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.data.description).toBeUndefined();
expect(data.data.tags).toBeUndefined();
expect(data.data.publisher).toBeUndefined();
});
});

describe("Validation", () => {
it("should reject request with missing provider ID", async () => {
// Arrange
const request = createMockRequest(
"GET",
"/api/providers//metadata/123"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "", externalId: "123" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toContain("Provider ID and external ID are required");
});

it("should reject request with missing external ID", async () => {
// Arrange
const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toContain("Provider ID and external ID are required");
});
});

describe("Error Handling", () => {
it("should handle circuit breaker open error", async () => {
// Arrange
vi.mocked(providerService.fetchMetadata).mockRejectedValue(
new Error("Circuit breaker is OPEN - provider unavailable")
);

const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/123"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "123" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(503);
expect(data.success).toBe(false);
expect(data.error).toBe("Provider temporarily unavailable");
expect(data.message).toContain("Circuit breaker");
});

it("should handle metadata not found error", async () => {
// Arrange
vi.mocked(providerService.fetchMetadata).mockRejectedValue(
new Error("Metadata not found for ID: 99999")
);

const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/99999"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "99999" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(404);
expect(data.success).toBe(false);
expect(data.error).toBe("Metadata not found");
});

it("should handle generic error", async () => {
// Arrange
vi.mocked(providerService.fetchMetadata).mockRejectedValue(
new Error("Network error")
);

const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/123"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "123" })
});
const data = await response.json();

// Assert
expect(response.status).toBe(500);
expect(data.success).toBe(false);
expect(data.error).toBe("Metadata fetch failed");
});
});

describe("Provider-Specific ID Formats", () => {
it("should handle Hardcover numeric IDs", async () => {
// Arrange
const mockMetadata: BookMetadata = {
title: "Book",
authors: ["Author"],
isbn: undefined,
description: undefined,
tags: undefined,
publisher: undefined,
pubDate: undefined,
totalPages: undefined,
coverImageUrl: undefined,
};

vi.mocked(providerService.fetchMetadata).mockResolvedValue(mockMetadata);

const request = createMockRequest(
"GET",
"/api/providers/hardcover/metadata/312460"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "hardcover", externalId: "312460" })
});

// Assert
expect(response.status).toBe(200);
expect(providerService.fetchMetadata).toHaveBeenCalledWith("hardcover", "312460");
});

it("should handle OpenLibrary work IDs with prefix", async () => {
// Arrange
const mockMetadata: BookMetadata = {
title: "Book",
authors: ["Author"],
isbn: undefined,
description: undefined,
tags: undefined,
publisher: undefined,
pubDate: undefined,
totalPages: undefined,
coverImageUrl: undefined,
};

vi.mocked(providerService.fetchMetadata).mockResolvedValue(mockMetadata);

const request = createMockRequest(
"GET",
"/api/providers/openlibrary/metadata/OL45804W"
);

// Act
const response = await GET(request as NextRequest, {
params: Promise.resolve({ providerId: "openlibrary", externalId: "OL45804W" })
});

// Assert
expect(response.status).toBe(200);
expect(providerService.fetchMetadata).toHaveBeenCalledWith("openlibrary", "OL45804W");
});
});
});
Loading