mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
test(metadata): add initial E2E coverage for metadata write flow
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
name: Metadata Write E2E
|
||||||
|
run-name: ${{ format('Metadata Write E2E - {0}', github.ref_name) }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "server/src/modules/book/**"
|
||||||
|
- "server/src/modules/library/**"
|
||||||
|
- "server/src/modules/file-write/**"
|
||||||
|
- "server/src/modules/metadata/**"
|
||||||
|
- "server/src/modules/app-settings/**"
|
||||||
|
- "server/test/metadata-write.e2e-spec.ts"
|
||||||
|
- "server/test/e2e/metadata-write/**"
|
||||||
|
- "server/src/db/schema/**"
|
||||||
|
- "packages/types/src/file-write.ts"
|
||||||
|
- "scripts/test/**"
|
||||||
|
- "scripts/db/**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "server/src/modules/book/**"
|
||||||
|
- "server/src/modules/library/**"
|
||||||
|
- "server/src/modules/file-write/**"
|
||||||
|
- "server/src/modules/metadata/**"
|
||||||
|
- "server/src/modules/app-settings/**"
|
||||||
|
- "server/test/metadata-write.e2e-spec.ts"
|
||||||
|
- "server/test/e2e/metadata-write/**"
|
||||||
|
- "server/src/db/schema/**"
|
||||||
|
- "packages/types/src/file-write.ts"
|
||||||
|
- "scripts/test/**"
|
||||||
|
- "scripts/db/**"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
metadata-write-e2e:
|
||||||
|
uses: ./.github/workflows/e2e-runner.yml
|
||||||
|
with:
|
||||||
|
suite-id: metadata-write
|
||||||
|
check-name: Server Metadata Write E2E
|
||||||
|
artifact-name: metadata-write-e2e-report
|
||||||
|
job-timeout-minutes: 60
|
||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
|
E2E_DATABASE_URL="${E2E_DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}"
|
||||||
|
|
||||||
echo "Preparing dedicated e2e database..."
|
echo "Preparing dedicated e2e database..."
|
||||||
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server db:prepare:e2e
|
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server db:prepare:e2e
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export const E2E_SUITES = Object.freeze({
|
|||||||
prepareDedicatedDatabase: true,
|
prepareDedicatedDatabase: true,
|
||||||
useDedicatedDatabase: true,
|
useDedicatedDatabase: true,
|
||||||
},
|
},
|
||||||
|
"metadata-write": {
|
||||||
|
id: "metadata-write",
|
||||||
|
description: "Metadata write operations suite",
|
||||||
|
vitestTarget: "test/metadata-write.e2e-spec.ts",
|
||||||
|
junitOutput: `${TEST_RESULTS_DIR}/metadata-write-e2e-junit.xml`,
|
||||||
|
prepareDedicatedDatabase: true,
|
||||||
|
useDedicatedDatabase: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function listE2ESuites() {
|
export function listE2ESuites() {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async function main() {
|
|||||||
|
|
||||||
mkdirSync(resultsDir, { recursive: true });
|
mkdirSync(resultsDir, { recursive: true });
|
||||||
|
|
||||||
const e2eDatabaseUrl = process.env.E2E_DATABASE_URL ?? process.env.DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL;
|
const e2eDatabaseUrl = process.env.E2E_DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL;
|
||||||
if (!isCiEnvironment() && suites.some((suite) => suite.prepareDedicatedDatabase)) {
|
if (!isCiEnvironment() && suites.some((suite) => suite.prepareDedicatedDatabase)) {
|
||||||
console.log("Starting local PostgreSQL (dev compose)...");
|
console.log("Starting local PostgreSQL (dev compose)...");
|
||||||
await runCommand("pnpm", ["run", "db:up"]);
|
await runCommand("pnpm", ["run", "db:up"]);
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ describe('Cb7FormatWriter', () => {
|
|||||||
const result = await writer.write('/book.cb7', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false });
|
const result = await writer.write('/book.cb7', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false });
|
||||||
|
|
||||||
expect(mockBuildComicInfoXml).toHaveBeenCalledWith('<ComicInfo><Title>Old</Title></ComicInfo>', { title: 'Dune' }, new Set(['title']));
|
expect(mockBuildComicInfoXml).toHaveBeenCalledWith('<ComicInfo><Title>Old</Title></ComicInfo>', { title: 'Dune' }, new Set(['title']));
|
||||||
expect(sz.callMain).toHaveBeenCalledWith(['d', '/cbx-arc-abcdef123', 'ComicInfo.xml', '-y']);
|
expect(sz.callMain).toHaveBeenCalledWith(['d', '/cbx-arc-abcdef123.cb7', 'ComicInfo.xml', '-y']);
|
||||||
expect(sz.callMain).toHaveBeenCalledWith(['a', '/cbx-arc-abcdef123', '/ComicInfo.xml']);
|
expect(sz.callMain).toHaveBeenCalledWith(['a', '/cbx-arc-abcdef123.cb7', '/ComicInfo.xml']);
|
||||||
expect(mockWriteFile).toHaveBeenCalledWith('/.cbx-write-abcdef123', Buffer.from('modified-archive-bytes'));
|
expect(mockWriteFile).toHaveBeenCalledWith('/.cbx-write-abcdef123', Buffer.from('modified-archive-bytes'));
|
||||||
expect(mockRename).toHaveBeenCalledWith('/.cbx-write-abcdef123', '/book.cb7');
|
expect(mockRename).toHaveBeenCalledWith('/.cbx-write-abcdef123', '/book.cb7');
|
||||||
expect(result.status).toBe('success');
|
expect(result.status).toBe('success');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { buildComicInfoXml } from './comic-info-builder';
|
|||||||
// The XML path is shared but safe: it is only used within synchronous blocks and
|
// The XML path is shared but safe: it is only used within synchronous blocks and
|
||||||
// is always written before it is added to the archive, with no await in between.
|
// is always written before it is added to the archive, with no await in between.
|
||||||
const VFS_XML_PATH = '/ComicInfo.xml';
|
const VFS_XML_PATH = '/ComicInfo.xml';
|
||||||
|
const ENOENT_ERRNO = 44;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class Cb7FormatWriter implements FormatWriter {
|
export class Cb7FormatWriter implements FormatWriter {
|
||||||
@@ -33,7 +34,7 @@ export class Cb7FormatWriter implements FormatWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uid = randomUUID().replace(/-/g, '');
|
const uid = randomUUID().replace(/-/g, '');
|
||||||
const vfsArchivePath = `/cbx-arc-${uid}`;
|
const vfsArchivePath = `/cbx-arc-${uid}.cb7`;
|
||||||
const vfsExtractDir = `/cbx-ext-${uid}`;
|
const vfsExtractDir = `/cbx-ext-${uid}`;
|
||||||
|
|
||||||
const sz = await getSevenZip();
|
const sz = await getSevenZip();
|
||||||
@@ -63,14 +64,6 @@ export class Cb7FormatWriter implements FormatWriter {
|
|||||||
const xml = buildComicInfoXml(existingXml, payload, fieldMask);
|
const xml = buildComicInfoXml(existingXml, payload, fieldMask);
|
||||||
const xmlBytes = Buffer.from(xml, 'utf-8');
|
const xmlBytes = Buffer.from(xml, 'utf-8');
|
||||||
|
|
||||||
try {
|
|
||||||
sz.FS.unlink(VFS_XML_PATH);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingVfsPathError(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Not present - expected on first write
|
|
||||||
}
|
|
||||||
const fd2 = sz.FS.open(VFS_XML_PATH, 'w+');
|
const fd2 = sz.FS.open(VFS_XML_PATH, 'w+');
|
||||||
sz.FS.write(fd2, xmlBytes, 0, xmlBytes.length);
|
sz.FS.write(fd2, xmlBytes, 0, xmlBytes.length);
|
||||||
sz.FS.close(fd2);
|
sz.FS.close(fd2);
|
||||||
@@ -99,6 +92,7 @@ export class Cb7FormatWriter implements FormatWriter {
|
|||||||
function cleanupVfs(vfs: SevenZipFS, archivePath: string, extractDir: string): void {
|
function cleanupVfs(vfs: SevenZipFS, archivePath: string, extractDir: string): void {
|
||||||
unlinkIfExists(vfs, VFS_XML_PATH);
|
unlinkIfExists(vfs, VFS_XML_PATH);
|
||||||
unlinkIfExists(vfs, archivePath);
|
unlinkIfExists(vfs, archivePath);
|
||||||
|
unlinkIfExists(vfs, `${archivePath}.7z`);
|
||||||
|
|
||||||
const files = readDirIfExists(vfs, extractDir);
|
const files = readDirIfExists(vfs, extractDir);
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
@@ -144,6 +138,9 @@ function isMissingComicInfoError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMissingVfsPathError(error: unknown): boolean {
|
function isMissingVfsPathError(error: unknown): boolean {
|
||||||
|
if (typeof error === 'object' && error !== null && 'errno' in error && (error as { errno?: unknown }).errno === ENOENT_ERRNO) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const message = normalizeErrorMessage(error);
|
const message = normalizeErrorMessage(error);
|
||||||
return (
|
return (
|
||||||
message.includes('no such file') || message.includes('not found') || message.includes('does not exist') || message.includes('path not found')
|
message.includes('no such file') || message.includes('not found') || message.includes('does not exist') || message.includes('path not found')
|
||||||
|
|||||||
@@ -116,21 +116,26 @@ export class LibraryController {
|
|||||||
const dryRun = dryRunParam === 'true';
|
const dryRun = dryRunParam === 'true';
|
||||||
|
|
||||||
let disconnected = false;
|
let disconnected = false;
|
||||||
|
let streamStarted = false;
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
disconnected = true;
|
disconnected = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
reply.raw.writeHead(200, {
|
const ensureStreamStarted = () => {
|
||||||
'Content-Type': 'text/event-stream',
|
if (streamStarted) return;
|
||||||
'Cache-Control': 'no-cache',
|
streamStarted = true;
|
||||||
Connection: 'keep-alive',
|
reply.raw.writeHead(200, {
|
||||||
});
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
reply.raw.on('close', handleDisconnect);
|
Connection: 'keep-alive',
|
||||||
reply.raw.on('aborted', handleDisconnect);
|
});
|
||||||
|
reply.raw.on('close', handleDisconnect);
|
||||||
|
reply.raw.on('aborted', handleDisconnect);
|
||||||
|
};
|
||||||
|
|
||||||
const writeEvent = (event: LibraryFileSyncProgressEvent) => {
|
const writeEvent = (event: LibraryFileSyncProgressEvent) => {
|
||||||
if (disconnected || reply.raw.writableEnded || reply.raw.destroyed) return;
|
if (disconnected || reply.raw.writableEnded || reply.raw.destroyed) return;
|
||||||
|
ensureStreamStarted();
|
||||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,12 +154,20 @@ export class LibraryController {
|
|||||||
};
|
};
|
||||||
writeEvent(doneEvent);
|
writeEvent(doneEvent);
|
||||||
|
|
||||||
if (!reply.raw.writableEnded && !reply.raw.destroyed) {
|
if (streamStarted && !reply.raw.writableEnded && !reply.raw.destroyed) {
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (streamStarted && !reply.raw.writableEnded && !reply.raw.destroyed) {
|
||||||
|
reply.raw.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
reply.raw.off('close', handleDisconnect);
|
if (streamStarted) {
|
||||||
reply.raw.off('aborted', handleDisconnect);
|
reply.raw.off('close', handleDisconnect);
|
||||||
|
reply.raw.off('aborted', handleDisconnect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function escapeIdentifier(identifier: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTargetDatabaseUrl(): URL {
|
function resolveTargetDatabaseUrl(): URL {
|
||||||
const connectionString = process.env.DATABASE_URL ?? process.env.E2E_DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL;
|
const connectionString = process.env.E2E_DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL;
|
||||||
return new URL(connectionString);
|
return new URL(connectionString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import archiver from 'archiver';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { createWriteStream } from 'fs';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { getSevenZip } from '../../../src/common/sevenzip';
|
||||||
|
|
||||||
|
export interface MetadataWriteFixtureRoot {
|
||||||
|
rootPath: string;
|
||||||
|
booksPath: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZipEntryInput {
|
||||||
|
path: string;
|
||||||
|
content: string | Buffer;
|
||||||
|
store?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EpubFixtureInput {
|
||||||
|
title?: string;
|
||||||
|
language?: string;
|
||||||
|
uid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComicFixtureInput {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
publisher?: string;
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelativePath(path: string): void {
|
||||||
|
if (path.startsWith('/')) {
|
||||||
|
throw new Error(`Fixture paths must be relative. Received "${path}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMetadataWriteFixtureRoot(prefix = 'metadata-write-e2e-'): Promise<MetadataWriteFixtureRoot> {
|
||||||
|
const rootPath = await mkdtemp(join(tmpdir(), prefix));
|
||||||
|
const booksPath = join(rootPath, 'books');
|
||||||
|
await mkdir(booksPath, { recursive: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootPath,
|
||||||
|
booksPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(rootPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeFixtureFile(rootPath: string, relativePath: string, content: string | Buffer): Promise<string> {
|
||||||
|
assertRelativePath(relativePath);
|
||||||
|
|
||||||
|
const absolutePath = join(rootPath, relativePath);
|
||||||
|
await mkdir(dirname(absolutePath), { recursive: true });
|
||||||
|
await writeFile(absolutePath, content);
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPdfFixture(rootPath: string, relativePath: string, title = 'Fixture PDF Title'): Promise<string> {
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
const page = pdf.addPage([360, 240]);
|
||||||
|
page.drawText('metadata write fixture');
|
||||||
|
pdf.setTitle(title);
|
||||||
|
const bytes = await pdf.save();
|
||||||
|
return writeFixtureFile(rootPath, relativePath, Buffer.from(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEpubFixture(rootPath: string, relativePath: string, input: EpubFixtureInput = {}): Promise<string> {
|
||||||
|
assertRelativePath(relativePath);
|
||||||
|
const absolutePath = join(rootPath, relativePath);
|
||||||
|
await mkdir(dirname(absolutePath), { recursive: true });
|
||||||
|
|
||||||
|
const uid = input.uid ?? `urn:uuid:${randomUUID()}`;
|
||||||
|
const title = input.title ?? 'Fixture EPUB Title';
|
||||||
|
const language = input.language ?? 'en';
|
||||||
|
|
||||||
|
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" />
|
||||||
|
</rootfiles>
|
||||||
|
</container>`;
|
||||||
|
|
||||||
|
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="uid">${escapeXml(uid)}</dc:identifier>
|
||||||
|
<dc:title>${escapeXml(title)}</dc:title>
|
||||||
|
<dc:language>${escapeXml(language)}</dc:language>
|
||||||
|
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
<itemref idref="chapter" />
|
||||||
|
</spine>
|
||||||
|
</package>`;
|
||||||
|
|
||||||
|
const chapterXml = `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeXml(title)}</title></head><body><p>fixture</p></body></html>`;
|
||||||
|
|
||||||
|
await writeZipArchive(absolutePath, [
|
||||||
|
{ path: 'mimetype', content: 'application/epub+zip', store: true },
|
||||||
|
{ path: 'META-INF/container.xml', content: containerXml },
|
||||||
|
{ path: 'OPS/content.opf', content: opfXml },
|
||||||
|
{ path: 'OPS/chapter.xhtml', content: chapterXml },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCbzFixture(rootPath: string, relativePath: string, input: ComicFixtureInput = {}): Promise<string> {
|
||||||
|
assertRelativePath(relativePath);
|
||||||
|
const absolutePath = join(rootPath, relativePath);
|
||||||
|
await mkdir(dirname(absolutePath), { recursive: true });
|
||||||
|
|
||||||
|
const xml = buildComicInfoXml(input);
|
||||||
|
await writeZipArchive(absolutePath, [
|
||||||
|
{ path: 'ComicInfo.xml', content: xml },
|
||||||
|
{ path: 'page-001.txt', content: 'fixture page' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCb7Fixture(rootPath: string, relativePath: string, input: ComicFixtureInput = {}): Promise<string> {
|
||||||
|
const xml = buildComicInfoXml(input);
|
||||||
|
const archiveBytes = await buildCb7Archive(xml);
|
||||||
|
return writeFixtureFile(rootPath, relativePath, archiveBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeZipArchive(absolutePath: string, entries: ZipEntryInput[]): Promise<void> {
|
||||||
|
const output = createWriteStream(absolutePath);
|
||||||
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
output.on('close', resolve);
|
||||||
|
output.on('error', reject);
|
||||||
|
archive.on('error', reject);
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
archive.append(typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content, {
|
||||||
|
name: entry.path,
|
||||||
|
store: entry.store ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCb7Archive(comicInfoXml: string): Promise<Buffer> {
|
||||||
|
const sevenZip = await getSevenZip();
|
||||||
|
const archivePath = `/metadata-write-${randomUUID().replaceAll('-', '')}.cb7`;
|
||||||
|
const xmlPath = '/ComicInfo.xml';
|
||||||
|
const xmlBytes = Buffer.from(comicInfoXml, 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
safeVfsUnlink(sevenZip.FS, xmlPath);
|
||||||
|
safeVfsUnlink(sevenZip.FS, archivePath);
|
||||||
|
|
||||||
|
const fd = sevenZip.FS.open(xmlPath, 'w+');
|
||||||
|
sevenZip.FS.write(fd, xmlBytes, 0, xmlBytes.length);
|
||||||
|
sevenZip.FS.close(fd);
|
||||||
|
|
||||||
|
sevenZip.callMain(['a', archivePath, xmlPath]);
|
||||||
|
return Buffer.from(sevenZip.FS.readFile(archivePath));
|
||||||
|
} finally {
|
||||||
|
safeVfsUnlink(sevenZip.FS, xmlPath);
|
||||||
|
safeVfsUnlink(sevenZip.FS, archivePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeVfsUnlink(vfs: { unlink: (path: string) => void }, path: string): void {
|
||||||
|
try {
|
||||||
|
vfs.unlink(path);
|
||||||
|
} catch {
|
||||||
|
// no-op: path may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildComicInfoXml(input: ComicFixtureInput): string {
|
||||||
|
const title = escapeXml(input.title ?? 'Fixture Comic Title');
|
||||||
|
const author = escapeXml(input.author ?? 'Fixture Author');
|
||||||
|
const publisher = escapeXml(input.publisher ?? 'Fixture Publisher');
|
||||||
|
const year = input.year ?? 2024;
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<Title>${title}</Title>
|
||||||
|
<Writer>${author}</Writer>
|
||||||
|
<Publisher>${publisher}</Publisher>
|
||||||
|
<Year>${year}</Year>
|
||||||
|
</ComicInfo>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { hash } from 'bcryptjs';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { mkdir } from 'fs/promises';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { DEFAULT_FILE_WRITE_SETTINGS, DEFAULT_FORMAT_PRIORITY, type GlobalFileWriteSettings, type Permission } from '@projectx/types';
|
||||||
|
|
||||||
|
import { AppModule } from '../../../src/app.module';
|
||||||
|
import { DB } from '../../../src/db';
|
||||||
|
import * as schema from '../../../src/db/schema';
|
||||||
|
import { appSettings, bookFiles, books, fileWriteLog, libraries, libraryFolders, scanJobs } from '../../../src/db/schema';
|
||||||
|
import { createMetadataWriteFixtureRoot, type MetadataWriteFixtureRoot } from './metadata-write-fixture-builder';
|
||||||
|
|
||||||
|
type Db = NodePgDatabase<typeof schema>;
|
||||||
|
|
||||||
|
const ADMIN_SETUP_DTO = {
|
||||||
|
username: 'metadata-write-e2e-admin',
|
||||||
|
name: 'Metadata Write E2E Admin',
|
||||||
|
email: 'metadata-write-e2e-admin@example.com',
|
||||||
|
password: 'MetadataWriteAdmin123',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EnvSnapshot {
|
||||||
|
booksPath: string | undefined;
|
||||||
|
fileWriteDebounceMs: string | undefined;
|
||||||
|
fileWriteMaxConcurrentWrites: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataWriteE2EContext {
|
||||||
|
app: NestFastifyApplication;
|
||||||
|
db: Db;
|
||||||
|
adminToken: string;
|
||||||
|
fixture: MetadataWriteFixtureRoot;
|
||||||
|
envSnapshot: EnvSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedLibrary {
|
||||||
|
libraryId: number;
|
||||||
|
libraryFolderId: number;
|
||||||
|
folderPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestUserSession {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocatedBookFile {
|
||||||
|
bookId: number;
|
||||||
|
bookFileId: number;
|
||||||
|
absolutePath: string;
|
||||||
|
relPath: string | null;
|
||||||
|
format: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeader(token: string): Record<string, string> {
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMetadataWriteE2EContext(): Promise<MetadataWriteE2EContext> {
|
||||||
|
const fixture = await createMetadataWriteFixtureRoot();
|
||||||
|
const envSnapshot: EnvSnapshot = {
|
||||||
|
booksPath: process.env.BOOKS_PATH,
|
||||||
|
fileWriteDebounceMs: process.env.FILE_WRITE_DEBOUNCE_MS,
|
||||||
|
fileWriteMaxConcurrentWrites: process.env.FILE_WRITE_MAX_CONCURRENT_WRITES,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.BOOKS_PATH = fixture.booksPath;
|
||||||
|
process.env.FILE_WRITE_DEBOUNCE_MS = '100';
|
||||||
|
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '1';
|
||||||
|
|
||||||
|
const moduleFixture = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
await app.register(fastifyCookie as never);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
const db = app.get<Db>(DB);
|
||||||
|
const adminToken = await getAdminToken(app, db);
|
||||||
|
|
||||||
|
await setFileWriteSettings(db, {
|
||||||
|
enabled: true,
|
||||||
|
writeCover: false,
|
||||||
|
epub: { enabled: true, maxFileSizeBytes: DEFAULT_FILE_WRITE_SETTINGS.epub.maxFileSizeBytes },
|
||||||
|
pdf: { enabled: true, maxFileSizeBytes: DEFAULT_FILE_WRITE_SETTINGS.pdf.maxFileSizeBytes },
|
||||||
|
cbx: { enabled: true, maxFileSizeBytes: DEFAULT_FILE_WRITE_SETTINGS.cbx.maxFileSizeBytes, formats: ['cbz', 'cb7'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
db,
|
||||||
|
adminToken,
|
||||||
|
fixture,
|
||||||
|
envSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeMetadataWriteE2EContext(ctx: MetadataWriteE2EContext): Promise<void> {
|
||||||
|
await ctx.app.close();
|
||||||
|
await ctx.fixture.cleanup();
|
||||||
|
restoreEnv(ctx.envSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLibraryWithFolder(
|
||||||
|
ctx: MetadataWriteE2EContext,
|
||||||
|
options: {
|
||||||
|
mode?: 'book_per_file' | 'book_per_folder';
|
||||||
|
allowedFormats?: string[];
|
||||||
|
name?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<CreatedLibrary> {
|
||||||
|
const folderPath = `${ctx.fixture.booksPath}/library-${randomUUID()}`;
|
||||||
|
await mkdir(folderPath, { recursive: true });
|
||||||
|
|
||||||
|
const [library] = await ctx.db
|
||||||
|
.insert(libraries)
|
||||||
|
.values({
|
||||||
|
name: options.name ?? `metadata-write-${randomUUID()}`,
|
||||||
|
watch: false,
|
||||||
|
organizationMode: options.mode ?? 'book_per_file',
|
||||||
|
allowedFormats: options.allowedFormats ?? [],
|
||||||
|
excludePatterns: [],
|
||||||
|
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
|
||||||
|
})
|
||||||
|
.returning({ id: libraries.id });
|
||||||
|
|
||||||
|
const [libraryFolder] = await ctx.db
|
||||||
|
.insert(libraryFolders)
|
||||||
|
.values({
|
||||||
|
libraryId: library.id,
|
||||||
|
path: folderPath,
|
||||||
|
})
|
||||||
|
.returning({ id: libraryFolders.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
folderPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerAndWaitForLibraryScan(
|
||||||
|
ctx: MetadataWriteE2EContext,
|
||||||
|
libraryId: number,
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
): Promise<typeof scanJobs.$inferSelect> {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
|
||||||
|
headers: authHeader(ctx.adminToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode !== 202) {
|
||||||
|
throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = response.json() as { jobId?: number };
|
||||||
|
if (!body.jobId) {
|
||||||
|
throw new Error(`Scan endpoint returned no jobId: ${response.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitForScanCompletion(ctx.db, body.jobId, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForScanCompletion(db: Db, jobId: number, timeoutMs = 30_000, pollMs = 100): Promise<typeof scanJobs.$inferSelect> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
|
||||||
|
if (!job) throw new Error(`Scan job ${jobId} not found`);
|
||||||
|
|
||||||
|
if (job.status === 'completed') return job;
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
throw new Error(`Scan job ${jobId} failed: ${job.errorMessage ?? 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for scan job ${jobId} to complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function locateBookFileByRelPath(ctx: MetadataWriteE2EContext, libraryId: number, relPath: string): Promise<LocatedBookFile> {
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select({
|
||||||
|
bookId: books.id,
|
||||||
|
bookFileId: bookFiles.id,
|
||||||
|
absolutePath: bookFiles.absolutePath,
|
||||||
|
relPath: bookFiles.relPath,
|
||||||
|
format: bookFiles.format,
|
||||||
|
})
|
||||||
|
.from(bookFiles)
|
||||||
|
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||||
|
.where(and(eq(books.libraryId, libraryId), eq(bookFiles.relPath, relPath), eq(books.status, 'present')))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(`No present book file found for relPath "${relPath}" in library ${libraryId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFileWriteSettings(db: Db, patch: Partial<GlobalFileWriteSettings>): Promise<GlobalFileWriteSettings> {
|
||||||
|
const currentRow = await db.query.appSettings.findFirst({
|
||||||
|
where: eq(appSettings.key, 'file_write_settings'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = parseFileWriteSettings(currentRow?.value);
|
||||||
|
const merged = mergeFileWriteSettings(current, patch);
|
||||||
|
const value = JSON.stringify(merged);
|
||||||
|
|
||||||
|
await db.insert(appSettings).values({ key: 'file_write_settings', value }).onConflictDoUpdate({
|
||||||
|
target: appSettings.key,
|
||||||
|
set: { value },
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestWriteLogEntry(db: Db, bookId: number, triggeredBy?: 'auto' | 'sync') {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(fileWriteLog)
|
||||||
|
.where(triggeredBy ? and(eq(fileWriteLog.bookId, bookId), eq(fileWriteLog.triggeredBy, triggeredBy)) : eq(fileWriteLog.bookId, bookId))
|
||||||
|
.orderBy(desc(fileWriteLog.id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForWriteLogEntry(
|
||||||
|
db: Db,
|
||||||
|
bookId: number,
|
||||||
|
options: { triggeredBy?: 'auto' | 'sync'; status?: 'success' | 'skipped' | 'failed'; timeoutMs?: number } = {},
|
||||||
|
) {
|
||||||
|
let row: typeof fileWriteLog.$inferSelect | null = null;
|
||||||
|
|
||||||
|
await waitForCondition(async () => {
|
||||||
|
row = await getLatestWriteLogEntry(db, bookId, options.triggeredBy);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(`Write log not found for book ${bookId}`);
|
||||||
|
}
|
||||||
|
if (options.status && row.status !== options.status) {
|
||||||
|
throw new Error(`Write log status mismatch for book ${bookId}: expected ${options.status}, received ${row.status}`);
|
||||||
|
}
|
||||||
|
}, options.timeoutMs ?? 15_000);
|
||||||
|
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserAndLogin(
|
||||||
|
ctx: MetadataWriteE2EContext,
|
||||||
|
options: {
|
||||||
|
permissions?: Permission[];
|
||||||
|
isSuperuser?: boolean;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
email?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestUserSession> {
|
||||||
|
const suffix = randomUUID().replaceAll('-', '');
|
||||||
|
const username = options.username ?? `metadata-write-user-${suffix}`;
|
||||||
|
const password = options.password ?? 'MetadataWriteUser123';
|
||||||
|
const email = options.email ?? `${username}@example.com`;
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
username,
|
||||||
|
name: `Metadata Write User ${suffix}`,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
isSuperuser: options.isSuperuser ?? false,
|
||||||
|
isDefaultPassword: false,
|
||||||
|
provisioningMethod: 'local',
|
||||||
|
})
|
||||||
|
.returning({ id: schema.users.id });
|
||||||
|
|
||||||
|
const permissions = options.permissions ?? [];
|
||||||
|
if (permissions.length > 0) {
|
||||||
|
await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId: created.id, permissionName })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginResponse = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { username, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResponse.statusCode !== 200) {
|
||||||
|
throw new Error(`Login failed for ${username}: ${loginResponse.statusCode} ${loginResponse.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginBody = loginResponse.json() as { accessToken?: string };
|
||||||
|
if (!loginBody.accessToken) {
|
||||||
|
throw new Error(`Login for ${username} returned no accessToken`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: created.id,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
accessToken: loginBody.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantLibraryAccess(
|
||||||
|
ctx: MetadataWriteE2EContext,
|
||||||
|
userId: number,
|
||||||
|
libraryId: number,
|
||||||
|
accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer',
|
||||||
|
): Promise<void> {
|
||||||
|
await ctx.db
|
||||||
|
.insert(schema.userLibraryAccess)
|
||||||
|
.values({ userId, libraryId, accessLevel })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.userLibraryAccess.userId, schema.userLibraryAccess.libraryId],
|
||||||
|
set: { accessLevel },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 15_000, pollMs = 100): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await check();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<string> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/setup',
|
||||||
|
payload: ADMIN_SETUP_DTO,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode === 409) {
|
||||||
|
const existingToken = await loginForToken(app, ADMIN_SETUP_DTO.username, ADMIN_SETUP_DTO.password);
|
||||||
|
if (existingToken) return existingToken;
|
||||||
|
|
||||||
|
const suffix = randomUUID().replaceAll('-', '');
|
||||||
|
const fallbackUsername = `metadata-write-admin-${suffix}`;
|
||||||
|
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
|
||||||
|
|
||||||
|
await db.insert(schema.users).values({
|
||||||
|
username: fallbackUsername,
|
||||||
|
name: 'Metadata Write E2E Admin',
|
||||||
|
email: `${fallbackUsername}@example.com`,
|
||||||
|
passwordHash,
|
||||||
|
isSuperuser: true,
|
||||||
|
isDefaultPassword: false,
|
||||||
|
provisioningMethod: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackToken = await loginForToken(app, fallbackUsername, ADMIN_SETUP_DTO.password);
|
||||||
|
if (fallbackToken) return fallbackToken;
|
||||||
|
throw new Error('Setup is already complete and fallback admin login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 201) {
|
||||||
|
throw new Error(`Unable to complete setup: ${response.statusCode} ${response.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = response.json() as { accessToken?: string };
|
||||||
|
if (!body.accessToken) {
|
||||||
|
throw new Error('Setup succeeded but accessToken is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise<string | null> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { username, password },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200) return null;
|
||||||
|
const body = response.json() as { accessToken?: string };
|
||||||
|
return body.accessToken ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileWriteSettings(value: string | undefined): GlobalFileWriteSettings {
|
||||||
|
if (!value) return { ...DEFAULT_FILE_WRITE_SETTINGS };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Partial<GlobalFileWriteSettings>;
|
||||||
|
return mergeFileWriteSettings(DEFAULT_FILE_WRITE_SETTINGS, parsed);
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_FILE_WRITE_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFileWriteSettings(base: GlobalFileWriteSettings, patch: Partial<GlobalFileWriteSettings>): GlobalFileWriteSettings {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...patch,
|
||||||
|
epub: { ...base.epub, ...(patch.epub ?? {}) },
|
||||||
|
pdf: { ...base.pdf, ...(patch.pdf ?? {}) },
|
||||||
|
cbx: { ...base.cbx, ...(patch.cbx ?? {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(snapshot: EnvSnapshot): void {
|
||||||
|
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
|
||||||
|
else process.env.BOOKS_PATH = snapshot.booksPath;
|
||||||
|
|
||||||
|
if (snapshot.fileWriteDebounceMs === undefined) delete process.env.FILE_WRITE_DEBOUNCE_MS;
|
||||||
|
else process.env.FILE_WRITE_DEBOUNCE_MS = snapshot.fileWriteDebounceMs;
|
||||||
|
|
||||||
|
if (snapshot.fileWriteMaxConcurrentWrites === undefined) delete process.env.FILE_WRITE_MAX_CONCURRENT_WRITES;
|
||||||
|
else process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = snapshot.fileWriteMaxConcurrentWrites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import { mkdir, writeFile } from 'fs/promises';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { Permission, type LibraryFileSyncProgressEvent } from '@projectx/types';
|
||||||
|
|
||||||
|
import * as schema from '../src/db/schema';
|
||||||
|
import { extractCb7Metadata, extractCbzMetadata } from '../src/modules/metadata/lib/cbz-metadata';
|
||||||
|
import { extractEpubMetadata } from '../src/modules/metadata/lib/epub';
|
||||||
|
import { parsePdfFile } from '../src/modules/metadata/lib/pdf-parser';
|
||||||
|
import {
|
||||||
|
authHeader,
|
||||||
|
closeMetadataWriteE2EContext,
|
||||||
|
createLibraryWithFolder,
|
||||||
|
createMetadataWriteE2EContext,
|
||||||
|
createUserAndLogin,
|
||||||
|
getLatestWriteLogEntry,
|
||||||
|
grantLibraryAccess,
|
||||||
|
locateBookFileByRelPath,
|
||||||
|
setFileWriteSettings,
|
||||||
|
triggerAndWaitForLibraryScan,
|
||||||
|
waitForWriteLogEntry,
|
||||||
|
type LocatedBookFile,
|
||||||
|
type MetadataWriteE2EContext,
|
||||||
|
} from './e2e/metadata-write/metadata-write-harness';
|
||||||
|
import {
|
||||||
|
createCb7Fixture,
|
||||||
|
createCbzFixture,
|
||||||
|
createEpubFixture,
|
||||||
|
createPdfFixture,
|
||||||
|
writeFixtureFile,
|
||||||
|
} from './e2e/metadata-write/metadata-write-fixture-builder';
|
||||||
|
|
||||||
|
type SupportedFormat = 'pdf' | 'epub' | 'cbz' | 'cb7';
|
||||||
|
const SCENARIO_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
interface ScenarioRunResult {
|
||||||
|
id: string;
|
||||||
|
status: 'passed' | 'failed';
|
||||||
|
durationMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedLibraryResult {
|
||||||
|
libraryId: number;
|
||||||
|
books: Record<SupportedFormat, LocatedBookFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void> {
|
||||||
|
const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server');
|
||||||
|
await mkdir(reportDir, { recursive: true });
|
||||||
|
const reportPath = join(reportDir, 'metadata-write-e2e-scenarios.json');
|
||||||
|
await writeFile(
|
||||||
|
reportPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
total: results.length,
|
||||||
|
passed: results.filter((result) => result.status === 'passed').length,
|
||||||
|
failed: results.filter((result) => result.status === 'failed').length,
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||||
|
let context!: MetadataWriteE2EContext;
|
||||||
|
const scenarioResults: ScenarioRunResult[] = [];
|
||||||
|
let scenarioStartedAt = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
context = await createMetadataWriteE2EContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((taskContext) => {
|
||||||
|
const result = taskContext.task.result;
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const state = result.state === 'pass' ? 'passed' : 'failed';
|
||||||
|
const error = result.errors?.[0]?.message;
|
||||||
|
scenarioResults.push({
|
||||||
|
id: taskContext.task.name,
|
||||||
|
status: state,
|
||||||
|
durationMs: Math.max(0, Date.now() - scenarioStartedAt),
|
||||||
|
...(error ? { error } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await writeScenarioReport(scenarioResults);
|
||||||
|
if (context) {
|
||||||
|
await closeMetadataWriteE2EContext(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
scenarioStartedAt = Date.now();
|
||||||
|
await setFileWriteSettings(context.db, {
|
||||||
|
enabled: true,
|
||||||
|
writeCover: false,
|
||||||
|
epub: { enabled: true },
|
||||||
|
pdf: { enabled: true },
|
||||||
|
cbx: { enabled: true, formats: ['cbz', 'cb7'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('database metadata writes', () => {
|
||||||
|
it('persists scalar and relational metadata through PATCH /books/:id/metadata', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'db-write/book.pdf');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
const book = await locateBookFileByRelPath(context, library.libraryId, 'db-write/book.pdf');
|
||||||
|
|
||||||
|
const response = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
title: 'DB Write Title',
|
||||||
|
subtitle: 'DB Write Subtitle',
|
||||||
|
description: 'DB Write Description',
|
||||||
|
publisher: 'DB Write Publisher',
|
||||||
|
publishedYear: 2005,
|
||||||
|
language: 'en',
|
||||||
|
seriesName: 'DB Series',
|
||||||
|
seriesIndex: 2,
|
||||||
|
isbn13: '9781234567890',
|
||||||
|
rating: 4,
|
||||||
|
authors: ['DB Author A', 'DB Author B'],
|
||||||
|
genres: ['DB Genre'],
|
||||||
|
tags: ['DB Tag'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const [metadata] = await context.db
|
||||||
|
.select({
|
||||||
|
title: schema.bookMetadata.title,
|
||||||
|
subtitle: schema.bookMetadata.subtitle,
|
||||||
|
description: schema.bookMetadata.description,
|
||||||
|
publisher: schema.bookMetadata.publisher,
|
||||||
|
publishedYear: schema.bookMetadata.publishedYear,
|
||||||
|
language: schema.bookMetadata.language,
|
||||||
|
seriesName: schema.bookMetadata.seriesName,
|
||||||
|
seriesIndex: schema.bookMetadata.seriesIndex,
|
||||||
|
isbn13: schema.bookMetadata.isbn13,
|
||||||
|
rating: schema.bookMetadata.rating,
|
||||||
|
})
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, book.bookId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
title: 'DB Write Title',
|
||||||
|
subtitle: 'DB Write Subtitle',
|
||||||
|
description: 'DB Write Description',
|
||||||
|
publisher: 'DB Write Publisher',
|
||||||
|
publishedYear: 2005,
|
||||||
|
language: 'en',
|
||||||
|
seriesName: 'DB Series',
|
||||||
|
seriesIndex: 2,
|
||||||
|
isbn13: '9781234567890',
|
||||||
|
rating: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [authorRows, genreRows, tagRows] = await Promise.all([
|
||||||
|
context.db
|
||||||
|
.select({ name: schema.authors.name })
|
||||||
|
.from(schema.bookAuthors)
|
||||||
|
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
|
||||||
|
.where(eq(schema.bookAuthors.bookId, book.bookId))
|
||||||
|
.orderBy(schema.bookAuthors.displayOrder),
|
||||||
|
context.db
|
||||||
|
.select({ name: schema.genres.name })
|
||||||
|
.from(schema.bookGenres)
|
||||||
|
.innerJoin(schema.genres, eq(schema.genres.id, schema.bookGenres.genreId))
|
||||||
|
.where(eq(schema.bookGenres.bookId, book.bookId)),
|
||||||
|
context.db
|
||||||
|
.select({ name: schema.tags.name })
|
||||||
|
.from(schema.bookTags)
|
||||||
|
.innerJoin(schema.tags, eq(schema.tags.id, schema.bookTags.tagId))
|
||||||
|
.where(eq(schema.bookTags.bookId, book.bookId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(authorRows.map((row) => row.name)).toEqual(['DB Author A', 'DB Author B']);
|
||||||
|
expect(genreRows.map((row) => row.name)).toEqual(['DB Genre']);
|
||||||
|
expect(tagRows.map((row) => row.name)).toEqual(['DB Tag']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auto-write on metadata patch', () => {
|
||||||
|
it('writes PDF metadata to disk and records auto write-log entry', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'auto-write/book.pdf', 'Auto Seed PDF');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
const book = await locateBookFileByRelPath(context, library.libraryId, 'auto-write/book.pdf');
|
||||||
|
|
||||||
|
const response = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
title: 'Auto PDF Title',
|
||||||
|
authors: ['Auto PDF Author'],
|
||||||
|
description: 'Auto PDF Description',
|
||||||
|
publisher: 'Auto PDF Publisher',
|
||||||
|
tags: ['AutoTag'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const logEntry = await waitForWriteLogEntry(context.db, book.bookId, {
|
||||||
|
triggeredBy: 'auto',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
expect(logEntry.format).toBe('pdf');
|
||||||
|
|
||||||
|
const parsedPdf = await parsePdfFile(book.absolutePath);
|
||||||
|
expect(parsedPdf?.title).toBe('Auto PDF Title');
|
||||||
|
expect(parsedPdf?.authors.map((author) => author.name)).toContain('Auto PDF Author');
|
||||||
|
expect(parsedPdf?.description).toBe('Auto PDF Description');
|
||||||
|
|
||||||
|
const [metadata] = await context.db
|
||||||
|
.select({ lastWrittenAt: schema.bookMetadata.lastWrittenAt })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, book.bookId))
|
||||||
|
.limit(1);
|
||||||
|
expect(metadata?.lastWrittenAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes EPUB metadata to disk and records auto write-log entry', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createEpubFixture(library.folderPath, 'auto-write/book.epub', { title: 'Auto Seed EPUB' });
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
const book = await locateBookFileByRelPath(context, library.libraryId, 'auto-write/book.epub');
|
||||||
|
|
||||||
|
const response = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
title: 'Auto EPUB Title',
|
||||||
|
authors: ['Auto EPUB Author'],
|
||||||
|
language: 'fr',
|
||||||
|
publishedYear: 2012,
|
||||||
|
genres: ['Sci-Fi'],
|
||||||
|
tags: ['AutoEpubTag'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const logEntry = await waitForWriteLogEntry(context.db, book.bookId, {
|
||||||
|
triggeredBy: 'auto',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
expect(logEntry.format).toBe('epub');
|
||||||
|
|
||||||
|
const parsedEpub = await extractEpubMetadata(book.absolutePath);
|
||||||
|
expect(parsedEpub?.title).toBe('Auto EPUB Title');
|
||||||
|
expect(parsedEpub?.language).toBe('fr');
|
||||||
|
expect(parsedEpub?.publishedYear).toBe(2012);
|
||||||
|
expect(parsedEpub?.authors.map((author) => author.name)).toContain('Auto EPUB Author');
|
||||||
|
|
||||||
|
const [metadata] = await context.db
|
||||||
|
.select({ lastWrittenAt: schema.bookMetadata.lastWrittenAt })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, book.bookId))
|
||||||
|
.limit(1);
|
||||||
|
expect(metadata?.lastWrittenAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('library sync write flow', () => {
|
||||||
|
it('writes metadata to PDF/EPUB/CBZ/CB7 and emits SSE summary', async () => {
|
||||||
|
const prepared = await prepareLibraryWithAllFormats(context);
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, {
|
||||||
|
enabled: false,
|
||||||
|
cbx: { enabled: true, formats: ['cbz', 'cb7'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updates: Record<SupportedFormat, { title: string; author: string }> = {
|
||||||
|
pdf: { title: 'Sync PDF Title', author: 'Sync PDF Author' },
|
||||||
|
epub: { title: 'Sync EPUB Title', author: 'Sync EPUB Author' },
|
||||||
|
cbz: { title: 'Sync CBZ Title', author: 'Sync CBZ Author' },
|
||||||
|
cb7: { title: 'Sync CB7 Title', author: 'Sync CB7 Author' },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const format of Object.keys(prepared.books) as SupportedFormat[]) {
|
||||||
|
const book = prepared.books[format];
|
||||||
|
const payload = updates[format];
|
||||||
|
const patchResponse = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
title: payload.title,
|
||||||
|
authors: [payload.author],
|
||||||
|
publisher: `Publisher ${format.toUpperCase()}`,
|
||||||
|
publishedYear: 2020,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(patchResponse.statusCode).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, {
|
||||||
|
enabled: true,
|
||||||
|
writeCover: false,
|
||||||
|
cbx: { enabled: true, formats: ['cbz', 'cb7'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncResponse = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${prepared.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncResponse.statusCode).toBe(200);
|
||||||
|
const events = parseSseEvents(syncResponse.body);
|
||||||
|
const doneEvent = events.find(
|
||||||
|
(event): event is Extract<LibraryFileSyncProgressEvent, { done: true }> => 'done' in event && event.done === true,
|
||||||
|
);
|
||||||
|
expect(doneEvent).toMatchObject({ processed: 4, succeeded: 4, failed: 0, skipped: 0 });
|
||||||
|
expect(events.filter((event) => !('done' in event))).toHaveLength(4);
|
||||||
|
|
||||||
|
for (const format of Object.keys(prepared.books) as SupportedFormat[]) {
|
||||||
|
const book = prepared.books[format];
|
||||||
|
const logEntry = await getLatestWriteLogEntry(context.db, book.bookId, 'sync');
|
||||||
|
expect(logEntry?.status).toBe('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath);
|
||||||
|
expect(parsedPdf?.title).toBe(updates.pdf.title);
|
||||||
|
expect(parsedPdf?.authors.map((author) => author.name)).toContain(updates.pdf.author);
|
||||||
|
|
||||||
|
const parsedEpub = await extractEpubMetadata(prepared.books.epub.absolutePath);
|
||||||
|
expect(parsedEpub?.title).toBe(updates.epub.title);
|
||||||
|
expect(parsedEpub?.authors.map((author) => author.name)).toContain(updates.epub.author);
|
||||||
|
|
||||||
|
const parsedCbz = await extractCbzMetadata(prepared.books.cbz.absolutePath);
|
||||||
|
expect(parsedCbz?.title).toBe(updates.cbz.title);
|
||||||
|
expect(parsedCbz?.authors.map((author) => author.name)).toContain(updates.cbz.author);
|
||||||
|
|
||||||
|
const parsedCb7 = await extractCb7Metadata(prepared.books.cb7.absolutePath);
|
||||||
|
expect(parsedCb7?.title).toBe(updates.cb7.title);
|
||||||
|
expect(parsedCb7?.authors.map((author) => author.name)).toContain(updates.cb7.author);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports dry-run sync and records skipped write-log entries', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'dry-run/book.pdf');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
const book = await locateBookFileByRelPath(context, library.libraryId, 'dry-run/book.pdf');
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, { enabled: false });
|
||||||
|
const patchResponse = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
title: 'Dry Run Title',
|
||||||
|
authors: ['Dry Run Author'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(patchResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, { enabled: true });
|
||||||
|
|
||||||
|
const dryRunResponse = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files?dryRun=true`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dryRunResponse.statusCode).toBe(200);
|
||||||
|
const events = parseSseEvents(dryRunResponse.body);
|
||||||
|
const doneEvent = events.find(
|
||||||
|
(event): event is Extract<LibraryFileSyncProgressEvent, { done: true }> => 'done' in event && event.done === true,
|
||||||
|
);
|
||||||
|
expect(doneEvent).toMatchObject({ processed: 1, succeeded: 0, failed: 0, skipped: 1 });
|
||||||
|
|
||||||
|
const logEntry = await waitForWriteLogEntry(context.db, book.bookId, {
|
||||||
|
triggeredBy: 'sync',
|
||||||
|
status: 'skipped',
|
||||||
|
});
|
||||||
|
expect(logEntry.errorMessage).toBe('dry-run');
|
||||||
|
|
||||||
|
const [metadata] = await context.db
|
||||||
|
.select({ lastWrittenAt: schema.bookMetadata.lastWrittenAt })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, book.bookId))
|
||||||
|
.limit(1);
|
||||||
|
expect(metadata?.lastWrittenAt ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects sync writes when file-write settings are disabled', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'disabled-sync/book.pdf');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, { enabled: false });
|
||||||
|
|
||||||
|
const response = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
message: expect.stringContaining('Metadata file write is not enabled'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failure isolation', () => {
|
||||||
|
it('continues processing other books when one file write fails', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'failure-isolation/ok.pdf');
|
||||||
|
await writeFixtureFile(library.folderPath, 'failure-isolation/broken.epub', 'not a valid epub archive');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
|
||||||
|
const okBook = await locateBookFileByRelPath(context, library.libraryId, 'failure-isolation/ok.pdf');
|
||||||
|
const brokenBook = await locateBookFileByRelPath(context, library.libraryId, 'failure-isolation/broken.epub');
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, { enabled: false });
|
||||||
|
|
||||||
|
for (const target of [okBook, brokenBook]) {
|
||||||
|
const patchResponse = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${target.bookId}/metadata`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
title: target.bookId === okBook.bookId ? 'Failure Isolation Success' : 'Failure Isolation Broken',
|
||||||
|
authors: ['Failure Isolation Author'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(patchResponse.statusCode).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setFileWriteSettings(context.db, { enabled: true, cbx: { enabled: true, formats: ['cbz', 'cb7'] } });
|
||||||
|
|
||||||
|
const syncResponse = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
});
|
||||||
|
expect(syncResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const events = parseSseEvents(syncResponse.body);
|
||||||
|
const doneEvent = events.find(
|
||||||
|
(event): event is Extract<LibraryFileSyncProgressEvent, { done: true }> => 'done' in event && event.done === true,
|
||||||
|
);
|
||||||
|
expect(doneEvent).toMatchObject({ processed: 2, succeeded: 1, failed: 1, skipped: 0 });
|
||||||
|
|
||||||
|
const successLog = await waitForWriteLogEntry(context.db, okBook.bookId, { triggeredBy: 'sync', status: 'success' });
|
||||||
|
const failureLog = await waitForWriteLogEntry(context.db, brokenBook.bookId, { triggeredBy: 'sync', status: 'failed' });
|
||||||
|
expect(successLog.errorMessage ?? null).toBeNull();
|
||||||
|
expect(failureLog.errorMessage).toBeTruthy();
|
||||||
|
|
||||||
|
const parsedPdf = await parsePdfFile(okBook.absolutePath);
|
||||||
|
expect(parsedPdf?.title).toBe('Failure Isolation Success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('access control', () => {
|
||||||
|
it('enforces permission and library access for PATCH /books/:id/metadata', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'access/patch.pdf');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
const book = await locateBookFileByRelPath(context, library.libraryId, 'access/patch.pdf');
|
||||||
|
|
||||||
|
const noPermissionUser = await createUserAndLogin(context);
|
||||||
|
const withPermissionUser = await createUserAndLogin(context, {
|
||||||
|
permissions: [Permission.LibraryEditMetadata],
|
||||||
|
});
|
||||||
|
|
||||||
|
await grantLibraryAccess(context, noPermissionUser.userId, library.libraryId, 'editor');
|
||||||
|
|
||||||
|
const missingPermission = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(noPermissionUser.accessToken),
|
||||||
|
payload: { title: 'Should Fail' },
|
||||||
|
});
|
||||||
|
expect(missingPermission.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const missingAccess = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(withPermissionUser.accessToken),
|
||||||
|
payload: { title: 'Should Fail Access' },
|
||||||
|
});
|
||||||
|
expect(missingAccess.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'editor');
|
||||||
|
|
||||||
|
const success = await context.app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/books/${book.bookId}/metadata`,
|
||||||
|
headers: authHeader(withPermissionUser.accessToken),
|
||||||
|
payload: { title: 'Patch Access Success' },
|
||||||
|
});
|
||||||
|
expect(success.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces permission and editor-level library access for sync endpoint', async () => {
|
||||||
|
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
|
||||||
|
await createPdfFixture(library.folderPath, 'access/sync.pdf');
|
||||||
|
await triggerAndWaitForLibraryScan(context, library.libraryId);
|
||||||
|
|
||||||
|
const noPermissionUser = await createUserAndLogin(context);
|
||||||
|
const withPermissionUser = await createUserAndLogin(context, {
|
||||||
|
permissions: [Permission.LibraryEditMetadata],
|
||||||
|
});
|
||||||
|
|
||||||
|
await grantLibraryAccess(context, noPermissionUser.userId, library.libraryId, 'editor');
|
||||||
|
|
||||||
|
const missingPermission = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(noPermissionUser.accessToken),
|
||||||
|
});
|
||||||
|
expect(missingPermission.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const noLibraryAccess = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(withPermissionUser.accessToken),
|
||||||
|
});
|
||||||
|
expect(noLibraryAccess.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'viewer');
|
||||||
|
const viewerAccess = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(withPermissionUser.accessToken),
|
||||||
|
});
|
||||||
|
expect(viewerAccess.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'editor');
|
||||||
|
const editorAccess = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
|
||||||
|
headers: authHeader(withPermissionUser.accessToken),
|
||||||
|
});
|
||||||
|
expect(editorAccess.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const events = parseSseEvents(editorAccess.body);
|
||||||
|
const doneEvent = events.find(
|
||||||
|
(event): event is Extract<LibraryFileSyncProgressEvent, { done: true }> => 'done' in event && event.done === true,
|
||||||
|
);
|
||||||
|
expect(doneEvent).toMatchObject({ processed: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function prepareLibraryWithAllFormats(ctx: MetadataWriteE2EContext): Promise<PreparedLibraryResult> {
|
||||||
|
const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file' });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
createPdfFixture(library.folderPath, 'all-formats/book.pdf', 'All Formats PDF Seed'),
|
||||||
|
createEpubFixture(library.folderPath, 'all-formats/book.epub', { title: 'All Formats EPUB Seed' }),
|
||||||
|
createCbzFixture(library.folderPath, 'all-formats/book.cbz', { title: 'All Formats CBZ Seed' }),
|
||||||
|
createCb7Fixture(library.folderPath, 'all-formats/book.cb7', { title: 'All Formats CB7 Seed' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||||
|
|
||||||
|
const [pdf, epub, cbz, cb7] = await Promise.all([
|
||||||
|
locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.pdf'),
|
||||||
|
locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.epub'),
|
||||||
|
locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.cbz'),
|
||||||
|
locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.cb7'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraryId: library.libraryId,
|
||||||
|
books: { pdf, epub, cbz, cb7 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEvents(rawBody: string): LibraryFileSyncProgressEvent[] {
|
||||||
|
const events: LibraryFileSyncProgressEvent[] = [];
|
||||||
|
|
||||||
|
for (const chunk of rawBody.split('\n\n')) {
|
||||||
|
const line = chunk
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.find((item) => item.startsWith('data:'));
|
||||||
|
if (!line) continue;
|
||||||
|
const payload = line.slice('data:'.length).trim();
|
||||||
|
if (!payload) continue;
|
||||||
|
events.push(JSON.parse(payload) as LibraryFileSyncProgressEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
pool: 'forks',
|
pool: 'forks',
|
||||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.e2e-spec.ts'],
|
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||||
passWithNoTests: true,
|
passWithNoTests: true,
|
||||||
reporters: process.env.CI ? ['default', 'github-actions'] : ['default'],
|
reporters: process.env.CI ? ['default', 'github-actions'] : ['default'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
Reference in New Issue
Block a user