mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { spawn } from 'child_process';
|
|
import { mkdir, mkdtemp, rm } from 'fs/promises';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { hash } from 'bcryptjs';
|
|
import { sql } from 'drizzle-orm';
|
|
import mysql from 'mysql2/promise';
|
|
|
|
import type { Permission } from '@bookorbit/types';
|
|
import { coverDirPath } from '../../../src/modules/metadata/lib/cover';
|
|
import * as schema from '../../../src/db/schema';
|
|
import { closeE2EContext, createE2EContext, seedLibrary, type Db, type E2EContext, waitForCondition } from '../app-harness';
|
|
|
|
const TEST_USER_PREFIX = 'migration-booklore-';
|
|
const ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
|
|
|
interface EnvSnapshot {
|
|
appDataPath: string | undefined;
|
|
migrationEncryptionKey: string | undefined;
|
|
}
|
|
|
|
export interface MariaDbService {
|
|
containerName: string;
|
|
host: string;
|
|
port: number;
|
|
database: string;
|
|
user: string;
|
|
password: string;
|
|
rootPassword: string;
|
|
}
|
|
|
|
export interface MigrationBookloreE2EContext extends E2EContext {
|
|
db: Db;
|
|
fixtureRoot: string;
|
|
booksPath: string;
|
|
sourceMediaRoot: string;
|
|
envSnapshot: EnvSnapshot;
|
|
mariadb: MariaDbService;
|
|
}
|
|
|
|
export interface CreatedUser {
|
|
id: number;
|
|
username: string;
|
|
password: string;
|
|
accessToken?: string;
|
|
}
|
|
|
|
export interface JsonResponse<T> {
|
|
statusCode: number;
|
|
body: T;
|
|
}
|
|
|
|
export async function createMigrationBookloreE2EContext(): Promise<MigrationBookloreE2EContext> {
|
|
const fixtureRoot = await mkdtemp(join(tmpdir(), 'bookorbit-migration-booklore-'));
|
|
const booksPath = join(fixtureRoot, 'books');
|
|
const sourceMediaRoot = join(fixtureRoot, 'booklore-media');
|
|
const envSnapshot: EnvSnapshot = {
|
|
appDataPath: process.env.APP_DATA_PATH,
|
|
migrationEncryptionKey: process.env.MIGRATION_ENCRYPTION_KEY,
|
|
};
|
|
|
|
await mkdir(booksPath, { recursive: true });
|
|
await mkdir(sourceMediaRoot, { recursive: true });
|
|
process.env.APP_DATA_PATH = booksPath;
|
|
process.env.MIGRATION_ENCRYPTION_KEY = ENCRYPTION_KEY;
|
|
|
|
let base: E2EContext | null = null;
|
|
let mariadb: MariaDbService | null = null;
|
|
|
|
try {
|
|
base = await createE2EContext();
|
|
mariadb = await startMariaDbContainer();
|
|
await resetBookloreDatabase(mariadb);
|
|
|
|
return {
|
|
...base,
|
|
fixtureRoot,
|
|
booksPath,
|
|
sourceMediaRoot,
|
|
envSnapshot,
|
|
mariadb,
|
|
};
|
|
} catch (error) {
|
|
if (base) {
|
|
await closeE2EContext(base);
|
|
}
|
|
if (mariadb) {
|
|
await stopMariaDbContainer(mariadb);
|
|
}
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
restoreEnv(envSnapshot);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function closeMigrationBookloreE2EContext(ctx: MigrationBookloreE2EContext): Promise<void> {
|
|
await closeE2EContext(ctx);
|
|
await stopMariaDbContainer(ctx.mariadb);
|
|
await rm(ctx.fixtureRoot, { recursive: true, force: true });
|
|
restoreEnv(ctx.envSnapshot);
|
|
}
|
|
|
|
export async function resetMigrationBookloreState(ctx: MigrationBookloreE2EContext): Promise<void> {
|
|
await truncateScenarioTables(ctx.db);
|
|
await deleteTestUsers(ctx.db);
|
|
await resetDirectory(ctx.booksPath);
|
|
await resetDirectory(ctx.sourceMediaRoot);
|
|
await resetBookloreDatabase(ctx.mariadb);
|
|
}
|
|
|
|
export function authHeader(token: string): Record<string, string> {
|
|
return { authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
export async function apiJson<T>(
|
|
ctx: MigrationBookloreE2EContext,
|
|
input: {
|
|
method: string;
|
|
url: string;
|
|
payload?: unknown;
|
|
token?: string;
|
|
headers?: Record<string, string>;
|
|
},
|
|
): Promise<JsonResponse<T>> {
|
|
const response = await ctx.app.inject({
|
|
method: input.method,
|
|
url: input.url,
|
|
payload: input.payload,
|
|
headers: {
|
|
...(input.token ? authHeader(input.token) : {}),
|
|
...(input.headers ?? {}),
|
|
},
|
|
});
|
|
|
|
return {
|
|
statusCode: response.statusCode,
|
|
body: response.json() as T,
|
|
};
|
|
}
|
|
|
|
export async function apiText(
|
|
ctx: MigrationBookloreE2EContext,
|
|
input: {
|
|
method: string;
|
|
url: string;
|
|
payload?: unknown;
|
|
token?: string;
|
|
headers?: Record<string, string>;
|
|
},
|
|
): Promise<{ statusCode: number; body: string; headers: Record<string, string | string[] | undefined> }> {
|
|
const response = await ctx.app.inject({
|
|
method: input.method,
|
|
url: input.url,
|
|
payload: input.payload,
|
|
headers: {
|
|
...(input.token ? authHeader(input.token) : {}),
|
|
...(input.headers ?? {}),
|
|
},
|
|
});
|
|
|
|
return {
|
|
statusCode: response.statusCode,
|
|
body: response.body,
|
|
headers: response.headers,
|
|
};
|
|
}
|
|
|
|
export async function createUser(
|
|
ctx: MigrationBookloreE2EContext,
|
|
options: {
|
|
username?: string;
|
|
password?: string;
|
|
email?: string;
|
|
isSuperuser?: boolean;
|
|
permissions?: Permission[];
|
|
login?: boolean;
|
|
} = {},
|
|
): Promise<CreatedUser> {
|
|
const suffix = randomUUID().replaceAll('-', '');
|
|
const username = options.username ?? `${TEST_USER_PREFIX}${suffix}`;
|
|
const password = options.password ?? 'MigrationBooklore123';
|
|
const email = options.email ?? `${username}@example.com`;
|
|
const passwordHash = await hash(password, 12);
|
|
|
|
const [created] = await ctx.db
|
|
.insert(schema.users)
|
|
.values({
|
|
username,
|
|
name: `Migration Booklore ${suffix}`,
|
|
email,
|
|
passwordHash,
|
|
isSuperuser: options.isSuperuser ?? false,
|
|
isDefaultPassword: false,
|
|
provisioningMethod: 'local',
|
|
})
|
|
.returning({ id: schema.users.id });
|
|
|
|
if (options.permissions?.length) {
|
|
await ctx.db.insert(schema.userPermissions).values(options.permissions.map((permissionName) => ({ userId: created.id, permissionName })));
|
|
}
|
|
|
|
const accessToken = options.login ? await loginForToken(ctx, username, password) : undefined;
|
|
return { id: created.id, username, password, accessToken };
|
|
}
|
|
|
|
export async function waitForMigrationToFinish(
|
|
ctx: MigrationBookloreE2EContext,
|
|
runId: number,
|
|
timeoutMs = 45_000,
|
|
): Promise<{
|
|
progress: {
|
|
run: { id: number; state: string; currentStage: string | null };
|
|
totals: { processed: number; imported: number; skipped: number; unresolved: number; failed: number };
|
|
metrics: Array<Record<string, unknown>>;
|
|
};
|
|
report: {
|
|
run: { id: number; state: string; currentStage: string | null };
|
|
totals: { processed: number; imported: number; skipped: number; unresolved: number; failed: number };
|
|
metrics: Array<Record<string, unknown>>;
|
|
plan: Record<string, unknown> | null;
|
|
summary: Record<string, unknown> | null;
|
|
details: Record<string, unknown>;
|
|
};
|
|
}> {
|
|
let latestProgress:
|
|
| {
|
|
run: { id: number; state: string; currentStage: string | null };
|
|
totals: { processed: number; imported: number; skipped: number; unresolved: number; failed: number };
|
|
metrics: Array<Record<string, unknown>>;
|
|
}
|
|
| undefined;
|
|
|
|
await waitForCondition(
|
|
async () => {
|
|
const response = await apiJson<{
|
|
run: { id: number; state: string; currentStage: string | null };
|
|
totals: { processed: number; imported: number; skipped: number; unresolved: number; failed: number };
|
|
metrics: Array<Record<string, unknown>>;
|
|
}>(ctx, {
|
|
method: 'GET',
|
|
url: `/api/v1/migration/runs/${runId}/progress`,
|
|
token: ctx.adminToken,
|
|
});
|
|
|
|
if (response.statusCode !== 200) {
|
|
throw new Error(`Unexpected progress response: ${response.statusCode}`);
|
|
}
|
|
latestProgress = response.body;
|
|
if (response.body.run.state === 'running') {
|
|
throw new Error(`Migration run ${runId} is still running`);
|
|
}
|
|
},
|
|
timeoutMs,
|
|
200,
|
|
);
|
|
|
|
const reportResponse = await apiJson<{
|
|
run: { id: number; state: string; currentStage: string | null };
|
|
totals: { processed: number; imported: number; skipped: number; unresolved: number; failed: number };
|
|
metrics: Array<Record<string, unknown>>;
|
|
plan: Record<string, unknown> | null;
|
|
summary: Record<string, unknown> | null;
|
|
details: Record<string, unknown>;
|
|
}>(ctx, {
|
|
method: 'GET',
|
|
url: `/api/v1/migration/runs/${runId}/report`,
|
|
token: ctx.adminToken,
|
|
});
|
|
|
|
if (reportResponse.statusCode !== 200) {
|
|
throw new Error(`Unexpected report response: ${reportResponse.statusCode}`);
|
|
}
|
|
|
|
return {
|
|
progress: latestProgress!,
|
|
report: reportResponse.body,
|
|
};
|
|
}
|
|
|
|
export function coverDirectoryForBook(ctx: MigrationBookloreE2EContext, bookId: number): string {
|
|
return coverDirPath(ctx.booksPath, bookId);
|
|
}
|
|
|
|
export function buildBookloreConnectionConfig(
|
|
ctx: MigrationBookloreE2EContext,
|
|
overrides: Partial<Record<'host' | 'port' | 'user' | 'password' | 'database' | 'ssl' | 'mediaRootPath', unknown>> = {},
|
|
): Record<string, unknown> {
|
|
const config: Record<string, unknown> = {
|
|
host: ctx.mariadb.host,
|
|
port: ctx.mariadb.port,
|
|
user: ctx.mariadb.user,
|
|
password: ctx.mariadb.password,
|
|
database: ctx.mariadb.database,
|
|
ssl: false,
|
|
mediaRootPath: ctx.sourceMediaRoot,
|
|
...overrides,
|
|
};
|
|
|
|
if (config.mediaRootPath === null) {
|
|
delete config.mediaRootPath;
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
export async function withBookloreConnection<T>(
|
|
ctx: MigrationBookloreE2EContext,
|
|
handler: (connection: mysql.Connection) => Promise<T>,
|
|
options: { root?: boolean; database?: string } = {},
|
|
): Promise<T> {
|
|
const connection = await mysql.createConnection({
|
|
host: ctx.mariadb.host,
|
|
port: ctx.mariadb.port,
|
|
user: options.root ? 'root' : ctx.mariadb.user,
|
|
password: options.root ? ctx.mariadb.rootPassword : ctx.mariadb.password,
|
|
database: options.database ?? ctx.mariadb.database,
|
|
multipleStatements: true,
|
|
});
|
|
|
|
try {
|
|
return await handler(connection);
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async function loginForToken(ctx: MigrationBookloreE2EContext, username: string, password: string): Promise<string> {
|
|
const response = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { username, password },
|
|
});
|
|
|
|
if (response.statusCode !== 200) {
|
|
throw new Error(`Login failed for ${username}: ${response.statusCode} ${response.body}`);
|
|
}
|
|
|
|
const body = response.json() as { accessToken?: string };
|
|
if (!body.accessToken) {
|
|
throw new Error(`Login returned no accessToken for ${username}`);
|
|
}
|
|
|
|
return body.accessToken;
|
|
}
|
|
|
|
async function startMariaDbContainer(): Promise<MariaDbService> {
|
|
await runCommand('docker', ['version', '--format', '{{.Server.Version}}']);
|
|
|
|
const containerName = `bookorbit-migration-booklore-${randomUUID()}`;
|
|
const database = 'booklore';
|
|
const user = 'booklore';
|
|
const password = 'booklore-secret';
|
|
const rootPassword = 'root-secret';
|
|
|
|
await runCommand('docker', [
|
|
'run',
|
|
'--rm',
|
|
'-d',
|
|
'--name',
|
|
containerName,
|
|
'-e',
|
|
`MARIADB_DATABASE=${database}`,
|
|
'-e',
|
|
`MARIADB_USER=${user}`,
|
|
'-e',
|
|
`MARIADB_PASSWORD=${password}`,
|
|
'-e',
|
|
`MARIADB_ROOT_PASSWORD=${rootPassword}`,
|
|
'-p',
|
|
'127.0.0.1::3306',
|
|
'mariadb:11.4',
|
|
'--character-set-server=utf8mb4',
|
|
'--collation-server=utf8mb4_unicode_ci',
|
|
]);
|
|
|
|
const portText = await runCommand('docker', ['inspect', '-f', '{{(index (index .NetworkSettings.Ports "3306/tcp") 0).HostPort}}', containerName]);
|
|
const port = Number(portText.trim());
|
|
|
|
if (!Number.isFinite(port) || port < 1) {
|
|
throw new Error(`Could not resolve MariaDB port for container ${containerName}`);
|
|
}
|
|
|
|
const service: MariaDbService = {
|
|
containerName,
|
|
host: '127.0.0.1',
|
|
port,
|
|
database,
|
|
user,
|
|
password,
|
|
rootPassword,
|
|
};
|
|
|
|
await waitForCondition(
|
|
async () => {
|
|
const connection = await mysql.createConnection({
|
|
host: service.host,
|
|
port: service.port,
|
|
user: 'root',
|
|
password: service.rootPassword,
|
|
});
|
|
try {
|
|
await connection.query('SELECT 1');
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
},
|
|
60_000,
|
|
1_000,
|
|
);
|
|
|
|
return service;
|
|
}
|
|
|
|
async function stopMariaDbContainer(service: MariaDbService): Promise<void> {
|
|
await runCommand('docker', ['rm', '-f', service.containerName], true);
|
|
}
|
|
|
|
async function resetBookloreDatabase(service: MariaDbService): Promise<void> {
|
|
const connection = await mysql.createConnection({
|
|
host: service.host,
|
|
port: service.port,
|
|
user: 'root',
|
|
password: service.rootPassword,
|
|
multipleStatements: true,
|
|
});
|
|
|
|
try {
|
|
await connection.query(`DROP DATABASE IF EXISTS \`${service.database}\``);
|
|
await connection.query(`CREATE DATABASE \`${service.database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
await connection.query(`GRANT ALL PRIVILEGES ON \`${service.database}\`.* TO '${service.user}'@'%'`);
|
|
await connection.query('FLUSH PRIVILEGES');
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async function runCommand(command: string, args: string[], allowFailure = false): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
child.once('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
child.once('exit', (code) => {
|
|
if (code === 0 || allowFailure) {
|
|
resolve(stdout.trim());
|
|
return;
|
|
}
|
|
|
|
reject(new Error(`${command} ${args.join(' ')} failed: ${stderr.trim() || stdout.trim() || `exit code ${code ?? 'unknown'}`}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function truncateScenarioTables(db: Db): Promise<void> {
|
|
await db.execute(
|
|
sql.raw(`
|
|
TRUNCATE TABLE
|
|
migration_run_metrics,
|
|
migration_runs,
|
|
migration_plan_artifacts,
|
|
migration_profiles,
|
|
migration_sources,
|
|
collection_books,
|
|
collections,
|
|
annotations,
|
|
bookmarks,
|
|
audiobook_progress,
|
|
reading_progress,
|
|
user_book_status,
|
|
reader_preferences,
|
|
reader_default_preferences,
|
|
reading_sessions,
|
|
user_reading_daily_stats,
|
|
book_tags,
|
|
tags,
|
|
book_genres,
|
|
genres,
|
|
book_narrators,
|
|
narrators,
|
|
book_authors,
|
|
authors,
|
|
book_metadata,
|
|
book_files,
|
|
books,
|
|
library_folders,
|
|
libraries
|
|
RESTART IDENTITY CASCADE
|
|
`),
|
|
);
|
|
}
|
|
|
|
async function deleteTestUsers(db: Db): Promise<void> {
|
|
await db.execute(sql.raw(`DELETE FROM user_permissions WHERE user_id IN (SELECT id FROM users WHERE username LIKE '${TEST_USER_PREFIX}%')`));
|
|
await db.execute(sql.raw(`DELETE FROM user_library_access WHERE user_id IN (SELECT id FROM users WHERE username LIKE '${TEST_USER_PREFIX}%')`));
|
|
await db.execute(sql.raw(`DELETE FROM refresh_tokens WHERE user_id IN (SELECT id FROM users WHERE username LIKE '${TEST_USER_PREFIX}%')`));
|
|
await db.execute(sql.raw(`DELETE FROM password_reset_tokens WHERE user_id IN (SELECT id FROM users WHERE username LIKE '${TEST_USER_PREFIX}%')`));
|
|
await db.execute(sql.raw(`DELETE FROM users WHERE username LIKE '${TEST_USER_PREFIX}%'`));
|
|
}
|
|
|
|
async function resetDirectory(path: string): Promise<void> {
|
|
await rm(path, { recursive: true, force: true });
|
|
await mkdir(path, { recursive: true });
|
|
}
|
|
|
|
function restoreEnv(snapshot: EnvSnapshot): void {
|
|
process.env.APP_DATA_PATH = snapshot.appDataPath;
|
|
process.env.MIGRATION_ENCRYPTION_KEY = snapshot.migrationEncryptionKey;
|
|
}
|
|
|
|
export { seedLibrary };
|