mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
b0b21b2b78
Any write to book_metadata bumped updated_at and minted a fresh CoverImageId, so a device holding that cover locally fell back to the placeholder offline. Stamp cover_updated_at at each cover write instead; migration 0080 backfills it from updated_at so no id churns on upgrade. Also trims the DECIMAL(10,2) scale Booklore hands back for a series index, which sorted 2.50 after 2.9. Closes #943.
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, 'cover_custom.jpg'));
|
|
await access(join(isbnCoverDir, 'thumbnail.jpg'));
|
|
await access(join(audioCoverDir, 'cover_custom.png'));
|
|
await access(join(audioCoverDir, 'thumbnail.jpg'));
|
|
|
|
const audioCoverBytes = await readFile(join(audioCoverDir, '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);
|
|
}
|