/* Two devices, one server. Runs against a real Postgres and a real server — a fake would not exercise the trigger that advances change_seq, the LWW clause in the upsert, or the cursor paging, 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 --name hankan-pg-test -e POSTGRES_PASSWORD=test \ -e POSTGRES_DB=hankan -p 55432:5432 postgres:16-alpine DATABASE_URL=postgres://postgres:test@localhost:55432/hankan \ HANKAN_TOKEN=test-token PORT=8788 \ node --experimental-strip-types server/src/main.ts HANKAN_TEST_SERVER=http://localhost:8788 npm test */ import { describe, it, expect, beforeAll, beforeEach } 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, editMeta, editUnitConfidence, seedCard, seedMeta, seedProgress, } from "@app/db/writes.js"; import { syncOnce, type SyncConfig } from "@app/sync/client.js"; import { newCard } from "@lib/srs.js"; const BASE = process.env.HANKAN_TEST_SERVER; const TOKEN = process.env.HANKAN_TEST_TOKEN ?? "test-token"; const suite = BASE ? describe : describe.skip; /** A fresh device: its own database, migrated, with the dictionary rows it needs to hang cards off. */ async function device(): Promise { const db = await SqliteWasmDb.open({ memory: true }); await migrate(db); await db.exec(` INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (1, '밥', 'noun', 'rice', 'curated'), (2, '학교', 'noun', 'school', 'curated'), (3, '물', 'noun', 'water', 'curated'); `); return db; } /** Wipe the server between tests so each starts from an empty history. */ async function resetServer(cfg: SyncConfig): Promise { await fetch(`${cfg.baseUrl}/api/test/reset`, { method: "POST", headers: { authorization: `Bearer ${cfg.token}` }, }); } suite("sync — two devices, one server", () => { let cfg: SyncConfig; beforeAll(() => { cfg = { baseUrl: BASE!, token: TOKEN }; }); beforeEach(async () => { await resetServer(cfg); }); it("carries an edit from one device to the other", async () => { const a = await device(); const b = await device(); await editUnitConfidence(a, "1.1", 64); await syncOnce(a, cfg); await syncOnce(b, cfg); const row = await b.get<{ confidence: number }>( "SELECT confidence FROM progress WHERE unit_id = '1.1'", ); expect(row?.confidence).toBe(64); await a.close(); await b.close(); }); /* THE ARTIFACT'S BUG, as an executable test. A fresh device stamped its own empty defaults as newer than the server's real history and clobbered it. Here the seed rows carry updated_at = 0, so they are never dirty and never win — the device converges onto the server's data instead of destroying it. */ it("a fresh device's seeded state never overwrites real progress", async () => { const a = await device(); await editUnitConfidence(a, "1.1", 88); await editCard(a, 1, { ...newCard(), state: 2, interval: 21, due: 20_050, reps: 9 }); await syncOnce(a, cfg); // A brand-new device does exactly what boot does: seeds defaults. const fresh = await device(); await seedProgress(fresh, "1.1"); await seedMeta(fresh, "prefs.newPerDay", "10"); await seedCard(fresh, 1, newCard()); const seeded = await fresh.get<{ n: number }>( "SELECT count(*) AS n FROM progress WHERE updated_at != 0", ); expect(seeded?.n, "seed rows must carry no write timestamp").toBe(0); await syncOnce(fresh, cfg); // The fresh device adopts the real data… const onFresh = await fresh.get<{ confidence: number }>( "SELECT confidence FROM progress WHERE unit_id = '1.1'", ); expect(onFresh?.confidence).toBe(88); // …and the original device still has it after a round trip. await syncOnce(a, cfg); const onA = await a.get<{ confidence: number }>( "SELECT confidence FROM progress WHERE unit_id = '1.1'", ); expect(onA?.confidence, "the server's real history survived").toBe(88); const card = await a.get<{ interval: number }>("SELECT interval FROM card WHERE lemma_id = 1"); expect(card?.interval).toBe(21); await a.close(); await fresh.close(); }); it("last write wins on a genuine conflict", async () => { const a = await device(); const b = await device(); await editUnitConfidence(a, "2.1", 30); await syncOnce(a, cfg); await syncOnce(b, cfg); // b edits later, so b wins. await new Promise((r) => setTimeout(r, 5)); await editUnitConfidence(b, "2.1", 70); await syncOnce(b, cfg); await syncOnce(a, cfg); const onA = await a.get<{ confidence: number }>( "SELECT confidence FROM progress WHERE unit_id = '2.1'", ); expect(onA?.confidence).toBe(70); await a.close(); await b.close(); }); it("propagates a delete through a tombstone", async () => { const a = await device(); const b = await device(); await editCard(a, 2, { ...newCard(), state: 2, interval: 10 }); await syncOnce(a, cfg); await syncOnce(b, cfg); expect(await b.get("SELECT lemma_id FROM card WHERE lemma_id = 2")).toBeDefined(); // Without a tombstone the delete is invisible and b would push the card // back on its next turn. await new Promise((r) => setTimeout(r, 5)); await editCardReset(a, 2); await syncOnce(a, cfg); await syncOnce(b, cfg); expect(await b.get("SELECT lemma_id FROM card WHERE lemma_id = 2")).toBeUndefined(); // And it stays deleted after b syncs again — no resurrection. await syncOnce(b, cfg); await syncOnce(a, cfg); expect(await a.get("SELECT lemma_id FROM card WHERE lemma_id = 2")).toBeUndefined(); await a.close(); await b.close(); }); /* Device-local bookkeeping must not travel. dict.loadedBands is the dangerous one: it would tell a device it holds rows it never downloaded, and the word rail would then miss words it believes are present. */ it("never syncs device-local meta", async () => { const a = await device(); const b = await device(); await editMeta(a, "prefs.goal", "40"); // These are written the way the app writes them — unstamped bookkeeping. await a.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('dict.loadedBands','[0,1,2,3,4,5]',?)", [ Date.now(), ]); await a.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('server.token','secret',?)", [ Date.now(), ]); await syncOnce(a, cfg); await syncOnce(b, cfg); expect((await b.get<{ v: string }>("SELECT v FROM meta WHERE k='prefs.goal'"))?.v).toBe("40"); expect(await b.get("SELECT v FROM meta WHERE k='dict.loadedBands'")).toBeUndefined(); expect(await b.get("SELECT v FROM meta WHERE k='server.token'")).toBeUndefined(); await a.close(); await b.close(); }); });