Files
ReadaBook/apps/api/src/database/database.service.test.ts
Git Agent 93bb40a8cd feat(api,web): modélisation série + volume et parcours de série
- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres,
  extraction souple des numéros (T03, 003, etc.) via extract-series-volume
- Contrôleur et page de série, badge volume sur les cartes de livre,
  styles associés (fond/clarté série, ajustements globaux)
- Statuts de scan/enrichissement et provenance des métadonnées en base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 09:11:10 +02:00

244 lines
8.9 KiB
TypeScript

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;
}
}