mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
122 lines
5.8 KiB
TypeScript
122 lines
5.8 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { Db } from '../src/db/index.ts';
|
|
import { SCHEMA_SQL } from '../src/db/schema.ts';
|
|
import { ShelfService } from '../src/services/shelf.ts';
|
|
import { SyncService } from '../src/services/sync.ts';
|
|
|
|
function seed(db: Pick<Db, 'run'>): void {
|
|
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('user','reader','unused','member',1,1),('other','other','unused','member',1,1)");
|
|
db.run("INSERT INTO books(id,content_hash,format,created_at,updated_at) VALUES('book','hash','txt',1,1),('second','second','txt',1,1)");
|
|
}
|
|
|
|
function progress(bookId = 'book', updatedAt = 1) {
|
|
return { bookId, updatedAt, locator: 'chapter1', percentage: 0.5, chapterTitle: 'one', device: 'phone' };
|
|
}
|
|
|
|
test('offline writes and tombstones are visible after an already-issued server cursor', () => {
|
|
const db = new Db(':memory:');
|
|
try {
|
|
seed(db);
|
|
const sync = new SyncService(db, new ShelfService(db));
|
|
const before = sync.pull('user', 0).serverTime;
|
|
sync.push('user', { progress: [progress()], notes: [{ id: 'note', bookId: 'book', type: 'note', locator: '', text: 'offline', comment: '', color: '', updatedAt: 1, deleted: false }] });
|
|
const changed = sync.pull('user', before);
|
|
assert.equal(changed.progress.length, 1);
|
|
assert.equal(changed.notes.length, 1);
|
|
assert.equal(changed.progress[0]!.updatedAt, 1);
|
|
assert.ok(changed.serverTime > before);
|
|
db.run("UPDATE notes SET deleted = 1 WHERE id = 'note'");
|
|
assert.equal(sync.pull('user', changed.serverTime).notes[0]!.deleted, true);
|
|
assert.deepEqual(sync.pull('other', before).notes, []);
|
|
assert.deepEqual(sync.pull('user', before, 'second').progress, []);
|
|
} finally { db.close(); }
|
|
});
|
|
|
|
test('pagination shares one boundary across progress and notes with identical client timestamps', () => {
|
|
const db = new Db(':memory:');
|
|
try {
|
|
seed(db);
|
|
const sync = new SyncService(db, new ShelfService(db));
|
|
db.transaction(() => {
|
|
const insert = db.prepare("INSERT INTO notes(id,user_id,book_id,type,updated_at) VALUES(?,'user','book','note',1)");
|
|
for (let index = 0; index < 4999; index++) insert.run(`note-${index}`);
|
|
db.run("INSERT INTO reading_progress(user_id,book_id,updated_at) VALUES('user','book',1)");
|
|
insert.run('last-note');
|
|
});
|
|
const first = sync.pull('user', 0);
|
|
assert.equal(first.progress.length + first.notes.length, 5000);
|
|
assert.equal(first.hasMore, true);
|
|
assert.equal(first.progress.length, 1);
|
|
const second = sync.pull('user', first.serverTime);
|
|
assert.equal(second.notes.length, 1);
|
|
assert.equal(second.notes[0]!.id, 'last-note');
|
|
assert.equal(second.hasMore, false);
|
|
assert.equal(sync.pull('user', second.serverTime).notes.length, 0);
|
|
const notesOnly = sync.pull('user', 0, undefined, false);
|
|
assert.equal(notesOnly.progress.length, 0);
|
|
assert.equal(notesOnly.notes.length, 5000);
|
|
assert.equal(notesOnly.hasMore, false);
|
|
} finally { db.close(); }
|
|
});
|
|
|
|
test('cursor advances for tied timestamps and does not regress with the wall clock', () => {
|
|
const db = new Db(':memory:');
|
|
try {
|
|
seed(db);
|
|
const sync = new SyncService(db, new ShelfService(db));
|
|
db.run('UPDATE sync_clock SET revision = ? WHERE id = 1', Date.now() + 60_000);
|
|
const before = sync.pull('user', 0).serverTime;
|
|
sync.push('user', { progress: [progress()] });
|
|
const first = sync.pull('user', before);
|
|
assert.ok(first.serverTime > before);
|
|
sync.push('user', { progress: [{ ...progress(), locator: 'changed' }] });
|
|
const second = sync.pull('user', first.serverTime);
|
|
assert.ok(second.serverTime > first.serverTime);
|
|
assert.equal(second.progress[0]!.locator, 'changed');
|
|
sync.push('user', { progress: [{ ...progress('book', 0), locator: 'stale' }] });
|
|
const reconciled = sync.pull('user', second.serverTime);
|
|
assert.equal(reconciled.progress[0]!.locator, 'changed');
|
|
assert.equal(reconciled.progress[0]!.updatedAt, 1);
|
|
} finally { db.close(); }
|
|
});
|
|
|
|
test('existing databases are backfilled once and retain their cursor after reopening', () => {
|
|
const directory = mkdtempSync(join(tmpdir(), 'reader-sync-migration-'));
|
|
const path = join(directory, 'reader.db');
|
|
try {
|
|
const legacy = new DatabaseSync(path);
|
|
legacy.exec(SCHEMA_SQL);
|
|
seed({ run(sql: string) { legacy.exec(sql); } });
|
|
legacy.exec("INSERT INTO reading_progress(user_id,book_id,updated_at) VALUES('user','book',1)");
|
|
legacy.exec("INSERT INTO notes(id,user_id,book_id,type,updated_at) VALUES('old-note','user','book','note',1)");
|
|
legacy.close();
|
|
const upgraded = new Db(path);
|
|
const first = new SyncService(upgraded, new ShelfService(upgraded)).pull('user', 0);
|
|
assert.equal(first.progress.length, 1);
|
|
assert.equal(first.notes.length, 1);
|
|
upgraded.close();
|
|
const reopened = new Db(path);
|
|
try {
|
|
const next = new SyncService(reopened, new ShelfService(reopened)).pull('user', first.serverTime);
|
|
assert.equal(next.progress.length + next.notes.length, 0);
|
|
} finally { reopened.close(); }
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('invalid records fail validation before any batch records are written', () => {
|
|
const db = new Db(':memory:');
|
|
try {
|
|
seed(db);
|
|
const sync = new SyncService(db, new ShelfService(db));
|
|
for (const invalid of [null, [], { ...progress(), locator: {} }, { ...progress(), updatedAt: Infinity }, { ...progress(), percentage: 'wrong' }]) {
|
|
assert.throws(() => sync.push('user', { progress: [progress(), invalid as never] }), { code: 'BAD_PAYLOAD' });
|
|
assert.equal(sync.progressFor('user', 'book'), null);
|
|
}
|
|
} finally { db.close(); }
|
|
});
|