- Extract database schema and migrations into @openreader/database - Extract startup and orchestration scripts into @openreader/bootstrap - Move compute-worker into packages/compute-worker - Remove runtime dependency on drizzle-kit - Update Dockerfile to deploy isolated packages without merging node_modules
251 lines
7.3 KiB
TypeScript
251 lines
7.3 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
import { eq } from 'drizzle-orm';
|
|
import {
|
|
documentSettings,
|
|
documents,
|
|
ttsSegmentEntries,
|
|
ttsSegmentVariants,
|
|
} from '@openreader/database/schema-sqlite';
|
|
import { transferUserDocumentSettings, transferUserDocuments } from '../../src/lib/server/user/claim-data';
|
|
|
|
describe('transferUserDocuments', () => {
|
|
test('moves document rows to new user without PK conflicts', async () => {
|
|
process.env.BASE_URL = 'http://localhost:3003';
|
|
process.env.AUTH_SECRET = 'test-secret';
|
|
|
|
const sqlite = new Database(':memory:');
|
|
sqlite.exec(`
|
|
CREATE TABLE documents (
|
|
id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
last_modified INTEGER NOT NULL,
|
|
file_path TEXT NOT NULL,
|
|
parse_state TEXT,
|
|
parsed_json_key TEXT,
|
|
created_at INTEGER,
|
|
PRIMARY KEY (id, user_id)
|
|
);
|
|
`);
|
|
|
|
const db = drizzle(sqlite);
|
|
|
|
const fromUserId = 'anon';
|
|
const toUserId = 'user';
|
|
|
|
await db.insert(documents).values([
|
|
{
|
|
id: 'doc-a',
|
|
userId: fromUserId,
|
|
name: 'a.pdf',
|
|
type: 'pdf',
|
|
size: 1,
|
|
lastModified: 1,
|
|
filePath: 'doc-a__a.pdf',
|
|
},
|
|
{
|
|
id: 'doc-b',
|
|
userId: fromUserId,
|
|
name: 'b.txt',
|
|
type: 'html',
|
|
size: 2,
|
|
lastModified: 2,
|
|
filePath: 'doc-b__b.txt',
|
|
},
|
|
// Existing row for the destination user (conflict on insert)
|
|
{
|
|
id: 'doc-a',
|
|
userId: toUserId,
|
|
name: 'a.pdf',
|
|
type: 'pdf',
|
|
size: 1,
|
|
lastModified: 1,
|
|
filePath: 'doc-a__a.pdf',
|
|
},
|
|
]);
|
|
|
|
const transferred = await transferUserDocuments(fromUserId, toUserId, { db });
|
|
expect(transferred).toBe(2);
|
|
|
|
const remainingFrom = await db.select().from(documents).where(eq(documents.userId, fromUserId));
|
|
expect(remainingFrom.length).toBe(0);
|
|
|
|
const remainingTo = await db.select().from(documents).where(eq(documents.userId, toUserId));
|
|
const ids = remainingTo.map((r) => r.id).sort();
|
|
expect(ids).toEqual(['doc-a', 'doc-b']);
|
|
});
|
|
|
|
test('moves document settings while preserving newer destination settings', async () => {
|
|
const sqlite = new Database(':memory:');
|
|
sqlite.exec(`
|
|
CREATE TABLE document_settings (
|
|
document_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
data_json TEXT NOT NULL DEFAULT '{}',
|
|
client_updated_at_ms INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
PRIMARY KEY (document_id, user_id)
|
|
);
|
|
`);
|
|
const settingsDb = drizzle(sqlite);
|
|
|
|
await settingsDb.insert(documentSettings).values([
|
|
{
|
|
documentId: 'doc-newer-anon',
|
|
userId: 'anon',
|
|
dataJson: '{"source":"anon"}',
|
|
clientUpdatedAtMs: 20,
|
|
},
|
|
{
|
|
documentId: 'doc-newer-user',
|
|
userId: 'anon',
|
|
dataJson: '{"source":"anon"}',
|
|
clientUpdatedAtMs: 10,
|
|
},
|
|
{
|
|
documentId: 'doc-newer-user',
|
|
userId: 'user',
|
|
dataJson: '{"source":"user"}',
|
|
clientUpdatedAtMs: 30,
|
|
},
|
|
]);
|
|
|
|
const transferred = await transferUserDocumentSettings('anon', 'user', {
|
|
db: settingsDb,
|
|
});
|
|
expect(transferred).toBe(2);
|
|
|
|
const rows = await settingsDb.select().from(documentSettings);
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
documentId: 'doc-newer-anon',
|
|
userId: 'user',
|
|
dataJson: '{"source":"anon"}',
|
|
clientUpdatedAtMs: 20,
|
|
}),
|
|
expect.objectContaining({
|
|
documentId: 'doc-newer-user',
|
|
userId: 'user',
|
|
dataJson: '{"source":"user"}',
|
|
clientUpdatedAtMs: 30,
|
|
}),
|
|
]));
|
|
});
|
|
|
|
test('moves TTS metadata without requiring S3', async () => {
|
|
const sqlite = new Database(':memory:');
|
|
sqlite.exec(`
|
|
CREATE TABLE documents (
|
|
id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
last_modified INTEGER NOT NULL,
|
|
file_path TEXT NOT NULL,
|
|
parse_state TEXT,
|
|
parsed_json_key TEXT,
|
|
created_at INTEGER,
|
|
PRIMARY KEY (id, user_id)
|
|
);
|
|
CREATE TABLE tts_segment_entries (
|
|
segment_entry_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
document_id TEXT NOT NULL,
|
|
reader_type TEXT NOT NULL,
|
|
document_version INTEGER NOT NULL,
|
|
segment_index INTEGER NOT NULL,
|
|
segment_key TEXT,
|
|
locator_reader_rank INTEGER NOT NULL,
|
|
locator_reader_type TEXT NOT NULL,
|
|
locator_page INTEGER NOT NULL,
|
|
locator_spine_index INTEGER NOT NULL,
|
|
locator_spine_href TEXT NOT NULL,
|
|
locator_char_offset INTEGER NOT NULL,
|
|
locator_location TEXT NOT NULL,
|
|
locator_identity_key TEXT NOT NULL,
|
|
text_hash TEXT NOT NULL,
|
|
text_length INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
PRIMARY KEY (segment_entry_id, user_id)
|
|
);
|
|
CREATE TABLE tts_segment_variants (
|
|
segment_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
segment_entry_id TEXT NOT NULL,
|
|
settings_hash TEXT NOT NULL,
|
|
settings_json TEXT NOT NULL,
|
|
audio_key TEXT,
|
|
audio_format TEXT NOT NULL DEFAULT 'mp3',
|
|
duration_ms INTEGER,
|
|
alignment_json TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
error TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
PRIMARY KEY (segment_id, user_id)
|
|
);
|
|
`);
|
|
const transferDb = drizzle(sqlite);
|
|
|
|
await transferDb.insert(documents).values({
|
|
id: 'doc',
|
|
userId: 'anon',
|
|
name: 'doc.pdf',
|
|
type: 'pdf',
|
|
size: 1,
|
|
lastModified: 1,
|
|
filePath: 'doc__doc.pdf',
|
|
});
|
|
await transferDb.insert(ttsSegmentEntries).values({
|
|
segmentEntryId: 'entry',
|
|
userId: 'anon',
|
|
documentId: 'doc',
|
|
readerType: 'pdf',
|
|
documentVersion: 1,
|
|
segmentIndex: 0,
|
|
locatorReaderRank: 0,
|
|
locatorReaderType: 'pdf',
|
|
locatorPage: 1,
|
|
locatorSpineIndex: 0,
|
|
locatorSpineHref: '',
|
|
locatorCharOffset: 0,
|
|
locatorLocation: '1',
|
|
locatorIdentityKey: 'page:1',
|
|
textHash: 'hash',
|
|
});
|
|
await transferDb.insert(ttsSegmentVariants).values({
|
|
segmentId: 'segment',
|
|
userId: 'anon',
|
|
segmentEntryId: 'entry',
|
|
settingsHash: 'settings',
|
|
settingsJson: '{}',
|
|
audioKey: 'openreader/tts_segments_v2/users/anon/docs/doc/1/settings/segment.mp3',
|
|
status: 'completed',
|
|
});
|
|
|
|
await transferUserDocuments('anon', 'user', {
|
|
db: transferDb,
|
|
transferTts: true,
|
|
skipStorage: true,
|
|
});
|
|
|
|
expect(await transferDb.select().from(ttsSegmentEntries)).toEqual([
|
|
expect.objectContaining({ segmentEntryId: 'entry', userId: 'user', documentId: 'doc' }),
|
|
]);
|
|
expect(await transferDb.select().from(ttsSegmentVariants)).toEqual([
|
|
expect.objectContaining({
|
|
segmentId: 'segment',
|
|
userId: 'user',
|
|
audioKey: 'openreader/tts_segments_v2/users/user/docs/doc/1/settings/segment.mp3',
|
|
}),
|
|
]);
|
|
});
|
|
});
|