refactor(scanner): rename staging module to book bucket

This commit is contained in:
neon
2026-04-03 20:50:11 -06:00
parent af7df49b02
commit 0a58a87239
82 changed files with 1073 additions and 1094 deletions
+11 -11
View File
@@ -18,7 +18,7 @@ import {
createLibraryWithFolder,
createOpdsUser,
createReadingSession,
createStagingRow,
createBookBucketRow,
createUserAndLogin,
grantLibraryAccess,
locateBookByAbsolutePath,
@@ -85,7 +85,7 @@ interface Personas {
koboActive: TestUserSession;
koboDisabled: TestUserSession;
koboRevoked: TestUserSession;
stagingUser: TestUserSession;
bookBucketUser: TestUserSession;
uploadUser: TestUserSession;
ownerUser: TestUserSession;
otherUser: TestUserSession;
@@ -172,7 +172,7 @@ describe('Authorization matrix (e2e)', () => {
koboActive: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
koboDisabled: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
koboRevoked: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
stagingUser: await createUserAndLogin(ctx, { permissions: [Permission.StagingAccess] }),
bookBucketUser: await createUserAndLogin(ctx, { permissions: [Permission.BookBucketAccess] }),
uploadUser: await createUserAndLogin(ctx, { permissions: [Permission.LibraryUpload] }),
ownerUser: await createUserAndLogin(ctx),
otherUser: await createUserAndLogin(ctx),
@@ -183,7 +183,7 @@ describe('Authorization matrix (e2e)', () => {
grantLibraryAccess(ctx, personas.allPermsUser.userId, libraryA.libraryId, 'owner'),
grantLibraryAccess(ctx, personas.allPermsUser.userId, libraryB.libraryId, 'owner'),
grantLibraryAccess(ctx, personas.metadataEditor.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.stagingUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.bookBucketUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.ownerUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.opdsOwner.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.opdsIntruder.userId, libraryA.libraryId, 'viewer'),
@@ -370,9 +370,9 @@ describe('Authorization matrix (e2e)', () => {
path: '/opds-users',
token: 'allPerms',
},
[Permission.StagingAccess]: {
[Permission.BookBucketAccess]: {
method: 'GET',
path: '/staging/summary',
path: '/book-bucket/summary',
token: 'allPerms',
},
[Permission.EmailSend]: {
@@ -916,14 +916,14 @@ describe('Authorization matrix (e2e)', () => {
});
describe('service authz - library scoped data and mixed batch semantics', () => {
it('returns mixed-result finalize envelope for staging authorization failures', async () => {
const accessibleRow = await createStagingRow(ctx, {
it('returns mixed-result finalize envelope for Book Bucket authorization failures', async () => {
const accessibleRow = await createBookBucketRow(ctx, {
fileName: `authz-finalize-ok-${randomUUID()}.fb2`,
targetLibraryId: libraryA.libraryId,
targetFolderId: libraryA.libraryFolderId,
status: 'ready',
});
const inaccessibleRow = await createStagingRow(ctx, {
const inaccessibleRow = await createBookBucketRow(ctx, {
fileName: `authz-finalize-denied-${randomUUID()}.fb2`,
targetLibraryId: libraryB.libraryId,
targetFolderId: libraryB.libraryFolderId,
@@ -932,8 +932,8 @@ describe('Authorization matrix (e2e)', () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
headers: authHeader(personas.stagingUser.accessToken),
url: '/api/v1/book-bucket/finalize',
headers: authHeader(personas.bookBucketUser.accessToken),
payload: {
fileIds: [accessibleRow.id, inaccessibleRow.id],
},
@@ -3,25 +3,25 @@ import { dirname, join } from 'path';
import { afterEach } from 'vitest';
import { eq, inArray } from 'drizzle-orm';
import { Permission, type StagingFile, type StagingFinalizeResult } from '@projectx/types';
import { Permission, type BookBucketFile, type BookBucketFinalizeResult } from '@projectx/types';
import * as schema from '../src/db/schema';
import { buildFb2Fixture } from './e2e/staging/staging-fixture-builder';
import { buildFb2Fixture } from './e2e/book-bucket/book-bucket-fixture-builder';
import {
authHeader,
closeStagingE2EContext,
closeBookBucketE2EContext,
createLibraryWithFolder,
createStagingE2EContext,
createStagingRow,
createBookBucketE2EContext,
createBookBucketRow,
createUserAndLogin,
fileExists,
getStagingRow,
getBookBucketRow,
grantLibraryAccess,
resetStagingState,
uploadStagingFile,
waitForStagingStatus,
type StagingE2EContext,
} from './e2e/staging/staging-harness';
resetBookBucketState,
uploadBookBucketFile,
waitForBookBucketStatus,
type BookBucketE2EContext,
} from './e2e/book-bucket/book-bucket-harness';
interface ScenarioRunResult {
id: string;
@@ -33,7 +33,7 @@ interface ScenarioRunResult {
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, 'staging-ingest-finalize-e2e-scenarios.json');
const reportPath = join(reportDir, 'book-bucket-ingest-finalize-e2e-scenarios.json');
await writeFile(
reportPath,
JSON.stringify(
@@ -50,13 +50,13 @@ async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void>
);
}
describe('Staging ingest + finalize (e2e)', () => {
let context!: StagingE2EContext;
describe('Book Bucket ingest + finalize (e2e)', () => {
let context!: BookBucketE2EContext;
const scenarioResults: ScenarioRunResult[] = [];
let scenarioStartedAt = 0;
beforeAll(async () => {
context = await createStagingE2EContext();
context = await createBookBucketE2EContext();
});
afterEach((taskContext) => {
@@ -76,19 +76,19 @@ describe('Staging ingest + finalize (e2e)', () => {
afterAll(async () => {
await writeScenarioReport(scenarioResults);
if (context) {
await closeStagingE2EContext(context);
await closeBookBucketE2EContext(context);
}
});
beforeEach(async () => {
scenarioStartedAt = Date.now();
await resetStagingState(context);
await resetBookBucketState(context);
});
it('ingests upload, extracts metadata, and supports review editing', async () => {
const destination = await createLibraryWithFolder(context);
const uploadResponse = await uploadStagingFile(context, {
const uploadResponse = await uploadBookBucketFile(context, {
token: context.adminToken,
fileName: 'ingest-review.fb2',
content: buildFb2Fixture({
@@ -100,8 +100,8 @@ describe('Staging ingest + finalize (e2e)', () => {
});
expect(uploadResponse.statusCode).toBe(201);
const uploaded = uploadResponse.json() as StagingFile;
const ready = await waitForStagingStatus(context, uploaded.id, ['ready']);
const uploaded = uploadResponse.json() as BookBucketFile;
const ready = await waitForBookBucketStatus(context, uploaded.id, ['ready']);
expect(ready.embeddedMetadata?.title).toBe('Ingest Review Title');
expect(ready.embeddedMetadata?.authors).toEqual(['Ingest Review Author']);
@@ -109,7 +109,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const patchResponse = await context.app.inject({
method: 'PATCH',
url: `/api/v1/staging/files/${uploaded.id}`,
url: `/api/v1/book-bucket/files/${uploaded.id}`,
headers: authHeader(context.adminToken),
payload: {
selectedMetadata: {
@@ -123,7 +123,7 @@ describe('Staging ingest + finalize (e2e)', () => {
});
expect(patchResponse.statusCode).toBe(200);
const updated = patchResponse.json() as StagingFile;
const updated = patchResponse.json() as BookBucketFile;
expect(updated.selectedMetadata).toMatchObject({
title: 'Edited Review Title',
authors: ['Edited Review Author'],
@@ -134,7 +134,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const summaryResponse = await context.app.inject({
method: 'GET',
url: '/api/v1/staging/summary',
url: '/api/v1/book-bucket/summary',
headers: authHeader(context.adminToken),
});
expect(summaryResponse.statusCode).toBe(200);
@@ -143,14 +143,14 @@ describe('Staging ingest + finalize (e2e)', () => {
it('bulk set-target selectAll with status=pending includes pending, extracting, and fetching', async () => {
const destination = await createLibraryWithFolder(context);
const pendingRow = await createStagingRow(context, { fileName: 'pending-target.fb2', status: 'pending' });
const extractingRow = await createStagingRow(context, { fileName: 'extracting-target.fb2', status: 'extracting' });
const fetchingRow = await createStagingRow(context, { fileName: 'fetching-target.fb2', status: 'fetching' });
const readyRow = await createStagingRow(context, { fileName: 'ready-target.fb2', status: 'ready' });
const pendingRow = await createBookBucketRow(context, { fileName: 'pending-target.fb2', status: 'pending' });
const extractingRow = await createBookBucketRow(context, { fileName: 'extracting-target.fb2', status: 'extracting' });
const fetchingRow = await createBookBucketRow(context, { fileName: 'fetching-target.fb2', status: 'fetching' });
const readyRow = await createBookBucketRow(context, { fileName: 'ready-target.fb2', status: 'ready' });
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/set-target',
url: '/api/v1/book-bucket/files/set-target',
headers: authHeader(context.adminToken),
payload: {
selectAll: true,
@@ -166,12 +166,12 @@ describe('Staging ingest + finalize (e2e)', () => {
const rows = await context.db
.select({
id: schema.stagingFiles.id,
targetLibraryId: schema.stagingFiles.targetLibraryId,
targetFolderId: schema.stagingFiles.targetFolderId,
id: schema.bookBucketFiles.id,
targetLibraryId: schema.bookBucketFiles.targetLibraryId,
targetFolderId: schema.bookBucketFiles.targetFolderId,
})
.from(schema.stagingFiles)
.where(inArray(schema.stagingFiles.id, [pendingRow.id, extractingRow.id, fetchingRow.id, readyRow.id]));
.from(schema.bookBucketFiles)
.where(inArray(schema.bookBucketFiles.id, [pendingRow.id, extractingRow.id, fetchingRow.id, readyRow.id]));
const rowById = new Map(rows.map((row) => [row.id, row]));
expect(rowById.get(pendingRow.id)).toMatchObject({
@@ -187,17 +187,17 @@ describe('Staging ingest + finalize (e2e)', () => {
});
it('bulk edit and apply fetched respect filters and metadataEditedAt', async () => {
const editableA = await createStagingRow(context, {
const editableA = await createBookBucketRow(context, {
fileName: 'bulk-edit-target-a.fb2',
selectedMetadata: { authors: ['Existing Author'] },
fetchedMetadata: { title: 'Fetched A', authors: ['Fetched Author A'] },
});
const editableB = await createStagingRow(context, {
const editableB = await createBookBucketRow(context, {
fileName: 'bulk-apply-edited-b.fb2',
fetchedMetadata: { title: 'Fetched B' },
metadataEditedAt: new Date(),
});
const untouched = await createStagingRow(context, {
const untouched = await createBookBucketRow(context, {
fileName: 'untouched-record.fb2',
selectedMetadata: { title: 'Untouched' },
fetchedMetadata: { title: 'Fetched Untouched' },
@@ -205,7 +205,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const bulkEditResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/bulk-edit',
url: '/api/v1/book-bucket/files/bulk-edit',
headers: authHeader(context.adminToken),
payload: {
selectAll: true,
@@ -224,7 +224,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const applyFetchedResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/apply-fetched',
url: '/api/v1/book-bucket/files/apply-fetched',
headers: authHeader(context.adminToken),
payload: {
fileIds: [editableA.id, editableB.id, untouched.id],
@@ -240,9 +240,9 @@ describe('Staging ingest + finalize (e2e)', () => {
});
const [rowA, rowB, rowUntouched] = await Promise.all([
getStagingRow(context, editableA.id),
getStagingRow(context, editableB.id),
getStagingRow(context, untouched.id),
getBookBucketRow(context, editableA.id),
getBookBucketRow(context, editableB.id),
getBookBucketRow(context, untouched.id),
]);
expect(rowA?.selectedMetadata).toMatchObject({ title: 'Fetched A', authors: ['Fetched Author A'] });
@@ -251,13 +251,13 @@ describe('Staging ingest + finalize (e2e)', () => {
});
it('bulk discard with selectAll + search honors excluded ids and removes files', async () => {
const removable = await createStagingRow(context, { fileName: 'discard-me-a.fb2' });
const excluded = await createStagingRow(context, { fileName: 'discard-me-b.fb2' });
const untouched = await createStagingRow(context, { fileName: 'keep-me.fb2' });
const removable = await createBookBucketRow(context, { fileName: 'discard-me-a.fb2' });
const excluded = await createBookBucketRow(context, { fileName: 'discard-me-b.fb2' });
const untouched = await createBookBucketRow(context, { fileName: 'keep-me.fb2' });
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/discard',
url: '/api/v1/book-bucket/files/discard',
headers: authHeader(context.adminToken),
payload: {
selectAll: true,
@@ -267,9 +267,9 @@ describe('Staging ingest + finalize (e2e)', () => {
});
expect(response.statusCode).toBe(204);
expect(await getStagingRow(context, removable.id)).toBeUndefined();
expect(await getStagingRow(context, excluded.id)).toBeDefined();
expect(await getStagingRow(context, untouched.id)).toBeDefined();
expect(await getBookBucketRow(context, removable.id)).toBeUndefined();
expect(await getBookBucketRow(context, excluded.id)).toBeDefined();
expect(await getBookBucketRow(context, untouched.id)).toBeDefined();
expect(await fileExists(removable.absolutePath)).toBe(false);
expect(await fileExists(excluded.absolutePath)).toBe(true);
@@ -278,7 +278,7 @@ describe('Staging ingest + finalize (e2e)', () => {
it('finalize moves files into destination and creates book records', async () => {
const destination = await createLibraryWithFolder(context);
const stagingRow = await createStagingRow(context, {
const bookBucketRow = await createBookBucketRow(context, {
fileName: 'finalize-success.fb2',
selectedMetadata: {
title: 'Finalize Success Title',
@@ -290,15 +290,15 @@ describe('Staging ingest + finalize (e2e)', () => {
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [stagingRow.id],
fileIds: [bookBucketRow.id],
},
});
expect(response.statusCode).toBe(201);
const body = response.json() as StagingFinalizeResult;
const body = response.json() as BookBucketFinalizeResult;
expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
expect(body.results[0]?.bookId).toEqual(expect.any(Number));
@@ -325,7 +325,7 @@ describe('Staging ingest + finalize (e2e)', () => {
expect(bookFile).toBeDefined();
expect(bookFile!.absolutePath.startsWith(destination.folderPath)).toBe(true);
expect(await fileExists(bookFile!.absolutePath)).toBe(true);
expect(await fileExists(stagingRow.absolutePath)).toBe(false);
expect(await fileExists(bookBucketRow.absolutePath)).toBe(false);
const [metadata] = await context.db
.select({ title: schema.bookMetadata.title })
@@ -333,13 +333,13 @@ describe('Staging ingest + finalize (e2e)', () => {
.where(eq(schema.bookMetadata.bookId, finalizedBookId))
.limit(1);
expect(metadata?.title).toBe('Finalize Success Title');
expect(await getStagingRow(context, stagingRow.id)).toBeUndefined();
expect(await getBookBucketRow(context, bookBucketRow.id)).toBeUndefined();
});
it('finalize returns partial success with duplicate and destination conflicts', async () => {
const destination = await createLibraryWithFolder(context);
const seedRow = await createStagingRow(context, {
const seedRow = await createBookBucketRow(context, {
fileName: 'seed-duplicate.fb2',
selectedMetadata: { title: 'Duplicate Title', authors: ['Duplicate Author'] },
targetLibraryId: destination.libraryId,
@@ -347,26 +347,26 @@ describe('Staging ingest + finalize (e2e)', () => {
});
const seedResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: { fileIds: [seedRow.id] },
});
const seedBody = seedResponse.json() as StagingFinalizeResult;
const seedBody = seedResponse.json() as BookBucketFinalizeResult;
const existingBookId = seedBody.results[0]!.bookId!;
const duplicateRow = await createStagingRow(context, {
const duplicateRow = await createBookBucketRow(context, {
fileName: 'duplicate-input.fb2',
selectedMetadata: { title: 'Duplicate Title', authors: ['Another Author'] },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const conflictRow = await createStagingRow(context, {
const conflictRow = await createBookBucketRow(context, {
fileName: 'name-conflict.fb2',
selectedMetadata: { title: 'Conflict Title', authors: ['Conflict Author'] },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const successRow = await createStagingRow(context, {
const successRow = await createBookBucketRow(context, {
fileName: 'will-succeed.fb2',
selectedMetadata: { title: 'Success Title', authors: ['Success Author'] },
targetLibraryId: destination.libraryId,
@@ -375,7 +375,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const previewNamesResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/preview-names',
url: '/api/v1/book-bucket/files/preview-names',
headers: authHeader(context.adminToken),
payload: {
fileIds: [conflictRow.id],
@@ -393,7 +393,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [duplicateRow.id, conflictRow.id, successRow.id],
@@ -401,7 +401,7 @@ describe('Staging ingest + finalize (e2e)', () => {
});
expect(response.statusCode).toBe(201);
const body = response.json() as StagingFinalizeResult;
const body = response.json() as BookBucketFinalizeResult;
expect(body).toMatchObject({ total: 3, succeeded: 1, failed: 2 });
const duplicateResult = body.results.find((result) => result.fileId === duplicateRow.id);
@@ -418,25 +418,25 @@ describe('Staging ingest + finalize (e2e)', () => {
expect(successResult?.success).toBe(true);
expect(successResult?.bookId).toEqual(expect.any(Number));
expect(await getStagingRow(context, duplicateRow.id)).toBeDefined();
expect(await getStagingRow(context, conflictRow.id)).toBeDefined();
expect(await getStagingRow(context, successRow.id)).toBeUndefined();
expect(await getBookBucketRow(context, duplicateRow.id)).toBeDefined();
expect(await getBookBucketRow(context, conflictRow.id)).toBeDefined();
expect(await getBookBucketRow(context, successRow.id)).toBeUndefined();
});
it('finalize selectAll honors status/search filters and excluded ids', async () => {
const destination = await createLibraryWithFolder(context);
const selected = await createStagingRow(context, {
const selected = await createBookBucketRow(context, {
fileName: 'batch-finalize-target-1.fb2',
selectedMetadata: { title: 'Batch Finalize 1' },
status: 'ready',
});
const excluded = await createStagingRow(context, {
const excluded = await createBookBucketRow(context, {
fileName: 'batch-finalize-target-2.fb2',
selectedMetadata: { title: 'Batch Finalize 2' },
status: 'ready',
});
const unselected = await createStagingRow(context, {
const unselected = await createBookBucketRow(context, {
fileName: 'other-finalize-target.fb2',
selectedMetadata: { title: 'Other Finalize Target' },
status: 'ready',
@@ -444,7 +444,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
selectAll: true,
@@ -458,14 +458,14 @@ describe('Staging ingest + finalize (e2e)', () => {
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
expect(await getStagingRow(context, selected.id)).toBeUndefined();
expect(await getStagingRow(context, excluded.id)).toBeDefined();
expect(await getStagingRow(context, unselected.id)).toBeDefined();
expect(await getBookBucketRow(context, selected.id)).toBeUndefined();
expect(await getBookBucketRow(context, excluded.id)).toBeDefined();
expect(await getBookBucketRow(context, unselected.id)).toBeDefined();
});
it('finalize marks missing file ids as failures instead of silently skipping them', async () => {
const destination = await createLibraryWithFolder(context);
const successfulRow = await createStagingRow(context, {
const successfulRow = await createBookBucketRow(context, {
fileName: 'missing-id-success.fb2',
selectedMetadata: { title: 'Missing Id Success' },
targetLibraryId: destination.libraryId,
@@ -475,7 +475,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [successfulRow.id, missingId],
@@ -483,22 +483,22 @@ describe('Staging ingest + finalize (e2e)', () => {
});
expect(response.statusCode).toBe(201);
const body = response.json() as StagingFinalizeResult;
const body = response.json() as BookBucketFinalizeResult;
expect(body).toMatchObject({ total: 2, succeeded: 1, failed: 1 });
expect(body.results.find((result) => result.fileId === missingId)).toMatchObject({
success: false,
message: 'Staging file not found',
message: 'Book Bucket file not found',
});
expect(await getStagingRow(context, successfulRow.id)).toBeUndefined();
expect(await getBookBucketRow(context, successfulRow.id)).toBeUndefined();
});
it('enforces staging permission and library access for finalize', async () => {
it('enforces Book Bucket permission and library access for finalize', async () => {
const noPermissionUser = await createUserAndLogin(context);
const withPermissionUser = await createUserAndLogin(context, {
permissions: [Permission.StagingAccess],
permissions: [Permission.BookBucketAccess],
});
const destination = await createLibraryWithFolder(context);
const stagingRow = await createStagingRow(context, {
const bookBucketRow = await createBookBucketRow(context, {
fileName: 'permission-check.fb2',
selectedMetadata: { title: 'Permission Check' },
status: 'ready',
@@ -506,43 +506,43 @@ describe('Staging ingest + finalize (e2e)', () => {
const forbiddenSummary = await context.app.inject({
method: 'GET',
url: '/api/v1/staging/summary',
url: '/api/v1/book-bucket/summary',
headers: authHeader(noPermissionUser.accessToken),
});
expect(forbiddenSummary.statusCode).toBe(403);
const allowedSummary = await context.app.inject({
method: 'GET',
url: '/api/v1/staging/summary',
url: '/api/v1/book-bucket/summary',
headers: authHeader(withPermissionUser.accessToken),
});
expect(allowedSummary.statusCode).toBe(200);
const noAccessFinalize = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(withPermissionUser.accessToken),
payload: {
fileIds: [stagingRow.id],
fileIds: [bookBucketRow.id],
defaultLibraryId: destination.libraryId,
defaultFolderId: destination.libraryFolderId,
},
});
expect(noAccessFinalize.statusCode).toBe(201);
const noAccessBody = noAccessFinalize.json() as StagingFinalizeResult;
const noAccessBody = noAccessFinalize.json() as BookBucketFinalizeResult;
expect(noAccessBody).toMatchObject({ total: 1, succeeded: 0, failed: 1 });
expect(noAccessBody.results[0]?.message).toContain('No access to this library');
expect(await getStagingRow(context, stagingRow.id)).toBeDefined();
expect(await getBookBucketRow(context, bookBucketRow.id)).toBeDefined();
await grantLibraryAccess(context, withPermissionUser.userId, destination.libraryId);
const withAccessFinalize = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(withPermissionUser.accessToken),
payload: {
fileIds: [stagingRow.id],
fileIds: [bookBucketRow.id],
defaultLibraryId: destination.libraryId,
defaultFolderId: destination.libraryFolderId,
},
@@ -550,13 +550,13 @@ describe('Staging ingest + finalize (e2e)', () => {
expect(withAccessFinalize.statusCode).toBe(201);
expect(withAccessFinalize.json()).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
expect(await getStagingRow(context, stagingRow.id)).toBeUndefined();
expect(await getBookBucketRow(context, bookBucketRow.id)).toBeUndefined();
});
it('rejects invalid request payloads and supports retry-fetch recovery', async () => {
const invalidPayloadResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/bulk-edit',
url: '/api/v1/book-bucket/files/bulk-edit',
headers: authHeader(context.adminToken),
payload: {
fileIds: [123],
@@ -573,7 +573,7 @@ describe('Staging ingest + finalize (e2e)', () => {
const targetValidationResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/set-target',
url: '/api/v1/book-bucket/files/set-target',
headers: authHeader(context.adminToken),
payload: {
fileIds: [],
@@ -586,7 +586,7 @@ describe('Staging ingest + finalize (e2e)', () => {
message: 'targetLibraryId and targetFolderId must both be set or both be null',
});
const retriableErrorRow = await createStagingRow(context, {
const retriableErrorRow = await createBookBucketRow(context, {
fileName: 'retry-fetch.fb2',
status: 'error',
errorMessage: 'Previous metadata failure',
@@ -595,14 +595,14 @@ describe('Staging ingest + finalize (e2e)', () => {
authors: ['Retry Author'],
}),
});
const nonErrorRow = await createStagingRow(context, {
const nonErrorRow = await createBookBucketRow(context, {
fileName: 'retry-ignore.fb2',
status: 'ready',
});
const retryResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/files/retry-fetch',
url: '/api/v1/book-bucket/files/retry-fetch',
headers: authHeader(context.adminToken),
payload: {
fileIds: [retriableErrorRow.id, nonErrorRow.id],
@@ -612,12 +612,12 @@ describe('Staging ingest + finalize (e2e)', () => {
expect(retryResponse.statusCode).toBe(201);
expect(retryResponse.json()).toEqual({ total: 2, queued: 1 });
const retried = await waitForStagingStatus(context, retriableErrorRow.id, ['ready']);
const retried = await waitForBookBucketStatus(context, retriableErrorRow.id, ['ready']);
expect(retried.errorMessage).toBeNull();
});
it('rejects unsupported upload formats', async () => {
const response = await uploadStagingFile(context, {
const response = await uploadBookBucketFile(context, {
token: context.adminToken,
fileName: 'unsupported.txt',
content: 'not a supported book format',
@@ -633,14 +633,14 @@ describe('Staging ingest + finalize (e2e)', () => {
it('returns destination validation failure when folder does not belong to selected library', async () => {
const destinationA = await createLibraryWithFolder(context);
const destinationB = await createLibraryWithFolder(context);
const row = await createStagingRow(context, {
const row = await createBookBucketRow(context, {
fileName: 'folder-mismatch.fb2',
selectedMetadata: { title: 'Folder Mismatch' },
});
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/staging/finalize',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [row.id],
@@ -650,9 +650,9 @@ describe('Staging ingest + finalize (e2e)', () => {
});
expect(response.statusCode).toBe(201);
const body = response.json() as StagingFinalizeResult;
const body = response.json() as BookBucketFinalizeResult;
expect(body).toMatchObject({ total: 1, succeeded: 0, failed: 1 });
expect(body.results[0]?.message).toContain('Folder does not belong to this library');
expect(await getStagingRow(context, row.id)).toBeDefined();
expect(await getBookBucketRow(context, row.id)).toBeDefined();
});
});
@@ -7,7 +7,7 @@ export { createEpubFixture } from '../metadata-write/metadata-write-fixture-buil
export interface AuthorizationMatrixFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
bookBucketPath: string;
cleanup: () => Promise<void>;
}
@@ -29,13 +29,13 @@ function assertRelativePath(path: string): void {
export async function createAuthorizationFixtureRoot(prefix = 'authorization-matrix-e2e-'): Promise<AuthorizationMatrixFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
await mkdir(stagingPath, { recursive: true });
const bookBucketPath = join(booksPath, 'book-bucket');
await mkdir(bookBucketPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
bookBucketPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
@@ -7,13 +7,13 @@ import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fa
import { mkdir, stat } from 'fs/promises';
import { and, eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { Permission, StagingMetadata } from '@projectx/types';
import type { Permission, BookBucketMetadata } from '@projectx/types';
import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
import { seedLibrary, waitForScanCompletion } from '../app-harness';
import {
buildFb2Fixture,
@@ -33,7 +33,7 @@ const ADMIN_SETUP_DTO = {
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
bookBucketPath: string | undefined;
}
export interface AuthorizationMatrixE2EContext {
@@ -66,14 +66,14 @@ export interface LocatedBookFile {
format: string | null;
}
export interface CreateStagingRowInput {
export interface CreateBookBucketRowInput {
fileName?: string;
content?: string | Buffer;
status?: typeof schema.stagingFiles.$inferInsert.status;
status?: typeof schema.bookBucketFiles.$inferInsert.status;
format?: string;
embeddedMetadata?: StagingMetadata | null;
selectedMetadata?: StagingMetadata | null;
fetchedMetadata?: StagingMetadata | null;
embeddedMetadata?: BookBucketMetadata | null;
selectedMetadata?: BookBucketMetadata | null;
fetchedMetadata?: BookBucketMetadata | null;
targetLibraryId?: number | null;
targetFolderId?: number | null;
confidence?: number | null;
@@ -99,11 +99,11 @@ export async function createAuthorizationMatrixE2EContext(): Promise<Authorizati
const fixture = await createAuthorizationFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -119,12 +119,12 @@ export async function createAuthorizationMatrixE2EContext(): Promise<Authorizati
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
await stopBookBucketWatcher(app);
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
await setSettingValue(db, 'staging_auto_fetch_metadata', 'false');
await setSettingValue(db, 'staging_auto_finalize_enabled', 'false');
await setSettingValue(db, 'book_bucket_auto_fetch_metadata', 'false');
await setSettingValue(db, 'book_bucket_auto_finalize_enabled', 'false');
await setSettingValue(db, 'opds_enabled', 'true');
return {
@@ -309,18 +309,18 @@ export async function createBookCoverArtifacts(
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent);
}
export async function createStagingRow(
export async function createBookBucketRow(
ctx: AuthorizationMatrixE2EContext,
input: CreateStagingRowInput = {},
): Promise<typeof schema.stagingFiles.$inferSelect> {
const fileName = input.fileName ?? `authz-staging-${randomUUID()}.fb2`;
input: CreateBookBucketRowInput = {},
): Promise<typeof schema.bookBucketFiles.$inferSelect> {
const fileName = input.fileName ?? `authz-book-bucket-${randomUUID()}.fb2`;
const content = input.content ?? buildFb2Fixture({ title: `Authorization Matrix ${randomUUID()}`, authors: ['Fixture Author'] });
const format = input.format ?? fileName.split('.').pop()?.toLowerCase() ?? 'fb2';
const absolutePath = await writeFixtureFile(ctx.fixture.booksPath, `staging/${fileName}`, content);
const absolutePath = await writeFixtureFile(ctx.fixture.booksPath, `book-bucket/${fileName}`, content);
const fileStat = await stat(absolutePath);
const [row] = await ctx.db
.insert(schema.stagingFiles)
.insert(schema.bookBucketFiles)
.values({
fileName,
absolutePath,
@@ -455,8 +455,8 @@ function makeMetadataNoopMock(): Pick<
};
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
async function stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(BookBucketWatcherService);
await watcher.onModuleDestroy();
}
@@ -525,6 +525,6 @@ function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
}
@@ -5,7 +5,7 @@
"Permission.ManageMetadataConfig": 32,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 17,
"Permission.StagingAccess": 17,
"Permission.BookBucketAccess": 17,
"Permission.ManageAppSettings": 16,
"Permission.ManageLibraries": 13,
"Permission.ManageUsers": 10,
@@ -21,13 +21,5 @@
"'viewer'": 3,
"'editor'": 1
},
"routeChunks": [
"routes-01.json",
"routes-02.json",
"routes-03.json",
"routes-04.json",
"routes-05.json",
"routes-06.json",
"routes-07.json"
]
"routeChunks": ["routes-01.json", "routes-02.json", "routes-03.json", "routes-04.json", "routes-05.json", "routes-06.json", "routes-07.json"]
}
@@ -5,9 +5,7 @@
"methodName": "updateAccess",
"httpMethod": "PATCH",
"path": "/libraries/:libraryId/access/:userId",
"permissions": [
"Permission.ManageLibraries"
],
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -19,9 +17,7 @@
"methodName": "revokeAccess",
"httpMethod": "DELETE",
"path": "/libraries/:libraryId/access/:userId",
"permissions": [
"Permission.ManageLibraries"
],
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -45,9 +41,7 @@
"methodName": "listProviderRuntime",
"httpMethod": "GET",
"path": "/metadata-fetch/providers/runtime",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -71,9 +65,7 @@
"methodName": "getGlobal",
"httpMethod": "GET",
"path": "/metadata-preferences/global",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -85,9 +77,7 @@
"methodName": "setGlobal",
"httpMethod": "PUT",
"path": "/metadata-preferences/global",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -99,9 +89,7 @@
"methodName": "getForLibrary",
"httpMethod": "GET",
"path": "/metadata-preferences/libraries/:id",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -113,9 +101,7 @@
"methodName": "setLibraryOverrides",
"httpMethod": "PUT",
"path": "/metadata-preferences/libraries/:id",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -127,9 +113,7 @@
"methodName": "resetGlobal",
"httpMethod": "DELETE",
"path": "/metadata-preferences/global",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -141,9 +125,7 @@
"methodName": "resetLibraryToGlobal",
"httpMethod": "DELETE",
"path": "/metadata-preferences/libraries/:id",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -155,9 +137,7 @@
"methodName": "getConfig",
"httpMethod": "GET",
"path": "/metadata-preferences/providers",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -169,9 +149,7 @@
"methodName": "updateConfig",
"httpMethod": "PUT",
"path": "/metadata-preferences/providers",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -195,9 +173,7 @@
"methodName": "updateWeights",
"httpMethod": "PATCH",
"path": "/metadata-score/weights",
"permissions": [
"Permission.ManageAppSettings"
],
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -209,9 +185,7 @@
"methodName": "recalculate",
"httpMethod": "POST",
"path": "/metadata-score/recalculate",
"permissions": [
"Permission.ManageAppSettings"
],
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -223,9 +197,7 @@
"methodName": "getRecalculationStatus",
"httpMethod": "GET",
"path": "/metadata-score/recalculate/status",
"permissions": [
"Permission.ManageAppSettings"
],
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -237,9 +209,7 @@
"methodName": "findAll",
"httpMethod": "GET",
"path": "/opds-users",
"permissions": [
"Permission.OpdsAccess"
],
"permissions": ["Permission.OpdsAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -251,9 +221,7 @@
"methodName": "create",
"httpMethod": "POST",
"path": "/opds-users",
"permissions": [
"Permission.OpdsAccess"
],
"permissions": ["Permission.OpdsAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -265,9 +233,7 @@
"methodName": "update",
"httpMethod": "PATCH",
"path": "/opds-users/:id",
"permissions": [
"Permission.OpdsAccess"
],
"permissions": ["Permission.OpdsAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -279,9 +245,7 @@
"methodName": "delete",
"httpMethod": "DELETE",
"path": "/opds-users/:id",
"permissions": [
"Permission.OpdsAccess"
],
"permissions": ["Permission.OpdsAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -297,9 +261,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -311,9 +273,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -325,9 +285,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -339,9 +297,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -353,9 +309,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -367,9 +321,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -381,9 +333,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -395,9 +345,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -409,9 +357,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -423,9 +369,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -437,9 +381,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -451,9 +393,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/opds/opds.controller.ts",
@@ -465,9 +405,7 @@
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": [
"OpdsEnabledGuard, OpdsAuthGuard"
]
"useGuards": ["OpdsEnabledGuard, OpdsAuthGuard"]
},
{
"file": "server/src/modules/path/path.controller.ts",
@@ -475,9 +413,7 @@
"methodName": "listDirectories",
"httpMethod": "GET",
"path": "/path",
"permissions": [
"Permission.ManageLibraries"
],
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -633,9 +569,7 @@
"methodName": "scan",
"httpMethod": "POST",
"path": "/scanner/libraries/:id/scan",
"permissions": [
"Permission.ManageLibraries"
],
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -647,23 +581,19 @@
"methodName": "refreshCovers",
"httpMethod": "POST",
"path": "/scanner/libraries/:id/refresh-covers",
"permissions": [
"Permission.ManageLibraries"
],
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "listFiles",
"httpMethod": "GET",
"path": "/staging/files",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -1,223 +1,191 @@
[
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "getSummary",
"httpMethod": "GET",
"path": "/staging/summary",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/summary",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "getStatistics",
"httpMethod": "GET",
"path": "/staging/statistics",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/statistics",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "getFile",
"httpMethod": "GET",
"path": "/staging/files/:id",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/:id",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "getCover",
"httpMethod": "GET",
"path": "/staging/files/:id/cover",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/:id/cover",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "upload",
"httpMethod": "POST",
"path": "/staging/upload",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/upload",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "updateFile",
"httpMethod": "PATCH",
"path": "/staging/files/:id",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/:id",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "discardFile",
"httpMethod": "DELETE",
"path": "/staging/files/:id",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/:id",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "bulkDiscard",
"httpMethod": "POST",
"path": "/staging/files/discard",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/discard",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "applyFetched",
"httpMethod": "POST",
"path": "/staging/files/apply-fetched",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/apply-fetched",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "retryFetch",
"httpMethod": "POST",
"path": "/staging/files/retry-fetch",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/retry-fetch",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "setTarget",
"httpMethod": "POST",
"path": "/staging/files/set-target",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/set-target",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "selectionSummary",
"httpMethod": "POST",
"path": "/staging/files/selection-summary",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/selection-summary",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "bulkEdit",
"httpMethod": "POST",
"path": "/staging/files/bulk-edit",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/bulk-edit",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "previewNames",
"httpMethod": "POST",
"path": "/staging/files/preview-names",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/files/preview-names",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "finalize",
"httpMethod": "POST",
"path": "/staging/finalize",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/finalize",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/staging/staging.controller.ts",
"className": "StagingController",
"file": "server/src/modules/book-bucket/book-bucket.controller.ts",
"className": "BookBucketController",
"methodName": "rescan",
"httpMethod": "POST",
"path": "/staging/rescan",
"permissions": [
"Permission.StagingAccess"
],
"path": "/book-bucket/rescan",
"permissions": ["Permission.BookBucketAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -469,9 +437,7 @@
"methodName": "uploadBook",
"httpMethod": "POST",
"path": "/libraries/:id/upload",
"permissions": [
"Permission.LibraryUpload"
],
"permissions": ["Permission.LibraryUpload"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -483,9 +449,7 @@
"methodName": "findAll",
"httpMethod": "GET",
"path": "/users",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -497,9 +461,7 @@
"methodName": "findAssignable",
"httpMethod": "GET",
"path": "/users/assignable",
"permissions": [
"Permission.ManageLibraries"
],
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -547,9 +509,7 @@
"methodName": "findById",
"httpMethod": "GET",
"path": "/users/:id",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -573,9 +533,7 @@
"methodName": "createUser",
"httpMethod": "POST",
"path": "/users",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -587,9 +545,7 @@
"methodName": "updateUser",
"httpMethod": "PATCH",
"path": "/users/:id",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -601,9 +557,7 @@
"methodName": "deleteUser",
"httpMethod": "DELETE",
"path": "/users/:id",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -615,9 +569,7 @@
"methodName": "setPermissions",
"httpMethod": "PUT",
"path": "/users/:id/permissions",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -629,9 +581,7 @@
"methodName": "setSuperuser",
"httpMethod": "PUT",
"path": "/users/:id/superuser",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -643,9 +593,7 @@
"methodName": "getLibraries",
"httpMethod": "GET",
"path": "/users/:id/libraries",
"permissions": [
"Permission.ManageUsers"
],
"permissions": ["Permission.ManageUsers"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
export interface StagingFixtureRoot {
export interface BookBucketFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
bookBucketPath: string;
cleanup: () => Promise<void>;
}
@@ -15,17 +15,17 @@ function assertRelativePath(path: string): void {
}
}
export async function createStagingFixtureRoot(prefix = 'staging-e2e-'): Promise<StagingFixtureRoot> {
export async function createBookBucketFixtureRoot(prefix = 'book-bucket-e2e-'): Promise<BookBucketFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
const bookBucketPath = join(booksPath, 'book-bucket');
await mkdir(stagingPath, { recursive: true });
await mkdir(bookBucketPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
bookBucketPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
@@ -51,8 +51,8 @@ export interface Fb2FixtureInput {
}
export function buildFb2Fixture(input: Fb2FixtureInput = {}): string {
const title = input.title ?? 'Staging Fixture Title';
const authors = input.authors ?? ['Staging Fixture Author'];
const title = input.title ?? 'Book Bucket Fixture Title';
const authors = input.authors ?? ['Book Bucket Fixture Author'];
const language = input.language ?? 'en';
const genre = input.genre ?? 'fiction';
const description = input.description ?? 'Fixture description';
@@ -9,32 +9,32 @@ import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { eq } from 'drizzle-orm';
import { Permission, type StagingMetadata } from '@projectx/types';
import { Permission, type BookBucketMetadata } from '@projectx/types';
import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
import { seedLibrary, waitForCondition, type Db } from '../app-harness';
import { buildFb2Fixture, createStagingFixtureRoot, writeFixtureFile, type StagingFixtureRoot } from './staging-fixture-builder';
import { buildFb2Fixture, createBookBucketFixtureRoot, writeFixtureFile, type BookBucketFixtureRoot } from './book-bucket-fixture-builder';
const ADMIN_SETUP_DTO = {
username: 'staging-e2e-admin',
name: 'Staging E2E Admin',
email: 'staging-e2e-admin@example.com',
password: 'StagingAdmin123',
username: 'book-bucket-e2e-admin',
name: 'Book Bucket E2E Admin',
email: 'book-bucket-e2e-admin@example.com',
password: 'BookBucketAdmin123',
};
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
bookBucketPath: string | undefined;
}
export interface StagingE2EContext {
export interface BookBucketE2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
fixture: StagingFixtureRoot;
fixture: BookBucketFixtureRoot;
envSnapshot: EnvSnapshot;
}
@@ -45,21 +45,21 @@ export interface TestUserSession {
accessToken: string;
}
export interface UploadStagingFileInput {
export interface UploadBookBucketFileInput {
token: string;
fileName: string;
content: string | Buffer;
contentType?: string;
}
export interface CreateStagingRowInput {
export interface CreateBookBucketRowInput {
fileName?: string;
content?: string | Buffer;
status?: typeof schema.stagingFiles.$inferInsert.status;
status?: typeof schema.bookBucketFiles.$inferInsert.status;
format?: string;
embeddedMetadata?: StagingMetadata | null;
selectedMetadata?: StagingMetadata | null;
fetchedMetadata?: StagingMetadata | null;
embeddedMetadata?: BookBucketMetadata | null;
selectedMetadata?: BookBucketMetadata | null;
fetchedMetadata?: BookBucketMetadata | null;
targetLibraryId?: number | null;
targetFolderId?: number | null;
confidence?: number | null;
@@ -77,15 +77,15 @@ export function authHeader(token: string): Record<string, string> {
return { authorization: `Bearer ${token}` };
}
export async function createStagingE2EContext(): Promise<StagingE2EContext> {
const fixture = await createStagingFixtureRoot();
export async function createBookBucketE2EContext(): Promise<BookBucketE2EContext> {
const fixture = await createBookBucketFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -105,12 +105,12 @@ export async function createStagingE2EContext(): Promise<StagingE2EContext> {
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
await stopBookBucketWatcher(app);
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
await setSetting(db, 'staging_auto_fetch_metadata', 'false');
await setSetting(db, 'staging_auto_finalize_enabled', 'false');
await setSetting(db, 'book_bucket_auto_fetch_metadata', 'false');
await setSetting(db, 'book_bucket_auto_finalize_enabled', 'false');
return {
app,
@@ -121,20 +121,20 @@ export async function createStagingE2EContext(): Promise<StagingE2EContext> {
};
}
export async function closeStagingE2EContext(ctx: StagingE2EContext): Promise<void> {
export async function closeBookBucketE2EContext(ctx: BookBucketE2EContext): Promise<void> {
await ctx.app.close();
await ctx.fixture.cleanup();
restoreEnv(ctx.envSnapshot);
}
export async function resetStagingState(ctx: StagingE2EContext): Promise<void> {
await ctx.db.delete(schema.stagingFiles);
await rm(ctx.fixture.stagingPath, { recursive: true, force: true });
await mkdir(ctx.fixture.stagingPath, { recursive: true });
export async function resetBookBucketState(ctx: BookBucketE2EContext): Promise<void> {
await ctx.db.delete(schema.bookBucketFiles);
await rm(ctx.fixture.bookBucketPath, { recursive: true, force: true });
await mkdir(ctx.fixture.bookBucketPath, { recursive: true });
}
export async function createLibraryWithFolder(
ctx: StagingE2EContext,
ctx: BookBucketE2EContext,
options: {
mode?: 'book_per_file' | 'book_per_folder';
allowedFormats?: string[];
@@ -160,7 +160,7 @@ export async function createLibraryWithFolder(
}
export async function createUserAndLogin(
ctx: StagingE2EContext,
ctx: BookBucketE2EContext,
options: {
permissions?: Permission[];
isSuperuser?: boolean;
@@ -170,8 +170,8 @@ export async function createUserAndLogin(
} = {},
): Promise<TestUserSession> {
const suffix = randomUUID().replaceAll('-', '');
const username = options.username ?? `staging-user-${suffix}`;
const password = options.password ?? 'StagingUser123';
const username = options.username ?? `book-bucket-user-${suffix}`;
const password = options.password ?? 'BookBucketUser123';
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(password, 12);
@@ -179,7 +179,7 @@ export async function createUserAndLogin(
.insert(schema.users)
.values({
username,
name: `Staging User ${suffix}`,
name: `Book Bucket User ${suffix}`,
email,
passwordHash,
isSuperuser: options.isSuperuser ?? false,
@@ -217,7 +217,7 @@ export async function createUserAndLogin(
}
export async function grantLibraryAccess(
ctx: StagingE2EContext,
ctx: BookBucketE2EContext,
userId: number,
libraryId: number,
accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer',
@@ -231,13 +231,13 @@ export async function grantLibraryAccess(
});
}
export async function uploadStagingFile(ctx: StagingE2EContext, input: UploadStagingFileInput) {
export async function uploadBookBucketFile(ctx: BookBucketE2EContext, input: UploadBookBucketFileInput) {
const contentBuffer = typeof input.content === 'string' ? Buffer.from(input.content, 'utf8') : input.content;
const { body, boundary } = buildMultipartBody(input.fileName, contentBuffer, input.contentType ?? 'application/octet-stream');
return ctx.app.inject({
method: 'POST',
url: '/api/v1/staging/upload',
url: '/api/v1/book-bucket/upload',
headers: {
...authHeader(input.token),
'content-type': `multipart/form-data; boundary=${boundary}`,
@@ -247,16 +247,19 @@ export async function uploadStagingFile(ctx: StagingE2EContext, input: UploadSta
});
}
export async function createStagingRow(ctx: StagingE2EContext, input: CreateStagingRowInput = {}): Promise<typeof schema.stagingFiles.$inferSelect> {
const fileName = input.fileName ?? `staging-${randomUUID()}.fb2`;
export async function createBookBucketRow(
ctx: BookBucketE2EContext,
input: CreateBookBucketRowInput = {},
): Promise<typeof schema.bookBucketFiles.$inferSelect> {
const fileName = input.fileName ?? `book-bucket-${randomUUID()}.fb2`;
const content = input.content ?? buildFb2Fixture({ title: `Title ${randomUUID()}`, authors: ['Fixture Author'] });
const format = (input.format ?? extname(fileName).toLowerCase().slice(1)) || 'fb2';
const relativePath = `staging/${fileName}`;
const relativePath = `book-bucket/${fileName}`;
const absolutePath = await writeFixtureFile(ctx.fixture.booksPath, relativePath, content);
const fileStat = await stat(absolutePath);
const [row] = await ctx.db
.insert(schema.stagingFiles)
.insert(schema.bookBucketFiles)
.values({
fileName,
absolutePath,
@@ -277,27 +280,27 @@ export async function createStagingRow(ctx: StagingE2EContext, input: CreateStag
return row;
}
export async function getStagingRow(ctx: StagingE2EContext, fileId: number) {
return ctx.db.query.stagingFiles.findFirst({
where: eq(schema.stagingFiles.id, fileId),
export async function getBookBucketRow(ctx: BookBucketE2EContext, fileId: number) {
return ctx.db.query.bookBucketFiles.findFirst({
where: eq(schema.bookBucketFiles.id, fileId),
});
}
export async function waitForStagingStatus(
ctx: StagingE2EContext,
export async function waitForBookBucketStatus(
ctx: BookBucketE2EContext,
fileId: number,
statuses: Array<typeof schema.stagingFiles.$inferSelect.status>,
statuses: Array<typeof schema.bookBucketFiles.$inferSelect.status>,
timeoutMs = 20_000,
): Promise<typeof schema.stagingFiles.$inferSelect> {
let row: typeof schema.stagingFiles.$inferSelect | undefined;
): Promise<typeof schema.bookBucketFiles.$inferSelect> {
let row: typeof schema.bookBucketFiles.$inferSelect | undefined;
await waitForCondition(async () => {
row = await getStagingRow(ctx, fileId);
row = await getBookBucketRow(ctx, fileId);
if (!row) {
throw new Error(`Staging file ${fileId} was not found`);
throw new Error(`Book Bucket file ${fileId} was not found`);
}
if (!statuses.includes(row.status)) {
throw new Error(`Staging file ${fileId} is ${row.status}, waiting for one of: ${statuses.join(', ')}`);
throw new Error(`Book Bucket file ${fileId} is ${row.status}, waiting for one of: ${statuses.join(', ')}`);
}
}, timeoutMs);
@@ -311,7 +314,7 @@ export async function fileExists(path: string): Promise<boolean> {
}
function buildMultipartBody(fileName: string, content: Buffer, contentType: string): { body: Buffer; boundary: string } {
const boundary = `----projectx-staging-${randomUUID()}`;
const boundary = `----projectx-book-bucket-${randomUUID()}`;
const preamble = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`,
'utf8',
@@ -320,8 +323,8 @@ function buildMultipartBody(fileName: string, content: Buffer, contentType: stri
return { body: Buffer.concat([preamble, content, closing]), boundary };
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
async function stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(BookBucketWatcherService);
await watcher.onModuleDestroy();
}
@@ -337,14 +340,14 @@ async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<strin
if (existingToken) return existingToken;
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `staging-e2e-admin-${suffix}`;
const fallbackUsername = `book-bucket-e2e-admin-${suffix}`;
const fallbackPassword = ADMIN_SETUP_DTO.password;
const passwordHash = await hash(fallbackPassword, 12);
await db.insert(schema.users).values({
username: fallbackUsername,
name: 'Staging E2E Admin',
email: `staging-e2e-admin-${suffix}@example.com`,
name: 'Book Bucket E2E Admin',
email: `book-bucket-e2e-admin-${suffix}@example.com`,
passwordHash,
isSuperuser: true,
isDefaultPassword: false,
@@ -390,6 +393,6 @@ function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
}
@@ -7,7 +7,7 @@ export { createEpubFixture } from '../metadata-write/metadata-write-fixture-buil
export interface EmailFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
bookBucketPath: string;
cleanup: () => Promise<void>;
}
@@ -20,13 +20,13 @@ function assertRelativePath(path: string): void {
export async function createEmailFixtureRoot(prefix = 'email-lifecycle-e2e-'): Promise<EmailFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
await mkdir(stagingPath, { recursive: true });
const bookBucketPath = join(booksPath, 'book-bucket');
await mkdir(bookBucketPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
bookBucketPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
+9 -9
View File
@@ -14,7 +14,7 @@ import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
import { waitForScanCompletion } from '../app-harness';
import { createEmailFixtureRoot, type EmailFixtureRoot } from './email-fixture-builder';
@@ -33,7 +33,7 @@ function sleep(ms: number): Promise<void> {
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
bookBucketPath: string | undefined;
appUrl: string | undefined;
}
@@ -330,12 +330,12 @@ export async function createEmailE2EContext(): Promise<EmailE2EContext> {
const fixture = await createEmailFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appUrl: process.env.APP_URL,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_URL = 'http://localhost:4173';
const smtpSink = new InProcessSmtpSink();
@@ -361,7 +361,7 @@ export async function createEmailE2EContext(): Promise<EmailE2EContext> {
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
await stopBookBucketWatcher(app);
const db = app.get<Db>(DB);
const admin = await getAdminSession(app, db);
@@ -662,8 +662,8 @@ function makeMetadataNoopMock(): Pick<
};
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
async function stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(BookBucketWatcherService);
await watcher.onModuleDestroy();
}
@@ -748,8 +748,8 @@ function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appUrl === undefined) delete process.env.APP_URL;
else process.env.APP_URL = snapshot.appUrl;
+4 -4
View File
@@ -7,7 +7,7 @@ export { createEpubFixture } from '../metadata-write/metadata-write-fixture-buil
export interface OpdsFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
bookBucketPath: string;
cleanup: () => Promise<void>;
}
@@ -20,13 +20,13 @@ function assertRelativePath(path: string): void {
export async function createOpdsFixtureRoot(prefix = 'opds-e2e-'): Promise<OpdsFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
await mkdir(stagingPath, { recursive: true });
const bookBucketPath = join(booksPath, 'book-bucket');
await mkdir(bookBucketPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
bookBucketPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
+9 -9
View File
@@ -12,7 +12,7 @@ import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
import { seedLibrary, waitForScanCompletion } from '../app-harness';
import { createOpdsFixtureRoot, type OpdsFixtureRoot, writeFixtureFile } from './opds-fixture-builder';
@@ -27,7 +27,7 @@ const ADMIN_SETUP_DTO = {
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
bookBucketPath: string | undefined;
}
export interface OpdsE2EContext {
@@ -73,11 +73,11 @@ export async function createOpdsE2EContext(): Promise<OpdsE2EContext> {
const fixture = await createOpdsFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -92,7 +92,7 @@ export async function createOpdsE2EContext(): Promise<OpdsE2EContext> {
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
await stopBookBucketWatcher(app);
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
@@ -331,8 +331,8 @@ function makeMetadataNoopMock(): Pick<
};
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
async function stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(BookBucketWatcherService);
await watcher.onModuleDestroy();
}
@@ -400,8 +400,8 @@ function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
}
export { seedLibrary };
@@ -7,7 +7,7 @@ export { createEpubFixture } from '../metadata-write/metadata-write-fixture-buil
export interface ReaderStateIsolationFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
bookBucketPath: string;
cleanup: () => Promise<void>;
}
@@ -20,13 +20,13 @@ function assertRelativePath(path: string): void {
export async function createReaderStateIsolationFixtureRoot(prefix = 'reader-state-isolation-e2e-'): Promise<ReaderStateIsolationFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
await mkdir(stagingPath, { recursive: true });
const bookBucketPath = join(booksPath, 'book-bucket');
await mkdir(bookBucketPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
bookBucketPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
@@ -13,7 +13,7 @@ import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
import { seedLibrary, waitForScanCompletion } from '../app-harness';
import { createReaderStateIsolationFixtureRoot, type ReaderStateIsolationFixtureRoot } from './reader-state-isolation-fixture-builder';
@@ -28,7 +28,7 @@ const ADMIN_SETUP_DTO = {
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
bookBucketPath: string | undefined;
}
export interface ReaderStateIsolationE2EContext {
@@ -69,11 +69,11 @@ export async function createReaderStateIsolationE2EContext(): Promise<ReaderStat
const fixture = await createReaderStateIsolationFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -95,7 +95,7 @@ export async function createReaderStateIsolationE2EContext(): Promise<ReaderStat
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
await stopBookBucketWatcher(app);
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
@@ -263,8 +263,8 @@ export async function grantLibraryAccess(
});
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
async function stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(BookBucketWatcherService);
await watcher.onModuleDestroy();
}
@@ -338,8 +338,8 @@ function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
}
export { seedLibrary };
@@ -5,20 +5,20 @@ import { join } from 'path';
export interface UsersAdminLifecycleFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
bookBucketPath: string;
cleanup: () => Promise<void>;
}
export async function createUsersAdminLifecycleFixtureRoot(prefix = 'users-admin-lifecycle-e2e-'): Promise<UsersAdminLifecycleFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
await mkdir(stagingPath, { recursive: true });
const bookBucketPath = join(booksPath, 'book-bucket');
await mkdir(bookBucketPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
bookBucketPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
@@ -14,7 +14,7 @@ import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
import { seedLibrary } from '../app-harness';
import { createUsersAdminLifecycleFixtureRoot, type UsersAdminLifecycleFixtureRoot } from './users-admin-lifecycle-fixture-builder';
@@ -30,7 +30,7 @@ const MAX_MULTIPART_BYTES = 20 * 1024 * 1024;
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
bookBucketPath: string | undefined;
}
export interface UsersAdminLifecycleE2EContext {
@@ -68,11 +68,11 @@ export async function createUsersAdminLifecycleE2EContext(): Promise<UsersAdminL
const fixture = await createUsersAdminLifecycleFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -95,7 +95,7 @@ export async function createUsersAdminLifecycleE2EContext(): Promise<UsersAdminL
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
await stopBookBucketWatcher(app);
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
@@ -245,8 +245,8 @@ export async function setUserActive(ctx: UsersAdminLifecycleE2EContext, userId:
await ctx.db.update(schema.users).set({ active }).where(eq(schema.users.id, userId));
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
async function stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(BookBucketWatcherService);
await watcher.onModuleDestroy();
}
@@ -320,6 +320,6 @@ function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
}