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>
260 lines
9.6 KiB
TypeScript
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"]);
|
|
});
|
|
});
|