/* Two devices, one server — protocol 2, and the three gates. Runs against a real Postgres and a real server: a fake would not exercise the compare-and-swap, the advisory locks or the change_seq trigger, which is where sync actually goes wrong. Skipped unless HANKAN_TEST_SERVER is set, so the suite still runs on a machine without Docker: docker run -d --rm --name hankan-pg-test -e POSTGRES_PASSWORD=test \ -e POSTGRES_DB=hankan -p 127.0.0.1:55433:5432 postgres:16-alpine DATABASE_URL=postgres://postgres:test@127.0.0.1:55433/hankan \ HANKAN_TOKEN=test-token HANKAN_TEST_MODE=1 HANKAN_TUTOR_BACKEND=echo \ PORT=8788 node --experimental-strip-types server/src/main.ts HANKAN_TEST_SERVER=http://localhost:8788 npm test */ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js"; import { migrate } from "@app/db/migrate.js"; import type { Db } from "@app/db/types.js"; import { editCard, editCardReset, editChatClear, editChatTurn, editMeta, editPeek, editReset, editStudyLog, editUnitConfidence, editUnitDone, seedCard, seedMeta, seedProgress, } from "@app/db/writes.js"; import { syncOnce, type SyncConfig } from "@app/sync/client.js"; import { unitIndex } from "@app/domain/gate.js"; import { newCard } from "@lib/srs.js"; import { lemmaId } from "@shared/lemma-id.mjs"; import { PROTOCOL_HEADER } from "@shared/sync-protocol.mjs"; const BASE = process.env.HANKAN_TEST_SERVER; const TOKEN = process.env.HANKAN_TEST_TOKEN ?? "test-token"; const suite = BASE ? describe : describe.skip; const RICE = lemmaId("밥", "noun"); const SCHOOL = lemmaId("학교", "noun"); const WATER = lemmaId("물", "noun"); /** A fresh device: its own database, migrated, with lemmas to hang cards on. */ async function device(): Promise { const db = await SqliteWasmDb.open({ memory: true }); await migrate(db); await db.run( `INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (?, '밥', 'noun', 'rice', 'curated'), (?, '학교', 'noun', 'school', 'curated'), (?, '물', 'noun', 'water', 'curated')`, [RICE, SCHOOL, WATER], ); return db; } const one = async (db: Db, sql: string, params: (string | number)[] = []) => db.get(sql, params); const turns = async (db: Db) => (await db.all<{ body: string }>("SELECT body FROM chat ORDER BY created_at, id")).map((t) => t.body); suite("sync — protocol 2, two devices, one server", () => { let cfg: SyncConfig; const sync = (db: Db) => syncOnce(db, cfg, { unitIndex }); const open: Db[] = []; const make = async () => { const db = await device(); open.push(db); return db; }; beforeAll(() => { cfg = { baseUrl: BASE!, token: TOKEN }; }); beforeEach(async () => { await fetch(`${cfg.baseUrl}/api/test/reset`, { method: "POST", headers: { authorization: `Bearer ${cfg.token}` }, }); }); afterEach(async () => { vi.restoreAllMocks(); while (open.length) await open.pop()!.close(); }); it("carries an edit from one device to the other", async () => { const a = await make(); const b = await make(); await editUnitConfidence(a, "1.1", 64); await sync(a); await sync(b); expect(await one(b, "SELECT confidence FROM progress WHERE unit_id = '1.1'")).toEqual({ confidence: 64 }); }); /* The artifact's first bug: a fresh device's defaults clobbering real progress. Seeds are never dirty, so they never leave the device. */ it("never lets a fresh device's seeded state overwrite real progress", async () => { const a = await make(); await editUnitConfidence(a, "1.1", 88); await editMeta(a, "road.unit", "1.3"); await editCard(a, RICE, { ...newCard(), state: 2, interval: 21, due: 20_050, reps: 9 }); await sync(a); const fresh = await make(); await seedProgress(fresh, "1.1"); await seedMeta(fresh, "prefs.newPerDay", "10"); await seedCard(fresh, RICE, newCard()); const result = await sync(fresh); expect(result.pushed, "a seed is not news").toBe(0); expect(await one(fresh, "SELECT v FROM meta WHERE k = 'road.unit'")).toEqual({ v: "1.3" }); expect(await one(fresh, "SELECT interval FROM card WHERE lemma_id = ?", [RICE])).toEqual({ interval: 21 }); await sync(a); expect(await one(a, "SELECT confidence FROM progress WHERE unit_id = '1.1'")).toEqual({ confidence: 88 }); }); /* 14 Sep. A laptop with a week-old copy booted, re-stamped that copy, and pushed it over a week of phone work. Gate 1: it pulls first, so the phone's week arrives before anything leaves the laptop — and the laptop's stale re-stamp meets the week as a conflict, which the week wins. */ it("hydrates before it pushes: a week-old laptop cannot overwrite a week of work", async () => { const laptop = await make(); const phone = await make(); await editUnitConfidence(laptop, "1.1", 40); await sync(laptop); await sync(phone); // A week on the phone. await editUnitDone(phone, "1.1"); await editUnitConfidence(phone, "1.1", 91); await editMeta(phone, "road.unit", "1.2"); await editChatTurn(phone, "user", "a week of lessons"); await editCard(phone, WATER, { ...newCard(), state: 2, interval: 8, due: 20_010, reps: 6 }); await sync(phone); // The laptop boots and a stale write stamps the old data as new. await editUnitConfidence(laptop, "1.1", 40); await editMeta(laptop, "road.unit", "1.1"); await sync(laptop); await sync(phone); for (const d of [phone, laptop]) { expect(await one(d, "SELECT done, confidence FROM progress WHERE unit_id = '1.1'")).toEqual({ done: 1, confidence: 91, }); expect(await one(d, "SELECT v FROM meta WHERE k = 'road.unit'")).toEqual({ v: "1.2" }); expect(await turns(d)).toEqual(["a week of lessons"]); expect(await one(d, "SELECT reps FROM card WHERE lemma_id = ?", [WATER])).toEqual({ reps: 6 }); } }); /* Gate 2. Protocol 1 compared timestamps, so a device whose clock ran an hour fast won every conflict for an hour. The counter does not care. */ it("orders edits by the server's counter, not by either device's clock", async () => { const fast = await make(); const slow = await make(); const real = Date.now(); vi.spyOn(Date, "now").mockReturnValue(real + 60 * 60_000); await editMeta(fast, "prefs.goal", "40"); vi.restoreAllMocks(); await sync(fast); await sync(slow); // Later in fact, earlier by its own clock. vi.spyOn(Date, "now").mockReturnValue(real); await editMeta(slow, "prefs.goal", "25"); vi.restoreAllMocks(); await sync(slow); await sync(fast); expect(await one(fast, "SELECT v FROM meta WHERE k = 'prefs.goal'")).toEqual({ v: "25" }); expect(await one(slow, "SELECT v FROM meta WHERE k = 'prefs.goal'")).toEqual({ v: "25" }); }); it("keeps both devices' offline turns — ids that cannot collide", async () => { const a = await make(); const b = await make(); await sync(a); await sync(b); await editChatTurn(a, "user", "a1"); await editChatTurn(a, "assistant", "a2"); await editChatTurn(b, "user", "b1"); await editChatTurn(b, "assistant", "b2"); await sync(a); await sync(b); await sync(a); for (const d of [a, b]) expect((await turns(d)).sort()).toEqual(["a1", "a2", "b1", "b2"]); }); /* Gate 3. A cleared lesson is a deliberate shrink: the other device obeys it, even holding more turns than the device that cleared. */ it("keeps a cleared lesson cleared against a device holding a longer transcript", async () => { const a = await make(); const b = await make(); for (const t of ["one", "two", "three"]) await editChatTurn(a, "user", t); await sync(a); await sync(b); await editChatTurn(b, "user", "offline four"); await editChatTurn(b, "user", "offline five"); await editChatClear(a); await editChatTurn(a, "user", "a new lesson"); await sync(a); await sync(b); await sync(a); expect(await turns(b)).toEqual(["a new lesson"]); expect(await turns(a)).toEqual(["a new lesson"]); }); it("honours a full reset on a device that had not heard of it, its own offline work included", async () => { const a = await make(); const b = await make(); await editCard(a, RICE, { ...newCard(), state: 1, reps: 1 }); await editUnitConfidence(a, "1.1", 50); await sync(a); await sync(b); await editCard(b, SCHOOL, { ...newCard(), state: 1, reps: 1 }); // a card a never saw await editUnitConfidence(b, "1.2", 30); await editReset(a, "everything"); await sync(a); await sync(b); await sync(a); for (const d of [a, b]) { expect(await one(d, "SELECT count(*) AS n FROM card")).toEqual({ n: 0 }); expect(await one(d, "SELECT count(*) AS n FROM progress")).toEqual({ n: 0 }); } }); it("keeps a forgotten card forgotten against a review made before hearing of it", async () => { const a = await make(); const b = await make(); await editCard(a, WATER, { ...newCard(), state: 2, interval: 10, reps: 3 }); await sync(a); await sync(b); await editCardReset(a, WATER); await sync(a); await editCard(b, WATER, { ...newCard(), state: 2, interval: 20, reps: 4 }); await sync(b); await sync(a); for (const d of [a, b]) expect(await one(d, "SELECT lemma_id FROM card WHERE lemma_id = ?", [WATER])).toBeUndefined(); }); it("re-creates a card forgotten elsewhere, once this device has heard of the forgetting", async () => { const a = await make(); const b = await make(); await editCard(a, RICE, { ...newCard(), state: 1, reps: 1 }); await sync(a); await sync(b); await editCardReset(a, RICE); await sync(a); await sync(b); // b hears of it await editCard(b, RICE, { ...newCard(), state: 1, reps: 1 }); // and studies it again await sync(b); await sync(a); expect(await one(a, "SELECT reps FROM card WHERE lemma_id = ?", [RICE])).toEqual({ reps: 1 }); }); /* Protocol 1 joined a key's values with a space and split them back on one; 몇 명 came back as two values and stopped every pull at that row. */ it("carries and deletes a row whose key contains a space", async () => { const a = await make(); const b = await make(); await editPeek(a, "몇 명"); await editPeek(a, "몇 명"); await sync(a); await sync(b); expect(await one(b, "SELECT SUM(count) AS n FROM peek WHERE form = '몇 명'")).toEqual({ n: 2 }); await editReset(a, "everything"); await sync(a); await sync(b); expect(await one(b, "SELECT count(*) AS n FROM peek")).toEqual({ n: 0 }); }); it("counts two devices' reviews of the same day, rather than keeping one", async () => { const a = await make(); const b = await make(); await editStudyLog(a, 20_000, { reviews: 2 }); await editStudyLog(b, 20_000, { reviews: 3 }); await sync(a); await sync(b); await sync(a); for (const d of [a, b]) { expect(await one(d, "SELECT SUM(reviews) AS n FROM study_log WHERE day = 20000")).toEqual({ n: 5 }); } }); it("never syncs device-local meta", async () => { const a = await make(); const b = await make(); await editMeta(a, "prefs.goal", "40"); await editMeta(a, "server.token", "secret"); await a.run("INSERT OR REPLACE INTO meta (k, v, updated_at, dirty) VALUES ('dict.loadedBands', '[0,1,2]', 1, 1)"); await sync(a); await sync(b); expect(await one(b, "SELECT v FROM meta WHERE k = 'prefs.goal'")).toEqual({ v: "40" }); expect(await one(b, "SELECT v FROM meta WHERE k = 'server.token'")).toBeUndefined(); expect(await one(b, "SELECT v FROM meta WHERE k = 'dict.loadedBands'")).toBeUndefined(); }); it("re-hydrates when the server has lost what it held, and offers its data back", async () => { const a = await make(); await editUnitConfidence(a, "2.1", 70); await sync(a); await fetch(`${cfg.baseUrl}/api/test/reset`, { method: "POST", headers: { authorization: `Bearer ${cfg.token}` }, }); const again = await sync(a); expect(again.rehydrated).toBe(true); expect(again.pushed).toBeGreaterThan(0); const b = await make(); await sync(b); expect(await one(b, "SELECT confidence FROM progress WHERE unit_id = '2.1'")).toEqual({ confidence: 70 }); }); it("refuses a client that does not speak protocol 2", async () => { const res = await fetch(`${cfg.baseUrl}/api/sync?cursor=0`, { headers: { authorization: `Bearer ${cfg.token}` }, }); expect(res.status).toBe(426); const ok = await fetch(`${cfg.baseUrl}/api/sync?cursor=0`, { headers: { authorization: `Bearer ${cfg.token}`, [PROTOCOL_HEADER]: "2" }, }); expect(ok.status).toBe(200); }); });