Simpl-Resultat/src/services/db.test.ts
le king fu 5910387eb7
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m39s
test(profiles): pin the review's quick suggestions on the catch-up (#390)
From the /pr-review of PR #394:
- the profile database opens only once prepare_profile_db has resolved
  (deferred preparation; a load started early used to pass, RT1);
- a real VACUUM INTO failure, through a dangling symlink, names the full
  path and comes out scrubbed (RV6); the test first checks the raw message
  does name the folder;
- deleting a profile removes the -journal side file, the only one this
  app produces (RV5);
- test folders are removed when each test ends, a failing one included
  (TestDir guard), instead of 42 folders left in /tmp per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:46:26 -04:00

260 lines
9.6 KiB
TypeScript

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<unknown> => null,
}));
interface FakeDb {
execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>;
select: (q: string, p?: unknown[]) => Promise<unknown[]>;
close: () => Promise<void>;
}
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> = {}): ProfilePreparation {
return { catch_up: null, catch_up_error: null, repaired_flags: 0, elapsed_ms: 12, ...overrides };
}
function catchUp(overrides: Partial<NonNullable<ProfilePreparation["catch_up"]>> = {}) {
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"]);
});
});