mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
688cfdeaec
The player follows each track's stored sort order, which overlapping folder scans, Add File, and imports joining a book could leave out of step with the file names the Files tab shows. Realign a book's tracks after each of those writes, under the book row lock. Closes #1681
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { mkdtemp, rm } from 'fs/promises';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { expect, vi } from 'vitest';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyMultipart from '@fastify/multipart';
|
|
import { count, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
import { hash } from 'bcryptjs';
|
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
import { Test } from '@nestjs/testing';
|
|
import { DEFAULT_FORMAT_PRIORITY, type LibraryType } from '@bookorbit/types';
|
|
|
|
import { AppModule } from '../../src/app.module';
|
|
import { GlobalExceptionFilter } from '../../src/common/filters/http-exception.filter';
|
|
import { DB } from '../../src/db';
|
|
import * as schema from '../../src/db/schema';
|
|
import { books, bookFiles, libraries, libraryFolders, scanJobs } from '../../src/db/schema';
|
|
import { MetadataService } from '../../src/modules/metadata/metadata.service';
|
|
|
|
export type Db = NodePgDatabase<typeof schema>;
|
|
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
|
export type MetadataNoopMock = {
|
|
extractAndSave: ReturnType<typeof vi.fn>;
|
|
refreshCoverForBook: ReturnType<typeof vi.fn>;
|
|
extractAudioFileDuration: ReturnType<typeof vi.fn>;
|
|
aggregateAudioDuration: ReturnType<typeof vi.fn>;
|
|
extractAudioChaptersAndNarrators: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
const ADMIN_SETUP_DTO = {
|
|
username: 'scanner-e2e-admin',
|
|
name: 'Scanner E2E Admin',
|
|
email: 'scanner-e2e-admin@example.com',
|
|
password: 'ScannerE2E123',
|
|
};
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export function makeMetadataNoopMock(): MetadataNoopMock {
|
|
return {
|
|
extractAndSave: vi.fn().mockResolvedValue(undefined),
|
|
refreshCoverForBook: vi.fn().mockResolvedValue(false),
|
|
extractAudioFileDuration: vi.fn().mockResolvedValue(undefined),
|
|
aggregateAudioDuration: vi.fn().mockResolvedValue(undefined),
|
|
extractAudioChaptersAndNarrators: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
async function getAdminToken(app: NestFastifyApplication): Promise<string> {
|
|
const db = app.get<Db>(DB);
|
|
const setupResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/setup',
|
|
payload: ADMIN_SETUP_DTO,
|
|
});
|
|
|
|
if (setupResponse.statusCode === 201) {
|
|
const body = setupResponse.json() as { accessToken?: string };
|
|
if (!body.accessToken) throw new Error('Setup succeeded but accessToken was missing');
|
|
return body.accessToken;
|
|
}
|
|
|
|
if (setupResponse.statusCode === 409) {
|
|
const loginResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { username: ADMIN_SETUP_DTO.username, password: ADMIN_SETUP_DTO.password },
|
|
});
|
|
if (loginResponse.statusCode === 200) {
|
|
const body = loginResponse.json() as { accessToken?: string };
|
|
if (body.accessToken) return body.accessToken;
|
|
}
|
|
|
|
const suffix = randomUUID().replaceAll('-', '');
|
|
const fallbackUsername = `scanner-e2e-admin-${suffix}`;
|
|
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 4);
|
|
await db.insert(schema.users).values({
|
|
username: fallbackUsername,
|
|
name: 'Scanner E2E Admin (fallback)',
|
|
email: `${fallbackUsername}@example.com`,
|
|
passwordHash,
|
|
isSuperuser: true,
|
|
isDefaultPassword: false,
|
|
provisioningMethod: 'local',
|
|
});
|
|
const fallbackResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { username: fallbackUsername, password: ADMIN_SETUP_DTO.password },
|
|
});
|
|
if (fallbackResponse.statusCode !== 200) {
|
|
throw new Error(`app-harness: fallback admin login failed (${fallbackResponse.statusCode})`);
|
|
}
|
|
const body = fallbackResponse.json() as { accessToken?: string };
|
|
if (!body.accessToken) throw new Error('Fallback login succeeded but accessToken was missing');
|
|
return body.accessToken;
|
|
}
|
|
|
|
throw new Error(`Unexpected setup response: ${setupResponse.statusCode} ${setupResponse.body}`);
|
|
}
|
|
|
|
export interface E2EContext {
|
|
app: NestFastifyApplication;
|
|
db: Db;
|
|
adminToken: string;
|
|
/** Set when the context owns a throwaway data folder, which close removes again. */
|
|
isolatedAppData: { path: string; previous: string | undefined } | null;
|
|
}
|
|
|
|
/**
|
|
* Scans write and prune covers under APP_DATA_PATH, which `.env` points at the developer's own
|
|
* data folder. Suites that scan and do not set their own path pass `isolateAppData`. Suites that
|
|
* upload files pass `multipart`, which `main.ts` registers for the real server.
|
|
*/
|
|
export async function createE2EContext(options: { isolateAppData?: boolean; multipart?: boolean } = {}): Promise<E2EContext> {
|
|
let isolatedAppData: E2EContext['isolatedAppData'] = null;
|
|
if (options.isolateAppData) {
|
|
isolatedAppData = { path: await mkdtemp(join(tmpdir(), 'bookorbit-e2e-data-')), previous: process.env.APP_DATA_PATH };
|
|
process.env.APP_DATA_PATH = isolatedAppData.path;
|
|
}
|
|
const moduleFixture = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
})
|
|
.overrideProvider(MetadataService)
|
|
.useValue(makeMetadataNoopMock())
|
|
.compile();
|
|
|
|
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
app.setGlobalPrefix('api/v1');
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
|
await app.register(fastifyCookie as never);
|
|
if (options.multipart) await app.register(fastifyMultipart as never);
|
|
await app.init();
|
|
await app.getHttpAdapter().getInstance().ready();
|
|
|
|
const db = app.get<Db>(DB);
|
|
const adminToken = await getAdminToken(app);
|
|
return { app, db, adminToken, isolatedAppData };
|
|
}
|
|
|
|
export async function closeE2EContext(ctx: E2EContext): Promise<void> {
|
|
await ctx.app.close();
|
|
if (!ctx.isolatedAppData) return;
|
|
if (ctx.isolatedAppData.previous === undefined) delete process.env.APP_DATA_PATH;
|
|
else process.env.APP_DATA_PATH = ctx.isolatedAppData.previous;
|
|
await rm(ctx.isolatedAppData.path, { recursive: true, force: true });
|
|
}
|
|
|
|
export interface SeedLibraryInput {
|
|
rootPath: string;
|
|
mode: OrganizationMode;
|
|
type?: LibraryType;
|
|
allowedFormats?: string[];
|
|
excludePatterns?: string[];
|
|
watch?: boolean;
|
|
name?: string;
|
|
}
|
|
|
|
export async function seedLibrary(db: Db, input: SeedLibraryInput): Promise<{ libraryId: number; libraryFolderId: number }> {
|
|
const [library] = await db
|
|
.insert(libraries)
|
|
.values({
|
|
type: input.type ?? 'books',
|
|
name: input.name ?? `e2e-${input.mode}-${randomUUID()}`,
|
|
watch: input.watch ?? false,
|
|
organizationMode: input.mode,
|
|
allowedFormats: input.allowedFormats ?? [],
|
|
excludePatterns: input.excludePatterns ?? [],
|
|
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
|
|
})
|
|
.returning({ id: libraries.id });
|
|
|
|
const [libraryFolder] = await db
|
|
.insert(libraryFolders)
|
|
.values({
|
|
libraryId: library.id,
|
|
path: input.rootPath,
|
|
})
|
|
.returning({ id: libraryFolders.id });
|
|
|
|
return { libraryId: library.id, libraryFolderId: libraryFolder.id };
|
|
}
|
|
|
|
export async function triggerLibraryScan(ctx: E2EContext, libraryId: number): Promise<number> {
|
|
const response = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
|
|
headers: { authorization: `Bearer ${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 body.jobId;
|
|
}
|
|
|
|
export async function triggerAndWaitForLibraryScan(ctx: E2EContext, libraryId: number, timeoutMs = 30_000): Promise<typeof scanJobs.$inferSelect> {
|
|
const jobId = await triggerLibraryScan(ctx, libraryId);
|
|
return waitForScanCompletion(ctx.db, 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 interface LibraryBookState {
|
|
folderPath: string;
|
|
status: string;
|
|
primaryPath: string | null;
|
|
filePaths: string[];
|
|
}
|
|
|
|
export async function loadLibraryBookState(db: Db, libraryId: number): Promise<LibraryBookState[]> {
|
|
const bookRows = await db
|
|
.select({
|
|
id: books.id,
|
|
folderPath: books.folderPath,
|
|
status: books.status,
|
|
primaryFileId: books.primaryFileId,
|
|
})
|
|
.from(books)
|
|
.where(eq(books.libraryId, libraryId));
|
|
|
|
if (bookRows.length === 0) return [];
|
|
|
|
const bookIds = bookRows.map((r) => r.id);
|
|
const allFileRows = await db
|
|
.select({ bookId: bookFiles.bookId, id: bookFiles.id, absolutePath: bookFiles.absolutePath })
|
|
.from(bookFiles)
|
|
.where(inArray(bookFiles.bookId, bookIds));
|
|
|
|
const filesByBookId = new Map<number, { id: number; absolutePath: string }[]>();
|
|
const fileById = new Map<number, string>();
|
|
for (const f of allFileRows) {
|
|
if (!filesByBookId.has(f.bookId)) filesByBookId.set(f.bookId, []);
|
|
filesByBookId.get(f.bookId)!.push({ id: f.id, absolutePath: f.absolutePath });
|
|
fileById.set(f.id, f.absolutePath);
|
|
}
|
|
|
|
return bookRows
|
|
.map((row) => ({
|
|
folderPath: row.folderPath,
|
|
status: row.status,
|
|
primaryPath: row.primaryFileId !== null ? (fileById.get(row.primaryFileId) ?? null) : null,
|
|
filePaths: (filesByBookId.get(row.id) ?? []).map((f) => f.absolutePath).sort(),
|
|
}))
|
|
.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
|
|
}
|
|
|
|
export interface IntegritySnapshot {
|
|
orphanBookMetadata: number;
|
|
orphanBookFiles: number;
|
|
orphanBookAuthors: number;
|
|
orphanBookGenres: number;
|
|
orphanBookTags: number;
|
|
orphanBookNarrators: number;
|
|
orphanCollectionBooks: number;
|
|
orphanUserBookStatus: number;
|
|
orphanReadingProgress: number;
|
|
orphanReadingSessions: number;
|
|
orphanAudiobookProgressBook: number;
|
|
orphanAudiobookProgressFile: number;
|
|
orphanBookmarks: number;
|
|
orphanAnnotations: number;
|
|
orphanReaderPreferences: number;
|
|
booksWithoutMetadata: number;
|
|
invalidPrimaryFileRef: number;
|
|
duplicatePresentFilePathPerLibrary: number;
|
|
}
|
|
|
|
export async function loadIntegritySnapshot(db: Db): Promise<IntegritySnapshot> {
|
|
const [orphanBookMetadata] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookMetadata)
|
|
.leftJoin(schema.books, eq(schema.bookMetadata.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanBookFiles] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookFiles)
|
|
.leftJoin(schema.books, eq(schema.bookFiles.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanBookAuthors] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookAuthors)
|
|
.leftJoin(schema.books, eq(schema.bookAuthors.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanBookGenres] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookGenres)
|
|
.leftJoin(schema.books, eq(schema.bookGenres.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanBookTags] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookTags)
|
|
.leftJoin(schema.books, eq(schema.bookTags.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanBookNarrators] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookNarrators)
|
|
.leftJoin(schema.books, eq(schema.bookNarrators.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanCollectionBooks] = await db
|
|
.select({ value: count() })
|
|
.from(schema.collectionBooks)
|
|
.leftJoin(schema.books, eq(schema.collectionBooks.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanUserBookStatus] = await db
|
|
.select({ value: count() })
|
|
.from(schema.userBookStatus)
|
|
.leftJoin(schema.books, eq(schema.userBookStatus.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanReadingProgress] = await db
|
|
.select({ value: count() })
|
|
.from(schema.readingProgress)
|
|
.leftJoin(schema.bookFiles, eq(schema.readingProgress.bookFileId, schema.bookFiles.id))
|
|
.where(isNull(schema.bookFiles.id));
|
|
|
|
const [orphanReadingSessions] = await db
|
|
.select({ value: count() })
|
|
.from(schema.readingSessions)
|
|
.leftJoin(schema.bookFiles, eq(schema.readingSessions.bookFileId, schema.bookFiles.id))
|
|
.where(isNull(schema.bookFiles.id));
|
|
|
|
const [orphanAudiobookProgressBook] = await db
|
|
.select({ value: count() })
|
|
.from(schema.audiobookProgress)
|
|
.leftJoin(schema.books, eq(schema.audiobookProgress.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanAudiobookProgressFile] = await db
|
|
.select({ value: count() })
|
|
.from(schema.audiobookProgress)
|
|
.leftJoin(schema.bookFiles, eq(schema.audiobookProgress.currentFileId, schema.bookFiles.id))
|
|
.where(isNull(schema.bookFiles.id));
|
|
|
|
const [orphanBookmarks] = await db
|
|
.select({ value: count() })
|
|
.from(schema.bookmarks)
|
|
.leftJoin(schema.books, eq(schema.bookmarks.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanAnnotations] = await db
|
|
.select({ value: count() })
|
|
.from(schema.annotations)
|
|
.leftJoin(schema.books, eq(schema.annotations.bookId, schema.books.id))
|
|
.where(isNull(schema.books.id));
|
|
|
|
const [orphanReaderPreferences] = await db
|
|
.select({ value: count() })
|
|
.from(schema.readerPreferences)
|
|
.leftJoin(schema.bookFiles, eq(schema.readerPreferences.bookFileId, schema.bookFiles.id))
|
|
.where(isNull(schema.bookFiles.id));
|
|
|
|
const [booksWithoutMetadata] = await db
|
|
.select({ value: count() })
|
|
.from(schema.books)
|
|
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
|
.where(isNull(schema.bookMetadata.bookId));
|
|
|
|
const invalidPrimaryRows = await db
|
|
.select({ bookId: schema.books.id })
|
|
.from(schema.books)
|
|
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
|
|
.where(
|
|
sql`${schema.books.status} = 'present' AND ${schema.books.primaryFileId} IS NOT NULL AND (${schema.bookFiles.id} IS NULL OR ${schema.bookFiles.bookId} <> ${schema.books.id})`,
|
|
);
|
|
|
|
const duplicatePresentRows = await db
|
|
.select({
|
|
libraryId: schema.books.libraryId,
|
|
absolutePath: schema.bookFiles.absolutePath,
|
|
fileCount: sql<number>`count(*)`,
|
|
})
|
|
.from(schema.bookFiles)
|
|
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
|
.where(eq(schema.books.status, 'present'))
|
|
.groupBy(schema.books.libraryId, schema.bookFiles.absolutePath)
|
|
.having(sql`count(*) > 1`);
|
|
|
|
return {
|
|
orphanBookMetadata: Number(orphanBookMetadata.value),
|
|
orphanBookFiles: Number(orphanBookFiles.value),
|
|
orphanBookAuthors: Number(orphanBookAuthors.value),
|
|
orphanBookGenres: Number(orphanBookGenres.value),
|
|
orphanBookTags: Number(orphanBookTags.value),
|
|
orphanBookNarrators: Number(orphanBookNarrators.value),
|
|
orphanCollectionBooks: Number(orphanCollectionBooks.value),
|
|
orphanUserBookStatus: Number(orphanUserBookStatus.value),
|
|
orphanReadingProgress: Number(orphanReadingProgress.value),
|
|
orphanReadingSessions: Number(orphanReadingSessions.value),
|
|
orphanAudiobookProgressBook: Number(orphanAudiobookProgressBook.value),
|
|
orphanAudiobookProgressFile: Number(orphanAudiobookProgressFile.value),
|
|
orphanBookmarks: Number(orphanBookmarks.value),
|
|
orphanAnnotations: Number(orphanAnnotations.value),
|
|
orphanReaderPreferences: Number(orphanReaderPreferences.value),
|
|
booksWithoutMetadata: Number(booksWithoutMetadata.value),
|
|
invalidPrimaryFileRef: invalidPrimaryRows.length,
|
|
duplicatePresentFilePathPerLibrary: duplicatePresentRows.length,
|
|
};
|
|
}
|
|
|
|
export async function assertNoIntegrityViolations(db: Db): Promise<void> {
|
|
const snapshot = await loadIntegritySnapshot(db);
|
|
for (const [key, value] of Object.entries(snapshot)) {
|
|
expect(value, `integrity violation: ${key}`).toBe(0);
|
|
}
|
|
}
|
|
|
|
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 20_000, pollMs = 200): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError: unknown = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await check();
|
|
return;
|
|
} catch (err) {
|
|
lastError = err;
|
|
await sleep(pollMs);
|
|
}
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
}
|