diff --git a/__tests__/components/BookHeader.test.tsx b/__tests__/components/BookHeader.test.tsx index a1958b19..4a344ac9 100644 --- a/__tests__/components/BookHeader.test.tsx +++ b/__tests__/components/BookHeader.test.tsx @@ -31,6 +31,7 @@ afterEach(() => { describe("BookHeader", () => { const mockBook = { + id: 1, calibreId: 1, totalPages: 300, }; @@ -368,7 +369,7 @@ describe("BookHeader", () => { }); test("should handle book without totalPages", () => { - const bookWithoutPages = { calibreId: 1 }; + const bookWithoutPages = { id: 1, calibreId: 1 }; render(); diff --git a/__tests__/e2e/api/providers/metadata-fetch.test.ts b/__tests__/e2e/api/providers/metadata-fetch.test.ts new file mode 100644 index 00000000..00b0a301 --- /dev/null +++ b/__tests__/e2e/api/providers/metadata-fetch.test.ts @@ -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"); + }); + }); +}); diff --git a/__tests__/e2e/api/series/series.test.ts b/__tests__/e2e/api/series/series.test.ts index d07e35b0..ee04ce08 100644 --- a/__tests__/e2e/api/series/series.test.ts +++ b/__tests__/e2e/api/series/series.test.ts @@ -26,7 +26,7 @@ describe("GET /api/series", () => { describe("Basic Functionality", () => { test("should return all series with book counts", async () => { // Arrange: Create books in multiple series - await bookRepository.create({ + const book1 = await bookRepository.create({ calibreId: 1, path: "Author/Book1 (1)", title: "Foundation", @@ -37,7 +37,7 @@ describe("GET /api/series", () => { totalPages: 255, }); - await bookRepository.create({ + const book2 = await bookRepository.create({ calibreId: 2, path: "Author/Book2 (2)", title: "Foundation and Empire", @@ -48,7 +48,7 @@ describe("GET /api/series", () => { totalPages: 282, }); - await bookRepository.create({ + const book3 = await bookRepository.create({ calibreId: 3, path: "Author/Book3 (3)", title: "Dune", @@ -74,14 +74,15 @@ describe("GET /api/series", () => { expect(foundationSeries.bookCount).toBe(2); expect(foundationSeries.bookCoverIds).toBeInstanceOf(Array); expect(foundationSeries.bookCoverIds).toHaveLength(2); - expect(foundationSeries.bookCoverIds).toEqual([1, 2]); + // bookCoverIds now contains Tome book IDs (not Calibre IDs) + expect(foundationSeries.bookCoverIds).toEqual([book1.id, book2.id]); // Verify Dune Chronicles const duneSeries = data.find((s: any) => s.name === "Dune Chronicles"); expect(duneSeries).toBeDefined(); expect(duneSeries.bookCount).toBe(1); expect(duneSeries.bookCoverIds).toHaveLength(1); - expect(duneSeries.bookCoverIds).toEqual([3]); + expect(duneSeries.bookCoverIds).toEqual([book3.id]); }); test("should return empty array when no series exist", async () => { @@ -151,8 +152,9 @@ describe("GET /api/series", () => { test("should limit bookCoverIds to first 3 books", async () => { // Arrange: Create series with 5 books + const bookIds: number[] = []; for (let i = 1; i <= 5; i++) { - await bookRepository.create({ + const book = await bookRepository.create({ calibreId: i, path: `Author/Book${i} (${i})`, title: `Book ${i}`, @@ -161,6 +163,7 @@ describe("GET /api/series", () => { series: "Long Series", seriesIndex: i, }); + bookIds.push(book.id); } // Act @@ -172,14 +175,15 @@ describe("GET /api/series", () => { expect(data).toHaveLength(1); expect(data[0].bookCount).toBe(5); expect(data[0].bookCoverIds).toHaveLength(5); // Now returns up to 12 covers - expect(data[0].bookCoverIds).toEqual([1, 2, 3, 4, 5]); + // bookCoverIds now contains Tome book IDs (not Calibre IDs) + expect(data[0].bookCoverIds).toEqual(bookIds); }); }); describe("Orphaned Books", () => { test("should exclude orphaned books from series counts", async () => { // Arrange: Create series with mix of normal and orphaned books - await bookRepository.create({ + const book1 = await bookRepository.create({ calibreId: 1, path: "Author/Book1 (1)", title: "Book 1", @@ -201,7 +205,7 @@ describe("GET /api/series", () => { orphaned: true, }); - await bookRepository.create({ + const book3 = await bookRepository.create({ calibreId: 3, path: "Author/Book3 (3)", title: "Book 3", @@ -220,7 +224,8 @@ describe("GET /api/series", () => { expect(response.status).toBe(200); expect(data).toHaveLength(1); expect(data[0].bookCount).toBe(2); // Only non-orphaned books - expect(data[0].bookCoverIds).toEqual([1, 3]); // Should not include calibreId 2 + // bookCoverIds now contains Tome book IDs (not Calibre IDs) + expect(data[0].bookCoverIds).toEqual([book1.id, book3.id]); // Should not include orphaned book }); test("should not return series that only contain orphaned books", async () => { diff --git a/__tests__/e2e/api/sessions/read-next/move-to-top.test.ts b/__tests__/e2e/api/sessions/read-next/move-to-top.test.ts index 7ac34c5d..25a8bb14 100644 --- a/__tests__/e2e/api/sessions/read-next/move-to-top.test.ts +++ b/__tests__/e2e/api/sessions/read-next/move-to-top.test.ts @@ -76,12 +76,10 @@ describe("POST /api/sessions/read-next/[id]/move-to-top", () => { const book1 = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book1.epub", }); const book2 = await bookRepository.create({ title: "Book 2", calibreId: 2, - path: "/test/book2.epub", }); const session1 = await sessionRepository.create({ @@ -140,7 +138,6 @@ describe("POST /api/sessions/read-next/[id]/move-to-top", () => { const book = await bookRepository.create({ title: "Reading Book", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.create({ @@ -165,7 +162,6 @@ describe("POST /api/sessions/read-next/[id]/move-to-top", () => { const book = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.create({ @@ -194,7 +190,6 @@ describe("POST /api/sessions/read-next/[id]/move-to-top", () => { const book = await bookRepository.create({ title: "Only Book", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.create({ diff --git a/__tests__/e2e/api/shelves/[id]/books/bulk.test.ts b/__tests__/e2e/api/shelves/[id]/books/bulk.test.ts index 0c945be2..2c483701 100644 --- a/__tests__/e2e/api/shelves/[id]/books/bulk.test.ts +++ b/__tests__/e2e/api/shelves/[id]/books/bulk.test.ts @@ -74,7 +74,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -82,7 +81,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -90,7 +88,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Act: Add books via bulk endpoint @@ -128,7 +125,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Solo Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Act @@ -161,7 +157,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -169,7 +164,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -177,7 +171,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Add book1 beforehand @@ -212,7 +205,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Valid Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Act: Include one valid book and two non-existent IDs @@ -413,7 +405,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -421,7 +412,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Add both books beforehand @@ -455,7 +445,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Valid Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -463,7 +452,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Valid Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -471,7 +459,6 @@ describe('POST /api/shelves/[id]/books/bulk', () => { title: "Already on Shelf", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Add book3 beforehand @@ -595,7 +582,6 @@ describe('DELETE /api/shelves/[id]/books/bulk', () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -603,7 +589,6 @@ describe('DELETE /api/shelves/[id]/books/bulk', () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -773,7 +758,6 @@ describe('DELETE /api/shelves/[id]/books/bulk', () => { title: "Valid Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -808,7 +792,6 @@ describe('DELETE /api/shelves/[id]/books/bulk', () => { title: "Book On Shelf", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -816,7 +799,6 @@ describe('DELETE /api/shelves/[id]/books/bulk', () => { title: "Book Not On Shelf", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Only add book1 to shelf @@ -959,7 +941,6 @@ describe('DELETE /api/shelves/[id]/books/bulk', () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); diff --git a/__tests__/e2e/api/shelves/[id]/books/move-to-top.test.ts b/__tests__/e2e/api/shelves/[id]/books/move-to-top.test.ts index 8ef33142..f4b0cec2 100644 --- a/__tests__/e2e/api/shelves/[id]/books/move-to-top.test.ts +++ b/__tests__/e2e/api/shelves/[id]/books/move-to-top.test.ts @@ -149,7 +149,6 @@ describe("POST /api/shelves/[id]/books/[bookId]/move-to-top", () => { const book = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book.epub", }); const request = new Request(`http://localhost/api/shelves/99999/books/${book.id}/move-to-top`, { @@ -174,7 +173,6 @@ describe("POST /api/shelves/[id]/books/[bookId]/move-to-top", () => { const book = await bookRepository.create({ title: "Book Not On Shelf", calibreId: 1, - path: "/test/book.epub", }); const request = new Request( @@ -248,7 +246,6 @@ describe("POST /api/shelves/[id]/books/[bookId]/move-to-top", () => { const book = await bookRepository.create({ title: "Only Book", calibreId: 1, - path: "/test/book.epub", }); await shelfRepository.addBookToShelf(shelf.id, book.id); diff --git a/__tests__/e2e/api/shelves/shelves-get-with-books.test.ts b/__tests__/e2e/api/shelves/shelves-get-with-books.test.ts index 498fa5cc..ccb25544 100644 --- a/__tests__/e2e/api/shelves/shelves-get-with-books.test.ts +++ b/__tests__/e2e/api/shelves/shelves-get-with-books.test.ts @@ -65,7 +65,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book with To-Read Status", authors: ["Author 1"], tags: [], - path: "/path/1", totalPages: 300, }); @@ -74,7 +73,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book with Read-Next Status", authors: ["Author 2"], tags: [], - path: "/path/2", totalPages: 400, }); @@ -83,7 +81,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book with Reading Status", authors: ["Author 3"], tags: [], - path: "/path/3", totalPages: 500, }); @@ -92,7 +89,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book with Read Status (Inactive)", authors: ["Author 4"], tags: [], - path: "/path/4", totalPages: 600, }); @@ -101,7 +97,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book without Session", authors: ["Author 5"], tags: [], - path: "/path/5", totalPages: 700, }); @@ -230,7 +225,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book with Multiple Sessions", authors: ["Author 1"], tags: [], - path: "/path/1", totalPages: 500, }); @@ -295,7 +289,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Book Read Multiple Times", authors: ["Author 1"], tags: [], - path: "/path/1", totalPages: 400, }); @@ -416,7 +409,6 @@ describe('GET /api/shelves/:id - Status Display Regression', () => { title: "Test Book", authors: ["Author 1"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -458,7 +450,6 @@ describe('GET /api/shelves/:id - Sorting and Ordering', () => { title: "First Book", authors: ["Author A"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -466,7 +457,6 @@ describe('GET /api/shelves/:id - Sorting and Ordering', () => { title: "Second Book", authors: ["Author B"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -474,7 +464,6 @@ describe('GET /api/shelves/:id - Sorting and Ordering', () => { title: "Third Book", authors: ["Author C"], tags: [], - path: "/path/3", }); // Add books with specific sort order diff --git a/__tests__/e2e/api/shelves/shelves-id-route.test.ts b/__tests__/e2e/api/shelves/shelves-id-route.test.ts index 2b659480..659eb689 100644 --- a/__tests__/e2e/api/shelves/shelves-id-route.test.ts +++ b/__tests__/e2e/api/shelves/shelves-id-route.test.ts @@ -77,7 +77,6 @@ describe('GET /api/shelves/[id]', () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -85,7 +84,6 @@ describe('GET /api/shelves/[id]', () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -114,7 +112,6 @@ describe('GET /api/shelves/[id]', () => { title: "Zebra Book", authors: ["Author Z"], tags: [], - path: "/path/1", }); const bookA = await bookRepository.create({ @@ -122,7 +119,6 @@ describe('GET /api/shelves/[id]', () => { title: "Alpha Book", authors: ["Author A"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, bookZ!.id); @@ -437,7 +433,6 @@ describe('DELETE /api/shelves/[id]', () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); diff --git a/__tests__/e2e/api/tags/tags-bulk-delete.test.ts b/__tests__/e2e/api/tags/tags-bulk-delete.test.ts index de1dcf0c..0712294e 100644 --- a/__tests__/e2e/api/tags/tags-bulk-delete.test.ts +++ b/__tests__/e2e/api/tags/tags-bulk-delete.test.ts @@ -199,11 +199,12 @@ describe("POST /api/tags/bulk-delete", () => { // Assert: Batch Calibre sync was called with correct data expect(mockBatchUpdateCalibreTags).toHaveBeenCalledTimes(1); - // Note: Books are ordered by createdAt DESC, so book 21 (created second) comes first - expect(mockBatchUpdateCalibreTags).toHaveBeenCalledWith([ - { calibreId: 21, tags: [] }, - { calibreId: 20, tags: [] } - ]); + const syncCall = mockBatchUpdateCalibreTags.mock.calls[0][0]; + expect(syncCall).toHaveLength(2); + // Check that both books were synced with empty tags (order may vary) + const calibreIds = syncCall.map((call: any) => call.calibreId).sort(); + expect(calibreIds).toEqual([20, 21]); + syncCall.forEach((call: any) => expect(call.tags).toEqual([])); }); test("should suspend and resume watcher during bulk delete", async () => { diff --git a/__tests__/e2e/api/tags/tags-get-patch.test.ts b/__tests__/e2e/api/tags/tags-get-patch.test.ts index f7207b60..f89f06ca 100644 --- a/__tests__/e2e/api/tags/tags-get-patch.test.ts +++ b/__tests__/e2e/api/tags/tags-get-patch.test.ts @@ -127,8 +127,9 @@ describe("GET /api/tags/[tagName]", () => { expect(data.tag).toBe("Fantasy"); expect(data.books).toHaveLength(2); expect(data.total).toBe(2); - expect(data.books[0].id).toBe(book2.id); - expect(data.books[1].id).toBe(book1.id); + // Check that both books are returned (order may vary due to millisecond timestamps) + const titles = data.books.map((b: any) => b.title).sort(); + expect(titles).toEqual(["Fantasy Book 1", "Fantasy Book 2"]); }); test("should handle URL-encoded tag names", async () => { @@ -368,8 +369,9 @@ describe("PATCH /api/tags/[tagName]", () => { expect(mockBatchUpdateCalibreTags).toHaveBeenCalledTimes(1); const syncCall = mockBatchUpdateCalibreTags.mock.calls[0][0]; expect(syncCall).toHaveLength(2); - expect(syncCall[0].calibreId).toBe(2); - expect(syncCall[1].calibreId).toBe(1); + // Check that both books were synced (order may vary) + const calibreIds = syncCall.map((call: any) => call.calibreId).sort(); + expect(calibreIds).toEqual([1, 2]); }); test("should handle URL-encoded tag names", async () => { diff --git a/__tests__/fixtures/test-data.ts b/__tests__/fixtures/test-data.ts index 60bf1f00..464d42ef 100644 --- a/__tests__/fixtures/test-data.ts +++ b/__tests__/fixtures/test-data.ts @@ -196,6 +196,7 @@ export const mockCalibreBook = { export function createTestBook(overrides?: Partial): NewBook { return { calibreId: 1, + source: 'calibre', // Default source for test books title: "Test Book", authors: ["Test Author"], tags: [], diff --git a/__tests__/integration/api/opds-tome-integration.test.ts b/__tests__/integration/api/opds-tome-integration.test.ts index a40a4f5b..0f6cacf2 100644 --- a/__tests__/integration/api/opds-tome-integration.test.ts +++ b/__tests__/integration/api/opds-tome-integration.test.ts @@ -60,14 +60,12 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, // Dune title: 'Dune', authors: ['Frank Herbert'], - path: '/path/to/dune', }); const book2 = await bookRepository.create({ calibreId: 83, // Children of Dune title: 'Children of Dune', authors: ['Frank Herbert'], - path: '/path/to/children', }); await sessionRepository.create({ bookId: book1.id, @@ -97,7 +95,6 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/to/dune', }); await sessionRepository.create({ @@ -121,21 +118,18 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/1', }); const book2 = await bookRepository.create({ calibreId: 83, title: 'Children of Dune', authors: ['Frank Herbert'], - path: '/path/2', }); const book3 = await bookRepository.create({ calibreId: 84, title: 'Dune Messiah', authors: ['Frank Herbert'], - path: '/path/3', }); await sessionRepository.create({ @@ -216,7 +210,6 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/to/dune', }); await sessionRepository.create({ @@ -237,7 +230,6 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/to/dune', }); await sessionRepository.create({ @@ -258,7 +250,6 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/to/dune', }); // Create an archived "reading" session (should be ignored) @@ -285,14 +276,12 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/1', }); const book2 = await bookRepository.create({ calibreId: 83, title: 'Children of Dune', authors: ['Frank Herbert'], - path: '/path/2', }); // Add books to shelf1 @@ -340,21 +329,18 @@ describe('Tome Integration - OPDS Bridge', () => { calibreId: 147, title: 'Dune', authors: ['Frank Herbert'], - path: '/path/1', }); const book2 = await bookRepository.create({ calibreId: 83, title: 'Children of Dune', authors: ['Frank Herbert'], - path: '/path/2', }); const book3 = await bookRepository.create({ calibreId: 84, title: 'Dune Messiah', authors: ['Frank Herbert'], - path: '/path/3', }); // Add books with specific sort order (2, 0, 1) diff --git a/__tests__/integration/constraints.test.ts b/__tests__/integration/constraints.test.ts index feaf7931..e1880b80 100644 --- a/__tests__/integration/constraints.test.ts +++ b/__tests__/integration/constraints.test.ts @@ -31,7 +31,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author 1"], tags: [], - path: "/path/to/book", }); // Attempt to create duplicate @@ -41,7 +40,6 @@ describe("Database Constraints", () => { title: "Another Book", authors: ["Author 2"], tags: [], - path: "/path/to/another", }) ).rejects.toThrow(); }); @@ -55,7 +53,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); // Create first active session @@ -83,7 +80,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); // Create first inactive session @@ -111,7 +107,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); // Create session @@ -145,7 +140,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); // Attempt to create progress log with non-existent sessionId @@ -167,7 +161,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -204,7 +197,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -269,7 +261,6 @@ describe("Database Constraints", () => { title: "Rating Test Book", authors: ["Author"], tags: [], - path: "/path", rating: 5, // Valid rating }); expect(book.rating).toBe(5); @@ -282,7 +273,6 @@ describe("Database Constraints", () => { title: "Invalid Rating Book", authors: ["Author"], tags: [], - path: "/path", rating: 6, // Out of range }); } catch (e) { @@ -297,7 +287,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -330,7 +319,6 @@ describe("Database Constraints", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ diff --git a/__tests__/integration/edge-cases.test.ts b/__tests__/integration/edge-cases.test.ts index 22a8376d..28d6941e 100644 --- a/__tests__/integration/edge-cases.test.ts +++ b/__tests__/integration/edge-cases.test.ts @@ -29,7 +29,6 @@ describe("Edge Case Tests", () => { title: "Anonymous Book", authors: [], tags: [], - path: "/path", }); expect(book.authors).toEqual([]); @@ -45,7 +44,6 @@ describe("Edge Case Tests", () => { title: "Untagged Book", authors: ["Author"], tags: [], - path: "/path", }); expect(book.tags).toEqual([]); @@ -59,7 +57,6 @@ describe("Edge Case Tests", () => { title: "Orphan Book", authors: ["Author"], tags: [], - path: "/path", }); // Book exists @@ -79,7 +76,6 @@ describe("Edge Case Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -109,7 +105,6 @@ describe("Edge Case Tests", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path1", }); const result = await bookRepository.findWithFilters({}, 10, 1000); @@ -124,7 +119,6 @@ describe("Edge Case Tests", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path1", }); const result = await bookRepository.findWithFilters({}, 0, 0); @@ -141,7 +135,6 @@ describe("Edge Case Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); // Create session without started date @@ -200,7 +193,6 @@ describe("Edge Case Tests", () => { title: "Minimal Book", authors: ["Author"], tags: [], - path: "/path", // All optional fields omitted }); @@ -216,7 +208,6 @@ describe("Edge Case Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -242,7 +233,6 @@ describe("Edge Case Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", pubDate: specificDate, }); @@ -256,7 +246,6 @@ describe("Edge Case Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -296,7 +285,6 @@ describe("Edge Case Tests", () => { title: "Long Book", authors: ["Author"], tags: [], - path: "/path", totalPages: 10000, }); @@ -325,7 +313,6 @@ describe("Edge Case Tests", () => { title: "Re-read Many Times", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -346,7 +333,6 @@ describe("Edge Case Tests", () => { title: "Book: A Story (Part 1) - \"The Beginning\"", authors: ["O'Reilly"], tags: ["Sci-Fi", "Action/Adventure"], - path: "/path/to/book's/file", }); const retrieved = await bookRepository.findById(book.id); diff --git a/__tests__/integration/external/database/migrate.test.ts b/__tests__/integration/external/database/migrate.test.ts index 4cafa185..6bbca247 100644 --- a/__tests__/integration/external/database/migrate.test.ts +++ b/__tests__/integration/external/database/migrate.test.ts @@ -434,8 +434,8 @@ describe("Migration System", () => { // Insert test data with all required fields testSqlite.prepare(` - INSERT INTO books (calibre_id, title, authors, path) - VALUES (1, 'Test Book', '["Test Author"]', '/test/path') + INSERT INTO books (calibre_id, title, authors) + VALUES (1, 'Test Book', '["Test Author"]') `).run(); // Verify data exists @@ -479,8 +479,8 @@ describe("Migration System", () => { // Insert book and session with all required fields const bookResult = testSqlite.prepare(` - INSERT INTO books (calibre_id, title, authors, total_pages, path) - VALUES (1, 'Test Book', '["Author"]', 300, '/test/path') + INSERT INTO books (calibre_id, title, authors, total_pages) + VALUES (1, 'Test Book', '["Author"]', 300) `).run(); const sessionResult = testSqlite.prepare(` @@ -655,8 +655,8 @@ describe("Migration System", () => { // Insert book first const bookResult = testSqlite.prepare(` - INSERT INTO books (calibre_id, title, authors, path) - VALUES (1, 'Test Book', '["Author"]', '/test/path') + INSERT INTO books (calibre_id, title, authors) + VALUES (1, 'Test Book', '["Author"]') `).run(); // Insert session with valid book_id (should succeed) @@ -712,13 +712,13 @@ describe("Migration System", () => { // Insert multiple books and sessions const book1 = testSqlite.prepare(` - INSERT INTO books (calibre_id, title, authors, path) - VALUES (1, 'Book 1', '["Author 1"]', '/path1') + INSERT INTO books (calibre_id, title, authors) + VALUES (1, 'Book 1', '["Author 1"]') `).run(); const book2 = testSqlite.prepare(` - INSERT INTO books (calibre_id, title, authors, path) - VALUES (2, 'Book 2', '["Author 2"]', '/path2') + INSERT INTO books (calibre_id, title, authors) + VALUES (2, 'Book 2', '["Author 2"]') `).run(); testSqlite.prepare(` diff --git a/__tests__/integration/lib/db/sql-helpers.test.ts b/__tests__/integration/lib/db/sql-helpers.test.ts index 4625d901..f8cc8bd9 100644 --- a/__tests__/integration/lib/db/sql-helpers.test.ts +++ b/__tests__/integration/lib/db/sql-helpers.test.ts @@ -55,8 +55,8 @@ function insertBook(opts: { const rawDb = testDb.sqlite; rawDb .prepare( - `INSERT INTO books (title, calibre_id, authors, tags, path, orphaned) - VALUES (?, ?, '[]', '[]', '/test', ?)` + `INSERT INTO books (title, calibre_id, authors, tags, orphaned) + VALUES (?, ?, '[]', '[]', ?)` ) .run(opts.title, opts.calibreId, opts.orphaned ? 1 : 0); diff --git a/__tests__/integration/repositories/books/book-repository-tags.test.ts b/__tests__/integration/repositories/books/book-repository-tags.test.ts index 666d2409..21d7a154 100644 --- a/__tests__/integration/repositories/books/book-repository-tags.test.ts +++ b/__tests__/integration/repositories/books/book-repository-tags.test.ts @@ -450,9 +450,9 @@ describe("BookRepository.findByTag()", () => { // Assert expect(result.total).toBe(2); expect(result.books).toHaveLength(2); - // Books are sorted by createdAt DESC, so book2 (created later) comes first - expect(result.books[0].id).toBe(book2.id); - expect(result.books[1].id).toBe(book1.id); + // Check that both books are returned (order may vary due to millisecond timestamps) + const titles = result.books.map(b => b.title).sort(); + expect(titles).toEqual(["Fantasy Book 1", "Fantasy Book 2"]); }); test("should return empty result for non-existent tag", async () => { diff --git a/__tests__/integration/repositories/books/book-tag-filtering.test.ts b/__tests__/integration/repositories/books/book-tag-filtering.test.ts index 3af6ade2..29656f0a 100644 --- a/__tests__/integration/repositories/books/book-tag-filtering.test.ts +++ b/__tests__/integration/repositories/books/book-tag-filtering.test.ts @@ -33,7 +33,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters: should filter by single tag", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Fantasy Book", authors: ["Author 1"], tags: ["fantasy", "magic"], @@ -42,7 +41,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Sci-Fi Book", authors: ["Author 2"], tags: ["sci-fi", "space"], @@ -59,7 +57,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFiltersAndRelations: should filter by single tag", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Fantasy Book", authors: ["Author 1"], tags: ["fantasy", "magic"], @@ -68,7 +65,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Sci-Fi Book", authors: ["Author 2"], tags: ["sci-fi", "space"], @@ -93,7 +89,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { // Book A: tags ["fantasy", "magic", "adventure"] await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Book A - Fantasy Magic Adventure", authors: ["Author A"], tags: ["fantasy", "magic", "adventure"], @@ -103,7 +98,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { // Book B: tags ["fantasy", "adventure"] await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Book B - Fantasy Adventure", authors: ["Author B"], tags: ["fantasy", "adventure"], @@ -113,7 +107,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { // Book C: tags ["sci-fi", "space"] await bookRepository.create({ calibreId: 3, - path: "/path3", title: "Book C - Sci-Fi Space", authors: ["Author C"], tags: ["sci-fi", "space"], @@ -123,7 +116,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { // Book D: tags ["fantasy"] await bookRepository.create({ calibreId: 4, - path: "/path4", title: "Book D - Fantasy Only", authors: ["Author D"], tags: ["fantasy"], @@ -212,7 +204,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { // Book has many tags, we filter by a subset await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Rich Tag Book", authors: ["Author"], tags: ["fantasy", "magic", "adventure", "dragons", "quest", "young-adult"], @@ -221,7 +212,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Minimal Tag Book", authors: ["Author"], tags: ["fantasy", "adventure"], @@ -239,7 +229,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters: should handle empty tags filter", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Book 1", authors: ["Author"], tags: ["fantasy"], @@ -248,7 +237,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Book 2", authors: ["Author"], tags: ["sci-fi"], @@ -263,7 +251,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters: should handle books with no tags", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Tagged Book", authors: ["Author"], tags: ["fantasy"], @@ -272,7 +259,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Untagged Book", authors: ["Author"], tags: [], @@ -288,7 +274,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters: should handle duplicate tags in query", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Fantasy Book", authors: ["Author"], tags: ["fantasy", "magic"], @@ -307,7 +292,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters: should be case-sensitive for tag matching", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Lowercase Tags", authors: ["Author"], tags: ["fantasy", "magic"], @@ -316,7 +300,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Capitalized Tags", authors: ["Author"], tags: ["Fantasy", "Magic"], @@ -345,7 +328,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters: should combine tag AND logic with search filter", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Harry Potter Fantasy Adventure", authors: ["J.K. Rowling"], tags: ["fantasy", "adventure", "young-adult"], @@ -354,7 +336,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Harry Potter Mystery", authors: ["J.K. Rowling"], tags: ["mystery"], @@ -363,7 +344,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 3, - path: "/path3", title: "Lord of the Rings", authors: ["J.R.R. Tolkien"], tags: ["fantasy", "adventure"], @@ -383,7 +363,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFiltersAndRelations: should combine tag AND logic with search filter", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Epic Fantasy Adventure", authors: ["Author A"], tags: ["fantasy", "adventure", "epic"], @@ -392,7 +371,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Epic Sci-Fi", authors: ["Author B"], tags: ["sci-fi", "epic"], @@ -481,7 +459,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("should handle typical library filtering: 'young-adult' + 'fantasy'", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "YA Fantasy", authors: ["Author A"], tags: ["young-adult", "fantasy", "romance"], @@ -490,7 +467,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Adult Fantasy", authors: ["Author B"], tags: ["fantasy", "epic"], @@ -499,7 +475,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 3, - path: "/path3", title: "YA Contemporary", authors: ["Author C"], tags: ["young-adult", "contemporary"], @@ -517,7 +492,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("should handle genre + format tags: 'fantasy' + 'audiobook'", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Fantasy Audiobook", authors: ["Author A"], tags: ["fantasy", "audiobook", "adventure"], @@ -526,7 +500,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Fantasy eBook", authors: ["Author B"], tags: ["fantasy", "ebook"], @@ -535,7 +508,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 3, - path: "/path3", title: "Mystery Audiobook", authors: ["Author C"], tags: ["mystery", "audiobook"], @@ -553,7 +525,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("should handle series + status tags: 'series' + 'completed'", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Completed Series Book", authors: ["Author A"], tags: ["series", "completed", "fantasy"], @@ -562,7 +533,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Ongoing Series Book", authors: ["Author B"], tags: ["series", "ongoing"], @@ -571,7 +541,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 3, - path: "/path3", title: "Standalone Completed", authors: ["Author C"], tags: ["completed", "standalone"], @@ -595,7 +564,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { test("findWithFilters and findWithFiltersAndRelations should return same books", async () => { await bookRepository.create({ calibreId: 1, - path: "/path1", title: "Book A", authors: ["Author"], tags: ["fantasy", "magic", "adventure"], @@ -604,7 +572,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 2, - path: "/path2", title: "Book B", authors: ["Author"], tags: ["fantasy", "adventure"], @@ -613,7 +580,6 @@ describe("Book Repository - Tag Filtering with AND Logic", () => { await bookRepository.create({ calibreId: 3, - path: "/path3", title: "Book C", authors: ["Author"], tags: ["fantasy"], diff --git a/__tests__/integration/repositories/progress.repository.test.ts b/__tests__/integration/repositories/progress.repository.test.ts index 64a49f55..9d423d78 100644 --- a/__tests__/integration/repositories/progress.repository.test.ts +++ b/__tests__/integration/repositories/progress.repository.test.ts @@ -677,7 +677,6 @@ describe("ProgressRepository.recalculatePercentagesForBook()", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -693,7 +692,6 @@ describe("ProgressRepository.recalculatePercentagesForBook()", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -746,7 +744,6 @@ describe("ProgressRepository.recalculatePercentagesForBook()", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -784,7 +781,6 @@ describe("ProgressRepository.recalculatePercentagesForBook()", () => { calibreId: 1, title: "Book 1", authors: ["Author"], - path: "/test/path1", totalPages: 300, }); @@ -792,7 +788,6 @@ describe("ProgressRepository.recalculatePercentagesForBook()", () => { calibreId: 2, title: "Book 2", authors: ["Author"], - path: "/test/path2", totalPages: 300, }); @@ -860,8 +855,8 @@ describe("Malformed date defense (isValidDateFormat guard)", () => { const rawDb = testDb.sqlite; rawDb .prepare( - `INSERT INTO books (title, calibre_id, authors, tags, path, orphaned) - VALUES (?, ?, '[]', '[]', '/test', 0)` + `INSERT INTO books (title, calibre_id, authors, tags, orphaned) + VALUES (?, ?, '[]', '[]', 0)` ) .run(title, calibreId); diff --git a/__tests__/integration/repositories/reading-goals.repository.test.ts b/__tests__/integration/repositories/reading-goals.repository.test.ts index 7a3342bd..d3eb5dcc 100644 --- a/__tests__/integration/repositories/reading-goals.repository.test.ts +++ b/__tests__/integration/repositories/reading-goals.repository.test.ts @@ -839,8 +839,8 @@ describe("Malformed date defense (isValidDateFormat guard)", () => { const rawDb = testDb.sqlite; rawDb .prepare( - `INSERT INTO books (title, calibre_id, authors, tags, path, orphaned) - VALUES (?, ?, '[]', '[]', '/test', 0)` + `INSERT INTO books (title, calibre_id, authors, tags, orphaned) + VALUES (?, ?, '[]', '[]', 0)` ) .run(title, calibreId); @@ -1031,8 +1031,8 @@ describe("DNF book exclusion (status filter)", () => { const rawDb = testDb.sqlite; rawDb .prepare( - `INSERT INTO books (title, calibre_id, authors, tags, path, orphaned) - VALUES (?, ?, '[]', '[]', '/test', 0)` + `INSERT INTO books (title, calibre_id, authors, tags, orphaned) + VALUES (?, ?, '[]', '[]', 0)` ) .run(title, calibreId); diff --git a/__tests__/integration/repositories/series.repository.test.ts b/__tests__/integration/repositories/series.repository.test.ts index 3b9e3c94..0b476ca5 100644 --- a/__tests__/integration/repositories/series.repository.test.ts +++ b/__tests__/integration/repositories/series.repository.test.ts @@ -25,7 +25,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Series A", seriesIndex: 1, }); @@ -35,7 +34,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author 1"], tags: [], - path: "/path/2", series: "Series A", seriesIndex: 2, }); @@ -45,7 +43,6 @@ describe("SeriesRepository", () => { title: "Book 3", authors: ["Author 2"], tags: [], - path: "/path/3", series: "Series B", seriesIndex: 1, }); @@ -56,7 +53,6 @@ describe("SeriesRepository", () => { title: "Book 4", authors: ["Author 3"], tags: [], - path: "/path/4", }); const series = await seriesRepository.getAllSeries(); @@ -74,7 +70,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Series A", seriesIndex: 1, }); @@ -84,7 +79,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author 1"], tags: [], - path: "/path/2", series: "Series A", seriesIndex: 2, orphaned: true, @@ -108,7 +102,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Series A", seriesIndex: 1, }); @@ -119,7 +112,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", series: "", seriesIndex: null, }); @@ -140,7 +132,6 @@ describe("SeriesRepository", () => { title: "Book 3 of Series", authors: ["Author 1"], tags: ["tag1"], - path: "/path/1", series: "Test Series", seriesIndex: 3, totalPages: 300, @@ -151,7 +142,6 @@ describe("SeriesRepository", () => { title: "Book 1 of Series", authors: ["Author 1"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 1, totalPages: 200, @@ -162,7 +152,6 @@ describe("SeriesRepository", () => { title: "Book 2 of Series", authors: ["Author 1"], tags: [], - path: "/path/3", series: "Test Series", seriesIndex: 2, }); @@ -182,7 +171,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -211,7 +199,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -221,7 +208,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author 1"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 2, orphaned: true, @@ -241,7 +227,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -251,7 +236,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author 1"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 2, }); @@ -276,7 +260,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", series: "Harry Potter & the Philosopher's Stone", seriesIndex: 1, }); @@ -286,7 +269,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author 1"], tags: [], - path: "/path/2", series: 'Series with "Quotes"', seriesIndex: 1, }); @@ -296,7 +278,6 @@ describe("SeriesRepository", () => { title: "Book 3", authors: ["Author 1"], tags: [], - path: "/path/3", series: "Series: With Colon", seriesIndex: 1, }); @@ -323,7 +304,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -333,7 +313,6 @@ describe("SeriesRepository", () => { title: "Book 1.5", authors: ["Author"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 1.5, }); @@ -343,7 +322,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author"], tags: [], - path: "/path/3", series: "Test Series", seriesIndex: 2, }); @@ -364,7 +342,6 @@ describe("SeriesRepository", () => { title: "Book with Index", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -374,7 +351,6 @@ describe("SeriesRepository", () => { title: "Book without Index", authors: ["Author"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: null, }); @@ -395,7 +371,6 @@ describe("SeriesRepository", () => { title: "Book B", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -405,7 +380,6 @@ describe("SeriesRepository", () => { title: "Book A", authors: ["Author"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 1, }); @@ -424,7 +398,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "The Foundation", seriesIndex: 1, }); @@ -434,7 +407,6 @@ describe("SeriesRepository", () => { title: "Book 2", authors: ["Author"], tags: [], - path: "/path/2", series: "the foundation", seriesIndex: 1, }); @@ -459,7 +431,6 @@ describe("SeriesRepository", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: longName, seriesIndex: 1, }); diff --git a/__tests__/integration/repositories/sessions/session.repository.dashboard-sorting.test.ts b/__tests__/integration/repositories/sessions/session.repository.dashboard-sorting.test.ts index c7133ffd..102a3cd2 100644 --- a/__tests__/integration/repositories/sessions/session.repository.dashboard-sorting.test.ts +++ b/__tests__/integration/repositories/sessions/session.repository.dashboard-sorting.test.ts @@ -35,21 +35,18 @@ describe("sessionRepository.findByStatus - Dashboard Sorting", () => { calibreId: 1, title: "Book 1", authors: ["Author 1"], - path: "/test/path1", }); const book2 = await bookRepository.create({ calibreId: 2, title: "Book 2", authors: ["Author 2"], - path: "/test/path2", }); const book3 = await bookRepository.create({ calibreId: 3, title: "Book 3", authors: ["Author 3"], - path: "/test/path3", }); // Create read-next sessions with readNextOrder in non-sequential creation order @@ -144,21 +141,18 @@ describe("sessionRepository.findByStatus - Dashboard Sorting", () => { calibreId: 1, title: "Book 0", authors: ["Author 0"], - path: "/test/path0", }); const book2 = await bookRepository.create({ calibreId: 2, title: "Book 2", authors: ["Author 2"], - path: "/test/path2", }); const book5 = await bookRepository.create({ calibreId: 3, title: "Book 5", authors: ["Author 5"], - path: "/test/path5", }); // Create sessions with gaps in readNextOrder (0, 2, 5) diff --git a/__tests__/integration/repositories/sessions/session.repository.test.ts b/__tests__/integration/repositories/sessions/session.repository.test.ts index cf76d4d2..ac7897d9 100644 --- a/__tests__/integration/repositories/sessions/session.repository.test.ts +++ b/__tests__/integration/repositories/sessions/session.repository.test.ts @@ -36,7 +36,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.create({ @@ -63,7 +62,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.create({ @@ -114,7 +112,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); // First session with progress @@ -182,7 +179,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); const sessions = await sessionRepository.findAllByBookIdWithProgress(book.id); @@ -196,14 +192,12 @@ describe("SessionRepository - Edge Cases", () => { const normalBook = await bookRepository.create({ title: "Normal Book", calibreId: 1, - path: "/test/normal.epub", orphaned: false, }); const orphanedBook = await bookRepository.create({ title: "Orphaned Book", calibreId: 2, - path: "/test/orphaned.epub", orphaned: true, }); @@ -231,7 +225,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Book Without Orphaned Field", calibreId: 1, - path: "/test/book.epub", // orphaned field not set (defaults to null) }); @@ -252,14 +245,12 @@ describe("SessionRepository - Edge Cases", () => { const normalBook = await bookRepository.create({ title: "Normal Book", calibreId: 1, - path: "/test/normal.epub", orphaned: false, }); const orphanedBook = await bookRepository.create({ title: "Orphaned Book", calibreId: 2, - path: "/test/orphaned.epub", orphaned: true, }); @@ -288,7 +279,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -341,14 +331,12 @@ describe("SessionRepository - Edge Cases", () => { const normalBook = await bookRepository.create({ title: "Normal Book", calibreId: 1, - path: "/test/normal.epub", orphaned: false, }); const orphanedBook = await bookRepository.create({ title: "Orphaned Book", calibreId: 2, - path: "/test/orphaned.epub", orphaned: true, }); @@ -377,14 +365,12 @@ describe("SessionRepository - Edge Cases", () => { const normalBook = await bookRepository.create({ title: "Normal Book", calibreId: 1, - path: "/test/normal.epub", orphaned: false, }); const orphanedBook = await bookRepository.create({ title: "Orphaned Book", calibreId: 2, - path: "/test/orphaned.epub", orphaned: true, }); @@ -412,7 +398,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -433,7 +418,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -456,7 +440,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); // Active + reading (should match) @@ -487,7 +470,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); const sessions = await sessionRepository.findActiveSessionsByBookId(book.id); @@ -501,7 +483,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -539,7 +520,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -568,7 +548,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -589,7 +568,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -623,7 +601,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.findLatestByBookId(book.id); @@ -637,7 +614,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -665,7 +641,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -686,7 +661,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Test Book", calibreId: 1, - path: "/test/book.epub", }); await sessionRepository.create({ @@ -719,17 +693,14 @@ describe("SessionRepository - Edge Cases", () => { const book1 = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book1.epub", }); const book2 = await bookRepository.create({ title: "Book 2", calibreId: 2, - path: "/test/book2.epub", }); const book3 = await bookRepository.create({ title: "Book 3", calibreId: 3, - path: "/test/book3.epub", }); const session1 = await sessionRepository.create({ @@ -771,12 +742,10 @@ describe("SessionRepository - Edge Cases", () => { const book1 = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book1.epub", }); const book2 = await bookRepository.create({ title: "Book 2", calibreId: 2, - path: "/test/book2.epub", }); const session1 = await sessionRepository.create({ @@ -809,17 +778,14 @@ describe("SessionRepository - Edge Cases", () => { const book1 = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book1.epub", }); const book2 = await bookRepository.create({ title: "Book 2", calibreId: 2, - path: "/test/book2.epub", }); const book3 = await bookRepository.create({ title: "Book 3", calibreId: 3, - path: "/test/book3.epub", }); // Create read-next sessions @@ -863,7 +829,6 @@ describe("SessionRepository - Edge Cases", () => { const book = await bookRepository.create({ title: "Book 1", calibreId: 1, - path: "/test/book.epub", }); const session = await sessionRepository.create({ @@ -953,8 +918,8 @@ describe("Malformed date defense (isValidDateFormat guard)", () => { const rawDb = getRawDb(); rawDb .prepare( - `INSERT INTO books (title, calibre_id, authors, tags, path, orphaned) - VALUES (?, ?, '[]', '[]', '/test', 0)` + `INSERT INTO books (title, calibre_id, authors, tags, orphaned) + VALUES (?, ?, '[]', '[]', 0)` ) .run(title, calibreId); diff --git a/__tests__/integration/repositories/shelf-add-to-top.test.ts b/__tests__/integration/repositories/shelf-add-to-top.test.ts index 2be2d5ca..a5fb8b3d 100644 --- a/__tests__/integration/repositories/shelf-add-to-top.test.ts +++ b/__tests__/integration/repositories/shelf-add-to-top.test.ts @@ -30,7 +30,6 @@ describe("ShelfRepository - Add to Top", () => { title: "First Book", authors: ["Author 1"], tags: [], - path: "/path/1", }); // Add book to top of empty shelf @@ -62,7 +61,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -70,7 +68,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -78,7 +75,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Add books to end (positions 0, 1, 2) @@ -102,7 +98,6 @@ describe("ShelfRepository - Add to Top", () => { title: "New Book", authors: ["Author 4"], tags: [], - path: "/path/4", }); // Add new book to top @@ -185,7 +180,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -193,7 +187,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -201,7 +194,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Add books with specific sortOrder to create gaps (0, 5, 10) @@ -222,7 +214,6 @@ describe("ShelfRepository - Add to Top", () => { title: "New Book", authors: ["Author 4"], tags: [], - path: "/path/4", }); // Add new book to top @@ -272,7 +263,6 @@ describe("ShelfRepository - Add to Top", () => { title: "New Book", authors: ["Author 101"], tags: [], - path: "/path/101", }); // Measure time for adding to top @@ -312,7 +302,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -320,7 +309,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Add book1 to shelf @@ -358,7 +346,6 @@ describe("ShelfRepository - Add to Top", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); // Add book to shelf diff --git a/__tests__/integration/repositories/shelf.repository.test.ts b/__tests__/integration/repositories/shelf.repository.test.ts index f5eaa64e..10f0bcfd 100644 --- a/__tests__/integration/repositories/shelf.repository.test.ts +++ b/__tests__/integration/repositories/shelf.repository.test.ts @@ -32,7 +32,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book with Read Status", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -40,7 +39,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book with Reading Status", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -48,7 +46,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book with To-Read Status", authors: ["Author 3"], tags: [], - path: "/path/3", }); const book4 = await bookRepository.create({ @@ -56,7 +53,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book without Session", authors: ["Author 4"], tags: [], - path: "/path/4", }); // Add books to shelf @@ -127,7 +123,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book with Multiple Sessions", authors: ["Author 1"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -229,7 +224,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Brandon Sanderson"], authorSort: "Sanderson, Brandon", tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -238,7 +232,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Patrick Rothfuss"], authorSort: "Rothfuss, Patrick", tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -247,7 +240,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Joe Abercrombie"], authorSort: "Abercrombie, Joe", tags: [], - path: "/path/3", }); const book4 = await bookRepository.create({ @@ -256,7 +248,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Ursula K. Le Guin"], authorSort: "Guin, Ursula K. Le", tags: [], - path: "/path/4", }); const book5 = await bookRepository.create({ @@ -265,7 +256,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Plato"], authorSort: "Plato", tags: [], - path: "/path/5", }); // Add books to shelf in random order @@ -303,7 +293,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Brandon Sanderson"], authorSort: "Sanderson, Brandon", tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -312,7 +301,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Joe Abercrombie"], authorSort: "Abercrombie, Joe", tags: [], - path: "/path/2", }); // Add books to shelf @@ -344,7 +332,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Brandon Sanderson"], authorSort: "Sanderson, Brandon", tags: [], - path: "/path/1", }); // Create book with empty authors array @@ -354,7 +341,6 @@ describe("ShelfRepository - Status Display", () => { authors: [], authorSort: null, tags: [], - path: "/path/2", }); // Add books to shelf @@ -385,7 +371,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Ursula K. Le Guin"], authorSort: "Guin, Ursula K. Le", tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -394,7 +379,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Martin Luther King Jr."], authorSort: "Jr., Martin Luther King", tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -403,7 +387,6 @@ describe("ShelfRepository - Status Display", () => { authors: ["Vincent van Gogh"], authorSort: "Gogh, Vincent van", tags: [], - path: "/path/3", }); // Add books to shelf @@ -438,7 +421,6 @@ describe("ShelfRepository - Status Display", () => { title: "First Book", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -446,7 +428,6 @@ describe("ShelfRepository - Status Display", () => { title: "Second Book", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -454,7 +435,6 @@ describe("ShelfRepository - Status Display", () => { title: "Third Book", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Add books with explicit sortOrder values that have gaps @@ -552,7 +532,6 @@ describe("ShelfRepository - Status Display", () => { title: "Only Book", authors: ["Author 1"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id, 99); @@ -582,7 +561,6 @@ describe("ShelfRepository - Status Display", () => { title: "First Book", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -590,7 +568,6 @@ describe("ShelfRepository - Status Display", () => { title: "Second Book", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -598,7 +575,6 @@ describe("ShelfRepository - Status Display", () => { title: "Third Book", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Add books to shelf @@ -639,7 +615,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book Not On Shelf", authors: ["Author"], tags: [], - path: "/path/1", }); // Try to remove book that's not on shelf @@ -810,7 +785,6 @@ describe("ShelfRepository - Status Display", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -866,7 +840,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book On Shelf", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -874,7 +847,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book Not On Shelf", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -882,7 +854,6 @@ describe("ShelfRepository - Status Display", () => { title: "Another Book On Shelf", authors: ["Author 3"], tags: [], - path: "/path/3", }); // Only add book1 and book3 to shelf @@ -1022,7 +993,6 @@ describe("ShelfRepository - Status Display", () => { title: "Lonely Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -1083,7 +1053,6 @@ describe("ShelfRepository - Status Display", () => { title: "Book Not On Shelf", authors: ["Author"], tags: [], - path: "/path/1", }); // Try to move book that's not on shelf diff --git a/__tests__/integration/services/aggregations.test.ts b/__tests__/integration/services/aggregations.test.ts index 677e35dc..9e5a0a91 100644 --- a/__tests__/integration/services/aggregations.test.ts +++ b/__tests__/integration/services/aggregations.test.ts @@ -28,7 +28,6 @@ describe("Aggregation Query Tests", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -74,7 +73,6 @@ describe("Aggregation Query Tests", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -122,7 +120,6 @@ describe("Aggregation Query Tests", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path1", }); const book2 = await bookRepository.create({ @@ -130,7 +127,6 @@ describe("Aggregation Query Tests", () => { title: "Book 2", authors: ["Author"], tags: [], - path: "/path2", }); // Completed sessions @@ -168,7 +164,6 @@ describe("Aggregation Query Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); const currentYear = new Date().getFullYear(); @@ -207,7 +202,6 @@ describe("Aggregation Query Tests", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path1", }); const book2 = await bookRepository.create({ @@ -215,7 +209,6 @@ describe("Aggregation Query Tests", () => { title: "Book 2", authors: ["Author"], tags: [], - path: "/path2", }); // Active reading sessions @@ -255,7 +248,6 @@ describe("Aggregation Query Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ @@ -317,7 +309,6 @@ describe("Aggregation Query Tests", () => { title: "Book", authors: ["Author"], tags: [], - path: "/path", }); const session = await sessionRepository.create({ diff --git a/__tests__/integration/services/progress.service.test.ts b/__tests__/integration/services/progress.service.test.ts index c02d7790..3ae88bdb 100644 --- a/__tests__/integration/services/progress.service.test.ts +++ b/__tests__/integration/services/progress.service.test.ts @@ -600,7 +600,6 @@ describe("ProgressService", () => { calibreId: 9999, title: "No Pages Book", authors: ["Author"], - path: "/test/no-pages", totalPages: null as any, })); const sessionNoPages = await sessionRepository.create(createTestSession({ diff --git a/__tests__/integration/services/reading-stats.service.test.ts b/__tests__/integration/services/reading-stats.service.test.ts index f7b02d13..245c2bf4 100644 --- a/__tests__/integration/services/reading-stats.service.test.ts +++ b/__tests__/integration/services/reading-stats.service.test.ts @@ -25,8 +25,8 @@ afterEach(async () => { function insertBook(opts: { id?: number; title: string; calibreId: number; orphaned?: boolean }) { const rawDb = testDb.sqlite; rawDb.prepare(` - INSERT INTO books (title, calibre_id, authors, tags, path, orphaned) - VALUES (?, ?, '[]', '[]', '/test', ?) + INSERT INTO books (title, calibre_id, authors, tags, orphaned) + VALUES (?, ?, '[]', '[]', ?) `).run(opts.title, opts.calibreId, opts.orphaned ? 1 : 0); const row = rawDb.prepare("SELECT last_insert_rowid() as id").get() as { id: number }; diff --git a/__tests__/integration/services/search.test.ts b/__tests__/integration/services/search.test.ts index 7f032acf..96979a39 100644 --- a/__tests__/integration/services/search.test.ts +++ b/__tests__/integration/services/search.test.ts @@ -22,7 +22,6 @@ describe("Book Search Functionality", () => { title: "The Hobbit", authors: ["J.R.R. Tolkien"], tags: [], - path: "/path1", }); await bookRepository.create({ @@ -30,7 +29,6 @@ describe("Book Search Functionality", () => { title: "HARRY POTTER", authors: ["J.K. Rowling"], tags: [], - path: "/path2", }); // Search lowercase @@ -54,7 +52,6 @@ describe("Book Search Functionality", () => { title: "Book", authors: ["Brandon Sanderson"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "sanderson" }); @@ -70,7 +67,6 @@ describe("Book Search Functionality", () => { title: "The Way of Kings", authors: ["Brandon Sanderson"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "Way" }); @@ -83,7 +79,6 @@ describe("Book Search Functionality", () => { title: "Book", authors: ["Patrick Rothfuss"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "Roth" }); @@ -98,7 +93,6 @@ describe("Book Search Functionality", () => { title: "O'Reilly's Book", authors: ["Author"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "O'Reilly" }); @@ -111,7 +105,6 @@ describe("Book Search Functionality", () => { title: "Spider-Man", authors: ["Author"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "Spider-Man" }); @@ -124,7 +117,6 @@ describe("Book Search Functionality", () => { title: "Book (Special Edition)", authors: ["Author"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "(Special" }); @@ -139,7 +131,6 @@ describe("Book Search Functionality", () => { title: "Harry Potter and the Philosopher's Stone", authors: ["J.K. Rowling"], tags: [], - path: "/path1", }); await bookRepository.create({ @@ -147,7 +138,6 @@ describe("Book Search Functionality", () => { title: "Harry Potter and the Chamber of Secrets", authors: ["J.K. Rowling"], tags: [], - path: "/path2", }); await bookRepository.create({ @@ -155,7 +145,6 @@ describe("Book Search Functionality", () => { title: "The Hobbit", authors: ["J.R.R. Tolkien"], tags: [], - path: "/path3", }); const result = await bookRepository.findWithFilters({ search: "Harry Potter" }); @@ -186,7 +175,6 @@ describe("Book Search Functionality", () => { title: "The Hobbit", authors: ["J.R.R. Tolkien"], tags: [], - path: "/path", }); const result = await bookRepository.findWithFilters({ search: "Nonexistent" }); @@ -202,7 +190,6 @@ describe("Book Search Functionality", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path1", }); await bookRepository.create({ @@ -210,7 +197,6 @@ describe("Book Search Functionality", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path2", }); const result = await bookRepository.findWithFilters({ search: "" }); @@ -225,7 +211,6 @@ describe("Book Search Functionality", () => { title: "Fantasy Book", authors: ["Author"], tags: ["Fantasy", "Adventure"], - path: "/path1", }); await bookRepository.create({ @@ -233,7 +218,6 @@ describe("Book Search Functionality", () => { title: "Sci-Fi Book", authors: ["Author"], tags: ["Sci-Fi"], - path: "/path2", }); const result = await bookRepository.findWithFilters({ tags: ["Fantasy"] }); @@ -248,7 +232,6 @@ describe("Book Search Functionality", () => { title: "Fantasy Book", authors: ["Author"], tags: ["Fantasy"], - path: "/path1", }); // Book with Sci-Fi only @@ -257,7 +240,6 @@ describe("Book Search Functionality", () => { title: "Sci-Fi Book", authors: ["Author"], tags: ["Sci-Fi"], - path: "/path2", }); // Book with both Fantasy AND Sci-Fi @@ -266,7 +248,6 @@ describe("Book Search Functionality", () => { title: "Fantasy Sci-Fi Hybrid", authors: ["Author"], tags: ["Fantasy", "Sci-Fi"], - path: "/path3", }); // Book with Romance only @@ -275,7 +256,6 @@ describe("Book Search Functionality", () => { title: "Romance Book", authors: ["Author"], tags: ["Romance"], - path: "/path4", }); // When filtering by multiple tags, only books with ALL tags should be returned @@ -291,7 +271,6 @@ describe("Book Search Functionality", () => { title: "Epic Fantasy", authors: ["Author"], tags: ["fantasy", "magic", "adventure", "dragons"], - path: "/path1", }); // Book B: has fantasy, adventure (exact match) @@ -300,7 +279,6 @@ describe("Book Search Functionality", () => { title: "Simple Fantasy", authors: ["Author"], tags: ["fantasy", "adventure"], - path: "/path2", }); // Book C: has only fantasy (subset) @@ -309,7 +287,6 @@ describe("Book Search Functionality", () => { title: "Pure Fantasy", authors: ["Author"], tags: ["fantasy"], - path: "/path3", }); // Query for fantasy AND adventure - should return Books A and B @@ -325,7 +302,6 @@ describe("Book Search Functionality", () => { title: "Complete Book", authors: ["Author"], tags: ["fantasy", "magic", "adventure"], - path: "/path1", }); // Book with only two tags @@ -334,7 +310,6 @@ describe("Book Search Functionality", () => { title: "Incomplete Book", authors: ["Author"], tags: ["fantasy", "magic"], - path: "/path2", }); const result = await bookRepository.findWithFilters({ @@ -350,7 +325,6 @@ describe("Book Search Functionality", () => { title: "Fantasy Book", authors: ["Author"], tags: ["fantasy"], - path: "/path1", }); await bookRepository.create({ @@ -358,7 +332,6 @@ describe("Book Search Functionality", () => { title: "Sci-Fi Book", authors: ["Author"], tags: ["sci-fi"], - path: "/path2", }); // Query for tags that no book has together @@ -373,7 +346,6 @@ describe("Book Search Functionality", () => { title: "Book 1", authors: ["Author"], tags: ["fantasy"], - path: "/path1", }); await bookRepository.create({ @@ -381,7 +353,6 @@ describe("Book Search Functionality", () => { title: "Book 2", authors: ["Author"], tags: ["sci-fi"], - path: "/path2", }); // Empty tags array should return all books @@ -397,7 +368,6 @@ describe("Book Search Functionality", () => { title: "Harry Potter Fantasy", authors: ["J.K. Rowling"], tags: ["Fantasy"], - path: "/path1", }); await bookRepository.create({ @@ -405,7 +375,6 @@ describe("Book Search Functionality", () => { title: "Harry Potter Sci-Fi", authors: ["J.K. Rowling"], tags: ["Sci-Fi"], - path: "/path2", }); const result = await bookRepository.findWithFilters({ diff --git a/__tests__/integration/services/series.service.test.ts b/__tests__/integration/services/series.service.test.ts index 9225087c..9e995772 100644 --- a/__tests__/integration/services/series.service.test.ts +++ b/__tests__/integration/services/series.service.test.ts @@ -28,7 +28,6 @@ describe("SeriesService", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "Series A", seriesIndex: 1, }); @@ -38,7 +37,6 @@ describe("SeriesService", () => { title: "Book 2", authors: ["Author"], tags: [], - path: "/path/2", series: "Series B", seriesIndex: 1, }); @@ -88,7 +86,6 @@ describe("SeriesService", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -109,7 +106,6 @@ describe("SeriesService", () => { title: "Book 3", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 3, }); @@ -119,7 +115,6 @@ describe("SeriesService", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 1, }); @@ -136,7 +131,6 @@ describe("SeriesService", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "Harry Potter & the Philosopher's Stone", seriesIndex: 1, }); @@ -179,7 +173,6 @@ describe("SeriesService", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); @@ -189,7 +182,6 @@ describe("SeriesService", () => { title: "Book 2", authors: ["Author"], tags: [], - path: "/path/2", series: "Test Series", seriesIndex: 2, }); @@ -206,7 +198,6 @@ describe("SeriesService", () => { title: "Book 1", authors: ["Author"], tags: [], - path: "/path/1", series: "Test Series", seriesIndex: 1, }); diff --git a/__tests__/integration/services/session.service.delete.test.ts b/__tests__/integration/services/session.service.delete.test.ts index 9179f32a..3802f684 100644 --- a/__tests__/integration/services/session.service.delete.test.ts +++ b/__tests__/integration/services/session.service.delete.test.ts @@ -24,7 +24,6 @@ test("deleteSession - should delete archived session and create new to-read sess calibreId: 1, title: "Test Book", authors: ["Test Author"], - path: "/test/path", totalPages: 300, }); @@ -91,7 +90,6 @@ test("deleteSession - should delete active session and create new to-read sessio calibreId: 2, title: "Active Book", authors: ["Test Author"], - path: "/test/path2", totalPages: 400, }); @@ -143,7 +141,6 @@ test("deleteSession - should throw error if session not found", async () => { calibreId: 3, title: "Test Book", authors: ["Test Author"], - path: "/test/path3", }); await expect( @@ -157,14 +154,12 @@ test("deleteSession - should throw error if bookId mismatch", async () => { calibreId: 4, title: "Book 1", authors: ["Author 1"], - path: "/path1", }); const book2 = await bookRepository.create({ calibreId: 5, title: "Book 2", authors: ["Author 2"], - path: "/path2", }); // Create session for book1 @@ -187,7 +182,6 @@ test("deleteSession - should handle multiple sessions correctly", async () => { calibreId: 6, title: "Multi-Session Book", authors: ["Test Author"], - path: "/test/path6", totalPages: 500, }); @@ -248,7 +242,6 @@ test("deleteSession - REGRESSION: should not leave book without session when del calibreId: 7, title: "It", authors: ["Stephen King"], - path: "/stephen-king/it", totalPages: 1184, }); diff --git a/__tests__/integration/services/sessions/session/mark-as-read.strategies.test.ts b/__tests__/integration/services/sessions/session/mark-as-read.strategies.test.ts index a9cecc54..6423ab93 100644 --- a/__tests__/integration/services/sessions/session/mark-as-read.strategies.test.ts +++ b/__tests__/integration/services/sessions/session/mark-as-read.strategies.test.ts @@ -71,7 +71,7 @@ describe("SessionService - Mark as Read Strategies", () => { expect(result.strategy).toBeDefined(); }); - test("selects ManualSessionUpdateStrategy when no totalPages", () => { + test("selects LocalSessionUpdateStrategy when no totalPages", () => { const book = { id: 1, totalPages: null }; const isAlreadyRead = false; const has100Progress = false; @@ -82,7 +82,7 @@ describe("SessionService - Mark as Read Strategies", () => { has100Progress ); - expect(result.name).toBe("ManualSessionUpdate"); + expect(result.name).toBe("LocalSessionUpdate"); expect(result.strategy).toBeDefined(); }); @@ -101,7 +101,7 @@ describe("SessionService - Mark as Read Strategies", () => { expect(result.name).toBe("AlreadyRead"); }); - test("selects ManualSessionUpdateStrategy when totalPages is 0", () => { + test("selects LocalSessionUpdateStrategy when totalPages is 0", () => { const book = { id: 1, totalPages: 0 }; const isAlreadyRead = false; const has100Progress = false; @@ -112,7 +112,8 @@ describe("SessionService - Mark as Read Strategies", () => { has100Progress ); - expect(result.name).toBe("ManualSessionUpdate"); + expect(result.name).toBe("LocalSessionUpdate"); + expect(result.strategy).toBeDefined(); }); }); @@ -275,7 +276,7 @@ describe("SessionService - Mark as Read Strategies", () => { }); }); - describe("manualSessionUpdateStrategy", () => { + describe("localSessionUpdateStrategy", () => { test("updates existing active session to read", async () => { const book = await bookRepository.create(createTestBook({ totalPages: null })); @@ -306,7 +307,7 @@ describe("SessionService - Mark as Read Strategies", () => { logger, }; - const result = await (sessionService as any).manualSessionUpdateStrategy(context); + const result = await (sessionService as any).localSessionUpdateStrategy(context); expect(result.progressCreated).toBe(false); expect(result.sessionId).toBeDefined(); @@ -338,7 +339,7 @@ describe("SessionService - Mark as Read Strategies", () => { logger, }; - const result = await (sessionService as any).manualSessionUpdateStrategy(context); + const result = await (sessionService as any).localSessionUpdateStrategy(context); expect(result.progressCreated).toBe(false); expect(result.sessionId).toBeDefined(); @@ -373,7 +374,7 @@ describe("SessionService - Mark as Read Strategies", () => { logger, }; - const result = await (sessionService as any).manualSessionUpdateStrategy(context); + const result = await (sessionService as any).localSessionUpdateStrategy(context); const newSession = await sessionRepository.findById(result.sessionId!); expect(newSession?.completedDate).toEqual(customDate); diff --git a/__tests__/integration/services/shelves/shelf-add-to-top.test.ts b/__tests__/integration/services/shelves/shelf-add-to-top.test.ts index 99967e22..3d911d15 100644 --- a/__tests__/integration/services/shelves/shelf-add-to-top.test.ts +++ b/__tests__/integration/services/shelves/shelf-add-to-top.test.ts @@ -28,7 +28,6 @@ describe("ShelfService - Add to Top", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Try to add to non-existent shelf @@ -62,7 +61,6 @@ describe("ShelfService - Add to Top", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Add book to shelf normally @@ -87,7 +85,6 @@ describe("ShelfService - Add to Top", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -95,7 +92,6 @@ describe("ShelfService - Add to Top", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Add book1 to shelf normally @@ -132,7 +128,6 @@ describe("ShelfService - Add to Top", () => { title: "First Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Add book to top @@ -194,7 +189,6 @@ describe("ShelfService - Add to Top", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -202,7 +196,6 @@ describe("ShelfService - Add to Top", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -210,7 +203,6 @@ describe("ShelfService - Add to Top", () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); const newBook = await bookRepository.create({ @@ -218,7 +210,6 @@ describe("ShelfService - Add to Top", () => { title: "New Book", authors: ["Author 4"], tags: [], - path: "/path/4", }); // Add books to shelf at specific positions @@ -252,7 +243,6 @@ describe("ShelfService - Add to Top", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Add book to shelf diff --git a/__tests__/integration/services/shelves/shelf.service.test.ts b/__tests__/integration/services/shelves/shelf.service.test.ts index 0dffc801..74a71b09 100644 --- a/__tests__/integration/services/shelves/shelf.service.test.ts +++ b/__tests__/integration/services/shelves/shelf.service.test.ts @@ -76,7 +76,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -84,7 +83,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Add books to shelves @@ -130,7 +128,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -138,7 +135,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -148,7 +144,8 @@ describe("ShelfService", () => { expect(shelves).toHaveLength(1); expect(shelves[0].bookCount).toBe(2); - expect(shelves[0].bookCoverIds).toEqual([101, 102]); + // bookCoverIds now contains Tome book IDs (not Calibre IDs) + expect(shelves[0].bookCoverIds).toEqual([book1!.id, book2!.id]); }); test("should limit cover IDs to 12 books", async () => { @@ -194,7 +191,6 @@ describe("ShelfService", () => { title: "Zulu Book", authors: ["Author A"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -202,7 +198,6 @@ describe("ShelfService", () => { title: "Alpha Book", authors: ["Author B"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -228,7 +223,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -236,7 +230,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id, 5); @@ -491,7 +484,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -523,7 +515,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); const shelves = await shelfService.getShelvesForBook(book!.id); @@ -552,7 +543,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Add book to shelf1 and shelf2, but not shelf3 @@ -580,7 +570,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfService.addBookToShelf(shelf.id, book!.id); @@ -600,7 +589,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfService.addBookToShelf(shelf.id, book!.id, 5); @@ -620,7 +608,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -628,7 +615,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); await shelfService.addBookToShelf(shelf.id, book1!.id); @@ -647,7 +633,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await expect(shelfService.addBookToShelf(999, book!.id)).rejects.toThrow( @@ -677,7 +662,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfService.addBookToShelf(shelf.id, book!.id); @@ -700,7 +684,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -724,7 +707,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); const removed = await shelfService.removeBookFromShelf(shelf.id, book!.id); @@ -751,7 +733,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id, 0); @@ -779,7 +760,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await expect(shelfService.updateBookOrder(shelf.id, book!.id, 0)).rejects.toThrow( @@ -805,7 +785,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfService.manageBookShelves(book!.id, [shelf1.id, shelf2.id]); @@ -832,7 +811,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Add to both shelves @@ -858,7 +836,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -886,7 +863,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Try to add to shelf1 and non-existent shelf 999 @@ -911,7 +887,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -919,7 +894,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -927,7 +901,6 @@ describe("ShelfService", () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -961,7 +934,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -969,7 +941,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -990,7 +961,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -998,7 +968,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -1006,7 +975,6 @@ describe("ShelfService", () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); await shelfRepository.addBookToShelf(shelf.id, book1!.id); @@ -1036,7 +1004,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -1044,7 +1011,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const book3 = await bookRepository.create({ @@ -1052,7 +1018,6 @@ describe("ShelfService", () => { title: "Book 3", authors: ["Author 3"], tags: [], - path: "/path/3", }); const result = await shelfService.addBooksToShelf(shelf.id, [ @@ -1083,7 +1048,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -1091,7 +1055,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const result = await shelfService.addBooksToShelf(shelf.id, [book1!.id, book2!.id]); @@ -1119,7 +1082,6 @@ describe("ShelfService", () => { title: "Valid Book", authors: ["Author"], tags: [], - path: "/path/1", }); // Include valid and non-existent book IDs @@ -1149,7 +1111,6 @@ describe("ShelfService", () => { title: "Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -1157,7 +1118,6 @@ describe("ShelfService", () => { title: "Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Add book1 beforehand @@ -1217,7 +1177,6 @@ describe("ShelfService", () => { title: "Valid 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -1225,7 +1184,6 @@ describe("ShelfService", () => { title: "Valid 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); const result = await shelfService.addBooksToShelf(shelf.id, [ @@ -1262,7 +1220,6 @@ describe("ShelfService", () => { title: "Valid Book 1", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -1270,7 +1227,6 @@ describe("ShelfService", () => { title: "Valid Book 2", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Mix valid books with non-existent IDs @@ -1313,7 +1269,6 @@ describe("ShelfService", () => { title: "Existing Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, existingBook!.id, 5); @@ -1368,7 +1323,6 @@ describe("ShelfService", () => { title: "Test Book", authors: ["Author"], tags: [], - path: "/path/1", }); await expect( @@ -1399,7 +1353,6 @@ describe("ShelfService", () => { title: "Real Book", authors: ["Author"], tags: [], - path: "/path/1", }); await shelfRepository.addBookToShelf(shelf.id, book!.id); @@ -1534,7 +1487,6 @@ describe("ShelfService", () => { title: "Book On Shelf", authors: ["Author 1"], tags: [], - path: "/path/1", }); const book2 = await bookRepository.create({ @@ -1542,7 +1494,6 @@ describe("ShelfService", () => { title: "Book Not On Shelf", authors: ["Author 2"], tags: [], - path: "/path/2", }); // Only add book1 to shelf diff --git a/__tests__/integration/services/streaks-coverage.test.ts b/__tests__/integration/services/streaks-coverage.test.ts index 7294bf41..01911be9 100644 --- a/__tests__/integration/services/streaks-coverage.test.ts +++ b/__tests__/integration/services/streaks-coverage.test.ts @@ -50,7 +50,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -85,7 +84,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -128,7 +126,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -161,7 +158,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -204,7 +200,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -256,7 +251,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -304,7 +298,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -339,7 +332,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -373,7 +365,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -414,7 +405,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -551,7 +541,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -583,7 +572,6 @@ describe("StreakService - Coverage Improvement", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); diff --git a/__tests__/integration/services/streaks-timezone-asia.test.ts b/__tests__/integration/services/streaks-timezone-asia.test.ts index 88ed1e6f..58ea724d 100644 --- a/__tests__/integration/services/streaks-timezone-asia.test.ts +++ b/__tests__/integration/services/streaks-timezone-asia.test.ts @@ -55,7 +55,6 @@ describe("StreakService - Asia/* Timezone Handling (ADR-014 Compliance)", () => calibreId: 1, title: "Test Book", authors: ["Test Author"], - path: "/test/path", }); const session = await sessionRepository.create({ @@ -102,7 +101,6 @@ describe("StreakService - Asia/* Timezone Handling (ADR-014 Compliance)", () => calibreId: 1, title: "Test Book", authors: ["Test Author"], - path: "/test/path", }); const session = await sessionRepository.create({ @@ -156,7 +154,6 @@ describe("StreakService - Asia/* Timezone Handling (ADR-014 Compliance)", () => calibreId: 1, title: "Test Book", authors: ["Test Author"], - path: "/test/path", }); const session = await sessionRepository.create({ @@ -198,7 +195,6 @@ describe("StreakService - Asia/* Timezone Handling (ADR-014 Compliance)", () => calibreId: 1, title: "Test Book", authors: ["Test Author"], - path: "/test/path", }); const session = await sessionRepository.create({ @@ -265,7 +261,6 @@ describe("StreakService - Asia/* Timezone Handling (ADR-014 Compliance)", () => calibreId: 1, title: "Test Book", authors: ["Test Author"], - path: "/test/path", }); const session = await sessionRepository.create({ diff --git a/__tests__/integration/services/streaks.test.ts b/__tests__/integration/services/streaks.test.ts index f8bf4641..83ebcf65 100644 --- a/__tests__/integration/services/streaks.test.ts +++ b/__tests__/integration/services/streaks.test.ts @@ -1369,7 +1369,6 @@ describe("Reading Streak Tracking - Spec 001", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -1458,7 +1457,6 @@ describe("Reading Streak Tracking - Spec 001", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -1501,7 +1499,6 @@ describe("Reading Streak Tracking - Spec 001", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); @@ -1566,7 +1563,6 @@ describe("Reading Streak Tracking - Spec 001", () => { calibreId: 1, title: "Test Book", authors: ["Author"], - path: "/test/path", totalPages: 300, }); diff --git a/__tests__/integration/services/sync-service.test.ts b/__tests__/integration/services/sync-service.test.ts index 44eac262..86d3dff9 100644 --- a/__tests__/integration/services/sync-service.test.ts +++ b/__tests__/integration/services/sync-service.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { syncCalibreLibrary, getLastSyncTime, isSyncInProgress, CalibreDataSource } from "@/lib/sync-service"; -import { bookRepository, sessionRepository } from "@/lib/repositories"; +import { bookRepository, sessionRepository, bookSourceRepository } from "@/lib/repositories"; import { setupTestDatabase, teardownTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup"; import { mockCalibreBook , createTestBook, createTestSession, createTestProgress } from "@/__tests__/fixtures/test-data"; import { CalibreBook } from "@/lib/db/calibre"; @@ -144,11 +144,20 @@ describe("syncCalibreLibrary", () => { calibreId: 1, title: "Book Still in Calibre", authors: ["Author 1"], - tags: [], + tags: ["fantasy"], path: "Author1/Book1", orphaned: false, })); + // Create book_sources entry for book1 (simulates previous sync) + await bookSourceRepository.upsert({ + bookId: book1.id, + providerId: "calibre", + externalId: "1", + isPrimary: true, + syncEnabled: true, + }); + const book2 = await bookRepository.create(createTestBook({ calibreId: 2, title: "Book Removed from Calibre", @@ -158,9 +167,18 @@ describe("syncCalibreLibrary", () => { orphaned: false, })); + // Create book_sources entry for book2 (simulates previous sync) + await bookSourceRepository.upsert({ + bookId: book2.id, + providerId: "calibre", + externalId: "2", + isPrimary: true, + syncEnabled: true, + }); + // Create 10 more books to stay under 10% threshold (1/12 = 8.3%) for (let i = 3; i <= 12; i++) { - await bookRepository.create(createTestBook({ + const book = await bookRepository.create(createTestBook({ calibreId: i, title: `Book ${i}`, authors: [`Author ${i}`], @@ -168,6 +186,15 @@ describe("syncCalibreLibrary", () => { path: `Author${i}/Book${i}`, orphaned: false, })); + + // Create book_sources entry for each book (simulates previous sync) + await bookSourceRepository.upsert({ + bookId: book.id, + providerId: "calibre", + externalId: i.toString(), + isPrimary: true, + syncEnabled: true, + }); } // Mock Calibre with all books except book 2 @@ -718,15 +745,24 @@ describe("Sync Service - Orphaning Safety Checks", () => { }); test("CRITICAL: Mass orphaning (>10%) aborts sync with error", async () => { - // Arrange - Create 100 books in DB + // Arrange - Create 100 books in DB with book_sources entries for (let i = 1; i <= 100; i++) { - await bookRepository.create(createTestBook({ + const book = await bookRepository.create(createTestBook({ calibreId: i, title: `Book ${i}`, authors: [`Author ${i}`], tags: [], path: `Author${i}/Book${i}`, })); + + // Create book_sources entry (simulates previous sync) + await bookSourceRepository.upsert({ + bookId: book.id, + providerId: "calibre", + externalId: i.toString(), + isPrimary: true, + syncEnabled: true, + }); } // Mock Calibre with only 85 books (15 books would be orphaned = 15%) @@ -780,13 +816,20 @@ describe("Sync Service - Orphaning Safety Checks", () => { test("Allows orphaning under 10% threshold", async () => { // Arrange - Create 100 books in DB for (let i = 1; i <= 100; i++) { - await bookRepository.create(createTestBook({ + const book = await bookRepository.create(createTestBook({ calibreId: i, title: `Book ${i}`, authors: [`Author ${i}`], tags: [], path: `Author${i}/Book${i}`, })); + // Create book_sources entry to simulate previous sync + await bookSourceRepository.upsert({ + bookId: book.id, + providerId: "calibre", + externalId: String(i), + isPrimary: true, + }); } // Mock Calibre with 95 books (5 books would be orphaned = 5%) @@ -874,13 +917,20 @@ describe("Sync Service - Orphaning Safety Checks", () => { test("findNotInCalibreIds correctly identifies missing books", async () => { // Arrange - Create 5 books for (let i = 1; i <= 5; i++) { - await bookRepository.create(createTestBook({ + const book = await bookRepository.create(createTestBook({ calibreId: i, title: `Book ${i}`, authors: [`Author ${i}`], tags: [], path: `Author${i}/Book${i}`, })); + // Create book_sources entry to simulate previous sync + await bookSourceRepository.upsert({ + bookId: book.id, + providerId: "calibre", + externalId: String(i), + isPrimary: true, + }); } // Act - Call with calibreIds [1, 2, 3] (books 4 and 5 are missing) diff --git a/__tests__/unit/utils/cover-download.test.ts b/__tests__/unit/utils/cover-download.test.ts new file mode 100644 index 00000000..b38f326b --- /dev/null +++ b/__tests__/unit/utils/cover-download.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { downloadCover } from "@/lib/utils/cover-download"; + +// Mock fetch globally for these tests +const originalFetch = global.fetch; + +/** Helper to create a mock response with proper ArrayBuffer from a Buffer */ +function mockResponse(opts: { + ok: boolean; + status: number; + headers?: Headers; + body?: Buffer; +}) { + const headers = opts.headers ?? new Headers(); + return { + ok: opts.ok, + status: opts.status, + headers, + arrayBuffer: opts.body + ? vi.fn().mockResolvedValue( + opts.body.buffer.slice(opts.body.byteOffset, opts.body.byteOffset + opts.body.byteLength) + ) + : undefined, + }; +} + +function setMockFetch(impl: (...args: any[]) => any) { + (global as any).fetch = vi.fn(impl); +} + +function setMockFetchResolved(response: ReturnType) { + (global as any).fetch = vi.fn().mockResolvedValue(response); +} + +function setMockFetchRejected(error: Error | DOMException) { + (global as any).fetch = vi.fn().mockRejectedValue(error); +} + +describe("cover-download", () => { + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("URL validation", () => { + it("should return null for invalid URL", async () => { + const result = await downloadCover("not-a-url"); + expect(result).toBeNull(); + }); + + it("should return null for empty string URL", async () => { + const result = await downloadCover(""); + expect(result).toBeNull(); + }); + + it("should return null for ftp:// protocol", async () => { + const result = await downloadCover("ftp://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + + it("should return null for file:// protocol", async () => { + const result = await downloadCover("file:///etc/passwd"); + expect(result).toBeNull(); + }); + + it("should return null for data: URLs", async () => { + const result = await downloadCover("data:image/png;base64,abc"); + expect(result).toBeNull(); + }); + }); + + describe("HTTP response handling", () => { + it("should return null for non-OK response", async () => { + setMockFetchResolved(mockResponse({ ok: false, status: 404 })); + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + + it("should return null for 500 response", async () => { + setMockFetchResolved(mockResponse({ ok: false, status: 500 })); + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + + it("should return null when Content-Length exceeds maximum", async () => { + const headers = new Headers(); + headers.set("content-length", "999999999"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers })); + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + + it("should return null for empty response body", async () => { + const headers = new Headers(); + headers.set("content-type", "image/jpeg"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: Buffer.alloc(0) })); + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + + it("should return null when fetch throws a network error", async () => { + setMockFetchRejected(new Error("Network error")); + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + + it("should return null when fetch times out (AbortError)", async () => { + const abortError = new DOMException("The operation was aborted", "AbortError"); + setMockFetchRejected(abortError); + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).toBeNull(); + }); + }); + + describe("MIME type detection", () => { + it("should detect JPEG from Content-Type header", async () => { + const jpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]); + const headers = new Headers(); + headers.set("content-type", "image/jpeg"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: jpegBuffer })); + + const result = await downloadCover("https://example.com/cover.jpg"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/jpeg"); + }); + + it("should detect PNG from Content-Type header", async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const headers = new Headers(); + headers.set("content-type", "image/png"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: pngBuffer })); + + const result = await downloadCover("https://example.com/cover.png"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/png"); + }); + + it("should fall back to magic bytes when Content-Type is application/octet-stream", async () => { + const jpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]); + const headers = new Headers(); + headers.set("content-type", "application/octet-stream"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: jpegBuffer })); + + const result = await downloadCover("https://example.com/cover"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/jpeg"); + }); + + it("should detect PNG from magic bytes when no Content-Type", async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + setMockFetchResolved(mockResponse({ ok: true, status: 200, body: pngBuffer })); + + const result = await downloadCover("https://example.com/cover"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/png"); + }); + + it("should detect GIF from magic bytes", async () => { + const gifBuffer = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + const headers = new Headers(); + headers.set("content-type", "application/octet-stream"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: gifBuffer })); + + const result = await downloadCover("https://example.com/image.gif"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/gif"); + }); + + it("should detect WebP from magic bytes", async () => { + const webpBuffer = Buffer.from([ + 0x52, 0x49, 0x46, 0x46, + 0x00, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50, + 0x00, 0x00, + ]); + setMockFetchResolved(mockResponse({ ok: true, status: 200, body: webpBuffer })); + + const result = await downloadCover("https://example.com/image.webp"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/webp"); + }); + + it("should return null for unsupported image format", async () => { + const unknownBuffer = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + const headers = new Headers(); + headers.set("content-type", "image/bmp"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: unknownBuffer })); + + const result = await downloadCover("https://example.com/image.bmp"); + expect(result).toBeNull(); + }); + + it("should return null for buffer too small for magic byte detection", async () => { + const tinyBuffer = Buffer.from([0x00, 0x01]); + setMockFetchResolved(mockResponse({ ok: true, status: 200, body: tinyBuffer })); + + const result = await downloadCover("https://example.com/image"); + expect(result).toBeNull(); + }); + }); + + describe("successful download", () => { + it("should return buffer and mimeType for valid JPEG download", async () => { + const jpegData = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46]); + const headers = new Headers(); + headers.set("content-type", "image/jpeg"); + headers.set("content-length", jpegData.length.toString()); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: jpegData })); + + const result = await downloadCover("https://example.com/cover.jpg"); + + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/jpeg"); + expect(result!.buffer).toBeInstanceOf(Buffer); + expect(result!.buffer.length).toBe(jpegData.length); + }); + + it("should handle Content-Type with charset parameter", async () => { + const pngData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const headers = new Headers(); + headers.set("content-type", "image/png; charset=utf-8"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: pngData })); + + const result = await downloadCover("https://example.com/cover.png"); + expect(result).not.toBeNull(); + expect(result!.mimeType).toBe("image/png"); + }); + + it("should accept both http and https URLs", async () => { + const jpegData = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]); + const headers = new Headers(); + headers.set("content-type", "image/jpeg"); + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: jpegData })); + + const resultHttp = await downloadCover("http://example.com/cover.jpg"); + expect(resultHttp).not.toBeNull(); + + const resultHttps = await downloadCover("https://example.com/cover.jpg"); + expect(resultHttps).not.toBeNull(); + }); + }); + + describe("size validation", () => { + it("should reject downloads that exceed maximum size after download", async () => { + const headers = new Headers(); + headers.set("content-type", "image/jpeg"); + + const oversizedBuffer = Buffer.alloc(5 * 1024 * 1024 + 1); + oversizedBuffer[0] = 0xFF; + oversizedBuffer[1] = 0xD8; + oversizedBuffer[2] = 0xFF; + + setMockFetchResolved(mockResponse({ ok: true, status: 200, headers, body: oversizedBuffer })); + + const result = await downloadCover("https://example.com/huge-cover.jpg"); + expect(result).toBeNull(); + }); + }); +}); diff --git a/__tests__/unit/utils/cover-storage.test.ts b/__tests__/unit/utils/cover-storage.test.ts new file mode 100644 index 00000000..4b4b4bea --- /dev/null +++ b/__tests__/unit/utils/cover-storage.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync, rmSync } from "fs"; +import path from "path"; +import { + saveCover, + getCoverPath, + hasCover, + deleteCover, + readCover, + ensureCoverDirectory, + getCoversDir, + parseCoverMimeType, + mimeTypeFromExtension, + MAX_COVER_SIZE_BYTES, + ALLOWED_COVER_MIME_TYPES, + type CoverMimeType, +} from "@/lib/utils/cover-storage"; + +// Use a temporary directory for tests to avoid polluting real data +const TEST_COVERS_DIR = path.join(__dirname, "__test-covers__"); + +// Mock the covers directory to use our test location +vi.mock("@/lib/utils/cover-storage", async (importOriginal) => { + const original = await importOriginal(); + + // We can't easily override the internal COVERS_DIR constant, + // so we test the pure utility functions directly and test + // filesystem operations via integration-style tests with real paths. + return { + ...original, + }; +}); + +describe("cover-storage", () => { + describe("parseCoverMimeType", () => { + it("should parse valid JPEG content type", () => { + expect(parseCoverMimeType("image/jpeg")).toBe("image/jpeg"); + }); + + it("should parse valid PNG content type", () => { + expect(parseCoverMimeType("image/png")).toBe("image/png"); + }); + + it("should parse valid WebP content type", () => { + expect(parseCoverMimeType("image/webp")).toBe("image/webp"); + }); + + it("should parse valid GIF content type", () => { + expect(parseCoverMimeType("image/gif")).toBe("image/gif"); + }); + + it("should strip charset parameter from content type", () => { + expect(parseCoverMimeType("image/jpeg; charset=utf-8")).toBe("image/jpeg"); + }); + + it("should strip multiple parameters", () => { + expect(parseCoverMimeType("image/png; charset=utf-8; boundary=something")).toBe("image/png"); + }); + + it("should handle uppercase content type", () => { + expect(parseCoverMimeType("IMAGE/JPEG")).toBe("image/jpeg"); + }); + + it("should handle mixed case content type", () => { + expect(parseCoverMimeType("Image/Jpeg")).toBe("image/jpeg"); + }); + + it("should return null for unsupported types", () => { + expect(parseCoverMimeType("image/svg+xml")).toBeNull(); + expect(parseCoverMimeType("image/tiff")).toBeNull(); + expect(parseCoverMimeType("image/bmp")).toBeNull(); + expect(parseCoverMimeType("application/json")).toBeNull(); + expect(parseCoverMimeType("text/html")).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(parseCoverMimeType("")).toBeNull(); + }); + + it("should handle whitespace in content type", () => { + expect(parseCoverMimeType(" image/jpeg ")).toBe("image/jpeg"); + }); + }); + + describe("mimeTypeFromExtension", () => { + it("should resolve .jpg extension", () => { + expect(mimeTypeFromExtension(".jpg")).toBe("image/jpeg"); + }); + + it("should resolve .jpeg extension", () => { + expect(mimeTypeFromExtension(".jpeg")).toBe("image/jpeg"); + }); + + it("should resolve .png extension", () => { + expect(mimeTypeFromExtension(".png")).toBe("image/png"); + }); + + it("should resolve .webp extension", () => { + expect(mimeTypeFromExtension(".webp")).toBe("image/webp"); + }); + + it("should resolve .gif extension", () => { + expect(mimeTypeFromExtension(".gif")).toBe("image/gif"); + }); + + it("should handle extensions without leading dot", () => { + expect(mimeTypeFromExtension("jpg")).toBe("image/jpeg"); + expect(mimeTypeFromExtension("png")).toBe("image/png"); + expect(mimeTypeFromExtension("webp")).toBe("image/webp"); + expect(mimeTypeFromExtension("gif")).toBe("image/gif"); + }); + + it("should handle uppercase extensions", () => { + expect(mimeTypeFromExtension(".JPG")).toBe("image/jpeg"); + expect(mimeTypeFromExtension("PNG")).toBe("image/png"); + }); + + it("should return null for unsupported extensions", () => { + expect(mimeTypeFromExtension(".svg")).toBeNull(); + expect(mimeTypeFromExtension(".bmp")).toBeNull(); + expect(mimeTypeFromExtension(".tiff")).toBeNull(); + expect(mimeTypeFromExtension("txt")).toBeNull(); + }); + }); + + describe("constants", () => { + it("should have MAX_COVER_SIZE_BYTES set to 5MB", () => { + expect(MAX_COVER_SIZE_BYTES).toBe(5 * 1024 * 1024); + }); + + it("should have all four allowed MIME types", () => { + expect(ALLOWED_COVER_MIME_TYPES).toContain("image/jpeg"); + expect(ALLOWED_COVER_MIME_TYPES).toContain("image/png"); + expect(ALLOWED_COVER_MIME_TYPES).toContain("image/webp"); + expect(ALLOWED_COVER_MIME_TYPES).toContain("image/gif"); + expect(ALLOWED_COVER_MIME_TYPES).toHaveLength(4); + }); + }); + + describe("saveCover", () => { + it("should reject oversized buffers", () => { + const oversizedBuffer = Buffer.alloc(MAX_COVER_SIZE_BYTES + 1); + expect(() => saveCover(1, oversizedBuffer, "image/jpeg")).toThrow("Cover image too large"); + }); + + it("should accept buffer at exact size limit", () => { + // This will attempt to write to the real covers dir, but validates the size check passes + const exactBuffer = Buffer.alloc(MAX_COVER_SIZE_BYTES); + // We expect this to NOT throw a size error (it may throw a filesystem error depending on environment) + expect(() => { + try { + saveCover(99999, exactBuffer, "image/jpeg"); + } catch (e: any) { + if (e.message.includes("too large")) throw e; + // Ignore filesystem errors in unit test + } + }).not.toThrow(); + }); + }); + + describe("getCoversDir", () => { + it("should return a string path", () => { + const dir = getCoversDir(); + expect(typeof dir).toBe("string"); + expect(dir.length).toBeGreaterThan(0); + }); + + it("should end with 'covers'", () => { + const dir = getCoversDir(); + expect(path.basename(dir)).toBe("covers"); + }); + }); +}); diff --git a/__tests__/unit/utils/cover-url.test.ts b/__tests__/unit/utils/cover-url.test.ts index 1ae672aa..969e2d68 100644 --- a/__tests__/unit/utils/cover-url.test.ts +++ b/__tests__/unit/utils/cover-url.test.ts @@ -3,22 +3,22 @@ import { getCoverUrl } from "@/lib/utils/cover-url"; describe("getCoverUrl", () => { describe("basic functionality", () => { - it("should return base URL when no lastSynced provided", () => { + it("should return base URL when no updatedAt provided", () => { const url = getCoverUrl(123); expect(url).toBe("/api/books/123/cover"); }); - it("should return base URL when lastSynced is null", () => { + it("should return base URL when updatedAt is null", () => { const url = getCoverUrl(123, null); expect(url).toBe("/api/books/123/cover"); }); - it("should return base URL when lastSynced is undefined", () => { + it("should return base URL when updatedAt is undefined", () => { const url = getCoverUrl(123, undefined); expect(url).toBe("/api/books/123/cover"); }); - it("should include calibreId in URL", () => { + it("should include bookId in URL", () => { const url = getCoverUrl(456); expect(url).toContain("456"); expect(url).toBe("/api/books/456/cover"); @@ -64,12 +64,12 @@ describe("getCoverUrl", () => { }); describe("edge cases", () => { - it("should handle calibreId of 0", () => { + it("should handle bookId of 0", () => { const url = getCoverUrl(0); expect(url).toBe("/api/books/0/cover"); }); - it("should handle very large calibreIds", () => { + it("should handle very large bookIds", () => { const url = getCoverUrl(999999999); expect(url).toBe("/api/books/999999999/cover"); }); @@ -116,12 +116,12 @@ describe("getCoverUrl", () => { }); describe("practical usage scenarios", () => { - it("should generate different URLs before and after sync", () => { - const beforeSync = new Date("2024-01-15T10:00:00.000Z"); - const afterSync = new Date("2024-01-15T10:30:00.000Z"); + it("should generate different URLs before and after update", () => { + const beforeUpdate = new Date("2024-01-15T10:00:00.000Z"); + const afterUpdate = new Date("2024-01-15T10:30:00.000Z"); - const urlBefore = getCoverUrl(123, beforeSync); - const urlAfter = getCoverUrl(123, afterSync); + const urlBefore = getCoverUrl(123, beforeUpdate); + const urlAfter = getCoverUrl(123, afterUpdate); expect(urlBefore).not.toBe(urlAfter); expect(urlBefore).toContain("?t="); diff --git a/__tests__/unit/utils/fetch-image-url.test.ts b/__tests__/unit/utils/fetch-image-url.test.ts new file mode 100644 index 00000000..fdf7f604 --- /dev/null +++ b/__tests__/unit/utils/fetch-image-url.test.ts @@ -0,0 +1,370 @@ +/** + * Tests for Image URL Fetching Utility + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + fetchImageFromUrl, + blobToFile, + ImageFetchError, + MAX_COVER_SIZE_BYTES, + ACCEPTED_COVER_TYPES, +} from "@/lib/utils/fetch-image-url"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch as any; + +describe("fetchImageFromUrl", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("URL validation", () => { + it("should reject empty URL", async () => { + await expect(fetchImageFromUrl("")).rejects.toThrow(ImageFetchError); + await expect(fetchImageFromUrl("")).rejects.toThrow("URL is empty"); + }); + + it("should reject invalid URL format", async () => { + await expect(fetchImageFromUrl("not-a-url")).rejects.toThrow( + ImageFetchError + ); + await expect(fetchImageFromUrl("not-a-url")).rejects.toThrow( + "Invalid URL format" + ); + }); + + it("should reject non-http(s) protocols", async () => { + await expect(fetchImageFromUrl("ftp://example.com/image.jpg")).rejects.toThrow( + ImageFetchError + ); + await expect(fetchImageFromUrl("file:///path/to/image.jpg")).rejects.toThrow( + ImageFetchError + ); + }); + + it("should accept valid http URL", async () => { + const mockBlob = new Blob(["fake image data"], { type: "image/jpeg" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => { + if (key === "Content-Type") return "image/jpeg"; + if (key === "Content-Length") return "1024"; + return null; + } + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("http://example.com/image.jpg"); + expect(result).toBeInstanceOf(Blob); + }); + + it("should accept valid https URL", async () => { + const mockBlob = new Blob(["fake image data"], { type: "image/png" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => { + if (key === "Content-Type") return "image/png"; + if (key === "Content-Length") return "2048"; + return null; + } + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("https://example.com/image.png"); + expect(result).toBeInstanceOf(Blob); + }); + }); + + describe("Network errors", () => { + it("should handle network error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network failure")); + + try { + await fetchImageFromUrl("https://example.com/image.jpg"); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(ImageFetchError); + expect((error as ImageFetchError).code).toBe("NETWORK_ERROR"); + } + }); + + it("should handle CORS error", async () => { + mockFetch.mockRejectedValueOnce( + new Error("CORS policy blocked request") + ); + + try { + await fetchImageFromUrl("https://example.com/image.jpg"); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(ImageFetchError); + expect((error as ImageFetchError).code).toBe("CORS_ERROR"); + expect((error as ImageFetchError).message).toContain("CORS"); + } + }); + + it.skip("should handle timeout", async () => { + // Mock a fetch that never resolves + mockFetch.mockImplementationOnce( + () => + new Promise((resolve) => { + // Never resolve - will be aborted by timeout + }) + ); + + // Note: This test might take up to FETCH_TIMEOUT_MS + await expect( + fetchImageFromUrl("https://slow-server.com/image.jpg") + ).rejects.toThrow(ImageFetchError); + }, 15000); // Increase test timeout + }); + + describe("HTTP errors", () => { + it("should handle 404 error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + try { + await fetchImageFromUrl("https://example.com/missing.jpg"); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(ImageFetchError); + expect((error as ImageFetchError).code).toBe("HTTP_ERROR"); + expect((error as ImageFetchError).message).toContain("404"); + } + }); + + it("should handle 500 error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect( + fetchImageFromUrl("https://example.com/error.jpg") + ).rejects.toThrow(ImageFetchError); + }); + }); + + describe("Content-Type validation", () => { + it("should accept image/jpeg", async () => { + const mockBlob = new Blob(["data"], { type: "image/jpeg" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => key === "Content-Type" ? "image/jpeg" : null + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("https://example.com/image.jpg"); + expect(result.type).toBe("image/jpeg"); + }); + + it("should accept image/png", async () => { + const mockBlob = new Blob(["data"], { type: "image/png" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => key === "Content-Type" ? "image/png" : null + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("https://example.com/image.png"); + expect(result.type).toBe("image/png"); + }); + + it("should accept image/webp", async () => { + const mockBlob = new Blob(["data"], { type: "image/webp" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => key === "Content-Type" ? "image/webp" : null + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("https://example.com/image.webp"); + expect(result.type).toBe("image/webp"); + }); + + it("should accept image/gif", async () => { + const mockBlob = new Blob(["data"], { type: "image/gif" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => key === "Content-Type" ? "image/gif" : null + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("https://example.com/image.gif"); + expect(result.type).toBe("image/gif"); + }); + + it("should reject non-image content type", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => key === "Content-Type" ? "text/html" : null + }, + blob: async () => new Blob([""], { type: "text/html" }), + }); + + try { + await fetchImageFromUrl("https://example.com/page.html"); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(ImageFetchError); + expect((error as ImageFetchError).code).toBe("INVALID_CONTENT_TYPE"); + } + }); + + it("should reject missing content type", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: () => null + }, + blob: async () => new Blob(["data"]), + }); + + await expect( + fetchImageFromUrl("https://example.com/unknown") + ).rejects.toThrow(ImageFetchError); + }); + }); + + describe("Size validation", () => { + it("should accept file within size limit", async () => { + const data = "x".repeat(1024 * 1024); // 1MB + const mockBlob = new Blob([data], { type: "image/jpeg" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => { + if (key === "Content-Type") return "image/jpeg"; + if (key === "Content-Length") return String(mockBlob.size); + return null; + } + }, + blob: async () => mockBlob, + }); + + const result = await fetchImageFromUrl("https://example.com/image.jpg"); + expect(result.size).toBe(mockBlob.size); + }); + + it("should reject file exceeding size limit (via Content-Length)", async () => { + const tooLarge = MAX_COVER_SIZE_BYTES + 1; + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => { + if (key === "Content-Type") return "image/jpeg"; + if (key === "Content-Length") return String(tooLarge); + return null; + } + }, + blob: async () => new Blob(["x".repeat(tooLarge)], { type: "image/jpeg" }), + }); + + try { + await fetchImageFromUrl("https://example.com/huge.jpg"); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(ImageFetchError); + expect((error as ImageFetchError).code).toBe("FILE_TOO_LARGE"); + } + }); + + it("should reject file exceeding size limit (via blob size)", async () => { + const tooLarge = MAX_COVER_SIZE_BYTES + 1; + const mockBlob = new Blob(["x".repeat(tooLarge)], { type: "image/jpeg" }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => { + if (key === "Content-Type") return "image/jpeg"; + // No Content-Length header + return null; + } + }, + blob: async () => mockBlob, + }); + + await expect( + fetchImageFromUrl("https://example.com/huge.jpg") + ).rejects.toThrow(ImageFetchError); + }); + + it("should reject empty blob", async () => { + const mockBlob = new Blob([], { type: "image/jpeg" }); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { + get: (key: string) => key === "Content-Type" ? "image/jpeg" : null + }, + blob: async () => mockBlob, + }); + + await expect( + fetchImageFromUrl("https://example.com/empty.jpg") + ).rejects.toThrow(ImageFetchError); + }); + }); +}); + +describe("blobToFile", () => { + it("should convert blob to file with correct type", () => { + const blob = new Blob(["data"], { type: "image/jpeg" }); + const file = blobToFile(blob, "https://example.com/test.jpg"); + + expect(file).toBeInstanceOf(File); + expect(file.type).toBe("image/jpeg"); + expect(file.size).toBe(blob.size); + }); + + it("should extract filename from URL", () => { + const blob = new Blob(["data"], { type: "image/png" }); + const file = blobToFile(blob, "https://example.com/path/to/cover.png"); + + expect(file.name).toBe("cover.png"); + }); + + it("should use default filename if URL has no path", () => { + const blob = new Blob(["data"], { type: "image/jpeg" }); + const file = blobToFile(blob, "https://example.com/"); + + expect(file.name).toContain("cover"); + }); + + it("should add extension based on blob type if missing", () => { + const blob = new Blob(["data"], { type: "image/webp" }); + const file = blobToFile(blob, "https://example.com/noextension"); + + expect(file.name).toContain("webp"); + }); + + it("should handle complex URLs with query params", () => { + const blob = new Blob(["data"], { type: "image/gif" }); + const file = blobToFile( + blob, + "https://example.com/images/cover.gif?size=large&quality=high" + ); + + expect(file.name).toBe("cover.gif"); + }); +}); diff --git a/__tests__/utils/dateHelpers.test.ts b/__tests__/utils/dateHelpers.test.ts index 381f0092..9027d239 100644 --- a/__tests__/utils/dateHelpers.test.ts +++ b/__tests__/utils/dateHelpers.test.ts @@ -10,6 +10,7 @@ import { parseLocalDateToUtc, getCurrentDateInUserTimezone, toDateString, + parsePublishDate, } from '@/utils/dateHelpers.server'; import { streakRepository } from '@/lib/repositories'; import { getLogger } from '@/lib/logger'; @@ -468,5 +469,200 @@ describe("Server-Side Date Helpers", () => { expect(streakRepository.getOrCreate).toHaveBeenCalledTimes(2); }); }); + + describe("parsePublishDate", () => { + describe("ISO date formats", () => { + test("should parse full ISO date (YYYY-MM-DD)", () => { + const result = parsePublishDate("2022-02-15"); + + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(2022); + expect(result?.getUTCMonth()).toBe(1); // February (0-indexed) + expect(result?.getUTCDate()).toBe(15); + expect(result?.getUTCHours()).toBe(0); + expect(result?.getUTCMinutes()).toBe(0); + }); + + test("should parse partial ISO date (YYYY-MM) as 1st of month", () => { + const result = parsePublishDate("1967-07"); + + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(1967); + expect(result?.getUTCMonth()).toBe(6); // July + expect(result?.getUTCDate()).toBe(1); // Defaults to 1st + }); + + test("should handle various ISO dates", () => { + expect(parsePublishDate("2018-01-18")?.toISOString()).toBe("2018-01-18T00:00:00.000Z"); + expect(parsePublishDate("2020-12-31")?.toISOString()).toBe("2020-12-31T00:00:00.000Z"); + expect(parsePublishDate("1994-09")?.toISOString()).toBe("1994-09-01T00:00:00.000Z"); + }); + }); + + describe("Year-only formats", () => { + test("should parse year only as January 1st", () => { + const result = parsePublishDate("1996"); + + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(1996); + expect(result?.getUTCMonth()).toBe(0); // January + expect(result?.getUTCDate()).toBe(1); + }); + + test("should handle various years", () => { + expect(parsePublishDate("2015")?.getUTCFullYear()).toBe(2015); + expect(parsePublishDate("1866")?.getUTCFullYear()).toBe(1866); + expect(parsePublishDate("2022")?.getUTCFullYear()).toBe(2022); + }); + }); + + describe("Full text date formats", () => { + test("should parse 'Month Day, Year' format", () => { + const result = parsePublishDate("May 16, 2019"); + + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(2019); + expect(result?.getUTCMonth()).toBe(4); // May (0-indexed) + expect(result?.getUTCDate()).toBe(16); + }); + + test("should parse 'Month Year' format as 1st of month", () => { + const result = parsePublishDate("December 2001"); + + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(2001); + expect(result?.getUTCMonth()).toBe(11); // December + expect(result?.getUTCDate()).toBe(1); // Defaults to 1st + }); + + test("should parse 'Day Month Year' format", () => { + const result = parsePublishDate("15 January 2020"); + + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(2020); + expect(result?.getUTCMonth()).toBe(0); // January + expect(result?.getUTCDate()).toBe(15); + }); + + test("should parse various month names (full and abbreviated)", () => { + expect(parsePublishDate("January 2020")?.getUTCMonth()).toBe(0); + expect(parsePublishDate("Jan 2020")?.getUTCMonth()).toBe(0); + expect(parsePublishDate("February 2020")?.getUTCMonth()).toBe(1); + expect(parsePublishDate("Feb 2020")?.getUTCMonth()).toBe(1); + expect(parsePublishDate("September 2020")?.getUTCMonth()).toBe(8); + expect(parsePublishDate("Sep 2020")?.getUTCMonth()).toBe(8); + expect(parsePublishDate("December 2020")?.getUTCMonth()).toBe(11); + expect(parsePublishDate("Dec 2020")?.getUTCMonth()).toBe(11); + }); + + test("should handle dates with and without commas", () => { + expect(parsePublishDate("June 30, 1994")?.toISOString()).toBe("1994-06-30T00:00:00.000Z"); + expect(parsePublishDate("June 30 1994")?.toISOString()).toBe("1994-06-30T00:00:00.000Z"); + expect(parsePublishDate("October 2007")?.toISOString()).toBe("2007-10-01T00:00:00.000Z"); + }); + + test("should be case-insensitive for month names", () => { + expect(parsePublishDate("JANUARY 2020")?.getUTCMonth()).toBe(0); + expect(parsePublishDate("january 2020")?.getUTCMonth()).toBe(0); + expect(parsePublishDate("January 2020")?.getUTCMonth()).toBe(0); + }); + }); + + describe("Edge cases and invalid inputs", () => { + test("should return undefined for unparseable formats", () => { + expect(parsePublishDate("19xx")).toBeUndefined(); // Placeholder + expect(parsePublishDate("196x")).toBeUndefined(); // Placeholder + expect(parsePublishDate("1927-1928")).toBeUndefined(); // Range + expect(parsePublishDate("[ca. 1960]")).toBeUndefined(); // Bracketed + expect(parsePublishDate("not a date")).toBeUndefined(); + expect(parsePublishDate("")).toBeUndefined(); + expect(parsePublishDate(" ")).toBeUndefined(); + }); + + test("should return undefined for undefined or null", () => { + expect(parsePublishDate(undefined)).toBeUndefined(); + expect(parsePublishDate(null as any)).toBeUndefined(); + }); + + test("should return undefined for invalid month", () => { + expect(parsePublishDate("2022-13-01")).toBeUndefined(); // Month 13 + expect(parsePublishDate("2022-00-01")).toBeUndefined(); // Month 0 + expect(parsePublishDate("Octember 2020")).toBeUndefined(); // Invalid month name + }); + + test("should return undefined for invalid day", () => { + expect(parsePublishDate("2022-02-32")).toBeUndefined(); // Day 32 + expect(parsePublishDate("2022-02-00")).toBeUndefined(); // Day 0 + }); + + test("should handle whitespace gracefully", () => { + expect(parsePublishDate(" 2022-02-15 ")?.toISOString()).toBe("2022-02-15T00:00:00.000Z"); + expect(parsePublishDate(" May 16, 2019 ")?.toISOString()).toBe("2019-05-16T00:00:00.000Z"); + }); + }); + + describe("Real-world examples from OpenLibrary API", () => { + test("should parse various formats from OpenLibrary responses", () => { + // Samples from actual OpenLibrary API response for "The Great Gatsby" + expect(parsePublishDate("May 16, 2019")?.toISOString()).toBe("2019-05-16T00:00:00.000Z"); + expect(parsePublishDate("2007 October 1")?.toISOString()).toBe("2007-10-01T00:00:00.000Z"); + expect(parsePublishDate("Oct 31, 2020")?.toISOString()).toBe("2020-10-31T00:00:00.000Z"); + expect(parsePublishDate("1996")?.toISOString()).toBe("1996-01-01T00:00:00.000Z"); + expect(parsePublishDate("2022-02-15")?.toISOString()).toBe("2022-02-15T00:00:00.000Z"); + expect(parsePublishDate("December 2001")?.toISOString()).toBe("2001-12-01T00:00:00.000Z"); + + // Invalid formats that should be skipped + expect(parsePublishDate("196x")).toBeUndefined(); + expect(parsePublishDate("19xx")).toBeUndefined(); + expect(parsePublishDate("1927-1928")).toBeUndefined(); + expect(parsePublishDate("[ca. 1960]")).toBeUndefined(); + }); + + test("should handle partial ISO dates from OpenLibrary", () => { + expect(parsePublishDate("1967-07")?.toISOString()).toBe("1967-07-01T00:00:00.000Z"); + expect(parsePublishDate("1962-06")?.toISOString()).toBe("1962-06-01T00:00:00.000Z"); + expect(parsePublishDate("1994-09")?.toISOString()).toBe("1994-09-01T00:00:00.000Z"); + }); + }); + + describe("Date precision", () => { + test("should preserve full date precision when available", () => { + const fullDate = parsePublishDate("2019-05-16"); + expect(fullDate?.getUTCDate()).toBe(16); + expect(fullDate?.getUTCMonth()).toBe(4); + expect(fullDate?.getUTCFullYear()).toBe(2019); + }); + + test("should default to 1st of month when day missing", () => { + const monthOnly = parsePublishDate("October 2007"); + expect(monthOnly?.getUTCDate()).toBe(1); + expect(monthOnly?.getUTCMonth()).toBe(9); + expect(monthOnly?.getUTCFullYear()).toBe(2007); + }); + + test("should default to January 1st when only year available", () => { + const yearOnly = parsePublishDate("1996"); + expect(yearOnly?.getUTCDate()).toBe(1); + expect(yearOnly?.getUTCMonth()).toBe(0); + expect(yearOnly?.getUTCFullYear()).toBe(1996); + }); + + test("all dates should be at midnight UTC", () => { + const dates = [ + parsePublishDate("2022-02-15"), + parsePublishDate("May 16, 2019"), + parsePublishDate("October 2007"), + parsePublishDate("1996"), + ]; + + dates.forEach(date => { + expect(date?.getUTCHours()).toBe(0); + expect(date?.getUTCMinutes()).toBe(0); + expect(date?.getUTCSeconds()).toBe(0); + expect(date?.getUTCMilliseconds()).toBe(0); + }); + }); + }); + }); }); diff --git a/app/api/books/[id]/cover/route.ts b/app/api/books/[id]/cover/route.ts index becadc5c..d1665ede 100644 --- a/app/api/books/[id]/cover/route.ts +++ b/app/api/books/[id]/cover/route.ts @@ -2,7 +2,16 @@ import { getLogger } from "@/lib/logger"; import { NextRequest, NextResponse } from "next/server"; import { readFileSync, existsSync } from "fs"; import path from "path"; -import { getBookById } from "@/lib/db/calibre"; +import { getBookById as getCalibreBookById } from "@/lib/db/calibre"; +import { bookRepository, bookSourceRepository } from "@/lib/repositories"; +import { + readCover, + saveCover, + parseCoverMimeType, + MAX_COVER_SIZE_BYTES, + ALLOWED_COVER_MIME_TYPES, + type CoverMimeType, +} from "@/lib/utils/cover-storage"; import { coverCache, bookPathCache, @@ -12,11 +21,13 @@ import { getBookPathCacheStats, type CacheStats, } from "@/lib/cache/cover-cache"; +import { downloadCover } from "@/lib/utils/cover-download"; export const dynamic = 'force-dynamic'; // Re-export cache functions for backward compatibility export { clearCoverCache, clearBookPathCache, getCoverCacheStats, getBookPathCacheStats, type CacheStats }; + // Helper function to serve the placeholder "no cover" image function servePlaceholderImage() { const placeholderPath = path.join(process.cwd(), "public", "cover-fallback.png"); @@ -30,21 +41,19 @@ function servePlaceholderImage() { }); } +/** + * Serve a cover image for a book. + * + * Unified route: accepts Tome book ID, routes to: + * - Local books: local filesystem at ./data/covers/{bookId}.{ext} + * - Calibre books: Calibre library path via calibreId lookup + * + * Cache busting: Clients append ?t= which is ignored server-side. + * Server-side caching uses bookId as key. + */ export async function GET(request: NextRequest, props: { params: Promise<{ id: string }> }) { - // NOTE: Query params like ?t= are used for cache busting on the client - // but are intentionally ignored by the server. The server caches by bookId only. const params = await props.params; try { - const CALIBRE_DB_PATH = process.env.CALIBRE_DB_PATH; - - if (!CALIBRE_DB_PATH) { - getLogger().error({ envVar: "CALIBRE_DB_PATH" }, "CALIBRE_DB_PATH not configured"); - return servePlaceholderImage(); - } - - // Extract library path from database path (metadata.db is in the library root) - const libraryPath = path.dirname(CALIBRE_DB_PATH); - // Extract book ID from params const bookId = parseInt(params.id, 10); @@ -65,91 +74,272 @@ export async function GET(request: NextRequest, props: { params: Promise<{ id: s }); } - // Check book path cache to avoid Calibre DB query - let bookPath: string; - let hasCover: boolean; + // Look up book in Tome DB to determine source + const book = await bookRepository.findById(bookId); - const cachedBookPath = bookPathCache.get(bookId); - if (cachedBookPath) { - bookPath = cachedBookPath.path; - hasCover = cachedBookPath.hasCover; + if (!book) { + getLogger().debug({ bookId }, "Book not found in Tome DB"); + return servePlaceholderImage(); + } - if (!hasCover) { - return servePlaceholderImage(); - } + // Route based on book source + // Local books have no entries in book_sources table + const hasAnySources = await bookSourceRepository.hasAnySources(bookId); + + if (!hasAnySources) { + return serveLocalBookCover(bookId); } else { - // Look up the book in Calibre to get its path - const calibreBook = getBookById(bookId); + return serveCalibreBookCover(bookId, book.calibreId); + } + } catch (error) { + getLogger().error({ err: error }, "Error serving cover image"); + return servePlaceholderImage(); + } +} - if (!calibreBook) { - getLogger().error({ bookId }, "Book not found in Calibre"); - return servePlaceholderImage(); - } +/** + * Serve a cover from local filesystem storage (local books). + */ +function serveLocalBookCover(bookId: number): NextResponse { + const cover = readCover(bookId); + + if (!cover) { + return servePlaceholderImage(); + } + + // Cache the cover image for future requests + coverCache.set(bookId, cover.buffer, cover.contentType); + + return new NextResponse(new Uint8Array(cover.buffer), { + headers: { + "Content-Type": cover.contentType, + "Cache-Control": "public, max-age=604800", // 1 week + "X-Cache": "MISS", + }, + }); +} + +/** + * Serve a cover from Calibre library path (calibre books). + */ +function serveCalibreBookCover(bookId: number, calibreId: number | null): NextResponse { + const CALIBRE_DB_PATH = process.env.CALIBRE_DB_PATH; + + if (!CALIBRE_DB_PATH) { + getLogger().error({ envVar: "CALIBRE_DB_PATH" }, "CALIBRE_DB_PATH not configured"); + return servePlaceholderImage(); + } + + if (calibreId === null) { + getLogger().error({ bookId }, "Calibre book has no calibreId"); + return servePlaceholderImage(); + } + + // Extract library path from database path (metadata.db is in the library root) + const libraryPath = path.dirname(CALIBRE_DB_PATH); + + // Check book path cache to avoid Calibre DB query + let bookPath: string; + let hasCover: boolean; + + const cachedBookPath = bookPathCache.get(calibreId); + if (cachedBookPath) { + bookPath = cachedBookPath.path; + hasCover = cachedBookPath.hasCover; + + if (!hasCover) { + return servePlaceholderImage(); + } + } else { + // Look up the book in Calibre to get its path + const calibreBook = getCalibreBookById(calibreId); + + if (!calibreBook) { + getLogger().error({ bookId, calibreId }, "Book not found in Calibre"); + return servePlaceholderImage(); + } + + bookPath = calibreBook.path; + hasCover = Boolean(calibreBook.has_cover); + + // Cache the book path lookup (keyed by calibreId for Calibre books) + bookPathCache.set(calibreId, bookPath, hasCover); + + if (!hasCover) { + getLogger().warn({ bookId, calibreId }, "Book has no cover in Calibre"); + return servePlaceholderImage(); + } + } + + // Construct the file path + const filePath = path.join(libraryPath, bookPath, "cover.jpg"); + + // Security check: ensure the resolved path is still within the library + const resolvedPath = path.resolve(filePath); + const resolvedLibrary = path.resolve(libraryPath); + + if (!resolvedPath.startsWith(resolvedLibrary)) { + getLogger().error({ + resolvedPath, + resolvedLibrary, + }, "Invalid path - security check failed"); + return NextResponse.json( + { error: "Invalid path" }, + { status: 403 } + ); + } + + // Check if file exists + if (!existsSync(resolvedPath)) { + getLogger().error({ resolvedPath }, "Image not found"); + return servePlaceholderImage(); + } + + // Read the file + const imageBuffer = readFileSync(resolvedPath); + + // Determine content type based on file extension + const ext = path.extname(resolvedPath).toLowerCase(); + let contentType = "image/jpeg"; + + if (ext === ".png") { + contentType = "image/png"; + } else if (ext === ".gif") { + contentType = "image/gif"; + } else if (ext === ".webp") { + contentType = "image/webp"; + } + + // Cache the cover image for future requests (keyed by Tome bookId) + coverCache.set(bookId, imageBuffer, contentType); + + // Return the image + return new NextResponse(new Uint8Array(imageBuffer), { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=604800", // 1 week + "X-Cache": "MISS", + }, + }); +} + +/** + * Upload a cover image for a book. + * + * Accepts multipart form data with either: + * - A `cover` file field (direct file upload), OR + * - A `coverUrl` text field (server-side download from URL) + * + * Validates file type (JPEG, PNG, WebP, GIF) and size (max 5MB). + * Replaces any existing cover for this book. + * Invalidates the cover cache for this book. + */ +export async function POST(request: NextRequest, props: { params: Promise<{ id: string }> }) { + const params = await props.params; + const logger = getLogger(); + + try { + const bookId = parseInt(params.id, 10); + + if (isNaN(bookId)) { + return NextResponse.json({ error: "Invalid book ID" }, { status: 400 }); + } + + // Verify book exists + const book = await bookRepository.findById(bookId); + if (!book) { + return NextResponse.json({ error: "Book not found" }, { status: 404 }); + } - bookPath = calibreBook.path; - hasCover = Boolean(calibreBook.has_cover); + // Parse multipart form data + const formData = await request.formData(); + const file = formData.get("cover"); + const coverUrl = formData.get("coverUrl"); + + let buffer: Buffer; + let mimeType: CoverMimeType; + + // Option 1: Direct file upload + if (file && file instanceof File && file.size > 0) { + // Validate file size + if (file.size > MAX_COVER_SIZE_BYTES) { + return NextResponse.json( + { + error: `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: ${MAX_COVER_SIZE_BYTES / 1024 / 1024}MB`, + }, + { status: 400 } + ); + } - // Cache the book path lookup - bookPathCache.set(bookId, bookPath, hasCover); + // Determine MIME type from file type or Content-Type + const parsedMimeType = file.type ? parseCoverMimeType(file.type) : null; - if (!hasCover) { - getLogger().warn({ bookId }, "Book has no cover"); - return servePlaceholderImage(); + if (!parsedMimeType) { + return NextResponse.json( + { + error: `Unsupported file type: ${file.type || "unknown"}. Allowed: ${ALLOWED_COVER_MIME_TYPES.join(", ")}`, + }, + { status: 400 } + ); } + + // Read file data + const arrayBuffer = await file.arrayBuffer(); + buffer = Buffer.from(arrayBuffer); + mimeType = parsedMimeType; } + // Option 2: Server-side download from URL + else if (coverUrl && typeof coverUrl === "string" && coverUrl.trim()) { + logger.info({ bookId, coverUrl }, "[CoverUpload] Downloading cover from URL"); + + const downloaded = await downloadCover(coverUrl.trim()); - // Construct the file path - const filePath = path.join(libraryPath, bookPath, "cover.jpg"); + if (!downloaded) { + return NextResponse.json( + { error: "Failed to download image from URL. Please check the URL and try again." }, + { status: 400 } + ); + } - // Security check: ensure the resolved path is still within the library - const resolvedPath = path.resolve(filePath); - const resolvedLibrary = path.resolve(libraryPath); + buffer = downloaded.buffer; + mimeType = downloaded.mimeType; - if (!resolvedPath.startsWith(resolvedLibrary)) { - getLogger().error({ - resolvedPath, - resolvedLibrary, - }, "Invalid path - security check failed"); + logger.info( + { bookId, coverUrl, size: buffer.length, mimeType }, + "[CoverUpload] Cover downloaded from URL successfully" + ); + } + // Neither file nor URL provided + else { return NextResponse.json( - { error: "Invalid path" }, - { status: 403 } + { error: "Missing 'cover' file or 'coverUrl' in form data" }, + { status: 400 } ); } - // Check if file exists - if (!existsSync(resolvedPath)) { - getLogger().error({ resolvedPath }, "Image not found"); - return servePlaceholderImage(); - } + // Save cover (replaces existing) + const filePath = saveCover(bookId, buffer, mimeType); - // Read the file - const imageBuffer = readFileSync(resolvedPath); + // Update book's updatedAt timestamp to invalidate cache-busted cover URLs + await bookRepository.update(bookId, { updatedAt: new Date() } as any); - // Determine content type based on file extension - const ext = path.extname(resolvedPath).toLowerCase(); - let contentType = "image/jpeg"; + // Invalidate cover cache for this book + coverCache.set(bookId, buffer, mimeType); - if (ext === ".png") { - contentType = "image/png"; - } else if (ext === ".gif") { - contentType = "image/gif"; - } else if (ext === ".webp") { - contentType = "image/webp"; - } + logger.info( + { bookId, mimeType, size: buffer.length, filePath }, + "[CoverUpload] Cover uploaded successfully" + ); - // Cache the cover image for future requests - coverCache.set(bookId, imageBuffer, contentType); - - // Return the image - return new NextResponse(new Uint8Array(imageBuffer), { - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=604800", // 1 week - "X-Cache": "MISS", - }, - }); + return NextResponse.json( + { success: true, bookId, mimeType, size: buffer.length }, + { status: 200 } + ); } catch (error) { - getLogger().error({ err: error }, "Error serving cover image"); - return servePlaceholderImage(); + logger.error({ err: error }, "[CoverUpload] Error uploading cover"); + return NextResponse.json( + { error: "Failed to upload cover" }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/app/api/books/[id]/route.ts b/app/api/books/[id]/route.ts index 05c3e604..852b20f7 100644 --- a/app/api/books/[id]/route.ts +++ b/app/api/books/[id]/route.ts @@ -1,6 +1,11 @@ import { getLogger } from "@/lib/logger"; import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; import { bookService } from "@/lib/services/book.service"; +import { bookSourceRepository } from "@/lib/repositories/book-source.repository"; +import { bookRepository } from "@/lib/repositories/book.repository"; +import { localBookUpdateSchema } from "@/lib/validation/local-book.schema"; +import { ZodError } from "zod"; export const dynamic = 'force-dynamic'; @@ -28,6 +33,8 @@ export async function GET(request: NextRequest, props: { params: Promise<{ id: s export async function PATCH(request: NextRequest, props: { params: Promise<{ id: string }> }) { const params = await props.params; + const logger = getLogger(); + try { const bookId = parseInt(params.id); @@ -35,14 +42,63 @@ export async function PATCH(request: NextRequest, props: { params: Promise<{ id: return NextResponse.json({ error: "Invalid book ID format" }, { status: 400 }); } + // Check if book exists + const existingBook = await bookRepository.findById(bookId); + if (!existingBook) { + return NextResponse.json({ error: "Book not found" }, { status: 404 }); + } + const body = await request.json(); - const { totalPages } = body; - const book = await bookService.updateTotalPages(bookId, totalPages); + // Handle legacy totalPages-only update + if (body.totalPages !== undefined && Object.keys(body).length === 1) { + const book = await bookService.updateTotalPages(bookId, body.totalPages); + return NextResponse.json(book); + } - return NextResponse.json(book); + // If body is empty or only has totalPages=undefined, return error + if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.totalPages === undefined)) { + return NextResponse.json( + { error: "Total pages must be a positive number" }, + { status: 400 } + ); + } + + // Handle full book metadata update + // Check if book has any sources (Calibre, Audiobookshelf, etc.) + const hasSources = await bookSourceRepository.hasAnySources(bookId); + + if (hasSources) { + return NextResponse.json( + { + error: "Cannot edit books synced from external sources. Edit in the source application instead." + }, + { status: 403 } + ); + } + + // Validate update data + const validatedData = localBookUpdateSchema.parse(body); + + // Update book + const updatedBook = await bookRepository.update(bookId, validatedData); + + logger.info({ bookId, fields: Object.keys(validatedData) }, "Updated local book"); + + return NextResponse.json(updatedBook); } catch (error) { - getLogger().error({ err: error }, "Error updating book"); + if (error instanceof ZodError) { + logger.warn({ err: error, bookId: params.id }, "Validation error updating book"); + return NextResponse.json( + { + error: "Validation error", + details: error.issues + }, + { status: 400 } + ); + } + + logger.error({ err: error }, "Error updating book"); // Handle specific errors if (error instanceof Error) { @@ -57,3 +113,51 @@ export async function PATCH(request: NextRequest, props: { params: Promise<{ id: return NextResponse.json({ error: "Failed to update book" }, { status: 500 }); } } + +export async function DELETE(request: NextRequest, props: { params: Promise<{ id: string }> }) { + const params = await props.params; + const logger = getLogger(); + + try { + const bookId = parseInt(params.id); + + if (isNaN(bookId)) { + return NextResponse.json({ error: "Invalid book ID format" }, { status: 400 }); + } + + // Check if book exists + const existingBook = await bookRepository.findById(bookId); + if (!existingBook) { + return NextResponse.json({ error: "Book not found" }, { status: 404 }); + } + + // Check if book has any sources (Calibre, Audiobookshelf, etc.) + const hasSources = await bookSourceRepository.hasAnySources(bookId); + + if (hasSources) { + return NextResponse.json( + { + error: "Cannot delete books synced from external sources. Remove from the source application instead." + }, + { status: 403 } + ); + } + + // Delete book (cascade deletes sessions, progress, notes, shelves, and cleans up cover) + const deleted = await bookRepository.delete(bookId); + + if (!deleted) { + return NextResponse.json({ error: "Failed to delete book" }, { status: 500 }); + } + + logger.info({ bookId, title: existingBook.title }, "Deleted local book"); + + // Invalidate Next.js cache for library page + revalidatePath('/library'); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + logger.error({ err: error }, "Error deleting book"); + return NextResponse.json({ error: "Failed to delete book" }, { status: 500 }); + } +} diff --git a/app/api/books/[id]/sources/route.ts b/app/api/books/[id]/sources/route.ts new file mode 100644 index 00000000..ade528b8 --- /dev/null +++ b/app/api/books/[id]/sources/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { bookSourceRepository } from "@/lib/repositories"; +import { getLogger } from "@/lib/logger"; + +const logger = getLogger().child({ module: "api/books/[id]/sources" }); + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/books/[id]/sources + * Fetch all source providers for a specific book + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const bookId = parseInt(id); + + if (isNaN(bookId)) { + return NextResponse.json( + { error: "Invalid book ID" }, + { status: 400 } + ); + } + + const sources = await bookSourceRepository.findByBookId(bookId); + + return NextResponse.json({ + sources: sources.map(s => ({ + id: s.id, + providerId: s.providerId, + externalId: s.externalId, + isPrimary: s.isPrimary, + lastSynced: s.lastSynced, + syncEnabled: s.syncEnabled, + })), + }); + } catch (error) { + logger.error({ err: error }, "Failed to fetch book sources"); + return NextResponse.json( + { error: "Failed to fetch book sources" }, + { status: 500 } + ); + } +} diff --git a/app/api/books/route.ts b/app/api/books/route.ts index a165d3a9..f661a2ae 100644 --- a/app/api/books/route.ts +++ b/app/api/books/route.ts @@ -1,6 +1,8 @@ import { getLogger } from "@/lib/logger"; import { NextRequest, NextResponse } from "next/server"; import { bookRepository } from "@/lib/repositories"; +import { bookService } from "@/lib/services/book.service"; +import { ZodError } from "zod"; export const dynamic = 'force-dynamic'; @@ -10,6 +12,7 @@ export async function GET(request: NextRequest) { const status = searchParams.get("status") || undefined; const search = searchParams.get("search")?.trim() || undefined; const tagsParam = searchParams.get("tags"); + const sourcesParam = searchParams.get("sources"); // T048: Add source filtering const rating = searchParams.get("rating") || undefined; const shelfParam = searchParams.get("shelf"); const excludeShelfParam = searchParams.get("excludeShelfId"); @@ -21,6 +24,11 @@ export async function GET(request: NextRequest) { // Parse tags const tags = tagsParam ? tagsParam.split(",").map((t) => t.trim()) : undefined; + + // Parse sources (T048: Multi-source filtering) + const source = sourcesParam + ? sourcesParam.split(",").map((s) => s.trim()) as Array<"calibre" | "local"> + : undefined; // Parse shelf ID const shelfIds = shelfParam ? [parseInt(shelfParam)] : undefined; @@ -39,6 +47,7 @@ export async function GET(request: NextRequest) { status, search, tags, + source, // T048: Pass source filter to repository rating, shelfIds, excludeShelfId, @@ -66,10 +75,44 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { const body = await request.json(); + + // Check if this is a local book creation (has title field) + if (body.title && body.authors) { + // Local book creation + try { + const result = await bookService.createLocalBook(body); + + return NextResponse.json({ + book: result.book, + duplicates: result.duplicates, + }, { status: 201 }); + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json( + { + error: "Validation failed", + details: error.issues, + }, + { status: 400 } + ); + } + + getLogger().error({ err: error }, "Error creating local book"); + return NextResponse.json( + { error: "Failed to create local book" }, + { status: 500 } + ); + } + } + + // Legacy: Update book by calibreId const { calibreId, totalPages } = body; if (!calibreId) { - return NextResponse.json({ error: "calibreId is required" }, { status: 400 }); + return NextResponse.json( + { error: "Either (title + authors) or calibreId is required" }, + { status: 400 } + ); } const book = await bookRepository.findByCalibreId(calibreId); @@ -85,7 +128,10 @@ export async function POST(request: NextRequest) { return NextResponse.json(book); } catch (error) { - getLogger().error({ err: error }, "Error updating book"); - return NextResponse.json({ error: "Failed to update book" }, { status: 500 }); + getLogger().error({ err: error }, "Error in POST /api/books"); + return NextResponse.json( + { error: "Failed to process request" }, + { status: 500 } + ); } } diff --git a/app/api/books/validate/route.ts b/app/api/books/validate/route.ts new file mode 100644 index 00000000..8ed516dc --- /dev/null +++ b/app/api/books/validate/route.ts @@ -0,0 +1,49 @@ +/** + * POST /api/books/validate + * + * Real-time validation endpoint for local book input. + * Returns validation errors without creating the book. + */ + +import { getLogger } from "@/lib/logger"; +import { NextRequest, NextResponse } from "next/server"; +import { validateLocalBookInputSafe } from "@/lib/validation/local-book.schema"; +import { bookService } from "@/lib/services/book.service"; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate input + const validation = validateLocalBookInputSafe(body); + + if (!validation.success) { + return NextResponse.json( + { + valid: false, + errors: validation.errors.issues, + }, + { status: 200 } // 200 OK with validation errors + ); + } + + // Check for duplicates + const duplicates = await bookService.checkForDuplicates( + validation.data.title, + validation.data.authors + ); + + return NextResponse.json({ + valid: true, + duplicates: duplicates, + }); + } catch (error) { + getLogger().error({ err: error }, "Error validating local book input"); + return NextResponse.json( + { error: "Failed to validate input" }, + { status: 500 } + ); + } +} diff --git a/app/api/providers/[providerId]/config/route.ts b/app/api/providers/[providerId]/config/route.ts new file mode 100644 index 00000000..9cc2949b --- /dev/null +++ b/app/api/providers/[providerId]/config/route.ts @@ -0,0 +1,216 @@ +/** + * PATCH /api/providers/[providerId]/config - Update provider configuration + * + * Updates provider settings, credentials, and enabled state. + * + * @param {string} providerId - Provider identifier (calibre, hardcover, openlibrary) + * @body {Object} config - Configuration updates + * @body {boolean} [config.enabled] - Enable/disable provider + * @body {Object} [config.settings] - Provider-specific settings (timeout, etc.) + * @body {Object} [config.credentials] - Provider credentials (API keys) + * + * @returns {Provider} Updated provider configuration + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getLogger } from "@/lib/logger"; +import { getProvider, hasProvider } from "@/lib/providers/provider-map"; +import { providerConfigRepository } from "@/lib/repositories/provider-config.repository"; +import { providerService } from "@/lib/services/provider.service"; +import { searchService } from "@/lib/services/search.service"; +import type { ProviderId } from "@/lib/providers/base/IMetadataProvider"; + +const logger = getLogger().child({ module: "api-provider-config" }); + +export const dynamic = "force-dynamic"; + +// Validation schema for configuration updates +interface ConfigUpdate { + enabled?: boolean; + settings?: Record; + credentials?: Record; +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ providerId: string }> } +) { + try { + const { providerId } = await params; + + // Validate provider exists + if (!hasProvider(providerId as ProviderId)) { + return NextResponse.json( + { + error: "Provider not found", + message: `Provider '${providerId}' does not exist`, + }, + { status: 404 } + ); + } + + // Parse request body + const body = await request.json() as ConfigUpdate; + + // Validate request + if (body.enabled === undefined && body.settings === undefined && body.credentials === undefined) { + return NextResponse.json( + { + error: "Invalid request", + message: "At least one of enabled, settings, or credentials must be provided", + }, + { status: 400 } + ); + } + + logger.info( + { + providerId, + hasEnabled: body.enabled !== undefined, + hasSettings: !!body.settings, + hasCredentials: !!body.credentials, + }, + "Updating provider configuration" + ); + + // Get existing config + const existingConfig = await providerConfigRepository.findByProvider( + providerId as ProviderId + ); + + if (!existingConfig) { + return NextResponse.json( + { + error: "Provider configuration not found", + message: `No configuration found for provider '${providerId}'`, + }, + { status: 404 } + ); + } + + // Update enabled state (if provided) + if (body.enabled !== undefined) { + await providerService.setEnabled(providerId as ProviderId, body.enabled); + logger.info({ providerId, enabled: body.enabled }, "Updated provider enabled state"); + + // Invalidate search cache when provider enabled state changes + await searchService.clearCache(); + logger.debug("Cleared search cache due to provider enabled state change"); + } + + // Update settings (if provided) + if (body.settings) { + await providerConfigRepository.updateSettings( + providerId as ProviderId, + body.settings + ); + logger.info({ providerId, settings: body.settings }, "Updated provider settings"); + } + + // Update credentials (if provided) + if (body.credentials) { + await providerConfigRepository.updateCredentials( + providerId as ProviderId, + body.credentials + ); + logger.info({ providerId }, "Updated provider credentials (redacted)"); + } + + // Fetch updated configuration + const updatedConfig = await providerConfigRepository.findByProvider( + providerId as ProviderId + ); + const provider = getProvider(providerId as ProviderId); + + // Return updated configuration (without sensitive credentials) + return NextResponse.json({ + id: providerId, + name: provider?.name, + capabilities: provider?.capabilities, + enabled: updatedConfig?.enabled ?? true, + priority: updatedConfig?.priority ?? 100, + settings: updatedConfig?.settings || {}, + hasCredentials: !!(updatedConfig?.credentials && Object.keys(updatedConfig.credentials).length > 0), + updatedAt: updatedConfig?.updatedAt?.toISOString(), + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + const { providerId } = await params; + logger.error({ error: err.message, providerId }, "Failed to update provider configuration"); + + return NextResponse.json( + { + error: "Failed to update provider configuration", + message: err.message, + }, + { status: 500 } + ); + } +} + +/** + * GET /api/providers/[providerId]/config - Get provider configuration + * + * Returns detailed configuration for a specific provider. + * + * @param {string} providerId - Provider identifier + * @returns {Provider} Provider configuration + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ providerId: string }> } +) { + try { + const { providerId } = await params; + + // Validate provider exists + if (!hasProvider(providerId as ProviderId)) { + return NextResponse.json( + { + error: "Provider not found", + message: `Provider '${providerId}' does not exist`, + }, + { status: 404 } + ); + } + + const config = await providerConfigRepository.findByProvider( + providerId as ProviderId + ); + const provider = getProvider(providerId as ProviderId); + + if (!config) { + return NextResponse.json( + { + error: "Provider configuration not found", + message: `No configuration found for provider '${providerId}'`, + }, + { status: 404 } + ); + } + + return NextResponse.json({ + id: providerId, + name: provider?.name, + capabilities: provider?.capabilities, + enabled: config.enabled ?? true, + priority: config.priority ?? 100, + settings: config.settings || {}, + hasCredentials: !!(config.credentials && Object.keys(config.credentials).length > 0), + createdAt: config.createdAt?.toISOString(), + updatedAt: config.updatedAt?.toISOString(), + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + const { providerId } = await params; + logger.error({ error: err.message, providerId }, "Failed to get provider configuration"); + + return NextResponse.json( + { + error: "Failed to get provider configuration", + message: err.message, + }, + { status: 500 } + ); + } +} diff --git a/app/api/providers/[providerId]/metadata/[externalId]/route.ts b/app/api/providers/[providerId]/metadata/[externalId]/route.ts new file mode 100644 index 00000000..a492276f --- /dev/null +++ b/app/api/providers/[providerId]/metadata/[externalId]/route.ts @@ -0,0 +1,120 @@ +/** + * GET /api/providers/[providerId]/metadata/[externalId] + * + * Fetches complete book metadata from a specific provider. + * Used when user selects a search result to get full details + * including description, tags, and publisher. + * + * See: specs/003-non-calibre-books/spec.md (T069) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { providerService } from "@/lib/services/provider.service"; +import { getLogger } from "@/lib/logger"; +import type { ProviderId } from "@/lib/providers/base/IMetadataProvider"; + +const logger = getLogger().child({ module: "api-providers-metadata" }); + +/** + * GET /api/providers/[providerId]/metadata/[externalId] + * + * Fetches complete metadata for a book from a provider. + * + * Path parameters: + * - providerId: Provider identifier (hardcover, openlibrary, etc.) + * - externalId: External book ID in the provider's system + * + * Response: + * { + * "success": true, + * "data": { + * "title": "...", + * "authors": [...], + * "isbn": "...", + * "description": "...", + * "tags": [...], + * "publisher": "...", + * "pubDate": "...", + * "totalPages": 123, + * "coverImageUrl": "..." + * } + * } + */ +export async function GET( + request: NextRequest, + props: { params: Promise<{ providerId: string; externalId: string }> } +) { + const params = await props.params; + try { + const { providerId, externalId } = params; + + if (!providerId || !externalId) { + return NextResponse.json( + { + success: false, + error: "Provider ID and external ID are required", + }, + { status: 400 } + ); + } + + logger.info({ providerId, externalId }, "API: Fetching metadata"); + + // Fetch metadata from provider + const metadata = await providerService.fetchMetadata( + providerId as ProviderId, + externalId + ); + + logger.info( + { + providerId, + externalId, + title: metadata.title, + hasDescription: !!metadata.description, + hasTags: !!metadata.tags, + hasPublisher: !!metadata.publisher, + }, + "API: Metadata fetch complete" + ); + + return NextResponse.json({ + success: true, + data: metadata, + }); + } catch (error: any) { + logger.error({ err: error }, "API: Metadata fetch failed"); + + // Handle specific error types + if (error.message?.includes("Circuit breaker")) { + return NextResponse.json( + { + success: false, + error: "Provider temporarily unavailable", + message: error.message, + }, + { status: 503 } + ); + } + + if (error.message?.includes("not found")) { + return NextResponse.json( + { + success: false, + error: "Metadata not found", + message: error.message, + }, + { status: 404 } + ); + } + + return NextResponse.json( + { + success: false, + error: "Metadata fetch failed", + message: error.message || "An unexpected error occurred", + }, + { status: 500 } + ); + } +} diff --git a/app/api/providers/route.ts b/app/api/providers/route.ts new file mode 100644 index 00000000..b359b0e7 --- /dev/null +++ b/app/api/providers/route.ts @@ -0,0 +1,62 @@ +/** + * GET /api/providers - List all providers + * + * Returns all registered metadata providers with their configuration + * and capabilities. + * + * @returns {Provider[]} Array of provider configurations + */ + +import { NextResponse } from "next/server"; +import { getLogger } from "@/lib/logger"; +import { getAllProviders } from "@/lib/providers/provider-map"; +import { providerConfigRepository } from "@/lib/repositories/provider-config.repository"; +import type { ProviderId } from "@/lib/providers/base/IMetadataProvider"; + +const logger = getLogger().child({ module: "api-providers" }); + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + // Get all registered providers + const providers = getAllProviders(); + + // Fetch database configurations + const dbConfigs = await Promise.all( + providers.map(async (provider) => { + const config = await providerConfigRepository.findByProvider( + provider.id as ProviderId + ); + + return { + id: provider.id, + name: provider.name, + capabilities: provider.capabilities, + enabled: config?.enabled ?? true, + priority: config?.priority ?? 100, + settings: config?.settings || {}, + hasCredentials: !!(config?.credentials && Object.keys(config.credentials).length > 0), + }; + }) + ); + + logger.debug({ count: dbConfigs.length }, "Retrieved provider list"); + + return NextResponse.json({ + providers: dbConfigs, + count: dbConfigs.length, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error({ error: err.message }, "Failed to list providers"); + + return NextResponse.json( + { + error: "Failed to list providers", + message: err.message, + }, + { status: 500 } + ); + } +} diff --git a/app/api/providers/search/route.ts b/app/api/providers/search/route.ts new file mode 100644 index 00000000..a8ecefce --- /dev/null +++ b/app/api/providers/search/route.ts @@ -0,0 +1,107 @@ +/** + * POST /api/providers/search + * + * Federated metadata search across multiple providers. + * Searches Hardcover and OpenLibrary in parallel. + * + * See: specs/003-non-calibre-books/spec.md (T076) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { searchService } from "@/lib/services/search.service"; +import { getLogger } from "@/lib/logger"; + +const logger = getLogger().child({ module: "api-providers-search" }); + +// Request validation schema +const SearchRequestSchema = z.object({ + query: z.string().min(1, "Query cannot be empty").max(500, "Query too long"), +}); + +/** + * POST /api/providers/search + * + * Performs federated search across all enabled providers. + * + * Request body: + * { + * "query": "harry potter" + * } + * + * Response: + * { + * "success": true, + * "data": { + * "query": "harry potter", + * "results": [ + * { + * "provider": "hardcover", + * "results": [...], + * "status": "success", + * "duration": 1234 + * }, + * { + * "provider": "openlibrary", + * "results": [...], + * "status": "success", + * "duration": 2345 + * } + * ], + * "totalResults": 50, + * "successfulProviders": 2, + * "failedProviders": 0 + * } + * } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate request + const validation = SearchRequestSchema.safeParse(body); + if (!validation.success) { + logger.warn({ errors: validation.error.issues }, "Invalid search request"); + return NextResponse.json( + { + success: false, + error: "Validation failed", + details: validation.error.issues, + }, + { status: 400 } + ); + } + + const { query } = validation.data; + + logger.info({ query }, "API: Federated search request"); + + // Perform federated search + const searchResults = await searchService.federatedSearch(query); + + logger.info( + { + query, + totalResults: searchResults.totalResults, + successfulProviders: searchResults.successfulProviders, + failedProviders: searchResults.failedProviders, + }, + "API: Federated search complete" + ); + + return NextResponse.json({ + success: true, + data: searchResults, + }); + } catch (error: any) { + logger.error({ err: error }, "API: Federated search failed"); + return NextResponse.json( + { + success: false, + error: "Search failed", + message: error.message || "An unexpected error occurred", + }, + { status: 500 } + ); + } +} diff --git a/app/books/[id]/page.tsx b/app/books/[id]/page.tsx index c7a12ff4..c9537b1f 100644 --- a/app/books/[id]/page.tsx +++ b/app/books/[id]/page.tsx @@ -16,9 +16,13 @@ import ProgressEditModal from "@/components/Modals/ProgressEditModal"; import RereadConfirmModal from "@/components/Modals/RereadConfirmModal"; import ArchiveSessionModal from "@/components/Modals/ArchiveSessionModal"; import PageCountEditModal from "@/components/Modals/PageCountEditModal"; +import EditBookModal from "@/components/Modals/EditBookModal"; +import DeleteBookModal from "@/components/Modals/DeleteBookModal"; import TagEditor from "@/components/BookDetail/TagEditor"; import ShelfEditor from "@/components/BookDetail/ShelfEditor"; import BookHeader from "@/components/BookDetail/BookHeader"; +import { BookActionsMenu } from "@/components/BookDetail/BookActionsMenu"; +import { ProviderBadge, type SourceProviderId } from "@/components/Providers/ProviderBadge"; import { calculatePercentage } from "@/lib/utils/progress-calculations"; import type { MDXEditorMethods } from "@mdxeditor/editor"; import { getLogger } from "@/lib/logger"; @@ -51,6 +55,21 @@ export default function BookDetailPage() { updateTags, } = useBookDetail(bookId); + // Fetch book sources separately (Phase R1.6) + const { data: bookSourcesData } = useQuery<{ sources: Array<{ providerId: string }> }>({ + queryKey: ['bookSources', bookId], + queryFn: async () => { + const response = await fetch(`/api/books/${bookId}/sources`); + if (!response.ok) { + throw new Error('Failed to fetch book sources'); + } + return response.json(); + }, + enabled: !!bookId && !!book, // Only fetch if we have a book + staleTime: 60000, // Cache for 1 minute + }); + const bookSources = bookSourcesData?.sources?.map(s => s.providerId as SourceProviderId) || []; + const bookProgressHook = useBookProgress(bookId, book, async () => { // Invalidate relevant queries to refetch fresh data await queryClient.invalidateQueries({ queryKey: ['book', bookId] }); @@ -92,7 +111,7 @@ export default function BookDetailPage() { } // Update review to the session if provided and we have a session ID - // Check both bookProgressHook.completedSessionId (from auto-completion) and book.activeSession.id (from manual mark as read) + // Check both bookProgressHook.completedSessionId (from auto-completion) and book.activeSession.id (from local mark as read) const sessionId = bookProgressHook.completedSessionId || book?.activeSession?.id; if (review && sessionId) { const sessionBody = { review }; @@ -161,6 +180,8 @@ export default function BookDetailPage() { const [showPageCountModal, setShowPageCountModal] = useState(false); const [showTagEditor, setShowTagEditor] = useState(false); const [showShelfEditor, setShowShelfEditor] = useState(false); + const [showEditBookModal, setShowEditBookModal] = useState(false); + const [showDeleteBookModal, setShowDeleteBookModal] = useState(false); const [pendingStatusForPageCount, setPendingStatusForPageCount] = useState(null); const [isMobile, setIsMobile] = useState(false); @@ -422,9 +443,18 @@ export default function BookDetailPage() { {book.seriesIndex && ` #${book.seriesIndex}`} )} -

- {book.title} -

+
+

+ {book.title} +

+ {/* Book Actions Menu - only show for local books (no sources) */} + {bookSources.length === 0 && ( + setShowEditBookModal(true)} + onDelete={() => setShowDeleteBookModal(true)} + /> + )} +
{book.authors.map((author, index) => ( @@ -441,6 +471,21 @@ export default function BookDetailPage() { {/* Metadata */}
+ {/* Provider Badges - show multiple sources or none for local books */} + {bookSources.length > 0 && ( + <> + {bookSources.map((source, index) => ( + + + {index < bookSources.length - 1 && ( + + )} + + ))} + + + )} + {book.totalReads !== undefined && book.totalReads > 0 && (
@@ -569,9 +614,9 @@ export default function BookDetailPage() {
{book.tags.length > 0 ? (
- {book.tags.map((tag) => ( + {book.tags.map((tag, index) => ( @@ -647,7 +692,7 @@ export default function BookDetailPage() {
{/* Modals */} - {/* Manual completion from non-reading status (Want to Read / Read Next → Read) */} + {/* Local completion from non-reading status (Want to Read / Read Next → Read) */} handleCancelStatusChange()} @@ -659,7 +704,7 @@ export default function BookDetailPage() { defaultStartDate={book.activeSession?.startedDate ?? undefined} /> - {/* Manual status change from "reading" to "read" - uses mark-as-read API */} + {/* Local status change from "reading" to "read" - uses mark-as-read API */} handleCancelStatusChange()} @@ -668,7 +713,7 @@ export default function BookDetailPage() { bookId={bookId} /> - {/* Manual status change from "reading" to "dnf" - uses mark-as-dnf API */} + {/* Local status change from "reading" to "dnf" - uses mark-as-dnf API */} handleCancelStatusChange()} @@ -756,6 +801,30 @@ export default function BookDetailPage() { availableShelves={availableShelves} isMobile={isMobile} /> + + setShowEditBookModal(false)} + bookId={book.id} + currentBook={{ + title: book.title, + authors: book.authors, + isbn: (book as any).isbn || null, + publisher: (book as any).publisher || null, + pubDate: (book as any).pubDate || null, + series: (book as any).series || null, + seriesIndex: (book as any).seriesIndex || null, + description: (book as any).description || null, + totalPages: book.totalPages || null, + }} + /> + + setShowDeleteBookModal(false)} + bookId={book.id} + bookTitle={book.title} + />
); } diff --git a/app/journal/page.tsx b/app/journal/page.tsx index c16ee1b4..48e7897f 100644 --- a/app/journal/page.tsx +++ b/app/journal/page.tsx @@ -416,13 +416,13 @@ export default function JournalPage() { className="flex-shrink-0 hover:opacity-90 transition-opacity mx-auto xl:mx-0" >
- {bookGroup.bookTitle} + {bookGroup.bookTitle}
diff --git a/app/library/page.tsx b/app/library/page.tsx index 564c4933..30fa4853 100644 --- a/app/library/page.tsx +++ b/app/library/page.tsx @@ -8,6 +8,8 @@ import { LibraryHeader } from "@/components/Library/LibraryHeader"; import { LibraryFilters } from "@/components/Library/LibraryFilters"; import { BookGrid } from "@/components/Books/BookGrid"; import { ScrollToTopButton } from "@/components/Layout/ScrollToTopButton"; +import LocalBookForm from "@/components/Books/LocalBookForm"; +import FederatedSearchModal from "@/components/Providers/FederatedSearchModal"; import { toast } from "@/utils/toast"; function LibraryPageContent() { @@ -15,6 +17,8 @@ function LibraryPageContent() { const router = useRouter(); const [isReady, setIsReady] = useState(false); const [syncing, setSyncing] = useState(false); + const [showLocalBookForm, setShowLocalBookForm] = useState(false); + const [showFederatedSearch, setShowFederatedSearch] = useState(false); const [availableTags, setAvailableTags] = useState([]); const [loadingTags, setLoadingTags] = useState(true); const [availableShelves, setAvailableShelves] = useState>([]); @@ -66,9 +70,12 @@ function LibraryPageContent() { if (currentFilters.rating && currentFilters.rating !== 'all') { params.set('rating', currentFilters.rating); } - if (currentFilters.shelf) { + if (currentFilters.shelf) { params.set('shelf', currentFilters.shelf.toString()); } + if (currentFilters.sources && currentFilters.sources.length > 0) { + params.set('sources', currentFilters.sources.join(',')); // T053: Add sources to URL + } if (currentFilters.sort && currentFilters.sort !== 'created') { params.set('sort', currentFilters.sort); } @@ -98,6 +105,7 @@ function LibraryPageContent() { setShelf, setSortBy, setNoTags, + setSources, // T052: Add setSources filters, refresh, } = useLibraryData({ @@ -108,6 +116,7 @@ function LibraryPageContent() { shelf: searchParams?.get("shelf") ? parseInt(searchParams.get("shelf")!) : undefined, sortBy: searchParams?.get("sort") || undefined, noTags: searchParams?.get("noTags") === "true" || undefined, + sources: searchParams?.get("sources")?.split(",").filter(Boolean) || undefined, // T052: Parse sources from URL }); // Performance monitoring - track page load times @@ -305,10 +314,11 @@ function LibraryPageContent() { status: status || 'all', tags: filters.tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources sort: filters.sortBy || 'created', noTags: filters.noTags }); - }, [setStatus, updateURL, filters.search, filters.tags, filters.rating, filters.sortBy, filters.noTags]); + }, [setStatus, updateURL, filters.search, filters.tags, filters.rating, filters.sources, filters.sortBy, filters.noTags]); const handleTagsChange = useCallback((tags: string[] | undefined) => { setTags(tags); @@ -317,10 +327,11 @@ function LibraryPageContent() { status: filters.status || 'all', tags: tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources sort: filters.sortBy || 'created', noTags: filters.noTags }); - }, [setTags, updateURL, filters.search, filters.status, filters.rating, filters.sortBy, filters.noTags]); + }, [setTags, updateURL, filters.search, filters.status, filters.rating, filters.sources, filters.sortBy, filters.noTags]); const handleRatingChange = useCallback((rating: string | undefined) => { setRating(rating); @@ -329,10 +340,11 @@ function LibraryPageContent() { status: filters.status || 'all', tags: filters.tags || [], rating: rating || 'all', + sources: filters.sources || [], // T053: Include sources sort: filters.sortBy || 'created', noTags: filters.noTags }); - }, [setRating, updateURL, filters.search, filters.status, filters.tags, filters.sortBy, filters.noTags]); + }, [setRating, updateURL, filters.search, filters.status, filters.tags, filters.sources, filters.sortBy, filters.noTags]); const handleShelfChange = useCallback((shelfId: number | null) => { const shelf = shelfId || undefined; @@ -342,11 +354,12 @@ function LibraryPageContent() { status: filters.status || 'all', tags: filters.tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources shelf: shelf, sort: filters.sortBy || 'created', noTags: filters.noTags }); - }, [setShelf, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.sortBy, filters.noTags]); + }, [setShelf, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.sources, filters.sortBy, filters.noTags]); const handleNoTagsChange = useCallback((noTags: boolean) => { setNoTags(noTags || undefined); @@ -355,11 +368,12 @@ function LibraryPageContent() { status: filters.status || 'all', tags: noTags ? [] : filters.tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources shelf: filters.shelf, sort: filters.sortBy || 'created', noTags: noTags }); - }, [setNoTags, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.shelf, filters.sortBy]); + }, [setNoTags, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.sources, filters.shelf, filters.sortBy]); // Handle search submission const handleSearchSubmit = useCallback(() => { @@ -372,10 +386,11 @@ function LibraryPageContent() { status: filters.status || 'all', tags: filters.tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources sort: filters.sortBy || 'created', noTags: filters.noTags }); - }, [searchInput, setSearch, isReady, filters.status, filters.tags, filters.rating, filters.sortBy, filters.noTags, updateURL]); + }, [searchInput, setSearch, isReady, filters.status, filters.tags, filters.rating, filters.sources, filters.sortBy, filters.noTags, updateURL]); // Handle search clear (X button) const handleSearchClear = useCallback(() => { @@ -388,10 +403,11 @@ function LibraryPageContent() { status: filters.status || 'all', tags: filters.tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources sort: filters.sortBy || 'created', noTags: filters.noTags }); - }, [setSearch, isReady, filters.status, filters.tags, filters.rating, filters.sortBy, filters.noTags, updateURL]); + }, [setSearch, isReady, filters.status, filters.tags, filters.rating, filters.sources, filters.sortBy, filters.noTags, updateURL]); // Fetch available tags on mount useEffect(() => { @@ -469,6 +485,15 @@ function LibraryPageContent() { } } + function handleAddLocalBook() { + setShowLocalBookForm(true); + } + + async function handleLocalBookSuccess() { + setShowLocalBookForm(false); + await refresh(); + } + const handleSortChange = useCallback((sort: string) => { setSortBy(sort); updateURL({ @@ -476,10 +501,25 @@ function LibraryPageContent() { status: filters.status || 'all', tags: filters.tags || [], rating: filters.rating || 'all', + sources: filters.sources || [], // T053: Include sources sort: sort, noTags: filters.noTags }); - }, [setSortBy, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.noTags]); + }, [setSortBy, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.sources, filters.noTags]); + + // T053: Handle sources change + const handleSourcesChange = useCallback((sources: string[] | undefined) => { + setSources(sources); + updateURL({ + search: filters.search, + status: filters.status || 'all', + tags: filters.tags || [], + rating: filters.rating || 'all', + sources: sources || [], + sort: filters.sortBy || 'created', + noTags: filters.noTags + }); + }, [setSources, updateURL, filters.search, filters.status, filters.tags, filters.rating, filters.sortBy, filters.noTags]); function handleClearAll() { setSearchInput(""); @@ -490,6 +530,7 @@ function LibraryPageContent() { setShelf(undefined); setSortBy(undefined); setNoTags(undefined); + setSources(undefined); // T053: Clear sources // Update URL to remove all filter parameters router.replace('/library'); @@ -504,6 +545,8 @@ function LibraryPageContent() { totalBooks={total} syncing={syncing} onSync={handleSync} + onAddManualBook={handleAddLocalBook} + onSearchProviders={() => setShowFederatedSearch(true)} loading={isInitialLoading} /> @@ -524,6 +567,8 @@ function LibraryPageContent() { loadingShelves={loadingShelves} noTags={filters.noTags || false} onNoTagsChange={handleNoTagsChange} + selectedSources={filters.sources || []} // T053: Pass sources + onSourcesChange={(sources) => handleSourcesChange(sources.length > 0 ? sources : undefined)} // T053: Pass handler sortBy={filters.sortBy || "created"} onSortChange={handleSortChange} availableTags={availableTags} @@ -549,6 +594,20 @@ function LibraryPageContent() { {/* Scroll to top button */} + + {/* Local Book Form Modal */} + setShowLocalBookForm(false)} + onSuccess={handleLocalBookSuccess} + /> + + {/* Federated Search Modal */} + setShowFederatedSearch(false)} + onSuccess={handleLocalBookSuccess} + />
); } diff --git a/app/page.tsx b/app/page.tsx index b7a3f3c2..4f97a94e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -115,8 +115,6 @@ export default function Dashboard() { id={book.id.toString()} title={book.title} authors={book.authors} - calibreId={book.calibreId} - lastSynced={book.lastSynced} /> ))} diff --git a/app/series/[name]/page.tsx b/app/series/[name]/page.tsx index 3ce836c2..dcb78862 100644 --- a/app/series/[name]/page.tsx +++ b/app/series/[name]/page.tsx @@ -15,7 +15,7 @@ import { getCoverUrl } from "@/lib/utils/cover-url"; interface SeriesBook { id: number; - calibreId: number; + calibreId: number | null; title: string; authors: string[]; seriesIndex: number; @@ -24,7 +24,7 @@ interface SeriesBook { status?: string | null; tags: string[]; description?: string | null; - lastSynced?: Date | string | null; + updatedAt?: Date | string | null; } interface SeriesInfo { @@ -60,8 +60,8 @@ export default function SeriesDetailPage() { enabled: !!seriesName, }); - const handleImageError = (calibreId: number) => { - setImageErrors(prev => ({ ...prev, [calibreId]: true })); + const handleImageError = (bookId: number) => { + setImageErrors(prev => ({ ...prev, [bookId]: true })); }; if (isLoading) { @@ -113,14 +113,14 @@ export default function SeriesDetailPage() { {/* Book Cover */}
- {!imageErrors[book.calibreId] ? ( + {!imageErrors[book.id] ? ( {`Cover handleImageError(book.calibreId)} + onError={() => handleImageError(book.id)} /> ) : (
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 89d08c48..f08cabd0 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,4 +1,5 @@ -import { Settings as SettingsIcon, Github, Bug, BookOpen } from "lucide-react"; +import { Settings as SettingsIcon, Plug, ArrowRight, Github, Bug, BookOpen } from "lucide-react"; +import Link from "next/link"; import { ThemeSettings } from "@/components/Settings/ThemeSettings"; import { TimezoneSettings } from "@/components/Settings/TimezoneSettings"; import { PageHeader } from "@/components/Layout/PageHeader"; @@ -20,6 +21,27 @@ export default async function SettingsPage() { icon={SettingsIcon} /> + {/* Provider Settings Link */} + +
+
+ +
+

+ Provider Settings +

+

+ Configure metadata providers and API credentials +

+
+
+ +
+ + {/* Theme Settings */} diff --git a/app/settings/providers/page.tsx b/app/settings/providers/page.tsx new file mode 100644 index 00000000..d354cd21 --- /dev/null +++ b/app/settings/providers/page.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Plug, AlertCircle, CheckCircle } from "lucide-react"; +import { PageHeader } from "@/components/Layout/PageHeader"; +import { ProviderToggles } from "@/components/Settings/ProviderToggles"; +import { ProviderCredentials } from "@/components/Settings/ProviderCredentials"; + +interface Provider { + id: string; + name: string; + capabilities: { + hasSearch: boolean; + hasMetadataFetch: boolean; + hasSync: boolean; + requiresAuth: boolean; + }; + enabled: boolean; + priority: number; + settings: Record; + hasCredentials: boolean; +} + +interface ProvidersResponse { + providers: Provider[]; + count: number; +} + +export default function ProvidersSettingsPage() { + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const fetchProviders = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetch("/api/providers"); + + if (!response.ok) { + throw new Error(`Failed to fetch providers: ${response.statusText}`); + } + + const data: ProvidersResponse = await response.json(); + setProviders(data.providers); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load providers"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchProviders(); + }, [fetchProviders]); + + async function handleToggleProvider(providerId: string, enabled: boolean) { + try { + setError(null); + const response = await fetch(`/api/providers/${providerId}/config`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to update provider"); + } + + // Refresh providers list + await fetchProviders(); + + setSuccessMessage( + `${providerId} ${enabled ? "enabled" : "disabled"} successfully` + ); + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to toggle provider"); + } + } + + async function handleUpdateCredentials( + providerId: string, + credentials: Record + ) { + try { + setError(null); + const response = await fetch(`/api/providers/${providerId}/config`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credentials }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to update credentials"); + } + + // Refresh providers list + await fetchProviders(); + + setSuccessMessage(`Credentials updated for ${providerId}`); + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to update credentials" + ); + throw err; // Re-throw so component can handle it + } + } + + if (loading) { + return ( +
+ +
+
+
+

+ Loading providers... +

+
+
+
+ ); + } + + return ( +
+ + + {/* Status Messages */} + {error && ( +
+ +

{error}

+
+ )} + + {successMessage && ( +
+ +

{successMessage}

+
+ )} + + {/* Provider Toggles */} +
+
+

+ Active Providers +

+

+ Enable or disable metadata providers. Disabled providers will not be + used for searches or syncing. +

+
+ +
+ + {/* API Credentials */} +
+
+

+ API Credentials +

+

+ Configure API keys for providers that require authentication. Your + credentials are stored securely and only used to access provider + APIs. +

+
+ +
+ + {/* Info Section */} +
+

+ About Providers +

+
+

+ Calibre:{" "} + Syncs books from your Calibre library. Always enabled when Calibre + is configured. +

+

+ Local:{" "} + Allows you to add books locally without external metadata. Always + enabled. +

+

+ Hardcover:{" "} + Fetches book metadata from Hardcover.app. Requires an API key. +

+

+ + Open Library: + {" "} + Fetches book metadata from OpenLibrary.org. Free public API, no + authentication required. +

+
+
+
+ ); +} diff --git a/components/BookDetail/BookActionsMenu.tsx b/components/BookDetail/BookActionsMenu.tsx new file mode 100644 index 00000000..a05a39b9 --- /dev/null +++ b/components/BookDetail/BookActionsMenu.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { MoreVertical, Edit, Trash2 } from "lucide-react"; + +interface BookActionsMenuProps { + onEdit: () => void; + onDelete: () => void; +} + +interface MenuPosition { + top: number; + left: number; +} + +export function BookActionsMenu({ + onEdit, + onDelete, +}: BookActionsMenuProps) { + const [showMenu, setShowMenu] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const updateMenuPosition = useCallback(() => { + if (!buttonRef.current) return; + + const rect = buttonRef.current.getBoundingClientRect(); + const menuWidth = 192; // w-48 = 12rem = 192px + + setMenuPosition({ + top: rect.bottom + 4, // 4px gap below button + left: rect.right - menuWidth, // Align right edge with button + }); + }, []); + + // Update position when menu opens + useEffect(() => { + if (showMenu) { + updateMenuPosition(); + } + }, [showMenu, updateMenuPosition]); + + // Close menu when clicking outside + useEffect(() => { + if (!showMenu) return; + + function handleClickOutside(event: MouseEvent) { + const target = event.target as Node; + if ( + menuRef.current && + !menuRef.current.contains(target) && + buttonRef.current && + !buttonRef.current.contains(target) + ) { + setShowMenu(false); + } + } + + function handleScroll() { + setShowMenu(false); + } + + document.addEventListener("mousedown", handleClickOutside); + window.addEventListener("scroll", handleScroll, true); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("scroll", handleScroll, true); + }; + }, [showMenu]); + + const menuContent = showMenu && ( +
+ + + +
+ ); + + return ( +
+ + + {typeof document !== "undefined" && createPortal(menuContent, document.body)} +
+ ); +} diff --git a/components/BookDetail/BookHeader.tsx b/components/BookDetail/BookHeader.tsx index ce4f4eee..91f6e1e9 100644 --- a/components/BookDetail/BookHeader.tsx +++ b/components/BookDetail/BookHeader.tsx @@ -8,9 +8,10 @@ import { getCoverUrl } from "@/lib/utils/cover-url"; interface BookHeaderProps { book: { - calibreId: number; + id: number; + calibreId: number | null; totalPages?: number; - lastSynced?: Date | string | null; + updatedAt?: Date | string | null; }; selectedStatus: string; imageError: boolean; @@ -125,7 +126,7 @@ export default function BookHeader({
{!imageError ? ( Book cover { + // Note: This removes ALL instances of the tag if there are duplicates + // This is the expected behavior - clicking "Fantasy" should remove all "Fantasy" tags setTags(tags.filter((tag) => tag !== tagToRemove)); }; @@ -110,14 +112,14 @@ export default function TagEditor({
{tags.length > 0 ? (
- {tags.map((tag) => ( + {tags.map((tag, index) => ( +
+
+ ) : showExistingCover && existingCoverUrl ? ( + // Show existing cover with option to replace (edit mode) +
+
+
+ Current cover +
+
+

+ Current cover +

+ +
+
+ +
+
+ or +
+
+ +
+ onCoverUrlChange(e.target.value)} + disabled={disabled} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)] text-sm disabled:opacity-50 disabled:cursor-not-allowed" + placeholder="https://example.com/cover.jpg" + /> +

+ Enter image URL (if both file and URL provided, file takes priority) +

+
+
+ ) : ( + // No cover selected or existing (create mode / no existing cover) +
+ + +
+
+ or +
+
+ +
+ onCoverUrlChange(e.target.value)} + disabled={disabled} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)] text-sm disabled:opacity-50 disabled:cursor-not-allowed" + placeholder="https://example.com/cover.jpg" + /> +

+ Enter image URL (if both file and URL provided, file takes priority) +

+
+
+ )} + {validationError && ( +

{validationError}

+ )} +
+ ); +} diff --git a/components/Books/DraggableBookList.tsx b/components/Books/DraggableBookList.tsx index e6059170..d9df6e0f 100644 --- a/components/Books/DraggableBookList.tsx +++ b/components/Books/DraggableBookList.tsx @@ -27,7 +27,7 @@ import { cn } from "@/utils/cn"; interface Book { id: number; - calibreId: number; + calibreId: number | null; title: string; authors: string[]; series?: string | null; diff --git a/components/Books/DraggableBookTable.tsx b/components/Books/DraggableBookTable.tsx index 30a42494..8baa30c6 100644 --- a/components/Books/DraggableBookTable.tsx +++ b/components/Books/DraggableBookTable.tsx @@ -35,7 +35,7 @@ type SortDirection = "asc" | "desc"; interface BookTableBook { id: number; - calibreId: number; + calibreId: number | null; title: string; authors: string[]; series?: string | null; @@ -46,7 +46,7 @@ interface BookTableBook { addedAt?: Date | null; status?: string | null; sortOrder?: number; - lastSynced?: Date | string | null; + updatedAt?: Date | string | null; } interface DraggableBookTableProps { @@ -70,7 +70,7 @@ interface SortableRowProps { book: BookTableBook; index: number; imageErrors: Set; - onImageError: (calibreId: number) => void; + onImageError: (bookId: number) => void; onRemoveBook?: (bookId: number) => void; isDragEnabled: boolean; isSelectMode?: boolean; @@ -95,7 +95,7 @@ function SortableRow({ book, index, imageErrors, onImageError, onRemoveBook, isD opacity: isDragging ? 0.5 : 1, }; - const hasImageError = imageErrors.has(book.calibreId); + const hasImageError = imageErrors.has(book.id); const seriesInfo = book.series && book.seriesIndex ? `${book.series} #${book.seriesIndex}` : book.series || "-"; @@ -150,12 +150,12 @@ function SortableRow({ book, index, imageErrors, onImageError, onRemoveBook, isD
{!hasImageError ? ( {book.title} onImageError(book.calibreId)} + onError={() => onImageError(book.id)} /> ) : ( @@ -328,8 +328,8 @@ export function DraggableBookTable({ }) ); - const handleImageError = (calibreId: number) => { - setImageErrors((prev) => new Set(prev).add(calibreId)); + const handleImageError = (bookId: number) => { + setImageErrors((prev) => new Set(prev).add(bookId)); }; const handleColumnClick = (column: string) => { diff --git a/components/Books/LocalBookForm.tsx b/components/Books/LocalBookForm.tsx new file mode 100644 index 00000000..0a3800db --- /dev/null +++ b/components/Books/LocalBookForm.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import BaseModal from "@/components/Modals/BaseModal"; +import { BottomSheet } from "@/components/Layout/BottomSheet"; +import { Button } from "@/components/Utilities/Button"; +import { toast } from "@/utils/toast"; +import { getLogger } from "@/lib/logger"; +import { invalidateBookQueries } from "@/hooks/useBookStatus"; +import { BookPlus, X } from "lucide-react"; +import type { LocalBookInput } from "@/lib/validation/local-book.schema"; +import type { PotentialDuplicate } from "@/lib/services/duplicate-detection.service"; +import CoverUploadField from "./CoverUploadField"; +import { TagSelector } from "@/components/TagManagement/TagSelector"; + +const logger = getLogger().child({ component: "LocalBookForm" }); + +interface LocalBookFormProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (bookId: number) => void; +} + +interface ValidationError { + path: (string | number)[]; + message: string; +} + +export default function LocalBookForm({ + isOpen, + onClose, + onSuccess, +}: LocalBookFormProps) { + const queryClient = useQueryClient(); + + // Mobile detection + const [isMobile, setIsMobile] = useState(false); + + // Form state + const [title, setTitle] = useState(""); + const [authors, setAuthors] = useState(""); + const [isbn, setIsbn] = useState(""); + const [publisher, setPublisher] = useState(""); + const [pubDate, setPubDate] = useState(""); + const [totalPages, setTotalPages] = useState(""); + const [series, setSeries] = useState(""); + const [seriesIndex, setSeriesIndex] = useState(""); + const [description, setDescription] = useState(""); + const [tags, setTags] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + + // Cover upload state + const [coverFile, setCoverFile] = useState(null); + const [coverPreviewUrl, setCoverPreviewUrl] = useState(null); + const [coverUrl, setCoverUrl] = useState(""); + + // UI state + const [isSubmitting, setIsSubmitting] = useState(false); + const [validationErrors, setValidationErrors] = useState>({}); + const [duplicates, setDuplicates] = useState([]); + const [showDuplicateWarning, setShowDuplicateWarning] = useState(false); + + // Detect mobile viewport + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // Fetch available tags when modal opens + useEffect(() => { + if (isOpen) { + fetch("/api/tags") + .then((res) => res.json()) + .then((data) => setAvailableTags(data.tags || [])) + .catch((error) => { + logger.error({ error }, "Failed to fetch available tags"); + setAvailableTags([]); + }); + } + }, [isOpen]); + + // Reset form when modal opens/closes + useEffect(() => { + if (!isOpen) { + setTitle(""); + setAuthors(""); + setIsbn(""); + setPublisher(""); + setPubDate(""); + setTotalPages(""); + setSeries(""); + setSeriesIndex(""); + setDescription(""); + setTags([]); + setCoverFile(null); + setCoverUrl(""); + if (coverPreviewUrl) { + URL.revokeObjectURL(coverPreviewUrl); + setCoverPreviewUrl(null); + } + setValidationErrors({}); + setDuplicates([]); + setShowDuplicateWarning(false); + } + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + // Real-time validation on blur (title and authors only) + const validateField = async (field: "title" | "authors") => { + if (field === "title" && !title.trim()) { + setValidationErrors((prev) => ({ ...prev, title: "Title is required" })); + return; + } + + if (field === "authors" && !authors.trim()) { + setValidationErrors((prev) => ({ ...prev, authors: "At least one author is required" })); + return; + } + + // Clear error if field is valid + setValidationErrors((prev) => { + const updated = { ...prev }; + delete updated[field]; + return updated; + }); + + // Check for duplicates if both title and authors are filled + if (title.trim() && authors.trim()) { + try { + const response = await fetch("/api/books/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + authors: authors.split(",").map((a) => a.trim()).filter((a) => a), + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.duplicates?.hasDuplicates) { + setDuplicates(data.duplicates.duplicates); + } else { + setDuplicates([]); + } + } + } catch (error) { + logger.error({ error }, "Failed to check for duplicates"); + } + } + }; + + const handleSubmit = async () => { + // Clear previous errors + setValidationErrors({}); + + // Parse authors + const authorList = authors + .split(",") + .map((a) => a.trim()) + .filter((a) => a); + + if (!title.trim()) { + setValidationErrors({ title: "Title is required" }); + return; + } + + if (authorList.length === 0) { + setValidationErrors({ authors: "At least one author is required" }); + return; + } + + // Show duplicate warning if duplicates exist and not already shown + if (duplicates.length > 0 && !showDuplicateWarning) { + setShowDuplicateWarning(true); + return; + } + + setIsSubmitting(true); + + try { + // Prepare payload + const payload: Partial = { + title: title.trim(), + authors: authorList, + }; + + // Add optional fields + if (isbn.trim()) payload.isbn = isbn.trim(); + if (publisher.trim()) payload.publisher = publisher.trim(); + if (pubDate.trim()) payload.pubDate = new Date(pubDate); + if (totalPages.trim()) payload.totalPages = parseInt(totalPages, 10); + if (series.trim()) payload.series = series.trim(); + if (seriesIndex.trim()) payload.seriesIndex = parseFloat(seriesIndex); + if (description.trim()) payload.description = description.trim(); + if (tags.length > 0) { + payload.tags = tags; + } + + // Submit to API + const response = await fetch("/api/books", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.json(); + + if (error.details) { + // Zod validation errors + const errors: Record = {}; + error.details.forEach((err: ValidationError) => { + const field = err.path[0] as string; + errors[field] = err.message; + }); + setValidationErrors(errors); + } else { + toast.error(error.error || "Failed to create book"); + } + return; + } + + const result = await response.json(); + logger.info({ bookId: result.book.id }, "Local book created successfully"); + + // Upload cover image if one was selected (TC13) + // Send either file or URL to server (server will download URL) + if (coverFile || coverUrl.trim()) { + try { + const formData = new FormData(); + + if (coverFile) { + formData.append("cover", coverFile); + } else if (coverUrl.trim()) { + formData.append("coverUrl", coverUrl.trim()); + } + + const coverResponse = await fetch(`/api/books/${result.book.id}/cover`, { + method: "POST", + body: formData, + }); + + if (!coverResponse.ok) { + const coverError = await coverResponse.json(); + logger.warn( + { bookId: result.book.id, error: coverError }, + "Cover upload failed, but book was created successfully" + ); + // Non-blocking: book is created, just warn about cover + toast.warning(`Book added, but cover upload failed: ${coverError.error || "Unknown error"}`); + } else { + logger.info({ bookId: result.book.id }, "Cover uploaded successfully"); + + // Invalidate cache to ensure updated cover shows on book page + await invalidateBookQueries(queryClient, result.book.id.toString()); + } + } catch (coverError) { + logger.error({ error: coverError }, "Cover upload request failed"); + // Non-blocking: book created successfully + } + } + + toast.success(`"${result.book.title}" added to your library`); + onSuccess(result.book.id); + onClose(); + } catch (error) { + logger.error({ error }, "Failed to create local book"); + toast.error("Failed to create book. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + if (showDuplicateWarning) { + setShowDuplicateWarning(false); + } else { + onClose(); + } + }; + + // Determine modal title and subtitle + const modalTitle = showDuplicateWarning ? "Potential Duplicates Found" : "Add Local Book"; + const modalSubtitle = showDuplicateWarning + ? "We found books that might be duplicates. Proceed anyway?" + : "Add a book that's not in your Calibre library"; + const modalIcon = ; + + // Modal content + const modalContent = showDuplicateWarning ? ( +
+

+ The following {duplicates.length === 1 ? "book" : "books"} in your library {duplicates.length === 1 ? "appears" : "appear"} similar: +

+
+ {duplicates.map((dup) => ( +
+
+
+

+ {dup.title} +

+

+ by {dup.authors.join(", ")} +

+

+ Source: {dup.source} • {dup.similarity.toFixed(0)}% similar +

+
+
+
+ ))} +
+
+ ) : ( +
+ {/* Title - Required */} +
+ + setTitle(e.target.value)} + onBlur={() => validateField("title")} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="Enter book title" + /> + {validationErrors.title && ( +

{validationErrors.title}

+ )} +
+ + {/* Authors - Required */} +
+ + setAuthors(e.target.value)} + onBlur={() => validateField("authors")} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="e.g., Jane Doe, John Smith (comma-separated)" + /> + {validationErrors.authors && ( +

{validationErrors.authors}

+ )} +
+ + {/* Optional fields in grid */} +
+ {/* ISBN */} +
+ + setIsbn(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="ISBN-10 or ISBN-13" + /> + {validationErrors.isbn && ( +

{validationErrors.isbn}

+ )} +
+ + {/* Total Pages */} +
+ + setTotalPages(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="e.g., 350" + /> + {validationErrors.totalPages && ( +

{validationErrors.totalPages}

+ )} +
+
+ +
+ {/* Publisher */} +
+ + setPublisher(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="e.g., Penguin Books" + /> +
+ + {/* Publication Date */} +
+ + setPubDate(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + /> +
+
+ +
+ {/* Series */} +
+ + setSeries(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="e.g., Harry Potter" + /> +
+ + {/* Series Index */} +
+ + setSeriesIndex(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-color)] rounded-md bg-[var(--background)] text-[var(--foreground)]" + placeholder="e.g., 1" + /> +
+
+ + {/* Tags */} +
+ + +

+ Click tags to select, or press Enter to add and continue typing +

+ + {/* Current Tags Display */} + {tags.length > 0 && ( +
+
+ + Selected ({tags.length}) + + +
+
+ {tags.map((tag, index) => ( + + ))} +
+
+ )} +
+ + {/* Cover Image Upload */} + { + setValidationErrors((prev) => { + const updated = { ...prev }; + if (error) { + updated.cover = error; + } else { + delete updated.cover; + } + return updated; + }); + }} + disabled={isSubmitting} + /> + + {/* Description */} +
+ +