test(metadata): add initial E2E coverage for metadata write flow

This commit is contained in:
neon
2026-04-01 08:05:00 -06:00
parent 7491ad78c8
commit a6d84ae20c
12 changed files with 1335 additions and 26 deletions
+48
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
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..."
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server db:prepare:e2e
+8
View File
@@ -49,6 +49,14 @@ export const E2E_SUITES = Object.freeze({
prepareDedicatedDatabase: 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() {
+1 -1
View File
@@ -105,7 +105,7 @@ async function main() {
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)) {
console.log("Starting local PostgreSQL (dev compose)...");
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 });
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(['a', '/cbx-arc-abcdef123', '/ComicInfo.xml']);
expect(sz.callMain).toHaveBeenCalledWith(['d', '/cbx-arc-abcdef123.cb7', 'ComicInfo.xml', '-y']);
expect(sz.callMain).toHaveBeenCalledWith(['a', '/cbx-arc-abcdef123.cb7', '/ComicInfo.xml']);
expect(mockWriteFile).toHaveBeenCalledWith('/.cbx-write-abcdef123', Buffer.from('modified-archive-bytes'));
expect(mockRename).toHaveBeenCalledWith('/.cbx-write-abcdef123', '/book.cb7');
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
// is always written before it is added to the archive, with no await in between.
const VFS_XML_PATH = '/ComicInfo.xml';
const ENOENT_ERRNO = 44;
@Injectable()
export class Cb7FormatWriter implements FormatWriter {
@@ -33,7 +34,7 @@ export class Cb7FormatWriter implements FormatWriter {
}
const uid = randomUUID().replace(/-/g, '');
const vfsArchivePath = `/cbx-arc-${uid}`;
const vfsArchivePath = `/cbx-arc-${uid}.cb7`;
const vfsExtractDir = `/cbx-ext-${uid}`;
const sz = await getSevenZip();
@@ -63,14 +64,6 @@ export class Cb7FormatWriter implements FormatWriter {
const xml = buildComicInfoXml(existingXml, payload, fieldMask);
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+');
sz.FS.write(fd2, xmlBytes, 0, xmlBytes.length);
sz.FS.close(fd2);
@@ -99,6 +92,7 @@ export class Cb7FormatWriter implements FormatWriter {
function cleanupVfs(vfs: SevenZipFS, archivePath: string, extractDir: string): void {
unlinkIfExists(vfs, VFS_XML_PATH);
unlinkIfExists(vfs, archivePath);
unlinkIfExists(vfs, `${archivePath}.7z`);
const files = readDirIfExists(vfs, extractDir);
if (!files) return;
@@ -144,6 +138,9 @@ function isMissingComicInfoError(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);
return (
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';
let disconnected = false;
let streamStarted = false;
const handleDisconnect = () => {
disconnected = true;
};
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
reply.raw.on('close', handleDisconnect);
reply.raw.on('aborted', handleDisconnect);
const ensureStreamStarted = () => {
if (streamStarted) return;
streamStarted = true;
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
reply.raw.on('close', handleDisconnect);
reply.raw.on('aborted', handleDisconnect);
};
const writeEvent = (event: LibraryFileSyncProgressEvent) => {
if (disconnected || reply.raw.writableEnded || reply.raw.destroyed) return;
ensureStreamStarted();
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
};
@@ -149,12 +154,20 @@ export class LibraryController {
};
writeEvent(doneEvent);
if (!reply.raw.writableEnded && !reply.raw.destroyed) {
if (streamStarted && !reply.raw.writableEnded && !reply.raw.destroyed) {
reply.raw.end();
}
} catch (error) {
if (streamStarted && !reply.raw.writableEnded && !reply.raw.destroyed) {
reply.raw.end();
return;
}
throw error;
} finally {
reply.raw.off('close', handleDisconnect);
reply.raw.off('aborted', handleDisconnect);
if (streamStarted) {
reply.raw.off('close', handleDisconnect);
reply.raw.off('aborted', handleDisconnect);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ function escapeIdentifier(identifier: string): string {
}
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);
}
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
@@ -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));
}
+602
View File
@@ -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;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
globals: true,
environment: 'node',
pool: 'forks',
include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.e2e-spec.ts'],
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
passWithNoTests: true,
reporters: process.env.CI ? ['default', 'github-actions'] : ['default'],
coverage: {