import Database from "better-sqlite3"; 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.service.js"; import { books, libraries, series } from "./schema.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("database migrations", () => { it.runIf(canLoadBetterSqlite())("adds metadata columns to an existing comic-capable books table before creating dependent indexes", () => { const dir = mkdtempSync(join(tmpdir(), "readabook-migration-")); tempDirs.push(dir); const databasePath = join(dir, "readabook.sqlite"); const storageDir = join(dir, "storage"); const legacy = new Database(databasePath); const now = new Date().toISOString(); legacy.exec(` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE libraries ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, path TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, title TEXT NOT NULL, author TEXT, description TEXT, isbn TEXT, language TEXT, publisher TEXT, published_date TEXT, format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')), file_path TEXT NOT NULL UNIQUE, cover_path TEXT, file_size INTEGER NOT NULL, file_mtime TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, locator TEXT NOT NULL, percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, book_id) ); CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')), detail TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); INSERT INTO libraries (id, name, path, enabled, created_at, updated_at) VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}'); INSERT INTO books ( library_id, title, author, description, isbn, language, publisher, published_date, format, file_path, cover_path, file_size, file_mtime, created_at, updated_at ) VALUES (1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'), (1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'), (1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}'); `); legacy.close(); process.env.DATABASE_PATH = databasePath; process.env.STORAGE_DIR = storageDir; const database = new DatabaseService(); const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>; const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>; const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>; const seriesRows = database.sqlite .prepare( ` SELECT books.title, books.volume_number, books.volume_label, series.title AS series_title, series.normalized_title FROM books JOIN series ON series.id = books.series_id ORDER BY books.title ` ) .all() as Array<{ title: string; volume_number: number | null; volume_label: string | null; series_title: string; normalized_title: string; }>; const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as | { id: number; isbn_priority_enabled: number } | undefined; expect(bookColumns.map((column) => column.name)).toContain("isbn13"); expect(bookColumns.map((column) => column.name)).toContain("identifiers_json"); expect(bookColumns.map((column) => column.name)).toContain("scan_status"); expect(bookColumns.map((column) => column.name)).toContain("enrichment_status"); expect(bookColumns.map((column) => column.name)).toContain("series_id"); expect(bookColumns.map((column) => column.name)).toContain("volume_number"); expect(bookColumns.map((column) => column.name)).toContain("volume_label"); expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx"); expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx"); expect(seriesRows).toEqual([ { title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", volume_number: 1, volume_label: "T01", series_title: "Dragon Ball SD", normalized_title: "dragon ball sd" }, { title: "Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz", volume_number: 1, volume_label: "T01", series_title: "Eyeshield 21", normalized_title: "eyeshield 21" }, { title: "Solo Leveling T03", volume_number: 3, volume_label: "T03", series_title: "Solo Leveling", normalized_title: "solo leveling" } ]); expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]); expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 }); database.onModuleDestroy(); }); it.runIf(canLoadBetterSqlite())("backfills missing Daredevil volume numbers when series already exists", () => { const dir = mkdtempSync(join(tmpdir(), "readabook-series-backfill-")); tempDirs.push(dir); process.env.DATABASE_PATH = join(dir, "readabook.sqlite"); process.env.STORAGE_DIR = join(dir, "storage"); const first = new DatabaseService(); const now = first.now(); const library = first.db .insert(libraries) .values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now }) .returning() .get(); const daredevil = first.db .insert(series) .values({ title: "Daredevil", normalizedTitle: "daredevil", description: null, publisher: null, createdAt: now, updatedAt: now }) .returning() .get(); first.db .insert(books) .values({ libraryId: library.id, seriesId: daredevil.id, 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 - 001[Sebmov].cbz", coverPath: null, metadataStatus: "none", metadataProvenanceJson: JSON.stringify({ title: "local" }), scanStatus: "succeeded", enrichmentStatus: "idle", fileSize: 42, fileMtime: now, createdAt: now, updatedAt: now }) .run(); first.onModuleDestroy(); const second = new DatabaseService(); const row = second.sqlite.prepare("SELECT volume_number, volume_label FROM books WHERE file_path = ?").get( "/library/Daredevil - 001[Sebmov].cbz" ) as { volume_number: number | null; volume_label: string | null }; expect(row).toEqual({ volume_number: 1, volume_label: "001" }); second.onModuleDestroy(); }); }); function canLoadBetterSqlite(): boolean { try { new Database(":memory:").close(); return true; } catch { return false; } }