Simpl-Resultat/src/services/db.test.ts
le king fu 3e8c79f057 test: pin the \r path stop and the surviving marked-t keys (#437)
- 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>
2026-10-18 19:46:47 -04:00

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");
});
});