Files
bookorbit/server/test/missing-resources.e2e-spec.ts
neon e28bd67fa3 feat(cover): give books with both media separate ebook and audiobook covers
Keep one cover slot per medium and show the one each surface needs: the
library's shape for the face, square art on audio screens, the ebook slot
for Kobo and OPDS. Legacy covers convert on upgrade or on first read, and a
write that names no slot keeps working for released iOS builds. Also read
the dev client origin from one constant.
2026-09-23 19:55:25 +00:00

206 lines
8.6 KiB
TypeScript

import { and, eq, inArray } from 'drizzle-orm';
import { rm } from 'fs/promises';
import { join } from 'path';
import * as schema from '../src/db/schema';
import {
authHeader,
closeMetadataWriteE2EContext,
createLibraryWithFolder,
createMetadataWriteE2EContext,
locateBookFileByRelPath,
triggerAndWaitForLibraryScan,
type MetadataWriteE2EContext,
} from './e2e/metadata-write/metadata-write-harness';
import { createEpubFixture, createPdfFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
const SCENARIO_TIMEOUT_MS = 120_000;
type Sweep = {
status: 'running' | 'completed' | 'failed';
brokenCovers: number;
orphanedCoverDirs: number;
};
async function waitForSweep(ctx: MetadataWriteE2EContext): Promise<Sweep> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/maintenance/missing-resources/sweep',
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
const sweep = response.json() as Sweep | null;
if (sweep?.status === 'completed') return sweep;
if (sweep?.status === 'failed') throw new Error('Missing Resources sweep failed');
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('Missing Resources sweep did not complete');
}
describe('Missing Resources slot covers (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: MetadataWriteE2EContext;
beforeAll(async () => {
ctx = await createMetadataWriteE2EContext();
}, 60_000);
afterAll(async () => {
await closeMetadataWriteE2EContext(ctx);
});
it('finds, measures, and cleans slot-aware cover resources', async () => {
const library = await createLibraryWithFolder(ctx, { name: 'Missing Resources slots' });
await createEpubFixture(library.folderPath, 'covers/valid.epub', { title: 'Valid slot cover' });
await createPdfFixture(library.folderPath, 'covers/broken.pdf', 'Broken slot cover');
await createEpubFixture(library.folderPath, 'covers/half.epub', { title: 'Half broken slot covers' });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const valid = await locateBookFileByRelPath(ctx, library.libraryId, 'covers/valid.epub');
const broken = await locateBookFileByRelPath(ctx, library.libraryId, 'covers/broken.pdf');
const half = await locateBookFileByRelPath(ctx, library.libraryId, 'covers/half.epub');
const now = new Date();
await ctx.db.delete(schema.bookCovers).where(inArray(schema.bookCovers.bookId, [valid.bookId, broken.bookId, half.bookId]));
await ctx.db.insert(schema.bookCovers).values([
{
bookId: valid.bookId,
medium: 'ebook',
source: 'extracted',
origin: 'embedded',
updatedAt: now,
},
{
bookId: broken.bookId,
medium: 'ebook',
source: 'extracted',
origin: 'embedded',
updatedAt: now,
},
{ bookId: half.bookId, medium: 'ebook', source: 'extracted', origin: 'embedded', updatedAt: now },
{ bookId: half.bookId, medium: 'audio', source: 'custom', origin: 'upload', updatedAt: now },
]);
await ctx.db
.update(schema.bookMetadata)
.set({ coverSource: 'extracted', coverUpdatedAt: now, updatedAt: now })
.where(eq(schema.bookMetadata.bookId, valid.bookId));
await ctx.db
.update(schema.bookMetadata)
.set({ coverSource: 'extracted', coverUpdatedAt: now, updatedAt: now })
.where(eq(schema.bookMetadata.bookId, broken.bookId));
await ctx.db
.update(schema.bookMetadata)
.set({ coverSource: 'custom', coverUpdatedAt: now, updatedAt: now })
.where(eq(schema.bookMetadata.bookId, half.bookId));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${valid.bookId}/ebook/cover_extracted.jpg`, Buffer.from('valid-cover'));
await rm(join(ctx.fixture.booksPath, 'covers', String(broken.bookId)), { recursive: true, force: true });
await rm(join(ctx.fixture.booksPath, 'covers', String(half.bookId)), { recursive: true, force: true });
await writeFixtureFile(ctx.fixture.booksPath, `covers/${half.bookId}/ebook/cover_extracted.jpg`, Buffer.from('half-cover'));
const orphanBookId = 2_000_000_000;
const orphanCover = Buffer.from('orphan-cover');
const orphanThumbnail = Buffer.from('orphan-thumbnail');
await writeFixtureFile(ctx.fixture.booksPath, `covers/${orphanBookId}/audio/cover_custom.jpg`, orphanCover);
await writeFixtureFile(ctx.fixture.booksPath, `covers/${orphanBookId}/audio/thumbnail.jpg`, orphanThumbnail);
const detailResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${valid.bookId}`,
headers: authHeader(ctx.adminToken),
});
expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json()).toMatchObject({
coverMedia: ['ebook'],
covers: {
ebook: { source: 'extracted', updatedAt: now.toISOString(), width: null, height: null },
audio: null,
},
coverVersion: `ebook:${now.toISOString()}:-`,
});
const cardsResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(ctx.adminToken),
payload: { pagination: { page: 0, size: 50 } },
});
expect(cardsResponse.statusCode).toBe(201);
const cards = (cardsResponse.json() as { items: Array<{ id: number; coverVersion: string }> }).items;
expect(cards.find((card) => card.id === valid.bookId)?.coverVersion).toBe(`ebook:${now.toISOString()}:-`);
const start = await ctx.app.inject({
method: 'POST',
url: '/api/v1/maintenance/missing-resources/sweep',
headers: authHeader(ctx.adminToken),
});
expect(start.statusCode).toBe(201);
const sweep = await waitForSweep(ctx);
expect(sweep).toMatchObject({ brokenCovers: 2, orphanedCoverDirs: 1 });
const brokenResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/maintenance/missing-resources/broken-covers',
headers: authHeader(ctx.adminToken),
});
expect(brokenResponse.statusCode).toBe(200);
const brokenPage = brokenResponse.json() as { total: number; items: Array<{ id: number }> };
expect(brokenPage.total).toBe(2);
expect(brokenPage.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: broken.bookId, coverSource: 'extracted', slots: [{ medium: 'ebook', source: 'extracted' }] }),
expect.objectContaining({ id: half.bookId, coverSource: 'custom', slots: [{ medium: 'audio', source: 'custom' }] }),
]),
);
const orphanResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/maintenance/missing-resources/orphaned-covers',
headers: authHeader(ctx.adminToken),
});
expect(orphanResponse.statusCode).toBe(200);
expect(orphanResponse.json()).toMatchObject({
total: 1,
items: [
{
bookId: orphanBookId,
fileCount: 2,
sizeBytes: orphanCover.length + orphanThumbnail.length,
media: ['audio'],
},
],
});
const cleanup = await ctx.app.inject({
method: 'POST',
url: '/api/v1/maintenance/missing-resources/broken-covers/clean',
headers: authHeader(ctx.adminToken),
payload: { bookIds: [broken.bookId, half.bookId] },
});
expect(cleanup.statusCode).toBe(201);
expect(cleanup.json()).toMatchObject({ category: 'broken_covers', requested: 2, cleaned: 2, skipped: 0, remaining: 0 });
const [removedSlot] = await ctx.db
.select()
.from(schema.bookCovers)
.where(and(eq(schema.bookCovers.bookId, broken.bookId), eq(schema.bookCovers.medium, 'ebook')));
const [brokenMetadata] = await ctx.db
.select({ coverSource: schema.bookMetadata.coverSource, coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, broken.bookId));
const [validSlot] = await ctx.db.select().from(schema.bookCovers).where(eq(schema.bookCovers.bookId, valid.bookId));
expect(removedSlot).toBeUndefined();
expect(brokenMetadata).toMatchObject({ coverSource: null, coverUpdatedAt: expect.any(Date) });
expect(validSlot).toBeDefined();
const halfSlots = await ctx.db
.select({ medium: schema.bookCovers.medium })
.from(schema.bookCovers)
.where(eq(schema.bookCovers.bookId, half.bookId));
const [halfMetadata] = await ctx.db
.select({ coverSource: schema.bookMetadata.coverSource })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, half.bookId));
expect(halfSlots).toEqual([{ medium: 'ebook' }]);
expect(halfMetadata).toEqual({ coverSource: 'extracted' });
});
});