import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DatabaseService } from "../database/database.service.js"; import { books, libraries } from "../database/schema.js"; import { BookMetadata } from "../scanner/metadata.js"; import { MetadataProviderRequestError } from "./adapters/provider-fetch.js"; import { MetadataService } from "./metadata.service.js"; import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "./metadata.types.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; vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); describe("MetadataService", () => { it.runIf(canLoadBetterSqlite())("backfills description from provider lookup after a metadata search hit yields an ISBN", async () => { const database = createDatabase(); const localProvider = providerStub("local"); const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ { title: "Harry Potter et le Prince de sang-mêlé", author: "J. K. Rowling", isbn: "9782070612383", publishedDate: "2005" } ]); const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async (lookup) => { if (lookup.identifiers.isbn13 !== "9782070612383") return null; return { title: "Harry Potter et le Prince de sang-mêlé", author: "J. K. Rowling", isbn: "9782070612383", publishedDate: "2005", description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé." }; }); const openLibraryProvider: MetadataProvider = { id: "openlibrary", searchByMetadata: openLibrarySearch, lookup: openLibraryLookup }; const service = new MetadataService( database, localProvider as never, openLibraryProvider as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const localMetadata: BookMetadata = { title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }; const result = await service.enrichMetadata(localMetadata, "/library/HP/Harry Potter et le Prince de Sang Mele.epub", { remote: true }); expect(openLibrarySearch).toHaveBeenCalledOnce(); expect(openLibraryLookup).toHaveBeenCalledOnce(); expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383"); expect(result).toMatchObject({ title: "Harry Potter et le Prince de Sang Mele", isbn: "9782070612383", isbn13: "9782070612383", description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé." }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("continues enrichment after a provider DNS failure and logs the failure class", async () => { const database = createDatabase(); database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'googlebooks'").run(); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const openLibraryProvider: MetadataProvider = { id: "openlibrary", lookup: async () => null, searchByMetadata: async () => { throw new MetadataProviderRequestError("openlibrary", "dns", "EAI_AGAIN"); } }; const googleProvider: MetadataProvider = { id: "googlebooks", lookup: async () => null, searchByMetadata: async () => [ { title: "Daredevil", author: "Roy Thomas", description: "Daredevil keeps moving even when another provider is unreachable.", publishedDate: "2019" } ] }; const service = new MetadataService( database, providerStub("local") as never, openLibraryProvider as never, googleProvider as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Daredevil", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }, "/library/Daredevil.cbz", { remote: true } ); expect(warn).toHaveBeenCalledWith('[metadata] Provider openlibrary failed for "Daredevil": dns: EAI_AGAIN'); expect(result).toMatchObject({ title: "Daredevil", author: "Roy Thomas", description: "Daredevil keeps moving even when another provider is unreachable." }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("looks up details from a title search hit even when the hit has no ISBN", async () => { const database = createDatabase(); const localProvider = providerStub("local"); const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ { title: "Harry Potter et le prince de sang-mele", sourceId: "OL24333986M", publishedDate: "2005" } ]); const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async (lookup) => { if (lookup.sourceId !== "OL24333986M") return null; return { title: "Harry Potter et le prince de sang-mêlé", author: "J. K. Rowling", isbn: "9782070612383", publishedDate: "2005", description: "Sixième année à Poudlard." }; }); const openLibraryProvider: MetadataProvider = { id: "openlibrary", searchByMetadata: openLibrarySearch, lookup: openLibraryLookup }; const service = new MetadataService( database, localProvider as never, openLibraryProvider as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const localMetadata: BookMetadata = { title: "Harry Potter et le prince de sang mele", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }; const result = await service.enrichMetadata(localMetadata, "/library/Harry Potter et le prince de sang mele.epub", { remote: true }); expect(openLibrarySearch).toHaveBeenCalledOnce(); expect(openLibraryLookup).toHaveBeenCalledOnce(); expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M"); expect(result).toMatchObject({ title: "Harry Potter et le prince de sang mele", author: "J. K. Rowling", isbn: "9782070612383", isbn13: "9782070612383", description: "Sixième année à Poudlard." }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("backfills a missing ISBN lookup description from a high-confidence title search", async () => { const database = createDatabase(); const openLibraryProvider: MetadataProvider = { id: "openlibrary", lookup: async () => ({ title: "Harry Potter et la coupe de feu", author: "J. K. Rowling", isbn: "9782070624553", publisher: "Gallimard", publishedDate: "2016" }), searchByMetadata: async () => [ { title: "Harry Potter et la coupe de feu", author: "J. K. Rowling", isbn: "9782070619207", description: "Harry est invité à assister à la Coupe du monde de Quidditch." } ] }; const service = new MetadataService( database, providerStub("local") as never, openLibraryProvider as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Harry Potter et la coupe de feu", author: "J. K. Rowling", description: null, isbn: "9782070624553", language: null, publisher: null, publishedDate: null, coverPath: "/covers/local.jpg" }, "/library/Harry Potter et la coupe de feu.epub", { remote: true } ); expect(result).toMatchObject({ isbn: "9782070624553", description: "Harry est invité à assister à la Coupe du monde de Quidditch.", coverPath: "/covers/local.jpg" }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("uses provider priority as the tie-breaker for high-confidence title matches", async () => { const database = createDatabase(); database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run(); const openLibraryProvider: MetadataProvider = { id: "openlibrary", lookup: async () => null, searchByMetadata: async () => [ { title: "Daredevil", author: "Rosemary Carter", isbn: "9780373105601", publisher: "Harlequin Books", publishedDate: "1982" } ] }; const bnfProvider: MetadataProvider = { id: "bnf", lookup: async () => null, searchByMetadata: async () => [ { title: "Daredevil", author: "scénario, Roy Thomas, Gary Friedrich", isbn: "9782809476255", description: "Daredevil affronte l'Homme aux échasses.", language: "fre", publisher: "Panini comics", publishedDate: "2019" } ] }; const service = new MetadataService( database, providerStub("local") as never, openLibraryProvider as never, providerStub("googlebooks") as never, bnfProvider as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const localMetadata: BookMetadata = { title: "Daredevil", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }; const result = await service.enrichMetadata(localMetadata, "/library/Daredevil.cbz", { remote: true }); expect(result).toMatchObject({ title: "Daredevil", author: "Rosemary Carter", isbn: "9780373105601", isbn13: "9780373105601", description: "Daredevil affronte l'Homme aux échasses." }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("re-enriches from stored local hints instead of a previously failed remote ISBN", async () => { const database = createDatabase(); const now = database.now(); const library = database.db .insert(libraries) .values({ name: "Comics", path: "/library", enabled: true, createdAt: now, updatedAt: now }) .returning() .get(); const localMetadataJson = JSON.stringify({ title: "Daredevil", author: null, year: null, isbn: null, fileTitle: "Daredevil", raw: { title: "Daredevil", author: null, publishedDate: null, fileName: "Daredevil" } }); const book = database.db .insert(books) .values({ libraryId: library.id, title: "Daredevil", author: "Rosemary Carter", description: null, isbn: "9780373105601", isbn13: "9780373105601", identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }), localMetadataJson, language: null, publisher: "Harlequin Books", publishedDate: "1982", format: "cbz", filePath: "/library/Daredevil.cbz", coverPath: "/covers/daredevil.jpg", scanStatus: "succeeded", enrichmentStatus: "failed", fileSize: 42, fileMtime: now, createdAt: now, updatedAt: now }) .returning() .get(); const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async () => null); const bnfSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ { title: "Daredevil", author: "scénario, Roy Thomas, Gary Friedrich", isbn: "9782809476255", description: "Daredevil affronte l'Homme aux échasses.", publisher: "Panini comics", publishedDate: "2019" } ]); database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run(); const service = new MetadataService( database, providerStub("local") as never, { id: "openlibrary", lookup: openLibraryLookup, searchByMetadata: async () => [] } as never, providerStub("googlebooks") as never, { id: "bnf", lookup: async () => null, searchByMetadata: bnfSearch } as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichBook(book.id); expect(openLibraryLookup).not.toHaveBeenCalled(); expect(bnfSearch.mock.calls[0]?.[0].title).toBe("Daredevil"); expect(result).toMatchObject({ author: "Rosemary Carter", isbn: "9780373105601", isbn13: "9780373105601", publisher: "Harlequin Books", coverPath: "/covers/daredevil.jpg" }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("stores provider covers as local bytes and exposes field provenance with metadata status", async () => { const database = createDatabase(); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, headers: new Headers({ "content-type": "image/jpeg" }), arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer } as Response); const openLibraryProvider: MetadataProvider = { id: "openlibrary", lookup: async () => null, searchByMetadata: async () => [ { title: "Daredevil", author: "Roy Thomas", description: "Daredevil affronte une nouvelle menace.", isbn: "9782809476255", coverUrl: "https://covers.openlibrary.org/b/id/123-L.jpg" } ] }; const service = new MetadataService( database, providerStub("local") as never, openLibraryProvider as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Daredevil", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }, "/library/Daredevil.cbz", { remote: true } ); expect(fetchMock).toHaveBeenCalledWith("https://covers.openlibrary.org/b/id/123-L.jpg", expect.any(Object)); expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/); expect(result.metadataStatus).toBe("enriched"); expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({ title: "local", author: "openlibrary", description: "openlibrary", coverPath: "openlibrary" }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("retrofits a local cover for an existing book during metadata enrichment", async () => { const database = createDatabase(); mkdirSync(database.config.storageDir, { recursive: true }); const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ"); const zip = new AdmZip(); zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9])); zip.writeZip(filePath); const now = database.now(); const library = database.db .insert(libraries) .values({ name: "Comics", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now }) .returning() .get(); const book = database.db .insert(books) .values({ libraryId: library.id, title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, description: null, isbn: null, isbn13: null, identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }), localMetadataJson: JSON.stringify({ title: "Demon Slayer School Days T01 FRENCH", author: null, year: null, isbn: null, fileTitle: "Demon Slayer School Days T01 FRENCH", raw: { title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, publishedDate: null, fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz" } }), language: null, publisher: null, publishedDate: null, format: "cbz", filePath, coverPath: null, metadataStatus: "none", metadataProvenanceJson: JSON.stringify({ title: "local" }), scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 42, fileMtime: now, createdAt: now, updatedAt: now }) .returning() .get(); const service = new MetadataService( database, providerStub("local") as never, providerStub("openlibrary") as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichBook(book.id); expect(result.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/); expect(result.coverPath && existsSync(result.coverPath)).toBe(true); expect(result.metadataStatus).toBe("partial"); expect(JSON.parse(result.metadataProvenanceJson ?? "{}")).toMatchObject({ coverPath: "local" }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("drops sentinel publication dates from provider matches for real affected titles", async () => { const database = createDatabase(); const openLibraryProvider: MetadataProvider = { id: "openlibrary", lookup: async () => null, searchByMetadata: async () => [ { title: "Harry Potter et les reliques de la mort", author: "J. K. Rowling", publishedDate: "0101-01-01T00:00:00+00:00", description: "Septième année." } ] }; const service = new MetadataService( database, providerStub("local") as never, openLibraryProvider as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Harry Potter et les reliques de la mort", author: "J. K. Rowling", description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }, "/library/Harry Potter et les reliques de la mort.epub", { remote: true } ); expect(result.publishedDate).toBeNull(); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("does not overwrite an existing valid date with a provider sentinel", async () => { const database = createDatabase(); const now = database.now(); const library = database.db .insert(libraries) .values({ name: "Novels", path: "/library", enabled: true, createdAt: now, updatedAt: now }) .returning() .get(); const book = database.db .insert(books) .values({ libraryId: library.id, title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", description: null, isbn: null, isbn13: null, identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }), localMetadataJson: JSON.stringify({ title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", year: null, isbn: null, fileTitle: "Lord of the Mysteries", raw: { title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", publishedDate: null, fileName: "Lord of the Mysteries" } }), language: null, publisher: null, publishedDate: "2018", format: "epub", filePath: "/library/Lord of the Mysteries.epub", coverPath: null, metadataStatus: "partial", metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }), scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 42, fileMtime: now, createdAt: now, updatedAt: now }) .returning() .get(); const openLibraryProvider: MetadataProvider = { id: "openlibrary", lookup: async () => null, searchByMetadata: async () => [ { title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", publishedDate: "0101-01-01T00:00:00+00:00", description: "A mysterious sequence begins." } ] }; const service = new MetadataService( database, providerStub("local") as never, openLibraryProvider as never, providerStub("googlebooks") as never, providerStub("bnf") as never, providerStub("mangadex") as never, providerStub("comicvine") as never ); const result = await service.enrichBook(book.id); expect(result.publishedDate).toBe("2018"); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("records MangaDex provenance and stores its cover locally", async () => { const database = createDatabase(); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, headers: new Headers({ "content-type": "image/jpeg" }), arrayBuffer: async () => new Uint8Array([9, 8, 7]).buffer } as Response); const mangaDexProvider: MetadataProvider = { id: "mangadex", lookup: async () => null, searchByMetadata: async () => [ { title: "Solo Leveling", author: "Chugong", description: "A hunter levels up alone.", publishedDate: "2018", coverUrl: "https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg" } ] }; const service = new MetadataService( database, providerStub("local") as never, providerStub("openlibrary") as never, providerStub("googlebooks") as never, providerStub("bnf") as never, mangaDexProvider as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Solo Leveling T03", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }, "/library/Solo Leveling T03.cbz", { remote: true } ); expect(fetchMock).toHaveBeenCalledWith("https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg", expect.any(Object)); expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({ author: "mangadex", description: "mangadex", publishedDate: "mangadex", coverPath: "mangadex" }); expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("scores MangaDex matches against the cleaned series title instead of the noisy archive title", async () => { const database = createDatabase(); const mangaDexProvider: MetadataProvider = { id: "mangadex", lookup: async () => null, searchByMetadata: async () => [ { title: "Dragon Ball SD", author: "Naho Ooishi", description: "A super-deformed Dragon Ball spin-off.", publishedDate: "2010", sourceId: "dragon-ball-sd" } ] }; const service = new MetadataService( database, providerStub("local") as never, providerStub("openlibrary") as never, providerStub("googlebooks") as never, providerStub("bnf") as never, mangaDexProvider as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }, "/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz", { remote: true } ); expect(result).toMatchObject({ title: "Dragon Ball SD", author: "Naho Ooishi", description: "A super-deformed Dragon Ball spin-off.", publishedDate: "2010" }); expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({ title: "local", author: "mangadex", description: "mangadex" }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("accepts MangaDex alias matches through the provider score title", async () => { const database = createDatabase(); const mangaDexProvider: MetadataProvider = { id: "mangadex", lookup: async () => null, searchByMetadata: async () => [ { title: "Demon Slayer: Kimetsu Academy", scoreTitle: "Demon Slayer Kimetsu Academy", author: "Natsuki Hokami", description: "School spin-off.", publishedDate: "2021", sourceId: "kimetsu-academy" } ] }; const service = new MetadataService( database, providerStub("local") as never, providerStub("openlibrary") as never, providerStub("googlebooks") as never, providerStub("bnf") as never, mangaDexProvider as never, providerStub("comicvine") as never ); const result = await service.enrichMetadata( { title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, description: null, isbn: null, language: null, publisher: null, publishedDate: null, coverPath: null }, "/library/Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz", { remote: true } ); expect(result).toMatchObject({ title: "Demon Slayer School Days", author: "Natsuki Hokami", description: "School spin-off.", publishedDate: "2021" }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("re-enriches existing manga with the cleaned series title on the live book path", async () => { const database = createDatabase(); mkdirSync(database.config.storageDir, { recursive: true }); const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz"); const zip = new AdmZip(); zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9])); zip.writeZip(filePath); const now = database.now(); const library = database.db .insert(libraries) .values({ name: "Manga", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now }) .returning() .get(); const book = database.db .insert(books) .values({ libraryId: library.id, title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, description: null, isbn: null, isbn13: null, identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }), localMetadataJson: JSON.stringify({ title: "Demon Slayer School Days T01 FRENCH", author: null, year: null, isbn: null, fileTitle: "Demon Slayer School Days T01 FRENCH", raw: { title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, publishedDate: null, fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz" } }), language: null, publisher: null, publishedDate: null, format: "cbz", filePath, coverPath: null, metadataStatus: "none", metadataProvenanceJson: JSON.stringify({ title: "local" }), scanStatus: "succeeded", enrichmentStatus: "succeeded", fileSize: 42, fileMtime: now, createdAt: now, updatedAt: now }) .returning() .get(); const mangaDexSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ { title: "Demon Slayer School Days", author: "Natsuki Hokami", description: "School spin-off.", publishedDate: "2021", sourceId: "demon-slayer-school-days" } ]); const service = new MetadataService( database, providerStub("local") as never, providerStub("openlibrary") as never, providerStub("googlebooks") as never, providerStub("bnf") as never, { id: "mangadex", lookup: async () => null, searchByMetadata: mangaDexSearch } as never, providerStub("comicvine") as never ); const result = await service.enrichBook(book.id); expect(mangaDexSearch.mock.calls[0]?.[0].title).toBe("Demon Slayer School Days"); expect(result).toMatchObject({ title: "Demon Slayer School Days", author: "Natsuki Hokami", description: "School spin-off.", publishedDate: "2021" }); database.onModuleDestroy(); }); }); function createDatabase(): DatabaseService { const dir = mkdtempSync(join(tmpdir(), "readabook-metadata-service-")); tempDirs.push(dir); process.env.DATABASE_PATH = join(dir, "readabook.sqlite"); process.env.STORAGE_DIR = join(dir, "storage"); return new DatabaseService(); } function providerStub(id: MetadataProvider["id"]): MetadataProvider { return { id, lookup: async () => null, searchByMetadata: async () => [] }; } function canLoadBetterSqlite(): boolean { try { const database = createDatabase(); database.onModuleDestroy(); return true; } catch { return false; } }