import { describe, it, expect, vi, beforeEach } from "vitest"; // Regression coverage for the "database is locked" fix (#228 follow-up). // // tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool // and exposes no transaction primitive, so a BEGIN and its COMMIT issued as // separate db.execute calls could land on different pooled connections and // strand an open write transaction (= permanent lock). db.ts funnels every // operation through one FIFO lock, and withTransaction holds it for the whole // BEGIN..COMMIT. These tests assert that serialization, without a real SQLite. // Ordered log of every fake-db operation's start/end, shared with the fake db. const events: string[] = []; // Hoisted handle so the (hoisted) vi.mock factory can reach the per-test db. const h = vi.hoisted(() => ({ fakeDb: null as unknown as FakeDb, // What `prepare_profile_db` answers (#390); a test may make it throw. prepare: async (): Promise => null, })); interface FakeDb { execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>; select: (q: string, p?: unknown[]) => Promise; close: () => Promise; } vi.mock("@tauri-apps/plugin-sql", () => ({ default: { load: vi.fn(async () => h.fakeDb) }, })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async (command: string) => (command === "prepare_profile_db" ? h.prepare() : false)), })); vi.mock("./logService", () => ({ logInfo: vi.fn(), logError: vi.fn() })); import Database from "@tauri-apps/plugin-sql"; import { invoke } from "@tauri-apps/api/core"; import { logError, logInfo } from "./logService"; import { connectToProfile, describePreparation, getDb, withTransaction, type ProfilePreparation, } from "./db"; // Each op records start, yields a real macrotask (a window for interleaving if // the lock were broken), then records end. The lock must keep them ordered. function makeFakeDb(): FakeDb { return { execute: vi.fn(async (q: string) => { events.push(`start:${q}`); await new Promise((r) => setTimeout(r, 2)); events.push(`end:${q}`); return { rowsAffected: 0 }; }), select: vi.fn(async (q: string) => { events.push(`start:${q}`); await new Promise((r) => setTimeout(r, 2)); events.push(`end:${q}`); return []; }), close: vi.fn(async () => {}), }; } beforeEach(async () => { events.length = 0; h.fakeDb = makeFakeDb(); h.prepare = async () => null; await connectToProfile("test.db"); }); describe("db serialization (database-locked fix)", () => { it("serializes concurrent execute calls — no interleaving", async () => { const db = await getDb(); // Fire both without awaiting between them: they race for the lock. await Promise.all([db.execute("A"), db.execute("B")]); // A must fully complete before B starts (FIFO, no overlap). expect(events).toEqual(["start:A", "end:A", "start:B", "end:B"]); }); it("holds the lock across a whole withTransaction — an outside read cannot interleave", async () => { const db = await getDb(); // Queue the transaction first, then an outside read. const txn = withTransaction(async (tx) => { await tx.execute("BEGIN"); await tx.execute("WRITE"); await tx.execute("COMMIT"); }); const outside = db.select("READ"); await Promise.all([txn, outside]); // The outside read runs entirely AFTER the transaction's COMMIT — the // pre-fix bug was exactly an outside read interleaving mid-transaction. expect(events).toEqual([ "start:BEGIN", "end:BEGIN", "start:WRITE", "end:WRITE", "start:COMMIT", "end:COMMIT", "start:READ", "end:READ", ]); }); it("lets a nested getDb() call run directly inside a transaction — no deadlock", async () => { // A service called inside withTransaction might reach for getDb() (the // serialized proxy) instead of the threaded handle. The reentrancy guard // must run it directly rather than queue it behind the held lock (deadlock). const result = await withTransaction(async () => { const inner = await getDb(); await inner.execute("NESTED"); return "ok"; }); expect(result).toBe("ok"); expect(events).toEqual(["start:NESTED", "end:NESTED"]); }); it("releases the lock after a failed transaction so later ops still run", async () => { const db = await getDb(); await expect( withTransaction(async (tx) => { await tx.execute("BEGIN"); throw new Error("boom"); }) ).rejects.toThrow("boom"); // The lock must be free again: a subsequent op completes (no stuck queue). await db.execute("AFTER"); expect(events).toContain("start:AFTER"); expect(events).toContain("end:AFTER"); }); it("withTransaction rejects when no profile is connected", async () => { const { closeDb } = await import("./db"); await closeDb(); await expect(withTransaction(async () => undefined)).rejects.toThrow( /No database connection/ ); }); }); // --------------------------------------------------------------------------- // Profile preparation (#390) // --------------------------------------------------------------------------- function report(overrides: Partial = {}): ProfilePreparation { return { catch_up: null, catch_up_error: null, repaired_flags: 0, elapsed_ms: 12, ...overrides }; } function catchUp(overrides: Partial> = {}) { return { floor: 6, recorded: [], applied: [], fk_repaired: [], fk_violations: [], backup_file: null, ...overrides, }; } describe("a profile is prepared before the plugin opens it (#390)", () => { beforeEach(() => { vi.mocked(invoke).mockClear(); vi.mocked(Database.load).mockClear(); vi.mocked(logInfo).mockClear(); vi.mocked(logError).mockClear(); }); it("prepares after the checksum repair and before Database.load", async () => { await connectToProfile("profile_0a1b2c3d.db"); const invoked = vi.mocked(invoke).mock; expect(invoked.calls.map((call) => call[0])).toEqual(["repair_migrations", "prepare_profile_db"]); expect(invoked.calls[1][1]).toEqual({ dbFilename: "profile_0a1b2c3d.db" }); expect(invoked.invocationCallOrder[1]).toBeLessThan( vi.mocked(Database.load).mock.invocationCallOrder[0] ); }); it("opens the database only once the preparation has finished", async () => { // In production the command runs on the main thread while Database.load // is asynchronous: started early, the two would overlap on the same file. let finish: (report: ProfilePreparation | null) => void = () => {}; h.prepare = () => new Promise((resolve) => { finish = resolve; }); const connecting = connectToProfile("profile_0a1b2c3d.db"); await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("prepare_profile_db", { dbFilename: "profile_0a1b2c3d.db" }) ); // Give a load started too early every chance to show up. await new Promise((resolve) => setTimeout(resolve, 10)); expect(Database.load).not.toHaveBeenCalled(); finish(null); await connecting; expect(Database.load).toHaveBeenCalledWith("sqlite:profile_0a1b2c3d.db"); }); it("still opens the profile when the preparation fails, and says so in the Journal", async () => { h.prepare = async () => { throw "invalid_profile_db_filename"; }; await connectToProfile("profile_0a1b2c3d.db"); expect(Database.load).toHaveBeenCalledWith("sqlite:profile_0a1b2c3d.db"); expect(logError).toHaveBeenCalledWith( "Profile profile_0a1b2c3d.db: database preparation failed: invalid_profile_db_filename" ); }); it("writes what changed to the Journal", async () => { h.prepare = async () => report({ catch_up: catchUp({ applied: [7, 8, 17], backup_file: "profile_0a1b2c3d-before-v7.db" }), repaired_flags: 2, }); await connectToProfile("profile_0a1b2c3d.db"); expect(vi.mocked(logInfo).mock.calls.map((call) => call[0])).toEqual([ "Profile profile_0a1b2c3d.db: database updated with versions 7 to 17 in 12 ms, copy of the previous state kept as profile_0a1b2c3d-before-v7.db", "Profile profile_0a1b2c3d.db: 2 manual categorization flag(s) repaired", ]); expect(logError).not.toHaveBeenCalled(); }); }); describe("describePreparation (#390)", () => { it("says nothing when nothing changed", () => { expect(describePreparation("x.db", report({ catch_up: catchUp() }))).toEqual({ info: [], errors: [] }); }); it("tolerates a missing report", () => { expect(describePreparation("x.db", null)).toEqual({ info: [], errors: [] }); expect(describePreparation("x.db", false as never)).toEqual({ info: [], errors: [] }); }); it("notes a history written without any update", () => { const lines = describePreparation("x.db", report({ catch_up: catchUp({ recorded: [1, 2, 17] }) })); expect(lines.info).toEqual(["Profile x.db: migration history recorded up to version 17"]); }); it("names the rows a version detached from a missing parent", () => { const lines = describePreparation( "x.db", report({ catch_up: catchUp({ applied: [7], fk_repaired: [["categories", 3]] }) }) ); expect(lines.info).toContain("Profile x.db: 3 row(s) of categories detached from a missing parent"); }); it("reports a stopped catch-up as an error", () => { const lines = describePreparation("x.db", report({ catch_up_error: "migration_failed:v12: boom" })); expect(lines.errors).toEqual(["Profile x.db: database update stopped: migration_failed:v12: boom"]); }); });