import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { DatabaseService } from "../database/database.service.js"; import { books, libraries } from "../database/schema.js"; import { JobsService } from "../jobs/jobs.service.js"; import { enrichmentDigest, preserveExistingBookValues, scanDigest } from "./scanner.service.js"; import { ScannerService } from "./scanner.service.js"; const previousDatabasePath = process.env.DATABASE_PATH; const previousStorageDir = process.env.STORAGE_DIR; const tempDirs: string[] = []; afterEach(() => { process.env.DATABASE_PATH = previousDatabasePath; process.env.STORAGE_DIR = previousStorageDir; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); describe("scan digest", () => { it("reports incomplete files without exposing huge traces", () => { const detail = scanDigest( 19, 0, [ { filePath: "/library/broken.cbr", error: "x".repeat(300) }, { filePath: "/library/broken.epub", error: "Invalid EPUB" } ] ); expect(detail).toContain("Scanned 19 file(s)"); expect(detail).toContain("2 incomplete file(s)"); expect(detail).toContain("broken.cbr"); expect(detail.length).toBeLessThan(380); }); it("reports metadata enrichment jobs as enrichment, not scans", () => { expect(enrichmentDigest(35, [])).toBe("Enriched 35 book(s)"); }); it("does not erase existing metadata or cover when a rescan has less information", () => { const existing: typeof books.$inferSelect = { id: 1, libraryId: 1, seriesId: null, title: "Daredevil", author: "Roy Thomas", description: "Existing description", isbn: "9782809476255", isbn13: "9782809476255", identifiersJson: null, localMetadataJson: null, language: "fre", publisher: "Panini comics", publishedDate: "2019", volumeNumber: null, volumeLabel: null, format: "cbz", filePath: "/library/Daredevil.cbz", coverPath: "/storage/covers/daredevil.jpg", metadataStatus: "enriched", metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "openlibrary" }), scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 12, fileMtime: "2026-08-23T00:00:00.000Z", createdAt: "2026-08-23T00:00:00.000Z", updatedAt: "2026-08-23T00:00:00.000Z" }; const next = preserveExistingBookValues( { title: "Daredevil", author: null, description: null, isbn: null, isbn13: null, language: null, publisher: null, publishedDate: null, coverPath: null, metadataStatus: "none", metadataProvenanceJson: JSON.stringify({ title: "local" }), scanStatus: "succeeded" as const }, existing ); expect(next).toMatchObject({ author: "Roy Thomas", description: "Existing description", isbn: "9782809476255", coverPath: "/storage/covers/daredevil.jpg", metadataStatus: "enriched" }); expect(JSON.parse(String(next.metadataProvenanceJson))).toMatchObject({ title: "local", author: "bnf", coverPath: "openlibrary" }); }); it("does not replace an existing valid publication date with a sentinel date", () => { const existing = { id: 1, libraryId: 1, seriesId: null, title: "Lord of the Mysteries", author: null, description: null, isbn: null, isbn13: null, identifiersJson: null, localMetadataJson: null, language: null, publisher: null, publishedDate: "2018", volumeNumber: null, volumeLabel: null, format: "epub", filePath: "/library/Lord of the Mysteries.epub", coverPath: null, metadataStatus: "partial", metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }), scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 12, fileMtime: "2026-08-23T00:00:00.000Z", createdAt: "2026-08-23T00:00:00.000Z", updatedAt: "2026-08-23T00:00:00.000Z" } satisfies typeof books.$inferSelect; const next = preserveExistingBookValues( { title: "Lord of the Mysteries", publishedDate: "0101-01-01T00:00:00+00:00", metadataStatus: "partial", metadataProvenanceJson: JSON.stringify({ title: "local", publishedDate: "openlibrary" }) }, existing ); expect(next.publishedDate).toBe("2018"); }); it.runIf(canLoadBetterSqlite())("updates the existing book when an insert races with books.file_path uniqueness", () => { const database = createDatabase(); const now = database.now(); const library = database.db .insert(libraries) .values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now }) .returning() .get(); database.db .insert(books) .values({ libraryId: library.id, seriesId: null, title: "Daredevil", author: null, description: null, isbn: null, isbn13: null, identifiersJson: null, localMetadataJson: null, language: null, publisher: null, publishedDate: null, volumeNumber: null, volumeLabel: null, format: "cbz", filePath: "/library/Daredevil.cbz", coverPath: null, metadataStatus: "none", metadataProvenanceJson: null, scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 1, fileMtime: now, createdAt: now, updatedAt: now }) .run(); const scanner = new ScannerService(database, new JobsService(database), {} as never); const values: Omit = { libraryId: library.id, seriesId: null, title: "Daredevil", author: "Roy Thomas", description: "Updated metadata", isbn: null, isbn13: null, identifiersJson: null, localMetadataJson: null, language: null, publisher: null, publishedDate: null, volumeNumber: null, volumeLabel: null, format: "cbz", filePath: "/library/Daredevil.cbz", coverPath: "/storage/covers/daredevil.jpg", metadataStatus: "enriched", metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "local" }), scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 2, fileMtime: now, updatedAt: now }; (scanner as unknown as { upsertBookByFilePath(values: Omit, existing: undefined, createdAt: string): void; }).upsertBookByFilePath( values, undefined, now ); const rows = database.db.select().from(books).all(); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ author: "Roy Thomas", coverPath: "/storage/covers/daredevil.jpg", metadataStatus: "enriched", fileSize: 2 }); database.onModuleDestroy(); }); }); function createDatabase(): DatabaseService { const dir = mkdtempSync(join(tmpdir(), "readabook-scanner-service-")); tempDirs.push(dir); process.env.DATABASE_PATH = join(dir, "readabook.sqlite"); process.env.STORAGE_DIR = join(dir, "storage"); return new DatabaseService(); } function canLoadBetterSqlite(): boolean { try { const database = createDatabase(); database.onModuleDestroy(); return true; } catch { return false; } }