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 })); 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 () => false) })); import { connectToProfile, getDb, withTransaction } 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(); 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/ ); }); });