- db.test.ts: `\r` joins the path stops of withoutFolders (removing it from PATH_STOP now fails). - The six keys whose "t removed" mutation survived the review of PR #435 (comment 23499) are asserted in their marked form: transactions.transferIcon.ariaLabel (a row linked to a transfer) and transactions.notes.placeholder (title of every row's notes button) in TransactionTable.render.test.tsx; license.keyPlaceholder and license.activate in LicenseKeyForm.render.test.tsx; dashboard.kpiSectionTitle in DashboardPage.render.test.tsx; balance.priceFetching.consent.accept in PriceFetchControl.test.tsx, the consent dialog opened by presetting the control's first harness slot (the dialog's title, body and decline texts are pinned with it). - TransactionTable.wiring.test.ts opens a row's notes through its button and pins the textarea placeholder, the second call of the notes key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
752 lines
29 KiB
TypeScript
752 lines
29 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } 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(), logWarn: vi.fn(), logError: vi.fn() }));
|
|
|
|
import Database from "@tauri-apps/plugin-sql";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { logError, logInfo, logWarn } from "./logService";
|
|
import {
|
|
BLOCKED_ALERT_MS,
|
|
closeDb,
|
|
connectToProfile,
|
|
DbConnectionClosedError,
|
|
describePreparation,
|
|
getDb,
|
|
initializeNewProfileDb,
|
|
TransactionHandleCloseError,
|
|
TransactionHandleRevokedError,
|
|
withoutFolders,
|
|
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(name = "db"): 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 () => {
|
|
events.push(`close:${name}`);
|
|
}),
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
h.fakeDb = makeFakeDb();
|
|
h.prepare = async () => null;
|
|
await connectToProfile("test.db");
|
|
// Cleared after connecting: the connection opens by closing the previous
|
|
// test's fake, whose close is logged too.
|
|
events.length = 0;
|
|
});
|
|
|
|
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("makes a getDb() call from inside a transaction wait behind it — strict lock (#408)", async () => {
|
|
// The lock is not reentrant: only the callback's handle runs directly. A
|
|
// nested service reaching for getDb() queues behind the transaction (this
|
|
// test releases the callback by hand; a callback awaiting it deadlocks,
|
|
// which the blocking alert reports).
|
|
let release: () => void = () => {};
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let nested: Promise<unknown> = Promise.resolve();
|
|
const txn = withTransaction(async (tx) => {
|
|
await tx.execute("BEGIN");
|
|
nested = (await getDb()).execute("NESTED");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await tx.execute("COMMIT");
|
|
await gate;
|
|
});
|
|
try {
|
|
await vi.waitFor(() => expect(events).toContain("end:COMMIT"));
|
|
expect(events).not.toContain("start:NESTED");
|
|
} finally {
|
|
// Released even on failure, so a red assertion leaves no held lock.
|
|
release();
|
|
await txn;
|
|
await nested;
|
|
}
|
|
expect(events).toEqual(["start:BEGIN", "end:BEGIN", "start:COMMIT", "end:COMMIT", "start:NESTED", "end:NESTED"]);
|
|
});
|
|
|
|
it("runs a read launched from elsewhere during the callback after the COMMIT (#408)", async () => {
|
|
// The pre-#408 global flag let ANY query bypass the lock while a callback
|
|
// ran: a read started after BEGIN ran in parallel with the transaction.
|
|
const db = await getDb();
|
|
let outside: Promise<unknown> = Promise.resolve();
|
|
const txn = withTransaction(async (tx) => {
|
|
await tx.execute("BEGIN");
|
|
outside = db.select("READ");
|
|
await tx.execute("WRITE");
|
|
await tx.execute("COMMIT");
|
|
});
|
|
await txn;
|
|
await outside;
|
|
expect(events).toEqual([
|
|
"start:BEGIN",
|
|
"end:BEGIN",
|
|
"start:WRITE",
|
|
"end:WRITE",
|
|
"start:COMMIT",
|
|
"end:COMMIT",
|
|
"start:READ",
|
|
"end:READ",
|
|
]);
|
|
});
|
|
|
|
it("lets the callback's own handle run its statements without waiting", async () => {
|
|
const rows = await withTransaction(async (tx) => {
|
|
await tx.execute("BEGIN");
|
|
const result = await tx.select<unknown[]>("SELECT");
|
|
await tx.execute("COMMIT");
|
|
return result;
|
|
});
|
|
expect(rows).toEqual([]);
|
|
expect(events).toEqual(["start:BEGIN", "end:BEGIN", "start:SELECT", "end:SELECT", "start:COMMIT", "end:COMMIT"]);
|
|
});
|
|
|
|
it("revokes the transaction handle once the callback settled", async () => {
|
|
let kept: Awaited<ReturnType<typeof getDb>> | null = null;
|
|
await withTransaction(async (tx) => {
|
|
kept = tx;
|
|
});
|
|
await expect(kept!.execute("LATE")).rejects.toBeInstanceOf(TransactionHandleRevokedError);
|
|
await expect(kept!.select("LATE")).rejects.toBeInstanceOf(TransactionHandleRevokedError);
|
|
expect(events).not.toContain("start:LATE");
|
|
});
|
|
|
|
it("revokes the handle of a failed transaction too", async () => {
|
|
let kept: Awaited<ReturnType<typeof getDb>> | null = null;
|
|
await expect(
|
|
withTransaction(async (tx) => {
|
|
kept = tx;
|
|
throw new Error("boom");
|
|
})
|
|
).rejects.toThrow("boom");
|
|
await expect(kept!.execute("LATE")).rejects.toBeInstanceOf(TransactionHandleRevokedError);
|
|
});
|
|
|
|
it("refuses close() on the transaction handle", async () => {
|
|
await withTransaction(async (tx) => {
|
|
await expect(tx.close()).rejects.toBeInstanceOf(TransactionHandleCloseError);
|
|
});
|
|
expect(h.fakeDb.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 switch through the lock, connection generation, blocking alert (#408)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("profile switch goes through the lock (#408)", () => {
|
|
afterEach(() => {
|
|
vi.mocked(Database.load).mockImplementation(async () => h.fakeDb as never);
|
|
});
|
|
|
|
it("closes the connection only after a running transaction's COMMIT", async () => {
|
|
const old = h.fakeDb;
|
|
let switching: Promise<void> = Promise.resolve();
|
|
const txn = withTransaction(async (tx) => {
|
|
await tx.execute("BEGIN");
|
|
h.fakeDb = makeFakeDb("next");
|
|
switching = connectToProfile("next.db");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await tx.execute("COMMIT");
|
|
});
|
|
await txn;
|
|
await switching;
|
|
expect(events).toEqual(["start:BEGIN", "end:BEGIN", "start:COMMIT", "end:COMMIT", "close:db"]);
|
|
expect(old.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("two concurrent switches leave one pool open, on the last file asked for", async () => {
|
|
const pools = new Map<string, FakeDb>();
|
|
vi.mocked(Database.load).mockImplementation(async (url: string) => {
|
|
const fake = makeFakeDb(url);
|
|
pools.set(url, fake);
|
|
return fake as never;
|
|
});
|
|
await Promise.all([connectToProfile("a.db"), connectToProfile("b.db")]);
|
|
expect([...pools.keys()]).toEqual(["sqlite:a.db", "sqlite:b.db"]);
|
|
expect(pools.get("sqlite:a.db")!.close).toHaveBeenCalledTimes(1);
|
|
expect(pools.get("sqlite:b.db")!.close).not.toHaveBeenCalled();
|
|
await (await getDb()).execute("Q");
|
|
expect(pools.get("sqlite:b.db")!.execute).toHaveBeenCalledWith("Q", undefined);
|
|
});
|
|
|
|
it("invokes prepare_profile_db only once the old connection's close() resolved", async () => {
|
|
const old = h.fakeDb;
|
|
vi.mocked(old.close).mockImplementation(async () => {
|
|
events.push("close-start");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
events.push("close-end");
|
|
});
|
|
h.prepare = async () => {
|
|
events.push("prepare");
|
|
return null;
|
|
};
|
|
h.fakeDb = makeFakeDb("next");
|
|
await connectToProfile("next.db");
|
|
expect(events).toEqual(["close-start", "close-end", "prepare"]);
|
|
});
|
|
|
|
it("fails a transaction queued behind a switch instead of running it on the new database", async () => {
|
|
const old = h.fakeDb;
|
|
const next = makeFakeDb("next");
|
|
h.fakeDb = next;
|
|
const switching = connectToProfile("next.db");
|
|
const callback = vi.fn(async () => "ran");
|
|
const txn = withTransaction(callback);
|
|
await switching;
|
|
await expect(txn).rejects.toBeInstanceOf(DbConnectionClosedError);
|
|
await expect(txn).rejects.toThrow("connection closed by profile switch");
|
|
expect(callback).not.toHaveBeenCalled();
|
|
expect(next.execute).not.toHaveBeenCalled();
|
|
expect(old.execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails a query queued on the old handle behind a switch", async () => {
|
|
const before = await getDb();
|
|
const next = makeFakeDb("next");
|
|
h.fakeDb = next;
|
|
const switching = connectToProfile("next.db");
|
|
const late = before.select("OLD");
|
|
await switching;
|
|
await expect(late).rejects.toBeInstanceOf(DbConnectionClosedError);
|
|
expect(next.select).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closeDb waits for the queue, then the old handle refuses to run", async () => {
|
|
const db = await getDb();
|
|
const first = db.execute("FIRST");
|
|
const closing = closeDb();
|
|
await first;
|
|
await closing;
|
|
expect(events).toEqual(["start:FIRST", "end:FIRST", "close:db"]);
|
|
await expect(db.execute("AFTER")).rejects.toBeInstanceOf(DbConnectionClosedError);
|
|
});
|
|
|
|
it("close() on a getDb() handle kept from the previous profile rejects and keeps the current one open", async () => {
|
|
const before = await getDb();
|
|
const next = makeFakeDb("next");
|
|
h.fakeDb = next;
|
|
await connectToProfile("next.db");
|
|
events.length = 0;
|
|
await expect(before.close()).rejects.toBeInstanceOf(DbConnectionClosedError);
|
|
expect(next.close).not.toHaveBeenCalled();
|
|
await (await getDb()).execute("STILL OPEN");
|
|
expect(next.execute).toHaveBeenCalledWith("STILL OPEN", undefined);
|
|
});
|
|
|
|
it("close() on the current getDb() handle closes the connection", async () => {
|
|
const current = h.fakeDb;
|
|
await (await getDb()).close();
|
|
expect(current.close).toHaveBeenCalledTimes(1);
|
|
await expect(getDb()).rejects.toThrow(/No database connection/);
|
|
});
|
|
|
|
it("a failing close of the old pool does not prevent the switch, and goes to the Journal", async () => {
|
|
vi.mocked(logWarn).mockClear();
|
|
const old = h.fakeDb;
|
|
const before = await getDb();
|
|
vi.mocked(old.close).mockImplementation(async () => {
|
|
throw new Error("close refused for /home/someone/.config/x.db");
|
|
});
|
|
const next = makeFakeDb("next");
|
|
h.fakeDb = next;
|
|
await connectToProfile("next.db");
|
|
expect(old.close).toHaveBeenCalledTimes(1);
|
|
expect(logWarn).toHaveBeenCalledWith(
|
|
"DB: closing the previous connection failed, continuing without it: close refused for x.db"
|
|
);
|
|
await (await getDb()).execute("ON NEXT");
|
|
expect(next.execute).toHaveBeenCalledWith("ON NEXT", undefined);
|
|
await expect(before.execute("ON OLD")).rejects.toBeInstanceOf(DbConnectionClosedError);
|
|
expect(old.execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closeDb with a failing close resolves, warns, and leaves a coherent state without connection", async () => {
|
|
vi.mocked(logWarn).mockClear();
|
|
const old = h.fakeDb;
|
|
const before = await getDb();
|
|
vi.mocked(old.close).mockImplementation(async () => {
|
|
throw new Error("close refused");
|
|
});
|
|
await expect(closeDb()).resolves.toBeUndefined();
|
|
expect(logWarn).toHaveBeenCalledWith("DB: closing the previous connection failed, continuing without it: close refused");
|
|
// No connection, and the generation moved: the old handle refuses to run.
|
|
await expect(getDb()).rejects.toThrow(/No database connection/);
|
|
await expect(withTransaction(async () => undefined)).rejects.toThrow(/No database connection/);
|
|
await expect(before.execute("ON OLD")).rejects.toBeInstanceOf(DbConnectionClosedError);
|
|
expect(old.execute).not.toHaveBeenCalled();
|
|
// The old pool is no longer referenced: the next open does not close it again.
|
|
h.fakeDb = makeFakeDb("next");
|
|
await connectToProfile("next.db");
|
|
expect(old.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("initializeNewProfileDb runs its SQL inside the section, before a queued query", async () => {
|
|
const next = makeFakeDb("new");
|
|
h.fakeDb = next;
|
|
const init = initializeNewProfileDb("new.db", ["CREATE A", "CREATE B"]);
|
|
const after = (async () => {
|
|
await init;
|
|
return (await getDb()).select("READ");
|
|
})();
|
|
await after;
|
|
expect(events).toEqual([
|
|
"close:db",
|
|
"start:CREATE A",
|
|
"end:CREATE A",
|
|
"start:CREATE B",
|
|
"end:CREATE B",
|
|
"start:READ",
|
|
"end:READ",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("blocking alert (#408)", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(logWarn).mockClear();
|
|
vi.useFakeTimers();
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("warns once, naming the transaction that holds the lock, when getDb() is called from a callback", async () => {
|
|
let release: () => void = () => {};
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let nested: Promise<unknown> = Promise.resolve();
|
|
const startedAt = new Date(Date.now()).toISOString();
|
|
const txn = withTransaction(async () => {
|
|
nested = (await getDb()).execute("NESTED");
|
|
await gate;
|
|
});
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(BLOCKED_ALERT_MS - 1);
|
|
expect(logWarn).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(logWarn).toHaveBeenCalledTimes(1);
|
|
const message = String(vi.mocked(logWarn).mock.calls[0][0]);
|
|
expect(message).toContain("a query has waited 15 s");
|
|
expect(message).toContain(`held by a transaction since ${startedAt}`);
|
|
// A cause the reader may check, not a diagnosis: a long restore also waits 15 s.
|
|
expect(message).toContain("(possible cause: a getDb() call inside a withTransaction callback");
|
|
// Once per operation: still one warning much later.
|
|
await vi.advanceTimersByTimeAsync(BLOCKED_ALERT_MS * 3);
|
|
expect(logWarn).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
release();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await txn;
|
|
await nested;
|
|
}
|
|
});
|
|
|
|
it("says the lock is free, not who held it last, when the check lands between two operations", async () => {
|
|
// In the app only microtasks separate the end of one operation from the
|
|
// start of the next, so a timer cannot fire there; the test puts the check
|
|
// there to pin that the holder is cleared when an operation ends.
|
|
const db = await getDb();
|
|
let release: () => void = () => {};
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
vi.mocked(h.fakeDb.execute).mockImplementationOnce(async () => {
|
|
await gate;
|
|
return { rowsAffected: 0 };
|
|
});
|
|
const first = db.execute("LONG");
|
|
const waiting = db.select("WAITING");
|
|
// Registered after the queue's own reaction: runs once the first operation
|
|
// ended (holder cleared), before the waiting one starts.
|
|
const checked = first.then(() => vi.advanceTimersByTime(BLOCKED_ALERT_MS));
|
|
release();
|
|
await checked;
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await waiting;
|
|
expect(logWarn).toHaveBeenCalledTimes(1);
|
|
expect(String(vi.mocked(logWarn).mock.calls[0][0])).toBe("DB lock: a query has waited 15 s behind the queue");
|
|
});
|
|
|
|
it("says nothing for an operation that got the lock in time", async () => {
|
|
const db = await getDb();
|
|
const run = db.execute("QUICK");
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await run;
|
|
await vi.advanceTimersByTimeAsync(BLOCKED_ALERT_MS * 2);
|
|
expect(logWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("names a profile opening that holds the lock (long catch-up)", async () => {
|
|
let finish: () => void = () => {};
|
|
h.prepare = () =>
|
|
new Promise((resolve) => {
|
|
finish = () => resolve(null);
|
|
});
|
|
h.fakeDb = makeFakeDb("next");
|
|
const switching = connectToProfile("next.db");
|
|
// Settled outcome caught at once: the rejection lands while timers advance.
|
|
const waiting = withTransaction(async () => "late").catch((e: unknown) => e);
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(BLOCKED_ALERT_MS);
|
|
expect(logWarn).toHaveBeenCalledTimes(1);
|
|
expect(String(vi.mocked(logWarn).mock.calls[0][0])).toContain(
|
|
"a transaction has waited 15 s, the lock is held by a profile opening"
|
|
);
|
|
} finally {
|
|
finish();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await switching;
|
|
}
|
|
expect(await waiting).toBeInstanceOf(DbConnectionClosedError);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Profile preparation (#390)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function report(overrides: Partial<ProfilePreparation> = {}): ProfilePreparation {
|
|
return {
|
|
catch_up: null,
|
|
catch_up_error: null,
|
|
catch_up_backup: 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 no folder when the preparation fails with a path (#395)", async () => {
|
|
h.prepare = async () => {
|
|
throw new Error(
|
|
"Cannot read /home/someone/.config/com.simpl.resultat/profile_0a1b2c3d.db nor C:\\Users\\L'Heureux\\AppData\\x.db"
|
|
);
|
|
};
|
|
|
|
await connectToProfile("profile_0a1b2c3d.db");
|
|
|
|
expect(logError).toHaveBeenCalledWith(
|
|
"Profile profile_0a1b2c3d.db: database preparation failed: Cannot read profile_0a1b2c3d.db nor x.db"
|
|
);
|
|
});
|
|
|
|
it("names the copy in the Journal when the catch-up stopped after taking it (#395)", async () => {
|
|
h.prepare = async () =>
|
|
report({
|
|
catch_up_error: "history_write_failed: history refused",
|
|
catch_up_backup: "profile_0a1b2c3d-before-v17.db",
|
|
});
|
|
|
|
await connectToProfile("profile_0a1b2c3d.db");
|
|
|
|
expect(vi.mocked(logError).mock.calls.map((call) => call[0])).toEqual([
|
|
"Profile profile_0a1b2c3d.db: database update stopped: history_write_failed: history refused; copy of the previous state kept as profile_0a1b2c3d-before-v17.db",
|
|
]);
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
|
|
it("names the rows that already pointed to a missing row (RT2, #395)", () => {
|
|
const lines = describePreparation(
|
|
"x.db",
|
|
report({ catch_up: catchUp({ applied: [7], fk_violations: [["transactions", 1]] }) })
|
|
);
|
|
expect(lines.info).toContain("Profile x.db: 1 row(s) of transactions point to a missing row");
|
|
});
|
|
|
|
// The three ways a catch-up stops after its copy (profile_db.rs), each with
|
|
// the message shape Rust produces.
|
|
it.each([
|
|
["history_write_failed: history refused"],
|
|
["pragma_failed: database is locked"],
|
|
["migration_failed:v18: no such table: no_such_table (applied before stopping: [17])"],
|
|
])("names the copy taken before a catch-up that stopped on %s (#395)", (error) => {
|
|
const lines = describePreparation(
|
|
"x.db",
|
|
report({ catch_up_error: error, catch_up_backup: "x-before-v17.db" })
|
|
);
|
|
expect(lines.errors).toEqual([
|
|
`Profile x.db: database update stopped: ${error}; copy of the previous state kept as x-before-v17.db`,
|
|
]);
|
|
});
|
|
|
|
it("writes no folder in an error line (#395)", () => {
|
|
const lines = describePreparation(
|
|
"x.db",
|
|
report({
|
|
// What scrub leaves of a real VACUUM INTO failure, plus a copy name a
|
|
// regression would turn into a path, on both separators.
|
|
catch_up_error:
|
|
"backup_failed: unable to open database: <profile folder>/migration-backups/x-before-v17.db.tmp",
|
|
catch_up_backup: "C:\\Users\\someone\\migration-backups\\x-before-v17.db",
|
|
})
|
|
);
|
|
expect(lines.errors).toEqual([
|
|
"Profile x.db: database update stopped: backup_failed: unable to open database: x-before-v17.db.tmp; copy of the previous state kept as x-before-v17.db",
|
|
]);
|
|
for (const line of lines.errors) {
|
|
expect(line).not.toMatch(/[\\/]/);
|
|
}
|
|
});
|
|
|
|
it("withoutFolders keeps text without a path as is", () => {
|
|
expect(withoutFolders("migration_failed:v12: near \"x\": syntax error")).toBe(
|
|
'migration_failed:v12: near "x": syntax error'
|
|
);
|
|
expect(withoutFolders("a /home/u/f.db, b")).toBe("a f.db, b");
|
|
// A separator that is no path: kept readable, still no slash.
|
|
expect(withoutFolders("pragma_failed: disk I/O error")).toBe("pragma_failed: disk I-O error");
|
|
});
|
|
|
|
// A folder holding a blank must not leave its first word behind (#436 review, S1).
|
|
it.each([
|
|
["C:\\Users\\Jean Dupont\\AppData\\Roaming\\com.simpl.resultat\\profile_x.db", "profile_x.db"],
|
|
["C:/Users/Jean Dupont/x.db", "x.db"],
|
|
["/home/marie claire/.config/x.db", "x.db"],
|
|
["~/Jean Dupont/x.db", "x.db"],
|
|
["\\\\server\\share\\Jean Dupont\\x.db", "x.db"],
|
|
["C:\\Program Files (x86)\\Simpl\\x.db", "x.db"],
|
|
['"C:\\Users\\Jean Dupont\\x.db" failed', '"x.db" failed'],
|
|
["(see /home/marie claire/x.db) and I/O", "(see x.db) and I-O"],
|
|
["path=/home/marie claire/x.db;next", "path=x.db;next"],
|
|
])("withoutFolders reduces %s to its last segment", (input, expected) => {
|
|
const cleaned = withoutFolders(input);
|
|
expect(cleaned).toBe(expected);
|
|
expect(cleaned).not.toMatch(/Jean|Dupont|marie|claire|Users|home|Program|server/);
|
|
});
|
|
|
|
it("withoutFolders ends a path at sentence punctuation and at the next path", () => {
|
|
expect(withoutFolders("unable to open /home/marie claire/x.db: disk I/O error")).toBe(
|
|
"unable to open x.db: disk I-O error"
|
|
);
|
|
expect(withoutFolders("read /home/marie claire/a.db, then C:\\Users\\Jean Dupont\\b.db")).toBe(
|
|
"read a.db, then b.db"
|
|
);
|
|
expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'");
|
|
});
|
|
|
|
it.each([["\""], [";"], ["|"], ["`"], ["\n"], ["\r"]])("withoutFolders ends the folders at %j", (stop) => {
|
|
expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`);
|
|
});
|
|
|
|
it("withoutFolders keeps a single quote around the file name, both sides", () => {
|
|
expect(withoutFolders("'C:\\Users\\Bob\\x.db'")).toBe("'x.db'");
|
|
expect(withoutFolders("cannot open '/home/marie claire/x.db' now")).toBe("cannot open 'x.db' now");
|
|
});
|
|
|
|
it("withoutFolders drops more text rather than a folder when nothing ends the path", () => {
|
|
// Conservative: everything up to the last separator of the line goes.
|
|
expect(withoutFolders("/home/marie claire/x.db then I/O")).toBe("O");
|
|
});
|
|
});
|