mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
e28bd67fa3
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.
1309 lines
45 KiB
TypeScript
1309 lines
45 KiB
TypeScript
import { access, readFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { eq, inArray } from 'drizzle-orm';
|
|
|
|
import * as schema from '../src/db/schema';
|
|
import { assertNoIntegrityViolations } from './e2e/app-harness';
|
|
import {
|
|
apiJson,
|
|
apiText,
|
|
buildBookloreConnectionConfig,
|
|
closeMigrationBookloreE2EContext,
|
|
coverDirectoryForBook,
|
|
createMigrationBookloreE2EContext,
|
|
createUser,
|
|
resetMigrationBookloreState,
|
|
type MigrationBookloreE2EContext,
|
|
waitForMigrationToFinish,
|
|
} from './e2e/migration-booklore/migration-booklore-harness';
|
|
import {
|
|
seedCanonicalScenario,
|
|
seedCanonicalScenarioWithDuplicateUserStateRows,
|
|
seedCompatibilityScenario,
|
|
seedMissingRequiredTablesScenario,
|
|
seedWarningsOnlyScenario,
|
|
} from './e2e/migration-booklore/migration-booklore-fixture-builder';
|
|
|
|
interface MetricRow {
|
|
stage: string;
|
|
entityType: string;
|
|
processed: number;
|
|
imported: number;
|
|
skipped: number;
|
|
unresolved: number;
|
|
failed: number;
|
|
updatedAt: string;
|
|
}
|
|
|
|
describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
|
|
let ctx: MigrationBookloreE2EContext | null = null;
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createMigrationBookloreE2EContext();
|
|
}, 60_000);
|
|
|
|
beforeEach(async () => {
|
|
if (!ctx) throw new Error('Migration Booklore test context failed to initialize');
|
|
await resetMigrationBookloreState(ctx);
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (!ctx) return;
|
|
await closeMigrationBookloreE2EContext(ctx);
|
|
}, 60_000);
|
|
|
|
it('imports canonical Booklore data from API through Postgres and covers', async () => {
|
|
const scenario = await seedCanonicalScenario(ctx);
|
|
|
|
const supportedTypes = await apiJson<string[]>(ctx, {
|
|
method: 'GET',
|
|
url: '/api/v1/migration/supported-types',
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(supportedTypes.statusCode).toBe(200);
|
|
expect(supportedTypes.body).toEqual(expect.arrayContaining(['booklore', 'grimmory']));
|
|
|
|
const testedSource = await apiJson<{
|
|
ok: boolean;
|
|
sourceType: string;
|
|
warnings: string[];
|
|
counts: Record<string, number>;
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources/test',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(testedSource.statusCode).toBe(201);
|
|
expect(testedSource.body.ok).toBe(true);
|
|
expect(testedSource.body.sourceType).toBe('booklore');
|
|
expect(testedSource.body.warnings).toEqual([]);
|
|
expect(testedSource.body.counts.book).toBe(7);
|
|
|
|
const createdSource = await apiJson<{
|
|
id: number;
|
|
type: string;
|
|
name: string;
|
|
connectionConfig: Record<string, unknown>;
|
|
lastValidatedAt: string | null;
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
name: 'Canonical Booklore',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(createdSource.statusCode).toBe(201);
|
|
expect(createdSource.body.connectionConfig).toMatchObject({
|
|
host: '127.0.0.1',
|
|
user: 'booklore',
|
|
database: 'booklore',
|
|
password: '********',
|
|
mediaRootPath: ctx.sourceMediaRoot,
|
|
});
|
|
|
|
const sourceId = createdSource.body.id;
|
|
|
|
const validatedSource = await apiJson<{
|
|
ok: boolean;
|
|
missingTables: string[];
|
|
warnings: string[];
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${sourceId}/validate`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(validatedSource.statusCode).toBe(200);
|
|
expect(validatedSource.body.ok).toBe(true);
|
|
expect(validatedSource.body.missingTables).toEqual([]);
|
|
expect(validatedSource.body.warnings).toEqual([]);
|
|
|
|
const sourcePrefixes = await apiJson<{ prefixes: string[] }>(ctx, {
|
|
method: 'GET',
|
|
url: `/api/v1/migration/sources/${sourceId}/path-prefixes`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(sourcePrefixes.statusCode).toBe(200);
|
|
expect(sourcePrefixes.body.prefixes).toEqual(['/booklore-media/library']);
|
|
|
|
const suggestions = await apiJson<{
|
|
sourceId: number;
|
|
suggestions: Array<{
|
|
sourceUserId: string;
|
|
suggestedTargetUserId: number | null;
|
|
confidence: string | null;
|
|
}>;
|
|
}>(ctx, {
|
|
method: 'GET',
|
|
url: `/api/v1/migration/sources/${sourceId}/user-mapping-suggestions`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(suggestions.statusCode).toBe(200);
|
|
expect(suggestions.body.sourceId).toBe(sourceId);
|
|
expect(suggestions.body.suggestions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
sourceUserId: '1',
|
|
suggestedTargetUserId: scenario.targetUsers.alice.id,
|
|
confidence: 'high',
|
|
}),
|
|
expect.objectContaining({
|
|
sourceUserId: '2',
|
|
suggestedTargetUserId: scenario.targetUsers.bob.id,
|
|
confidence: 'high',
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const profile = await apiJson<{
|
|
id: number;
|
|
sourceId: number;
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/profiles',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
sourceId,
|
|
name: 'Canonical Profile',
|
|
userMappings: [
|
|
{ sourceUserId: '1', targetUserId: scenario.targetUsers.alice.id },
|
|
{ sourceUserId: '2', targetUserId: scenario.targetUsers.bob.id },
|
|
],
|
|
pathMappings: scenario.pathMappings,
|
|
},
|
|
});
|
|
expect(profile.statusCode).toBe(201);
|
|
expect(profile.body.sourceId).toBe(sourceId);
|
|
|
|
const pathValidation = await apiJson<{
|
|
sourceId: number;
|
|
persistedProfileId: number | null;
|
|
summary: {
|
|
totalSourceBooks: number;
|
|
booksWithFilePath: number;
|
|
mappedByPrefix: number;
|
|
matchedTargetPaths: number;
|
|
unmatchedTargetPaths: number;
|
|
};
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${sourceId}/path-mappings/validate`,
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
pathMappings: scenario.pathMappings,
|
|
sampleLimit: 5,
|
|
},
|
|
});
|
|
expect(pathValidation.statusCode).toBe(201);
|
|
expect(pathValidation.body.sourceId).toBe(sourceId);
|
|
expect(pathValidation.body.persistedProfileId).toBe(profile.body.id);
|
|
expect(pathValidation.body.summary.totalSourceBooks).toBe(7);
|
|
expect(pathValidation.body.summary.booksWithFilePath).toBe(7);
|
|
expect(pathValidation.body.summary.mappedByPrefix).toBe(2);
|
|
expect(pathValidation.body.summary.matchedTargetPaths).toBe(2);
|
|
expect(pathValidation.body.summary.unmatchedTargetPaths).toBe(5);
|
|
|
|
const dryRun = await apiJson<{
|
|
id: number;
|
|
profileId: number;
|
|
plan: {
|
|
duplicateBookMatches: Array<{ targetBookId: number; sourceBookIds: string[] }>;
|
|
};
|
|
summary: {
|
|
status: string;
|
|
matchedBooks: number;
|
|
unresolvedBooks: number;
|
|
duplicateBookMatches: number;
|
|
perUserPreview: Array<{
|
|
sourceUserId: string;
|
|
targetUserId: number;
|
|
counts: { statuses: number; fileProgress: number; readingSessions: number; bookmarks: number; annotations: number; shelves: number };
|
|
}>;
|
|
};
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/plans/dry-run',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
profileId: profile.body.id,
|
|
},
|
|
});
|
|
expect(dryRun.statusCode).toBe(201);
|
|
expect(dryRun.body.profileId).toBe(profile.body.id);
|
|
expect(dryRun.body.summary.status).toBe('blocked');
|
|
expect(dryRun.body.summary.matchedBooks).toBe(4);
|
|
expect(dryRun.body.summary.unresolvedBooks).toBe(1);
|
|
expect(dryRun.body.summary.duplicateBookMatches).toBe(1);
|
|
expect(dryRun.body.plan.duplicateBookMatches).toEqual([
|
|
expect.objectContaining({
|
|
targetBookId: scenario.books.duplicate.bookId,
|
|
sourceBookIds: [scenario.sourceBookIds.duplicatePreferred, scenario.sourceBookIds.duplicateRejected],
|
|
}),
|
|
]);
|
|
expect(dryRun.body.summary.perUserPreview).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
sourceUserId: '1',
|
|
targetUserId: scenario.targetUsers.alice.id,
|
|
counts: expect.objectContaining({
|
|
statuses: 1,
|
|
fileProgress: 2,
|
|
readingSessions: 2,
|
|
bookmarks: 1,
|
|
annotations: 1,
|
|
shelves: 2,
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
sourceUserId: '2',
|
|
targetUserId: scenario.targetUsers.bob.id,
|
|
counts: expect.objectContaining({
|
|
statuses: 1,
|
|
fileProgress: 1,
|
|
readingSessions: 1,
|
|
bookmarks: 1,
|
|
annotations: 1,
|
|
shelves: 1,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const blockedRun = await apiJson<{ message: string }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/runs/live',
|
|
token: ctx.adminToken,
|
|
payload: { planArtifactId: dryRun.body.id },
|
|
});
|
|
expectError(blockedRun, 400, 'duplicate target book matches');
|
|
|
|
const resolvedPlan = await apiJson<{
|
|
id: number;
|
|
summary: {
|
|
status: string;
|
|
matchedBooks: number;
|
|
unresolvedBooks: number;
|
|
duplicateBookMatches: number;
|
|
unresolvedByReason: Record<string, number>;
|
|
};
|
|
plan: {
|
|
duplicateBookMatches: unknown[];
|
|
};
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/plans/${dryRun.body.id}/resolve-duplicates`,
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
resolutions: [{ targetBookId: scenario.books.duplicate.bookId, selectedSourceBookId: scenario.sourceBookIds.duplicatePreferred }],
|
|
},
|
|
});
|
|
expect(resolvedPlan.statusCode).toBe(200);
|
|
expect(resolvedPlan.body.summary.status).toBe('ready_for_live_run');
|
|
expect(resolvedPlan.body.summary.matchedBooks).toBe(5);
|
|
expect(resolvedPlan.body.summary.unresolvedBooks).toBe(2);
|
|
expect(resolvedPlan.body.summary.duplicateBookMatches).toBe(0);
|
|
expect(resolvedPlan.body.summary.unresolvedByReason).toEqual({
|
|
no_title_author_match: 1,
|
|
duplicate_target_match: 1,
|
|
});
|
|
expect(resolvedPlan.body.plan.duplicateBookMatches).toEqual([]);
|
|
|
|
const startedRun = await apiJson<{
|
|
id: number;
|
|
state: string;
|
|
currentStage: string | null;
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/runs/live',
|
|
token: ctx.adminToken,
|
|
payload: { planArtifactId: resolvedPlan.body.id },
|
|
});
|
|
expect(startedRun.statusCode).toBe(201);
|
|
expect(startedRun.body.state).toBe('running');
|
|
expect(startedRun.body.currentStage).toBe('init');
|
|
|
|
const finished = await waitForMigrationToFinish(ctx, startedRun.body.id);
|
|
expect(finished.progress.run.state).toBe('completed');
|
|
expect(finished.report.run.state).toBe('completed');
|
|
expect(finished.report.plan).not.toBeNull();
|
|
expect(finished.report.summary).not.toBeNull();
|
|
|
|
const workflowState = await apiJson<{
|
|
active: {
|
|
source: { id: number; connectionConfig: Record<string, unknown> };
|
|
profile: { id: number } | null;
|
|
plan: { id: number } | null;
|
|
run: { id: number; state: string } | null;
|
|
} | null;
|
|
hasActiveRun: boolean;
|
|
}>(ctx, {
|
|
method: 'GET',
|
|
url: '/api/v1/migration/state',
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(workflowState.statusCode).toBe(200);
|
|
expect(workflowState.body.hasActiveRun).toBe(false);
|
|
expect(workflowState.body.active).toEqual(
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
id: sourceId,
|
|
connectionConfig: expect.objectContaining({
|
|
host: '127.0.0.1',
|
|
user: 'booklore',
|
|
password: '********',
|
|
}),
|
|
}),
|
|
profile: expect.objectContaining({ id: profile.body.id }),
|
|
plan: expect.objectContaining({ id: resolvedPlan.body.id }),
|
|
run: expect.objectContaining({ id: startedRun.body.id, state: 'completed' }),
|
|
}),
|
|
);
|
|
|
|
const jsonExport = await apiText(ctx, {
|
|
method: 'GET',
|
|
url: `/api/v1/migration/runs/${startedRun.body.id}/report/export?format=json`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(jsonExport.statusCode).toBe(200);
|
|
expect(jsonExport.headers['content-type']).toContain('application/json');
|
|
const parsedJsonExport = JSON.parse(jsonExport.body) as {
|
|
run: { state: string };
|
|
details: {
|
|
matchedBooks: Array<{ sourceBookId: string; sourceTitle: string | null; targetBookId: number; strategy: string }>;
|
|
userPreview: unknown[];
|
|
};
|
|
};
|
|
expect(parsedJsonExport.run.state).toBe('completed');
|
|
expect(parsedJsonExport.details.matchedBooks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
sourceBookId: '101',
|
|
sourceTitle: 'Imported ISBN Title',
|
|
targetBookId: scenario.books.isbn.bookId,
|
|
strategy: 'isbn',
|
|
}),
|
|
]),
|
|
);
|
|
expect(parsedJsonExport.details.userPreview).toHaveLength(2);
|
|
|
|
const csvExport = await apiText(ctx, {
|
|
method: 'GET',
|
|
url: `/api/v1/migration/runs/${startedRun.body.id}/report/export?format=csv`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(csvExport.statusCode).toBe(200);
|
|
expect(csvExport.headers['content-type']).toContain('text/csv');
|
|
expect(csvExport.body).toContain(
|
|
'section,stage,entityType,processed,imported,skipped,unresolved,failed,sourceBookId,sourceTitle,sourceAuthor,targetBookId,targetTitle,strategy,sourceUserId,targetUserId,username,reason,details,code,message,createdAt',
|
|
);
|
|
expect(csvExport.body).toContain('matched_books,shared_overlays,book_match');
|
|
expect(csvExport.body).toContain('Imported ISBN Title');
|
|
|
|
const reportDetails = finished.report.details as {
|
|
matchedBooks?: Array<{ sourceBookId: string; sourceTitle: string | null; targetBookId: number; strategy: string }>;
|
|
userPreview?: Array<{ username: string; counts: Record<string, number> }>;
|
|
};
|
|
expect(reportDetails.matchedBooks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
sourceBookId: '101',
|
|
sourceTitle: 'Imported ISBN Title',
|
|
targetBookId: scenario.books.isbn.bookId,
|
|
strategy: 'isbn',
|
|
}),
|
|
expect.objectContaining({
|
|
sourceBookId: '102',
|
|
sourceTitle: 'Imported Hash Title',
|
|
targetBookId: scenario.books.hash.bookId,
|
|
strategy: 'file_hash',
|
|
}),
|
|
]),
|
|
);
|
|
expect(reportDetails.userPreview).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
username: 'alice-source',
|
|
counts: expect.objectContaining({ statuses: 2, fileProgress: 2, readingSessions: 2, bookmarks: 1, annotations: 1, shelves: 2 }),
|
|
}),
|
|
expect.objectContaining({
|
|
username: 'bob-source',
|
|
counts: expect.objectContaining({ statuses: 1, fileProgress: 1, readingSessions: 1, bookmarks: 1, annotations: 1, shelves: 1 }),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const metricByKey = buildMetricMap(finished.report.metrics as MetricRow[]);
|
|
expect(metricByKey.get('shared_overlays:book_metadata')).toMatchObject({
|
|
processed: 5,
|
|
imported: 5,
|
|
skipped: 0,
|
|
unresolved: 0,
|
|
failed: 0,
|
|
});
|
|
expect(metricByKey.get('shared_overlays:book_authors')).toMatchObject({
|
|
processed: 5,
|
|
imported: 7,
|
|
});
|
|
expect(metricByKey.get('shared_overlays:book_narrators')).toMatchObject({
|
|
processed: 5,
|
|
imported: 4,
|
|
});
|
|
expect(metricByKey.get('shared_overlays:book_genres')).toMatchObject({
|
|
processed: 5,
|
|
imported: 3,
|
|
});
|
|
expect(metricByKey.get('shared_overlays:book_tags')).toMatchObject({
|
|
processed: 5,
|
|
imported: 3,
|
|
});
|
|
expect(metricByKey.get('book_covers:book_covers')).toMatchObject({
|
|
processed: 5,
|
|
imported: 2,
|
|
unresolved: 3,
|
|
failed: 0,
|
|
});
|
|
expect(metricByKey.get('user_state:user_book_status')).toMatchObject({
|
|
processed: 4,
|
|
imported: 3,
|
|
unresolved: 1,
|
|
});
|
|
expect(metricByKey.get('user_state:reading_progress')).toMatchObject({
|
|
processed: 3,
|
|
imported: 2,
|
|
unresolved: 1,
|
|
});
|
|
expect(metricByKey.get('user_state:audiobook_progress')).toMatchObject({
|
|
processed: 1,
|
|
imported: 1,
|
|
});
|
|
expect(metricByKey.get('user_state:reading_sessions')).toMatchObject({
|
|
processed: 4,
|
|
imported: 3,
|
|
unresolved: 1,
|
|
skipped: 0,
|
|
failed: 0,
|
|
});
|
|
expect(metricByKey.get('user_state:bookmarks')).toMatchObject({
|
|
processed: 2,
|
|
imported: 2,
|
|
});
|
|
expect(metricByKey.get('user_state:annotations')).toMatchObject({
|
|
processed: 2,
|
|
imported: 2,
|
|
});
|
|
expect(metricByKey.get('user_state:collections')).toMatchObject({
|
|
processed: 3,
|
|
imported: 3,
|
|
});
|
|
|
|
const metadataRows = await ctx.db
|
|
.select()
|
|
.from(schema.bookMetadata)
|
|
.where(
|
|
inArray(schema.bookMetadata.bookId, [
|
|
scenario.books.isbn.bookId,
|
|
scenario.books.hash.bookId,
|
|
scenario.books.audio.bookId,
|
|
scenario.books.titleAuthor.bookId,
|
|
scenario.books.duplicate.bookId,
|
|
]),
|
|
);
|
|
const metadataByBookId = new Map(metadataRows.map((row) => [row.bookId, row]));
|
|
|
|
expect(metadataByBookId.get(scenario.books.isbn.bookId)).toMatchObject({
|
|
title: 'Imported ISBN Title',
|
|
subtitle: 'Imported Subtitle',
|
|
description: 'Long imported description',
|
|
isbn10: '1234567890',
|
|
isbn13: '9781111111111',
|
|
publisher: 'Imported Publisher',
|
|
publishedYear: 2021,
|
|
language: 'en',
|
|
pageCount: 321,
|
|
seriesName: 'Series Prime',
|
|
seriesIndex: '2.5',
|
|
rating: 5,
|
|
googleBooksId: 'google-101',
|
|
goodreadsId: 'gr-101',
|
|
amazonId: 'ASIN101',
|
|
hardcoverId: 'hc-101',
|
|
audibleId: 'aud-101',
|
|
comicvineId: 'cv-101',
|
|
durationSeconds: 11111,
|
|
abridged: true,
|
|
coverSource: 'custom',
|
|
});
|
|
expect(metadataByBookId.get(scenario.books.hash.bookId)).toMatchObject({
|
|
title: 'Imported Hash Title',
|
|
description: 'Hash description',
|
|
subtitle: null,
|
|
publishedYear: null,
|
|
pageCount: null,
|
|
rating: null,
|
|
googleBooksId: null,
|
|
durationSeconds: null,
|
|
});
|
|
expect(metadataByBookId.get(scenario.books.audio.bookId)).toMatchObject({
|
|
title: 'Audio Path Match',
|
|
description: 'Audio description',
|
|
publisher: 'Audio Publisher',
|
|
audibleId: 'audio-id-103',
|
|
durationSeconds: 300,
|
|
abridged: true,
|
|
coverSource: 'custom',
|
|
});
|
|
expect(metadataByBookId.get(scenario.books.titleAuthor.bookId)?.title).toBe('Title Author Match');
|
|
expect(metadataByBookId.get(scenario.books.duplicate.bookId)?.title).toBe('Duplicate Preferred');
|
|
|
|
await expectContributorNames(ctx, scenario.books.isbn.bookId, 'author', ['Jane Primary', 'John Secondary']);
|
|
await expectContributorNames(ctx, scenario.books.audio.bookId, 'author', ['Audio Author', 'Guest Author']);
|
|
await expectContributorNames(ctx, scenario.books.titleAuthor.bookId, 'author', ['Unique Title Author']);
|
|
await expectContributorNames(ctx, scenario.books.audio.bookId, 'narrator', ['Narrator Alpha', 'Narrator Beta']);
|
|
|
|
expect(await loadNamesForBook(ctx, scenario.books.isbn.bookId, 'genre')).toEqual(['Adventure', 'Fantasy']);
|
|
expect(await loadNamesForBook(ctx, scenario.books.isbn.bookId, 'tag')).toEqual(['Imported Tag 1', 'Imported Tag 2']);
|
|
expect(await loadNamesForBook(ctx, scenario.books.hash.bookId, 'tag')).toEqual(['Hash Imported Tag']);
|
|
|
|
const statusRows = await ctx.db.select().from(schema.userBookStatus);
|
|
expect(statusRows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookId: scenario.books.isbn.bookId,
|
|
status: 'read',
|
|
source: 'manual',
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.bob.id,
|
|
bookId: scenario.books.audio.bookId,
|
|
status: 'reading',
|
|
source: 'manual',
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookId: scenario.books.duplicate.bookId,
|
|
status: 'want_to_read',
|
|
source: 'manual',
|
|
}),
|
|
]),
|
|
);
|
|
expect(statusRows.some((row) => row.userId !== scenario.targetUsers.alice.id && row.userId !== scenario.targetUsers.bob.id)).toBe(false);
|
|
|
|
const progressRows = await ctx.db.select().from(schema.readingProgress);
|
|
expect(progressRows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookFileId: scenario.books.isbn.primaryFileId,
|
|
percentage: 33.3,
|
|
cfi: 'epubcfi(/6/2[chap]!/4/1:0)',
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookFileId: scenario.books.hash.primaryFileId,
|
|
percentage: 22.4,
|
|
pageNumber: 10,
|
|
}),
|
|
]),
|
|
);
|
|
expect(progressRows).toHaveLength(2);
|
|
|
|
const audioProgressRows = await ctx.db.select().from(schema.audiobookProgress);
|
|
expect(audioProgressRows).toEqual([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.bob.id,
|
|
bookId: scenario.books.audio.bookId,
|
|
currentFileId: scenario.books.audio.fileIds[1],
|
|
percentage: 66,
|
|
positionSeconds: 12.5,
|
|
}),
|
|
]);
|
|
|
|
const readingSessions = await ctx.db
|
|
.select()
|
|
.from(schema.readingSessions)
|
|
.where(inArray(schema.readingSessions.userId, [scenario.targetUsers.alice.id, scenario.targetUsers.bob.id]));
|
|
expect(readingSessions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookId: scenario.books.isbn.bookId,
|
|
bookFileId: scenario.books.isbn.primaryFileId,
|
|
sessionId: 'booklore:rs:9001',
|
|
source: 'web',
|
|
durationSeconds: 1700,
|
|
progressDelta: 25,
|
|
endProgress: 45,
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookId: scenario.books.hash.bookId,
|
|
bookFileId: scenario.books.hash.primaryFileId,
|
|
sessionId: 'booklore:rs:9002',
|
|
durationSeconds: 1200,
|
|
progressDelta: 3,
|
|
endProgress: 25,
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.bob.id,
|
|
bookId: scenario.books.audio.bookId,
|
|
bookFileId: scenario.books.audio.primaryFileId,
|
|
sessionId: 'booklore:rs:9003',
|
|
durationSeconds: 1800,
|
|
progressDelta: null,
|
|
endProgress: null,
|
|
}),
|
|
]),
|
|
);
|
|
expect(readingSessions).toHaveLength(3);
|
|
|
|
const dailyStats = await ctx.db
|
|
.select()
|
|
.from(schema.userReadingDailyStats)
|
|
.where(inArray(schema.userReadingDailyStats.userId, [scenario.targetUsers.alice.id, scenario.targetUsers.bob.id]));
|
|
expect(dailyStats).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
day: '2024-01-06',
|
|
readingSeconds: 1700,
|
|
progressDelta: 25,
|
|
sessionsCount: 1,
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
day: '2024-01-07',
|
|
readingSeconds: 1200,
|
|
progressDelta: 3,
|
|
sessionsCount: 1,
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.bob.id,
|
|
day: '2024-02-06',
|
|
readingSeconds: 1800,
|
|
progressDelta: 0,
|
|
sessionsCount: 1,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const bookmarks = await ctx.db.select().from(schema.bookmarks);
|
|
expect(bookmarks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookId: scenario.books.isbn.bookId,
|
|
title: 'Chapter 1',
|
|
cfi: 'epubcfi(/6/2[chap]!/4/1:0)',
|
|
positionSeconds: null,
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.bob.id,
|
|
bookId: scenario.books.audio.bookId,
|
|
title: 'Imported bookmark',
|
|
cfi: null,
|
|
positionSeconds: 135,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const annotations = await ctx.db.select().from(schema.annotations);
|
|
expect(annotations).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.alice.id,
|
|
bookId: scenario.books.isbn.bookId,
|
|
color: 'blue',
|
|
style: 'underline',
|
|
note: 'remember this',
|
|
chapterTitle: 'Chapter One',
|
|
}),
|
|
expect.objectContaining({
|
|
userId: scenario.targetUsers.bob.id,
|
|
bookId: scenario.books.titleAuthor.bookId,
|
|
color: 'yellow',
|
|
style: 'highlight',
|
|
note: null,
|
|
chapterTitle: null,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const importedCollections = await ctx.db.select().from(schema.collections).where(eq(schema.collections.userId, scenario.targetUsers.alice.id));
|
|
expect(importedCollections).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: 'Favorites', description: 'Existing favorites collection' }),
|
|
expect.objectContaining({
|
|
name: 'Favorites (Booklore)',
|
|
description: 'Imported from Booklore migration shelf: 501',
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const importedFavorites = importedCollections.find((row) => row.description === 'Imported from Booklore migration shelf: 501');
|
|
expect(importedFavorites).toBeDefined();
|
|
const importedCollectionBooks = await ctx.db
|
|
.select()
|
|
.from(schema.collectionBooks)
|
|
.where(eq(schema.collectionBooks.collectionId, importedFavorites!.id));
|
|
expect(importedCollectionBooks.map((row) => row.bookId).sort((left, right) => left - right)).toEqual(
|
|
[scenario.books.isbn.bookId, scenario.books.titleAuthor.bookId].sort((left, right) => left - right),
|
|
);
|
|
|
|
const isbnCoverDir = coverDirectoryForBook(ctx, scenario.books.isbn.bookId);
|
|
const audioCoverDir = coverDirectoryForBook(ctx, scenario.books.audio.bookId);
|
|
await access(join(isbnCoverDir, 'ebook', 'cover_custom.jpg'));
|
|
await access(join(isbnCoverDir, 'ebook', 'thumbnail.jpg'));
|
|
await access(join(audioCoverDir, 'audio', 'cover_custom.png'));
|
|
await access(join(audioCoverDir, 'audio', 'thumbnail.jpg'));
|
|
|
|
const audioCoverBytes = await readFile(join(audioCoverDir, 'audio', 'cover_custom.png'));
|
|
expect(audioCoverBytes[0]).toBe(0x89);
|
|
expect(audioCoverBytes[1]).toBe(0x50);
|
|
|
|
await assertNoIntegrityViolations(ctx.db);
|
|
});
|
|
|
|
it('handles duplicate source user-state rows without failing migration and keeps latest values', async () => {
|
|
const scenario = await seedCanonicalScenarioWithDuplicateUserStateRows(ctx);
|
|
|
|
const createdSource = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
name: 'Canonical With Duplicate User State',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(createdSource.statusCode).toBe(201);
|
|
|
|
const validatedSource = await apiJson<{ ok: boolean }>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${createdSource.body.id}/validate`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(validatedSource.statusCode).toBe(200);
|
|
expect(validatedSource.body.ok).toBe(true);
|
|
|
|
const profile = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/profiles',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
sourceId: createdSource.body.id,
|
|
name: 'Duplicate User State Profile',
|
|
userMappings: [
|
|
{ sourceUserId: '1', targetUserId: scenario.targetUsers.alice.id },
|
|
{ sourceUserId: '2', targetUserId: scenario.targetUsers.bob.id },
|
|
],
|
|
pathMappings: scenario.pathMappings,
|
|
},
|
|
});
|
|
expect(profile.statusCode).toBe(201);
|
|
|
|
const validatedMappings = await apiJson<{ persistedProfileId: number | null }>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${createdSource.body.id}/path-mappings/validate`,
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
pathMappings: scenario.pathMappings,
|
|
},
|
|
});
|
|
expect(validatedMappings.statusCode).toBe(201);
|
|
expect(validatedMappings.body.persistedProfileId).toBe(profile.body.id);
|
|
|
|
const dryRun = await apiJson<{
|
|
id: number;
|
|
plan: { duplicateBookMatches: Array<{ targetBookId: number; sourceBookIds: string[] }> };
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/plans/dry-run',
|
|
token: ctx.adminToken,
|
|
payload: { profileId: profile.body.id },
|
|
});
|
|
expect(dryRun.statusCode).toBe(201);
|
|
expect(dryRun.body.plan.duplicateBookMatches).toHaveLength(1);
|
|
|
|
const resolvedPlan = await apiJson<{
|
|
id: number;
|
|
plan: { duplicateBookMatches: unknown[] };
|
|
summary: { status: string };
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/plans/${dryRun.body.id}/resolve-duplicates`,
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
resolutions: [{ targetBookId: scenario.books.duplicate.bookId, selectedSourceBookId: scenario.sourceBookIds.duplicatePreferred }],
|
|
},
|
|
});
|
|
expect(resolvedPlan.statusCode).toBe(200);
|
|
expect(resolvedPlan.body.plan.duplicateBookMatches).toEqual([]);
|
|
expect(resolvedPlan.body.summary.status).toBe('ready_for_live_run');
|
|
|
|
const startedRun = await apiJson<{ id: number; state: string }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/runs/live',
|
|
token: ctx.adminToken,
|
|
payload: { planArtifactId: resolvedPlan.body.id },
|
|
});
|
|
expect(startedRun.statusCode).toBe(201);
|
|
expect(startedRun.body.state).toBe('running');
|
|
|
|
const finished = await waitForMigrationToFinish(ctx, startedRun.body.id);
|
|
expect(finished.progress.run.state).toBe('completed');
|
|
|
|
const statusRows = await ctx.db.select().from(schema.userBookStatus);
|
|
expect(statusRows.find((row) => row.userId === scenario.targetUsers.alice.id && row.bookId === scenario.books.isbn.bookId)).toMatchObject({
|
|
status: 'reading',
|
|
});
|
|
expect(statusRows.find((row) => row.userId === scenario.targetUsers.bob.id && row.bookId === scenario.books.audio.bookId)).toMatchObject({
|
|
status: 'read',
|
|
});
|
|
|
|
const progressRows = await ctx.db.select().from(schema.readingProgress);
|
|
expect(
|
|
progressRows.find((row) => row.userId === scenario.targetUsers.alice.id && row.bookFileId === scenario.books.isbn.primaryFileId),
|
|
).toMatchObject({
|
|
percentage: 44.4,
|
|
cfi: 'epubcfi(/6/18!/4/2:10)',
|
|
});
|
|
expect(
|
|
progressRows.find((row) => row.userId === scenario.targetUsers.bob.id && row.bookFileId === scenario.books.audio.fileIds[1]),
|
|
).toBeUndefined();
|
|
|
|
const audioProgressRows = await ctx.db.select().from(schema.audiobookProgress);
|
|
expect(audioProgressRows.find((row) => row.userId === scenario.targetUsers.bob.id && row.bookId === scenario.books.audio.bookId)).toMatchObject({
|
|
percentage: 77,
|
|
currentFileId: scenario.books.audio.fileIds[1],
|
|
positionSeconds: 45,
|
|
});
|
|
});
|
|
|
|
it('supports alternate Booklore schema variants and join-through file progress', async () => {
|
|
const scenario = await seedCompatibilityScenario(ctx);
|
|
|
|
const createdSource = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
name: 'Compatibility Booklore',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(createdSource.statusCode).toBe(201);
|
|
|
|
const validatedSource = await apiJson<{ ok: boolean }>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${createdSource.body.id}/validate`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(validatedSource.statusCode).toBe(200);
|
|
expect(validatedSource.body.ok).toBe(true);
|
|
|
|
const profile = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/profiles',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
sourceId: createdSource.body.id,
|
|
name: 'Compatibility Profile',
|
|
userMappings: [{ sourceUserId: '41', targetUserId: scenario.targetUser.id }],
|
|
pathMappings: scenario.pathMappings,
|
|
},
|
|
});
|
|
expect(profile.statusCode).toBe(201);
|
|
|
|
const validatedMappings = await apiJson<{ persistedProfileId: number | null }>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${createdSource.body.id}/path-mappings/validate`,
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
pathMappings: scenario.pathMappings,
|
|
},
|
|
});
|
|
expect(validatedMappings.statusCode).toBe(201);
|
|
expect(validatedMappings.body.persistedProfileId).toBe(profile.body.id);
|
|
|
|
const dryRun = await apiJson<{ id: number; summary: { status: string; matchedBooks: number } }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/plans/dry-run',
|
|
token: ctx.adminToken,
|
|
payload: { profileId: profile.body.id },
|
|
});
|
|
expect(dryRun.statusCode).toBe(201);
|
|
expect(dryRun.body.summary).toMatchObject({
|
|
status: 'ready_for_live_run',
|
|
matchedBooks: 1,
|
|
});
|
|
|
|
const startedRun = await apiJson<{ id: number; state: string }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/runs/live',
|
|
token: ctx.adminToken,
|
|
payload: { planArtifactId: dryRun.body.id },
|
|
});
|
|
expect(startedRun.statusCode).toBe(201);
|
|
expect(startedRun.body.state).toBe('running');
|
|
|
|
await waitForMigrationToFinish(ctx, startedRun.body.id);
|
|
|
|
const metadata = await ctx.db.query.bookMetadata.findFirst({
|
|
where: eq(schema.bookMetadata.bookId, scenario.book.bookId),
|
|
});
|
|
expect(metadata).toMatchObject({
|
|
title: 'Variant Imported Title',
|
|
subtitle: 'Keep this subtitle',
|
|
description: 'Variant imported description',
|
|
isbn10: '0123456789',
|
|
isbn13: '9780000000401',
|
|
publisher: 'Variant Publisher',
|
|
publishedYear: 2022,
|
|
language: 'en',
|
|
pageCount: 222,
|
|
seriesName: 'Variant Series',
|
|
seriesIndex: '1.25',
|
|
rating: 4,
|
|
googleBooksId: 'variant-google-id',
|
|
durationSeconds: 987,
|
|
abridged: false,
|
|
});
|
|
|
|
await expectContributorNames(ctx, scenario.book.bookId, 'author', ['Variant Author', 'Second Variant Author']);
|
|
await expectContributorNames(ctx, scenario.book.bookId, 'narrator', ['Narrator Gamma', 'Narrator Delta']);
|
|
|
|
const userStatus = await ctx.db.query.userBookStatus.findFirst({
|
|
where: eq(schema.userBookStatus.bookId, scenario.book.bookId),
|
|
});
|
|
expect(userStatus).toMatchObject({
|
|
userId: scenario.targetUser.id,
|
|
status: 'on_hold',
|
|
source: 'manual',
|
|
});
|
|
|
|
const readingProgress = await ctx.db.query.readingProgress.findFirst({
|
|
where: eq(schema.readingProgress.bookFileId, scenario.book.primaryFileId),
|
|
});
|
|
expect(readingProgress).toMatchObject({
|
|
userId: scenario.targetUser.id,
|
|
percentage: 78,
|
|
cfi: 'epubcfi(/6/10!/4/2:20)',
|
|
pageNumber: 9,
|
|
positionSeconds: 6.5,
|
|
});
|
|
|
|
const bookmarks = await ctx.db.select().from(schema.bookmarks).where(eq(schema.bookmarks.bookId, scenario.book.bookId));
|
|
expect(bookmarks).toEqual([
|
|
expect.objectContaining({
|
|
userId: scenario.targetUser.id,
|
|
title: 'Variant bookmark',
|
|
cfi: 'epubcfi(/6/12!/4/2:1)',
|
|
positionSeconds: 2.5,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('fails validation and source creation when required Booklore tables are missing', async () => {
|
|
const connectionConfig = await seedMissingRequiredTablesScenario(ctx);
|
|
|
|
const testedSource = await apiJson<{
|
|
ok: boolean;
|
|
missingTables: string[];
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources/test',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
connectionConfig,
|
|
},
|
|
});
|
|
expect(testedSource.statusCode).toBe(201);
|
|
expect(testedSource.body.ok).toBe(false);
|
|
expect(testedSource.body.missingTables).toContain('book_file');
|
|
|
|
const createdSource = await apiJson<{ message: string }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
name: 'Missing Tables',
|
|
connectionConfig,
|
|
},
|
|
});
|
|
expectError(createdSource, 400, 'Missing required tables');
|
|
});
|
|
|
|
it('runs safely with optional domains missing and records warnings plus skipped metrics', async () => {
|
|
const scenario = await seedWarningsOnlyScenario(ctx);
|
|
|
|
const testedSource = await apiJson<{
|
|
ok: boolean;
|
|
warnings: string[];
|
|
}>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources/test',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(testedSource.statusCode).toBe(201);
|
|
expect(testedSource.body.ok).toBe(true);
|
|
expect(testedSource.body.warnings).toEqual(
|
|
expect.arrayContaining([
|
|
'book_metadata table not found; metadata overlays will be limited',
|
|
'author mapping tables not found; author migration disabled',
|
|
'user_book_progress table not found; status migration disabled',
|
|
'reading_sessions table not found; reading session migration disabled',
|
|
'book_marks table not found; bookmark migration disabled',
|
|
'mediaRootPath not configured; book cover/thumbnail import disabled',
|
|
]),
|
|
);
|
|
|
|
const source = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
name: 'Warnings Source',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(source.statusCode).toBe(201);
|
|
|
|
const validatedSource = await apiJson<{ ok: boolean }>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${source.body.id}/validate`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(validatedSource.statusCode).toBe(200);
|
|
expect(validatedSource.body.ok).toBe(true);
|
|
|
|
const profile = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/profiles',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
sourceId: source.body.id,
|
|
name: 'Warnings Profile',
|
|
userMappings: [{ sourceUserId: '91', targetUserId: scenario.targetUser.id }],
|
|
pathMappings: [],
|
|
},
|
|
});
|
|
expect(profile.statusCode).toBe(201);
|
|
|
|
const dryRun = await apiJson<{ id: number; summary: { status: string; matchedBooks: number } }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/plans/dry-run',
|
|
token: ctx.adminToken,
|
|
payload: { profileId: profile.body.id },
|
|
});
|
|
expect(dryRun.statusCode).toBe(201);
|
|
expect(dryRun.body.summary).toMatchObject({
|
|
status: 'ready_for_live_run',
|
|
matchedBooks: 1,
|
|
});
|
|
|
|
const startedRun = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/runs/live',
|
|
token: ctx.adminToken,
|
|
payload: { planArtifactId: dryRun.body.id },
|
|
});
|
|
expect(startedRun.statusCode).toBe(201);
|
|
|
|
const finished = await waitForMigrationToFinish(ctx, startedRun.body.id);
|
|
const metricByKey = buildMetricMap(finished.report.metrics as MetricRow[]);
|
|
|
|
expect(metricByKey.get('shared_overlays:book_metadata')).toMatchObject({
|
|
processed: 1,
|
|
imported: 0,
|
|
skipped: 1,
|
|
});
|
|
expect(metricByKey.get('shared_overlays:book_authors')).toMatchObject({
|
|
processed: 1,
|
|
imported: 0,
|
|
skipped: 1,
|
|
});
|
|
expect(metricByKey.get('book_covers:book_covers')).toMatchObject({
|
|
processed: 1,
|
|
imported: 0,
|
|
skipped: 1,
|
|
unresolved: 0,
|
|
});
|
|
expect(metricByKey.get('user_state:user_book_status')).toMatchObject({
|
|
processed: 0,
|
|
imported: 0,
|
|
skipped: 0,
|
|
unresolved: 0,
|
|
});
|
|
|
|
expect(await ctx.db.select().from(schema.userBookStatus)).toHaveLength(0);
|
|
expect(await ctx.db.select().from(schema.bookmarks)).toHaveLength(0);
|
|
expect(await ctx.db.select().from(schema.annotations)).toHaveLength(0);
|
|
});
|
|
|
|
it('invalidates dry-run plans after the source is revalidated', async () => {
|
|
const scenario = await seedWarningsOnlyScenario(ctx);
|
|
|
|
const source = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
name: 'Stale Plan Source',
|
|
connectionConfig: scenario.connectionConfig,
|
|
},
|
|
});
|
|
expect(source.statusCode).toBe(201);
|
|
|
|
const profile = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/profiles',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
sourceId: source.body.id,
|
|
name: 'Stale Plan Profile',
|
|
userMappings: [{ sourceUserId: '91', targetUserId: scenario.targetUser.id }],
|
|
},
|
|
});
|
|
expect(profile.statusCode).toBe(201);
|
|
|
|
const dryRun = await apiJson<{ id: number }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/plans/dry-run',
|
|
token: ctx.adminToken,
|
|
payload: { profileId: profile.body.id },
|
|
});
|
|
expect(dryRun.statusCode).toBe(201);
|
|
|
|
const revalidated = await apiJson<{ ok: boolean }>(ctx, {
|
|
method: 'POST',
|
|
url: `/api/v1/migration/sources/${source.body.id}/validate`,
|
|
token: ctx.adminToken,
|
|
});
|
|
expect(revalidated.statusCode).toBe(200);
|
|
expect(revalidated.body.ok).toBe(true);
|
|
|
|
const startedRun = await apiJson<{ message: string }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/runs/live',
|
|
token: ctx.adminToken,
|
|
payload: { planArtifactId: dryRun.body.id },
|
|
});
|
|
expectError(startedRun, 404, 'Migration plan artifact not found');
|
|
});
|
|
|
|
it('enforces authz and DTO validation for migration endpoints', async () => {
|
|
const anonymous = await apiJson<{ message: string }>(ctx, {
|
|
method: 'GET',
|
|
url: '/api/v1/migration/state',
|
|
});
|
|
expectError(anonymous, 401, 'Unauthorized');
|
|
|
|
const regularUser = await createUser(ctx, { login: true });
|
|
const forbidden = await apiJson<{ message: string }>(ctx, {
|
|
method: 'GET',
|
|
url: '/api/v1/migration/state',
|
|
token: regularUser.accessToken,
|
|
});
|
|
expectError(forbidden, 403, 'Missing permission');
|
|
|
|
const invalidDto = await apiJson<{ message: string | string[] }>(ctx, {
|
|
method: 'POST',
|
|
url: '/api/v1/migration/sources/test',
|
|
token: ctx.adminToken,
|
|
payload: {
|
|
type: 'booklore',
|
|
connectionConfig: buildBookloreConnectionConfig(ctx),
|
|
bogus: true,
|
|
},
|
|
});
|
|
expect(invalidDto.statusCode).toBe(400);
|
|
expect(JSON.stringify(invalidDto.body)).toContain('bogus');
|
|
});
|
|
});
|
|
|
|
function buildMetricMap(metrics: MetricRow[]): Map<string, MetricRow> {
|
|
return new Map(metrics.map((metric) => [`${metric.stage}:${metric.entityType}`, metric]));
|
|
}
|
|
|
|
function expectError<T>(response: { statusCode: number; body: T }, statusCode: number, messageFragment: string): void {
|
|
expect(response.statusCode).toBe(statusCode);
|
|
expect(JSON.stringify(response.body)).toContain(messageFragment);
|
|
}
|
|
|
|
async function expectContributorNames(
|
|
ctx: MigrationBookloreE2EContext,
|
|
bookId: number,
|
|
type: 'author' | 'narrator',
|
|
expectedNames: string[],
|
|
): Promise<void> {
|
|
const names = await loadNamesForBook(ctx, bookId, type);
|
|
expect(names).toEqual(expectedNames);
|
|
}
|
|
|
|
async function loadNamesForBook(ctx: MigrationBookloreE2EContext, bookId: number, type: 'author' | 'narrator' | 'genre' | 'tag'): Promise<string[]> {
|
|
if (type === 'author') {
|
|
const rows = await ctx.db
|
|
.select({
|
|
name: schema.authors.name,
|
|
displayOrder: schema.bookAuthors.displayOrder,
|
|
})
|
|
.from(schema.bookAuthors)
|
|
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
|
|
.where(eq(schema.bookAuthors.bookId, bookId))
|
|
.orderBy(schema.bookAuthors.displayOrder);
|
|
return rows.map((row) => row.name);
|
|
}
|
|
|
|
if (type === 'narrator') {
|
|
const rows = await ctx.db
|
|
.select({
|
|
name: schema.narrators.name,
|
|
displayOrder: schema.bookNarrators.displayOrder,
|
|
})
|
|
.from(schema.bookNarrators)
|
|
.innerJoin(schema.narrators, eq(schema.narrators.id, schema.bookNarrators.narratorId))
|
|
.where(eq(schema.bookNarrators.bookId, bookId))
|
|
.orderBy(schema.bookNarrators.displayOrder);
|
|
return rows.map((row) => row.name);
|
|
}
|
|
|
|
if (type === 'genre') {
|
|
const rows = await ctx.db
|
|
.select({
|
|
name: schema.genres.name,
|
|
})
|
|
.from(schema.bookGenres)
|
|
.innerJoin(schema.genres, eq(schema.genres.id, schema.bookGenres.genreId))
|
|
.where(eq(schema.bookGenres.bookId, bookId))
|
|
.orderBy(schema.genres.name);
|
|
return rows.map((row) => row.name);
|
|
}
|
|
|
|
const rows = await ctx.db
|
|
.select({
|
|
name: schema.tags.name,
|
|
})
|
|
.from(schema.bookTags)
|
|
.innerJoin(schema.tags, eq(schema.tags.id, schema.bookTags.tagId))
|
|
.where(eq(schema.bookTags.bookId, bookId))
|
|
.orderBy(schema.tags.name);
|
|
return rows.map((row) => row.name);
|
|
}
|