Database lock (the reported 0.10.0 regression): - tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool (Pool::connect => max_connections = 10) and exposes no JS transaction primitive. BEGIN and COMMIT issued as separate db.execute calls could land on different pooled connections, stranding an open write transaction on an idle connection — a permanent write lock until app restart. Triggered when the Bilan page's concurrent reads interleaved with an in-flight snapshot save. - Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction holds it across the whole BEGIN..COMMIT so a transaction's statements never span connections (and the saves become genuinely atomic). A reentrancy guard lets nested getDb() calls run directly instead of deadlocking. Applied to all 5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via in-place helper extraction. New db.test.ts covers serialization, cross-transaction non-interleaving, reentrancy, and lock release on error. Log console (Settings -> Systems -> Journaux): - getLogs returned the same mutated array reference, so useSyncExternalStore (identity comparison) never re-rendered on a new log -> the console never updated live. getLogs now returns an immutable snapshot rebuilt on each mutation (stable between mutations, new identity after each). - Add logInfo/logWarn/logError app-logging API and instrument the snapshot save (info on success, error on failure) so DB issues surface in the console. tsc + 635 vitest + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// `getDb` is stubbed with a fake DB that captures every SQL statement issued
|
|
// during applyMigration. This is a unit test — we do NOT run a real SQLite.
|
|
|
|
vi.mock("./db", () => {
|
|
const getDb = vi.fn();
|
|
return {
|
|
getDb,
|
|
// Tests don't exercise the real lock; withTransaction just runs the
|
|
// callback with whatever db the test wired getDb to resolve, so every
|
|
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
|
|
// service issues them on the mock db inside the callback).
|
|
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
};
|
|
});
|
|
|
|
// `setPreference` goes through the same fake DB — no extra stubbing needed
|
|
// because userPreferenceService itself calls getDb().
|
|
|
|
import { getDb } from "./db";
|
|
import { applyMigration, markSchemaVersionV1 } from "./categoryMigrationService";
|
|
import type { MigrationPlan, MappingRow } from "./categoryMappingService";
|
|
import type { BackupResult } from "./categoryBackupService";
|
|
|
|
interface CapturedCall {
|
|
sql: string;
|
|
params?: unknown[];
|
|
}
|
|
|
|
interface FakeDb {
|
|
calls: CapturedCall[];
|
|
failAt: { sql: RegExp; error: string } | null;
|
|
select: ReturnType<typeof vi.fn>;
|
|
execute: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function makeFakeDb(): FakeDb {
|
|
const db: FakeDb = {
|
|
calls: [],
|
|
failAt: null,
|
|
select: vi.fn(async () => []),
|
|
execute: vi.fn(),
|
|
};
|
|
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
|
|
db.calls.push({ sql, params });
|
|
if (db.failAt && db.failAt.sql.test(sql)) {
|
|
throw new Error(db.failAt.error);
|
|
}
|
|
// Return a rowsAffected=1 for UPDATE/DELETE/INSERT, 0 for BEGIN/COMMIT/ROLLBACK.
|
|
const upper = sql.trim().toUpperCase();
|
|
if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 };
|
|
// Heuristic: only simulate a write on statements that look like real
|
|
// UPDATE/DELETE/INSERT OR IGNORE — this gives us stable counts in tests.
|
|
return { rowsAffected: 1 };
|
|
});
|
|
return db;
|
|
}
|
|
|
|
const FAKE_BACKUP: BackupResult = {
|
|
path: "/tmp/profile-backup.sref",
|
|
size: 4096,
|
|
checksum: "abc123",
|
|
verifiedAt: "2026-04-20T12:00:00Z",
|
|
encrypted: false,
|
|
};
|
|
|
|
function makeRow(v2: number, v1: number | null): MappingRow {
|
|
return {
|
|
v2CategoryId: v2,
|
|
v2CategoryName: `v2-${v2}`,
|
|
v1TargetId: v1,
|
|
v1TargetName: v1 === null ? null : `v1-${v1}`,
|
|
confidence: v1 === null ? "none" : "high",
|
|
reason: v1 === null ? "review" : "keyword",
|
|
};
|
|
}
|
|
|
|
function makePlan(
|
|
rows: MappingRow[],
|
|
preserved: MappingRow[] = [],
|
|
): MigrationPlan {
|
|
const unresolved = rows.filter((r) => r.v1TargetId === null);
|
|
return {
|
|
rows,
|
|
preserved,
|
|
unresolved,
|
|
stats: {
|
|
total: rows.length,
|
|
high: rows.filter((r) => r.confidence === "high").length,
|
|
medium: rows.filter((r) => r.confidence === "medium").length,
|
|
low: rows.filter((r) => r.confidence === "low").length,
|
|
none: unresolved.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
let fake: FakeDb;
|
|
|
|
beforeEach(() => {
|
|
fake = makeFakeDb();
|
|
vi.mocked(getDb).mockResolvedValue(fake as never);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Guard: invalid backup short-circuits before any SQL runs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("applyMigration — backup guard", () => {
|
|
it("returns succeeded=false with no DB calls when backup has no path", async () => {
|
|
const outcome = await applyMigration(
|
|
makePlan([makeRow(10, 1011)]),
|
|
{ ...FAKE_BACKUP, path: "" },
|
|
);
|
|
expect(outcome.succeeded).toBe(false);
|
|
expect(outcome.error).toMatch(/invalid_backup/);
|
|
expect(fake.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("returns succeeded=false when backup has no checksum", async () => {
|
|
const outcome = await applyMigration(
|
|
makePlan([makeRow(10, 1011)]),
|
|
{ ...FAKE_BACKUP, checksum: "" },
|
|
);
|
|
expect(outcome.succeeded).toBe(false);
|
|
expect(outcome.error).toMatch(/invalid_backup/);
|
|
expect(fake.calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Happy path sequencing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("applyMigration — happy path sequencing", () => {
|
|
it("wraps the entire run in BEGIN / COMMIT", async () => {
|
|
const outcome = await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
expect(outcome.succeeded).toBe(true);
|
|
const trimmed = fake.calls.map((c) => c.sql.trim().toUpperCase());
|
|
expect(trimmed[0]).toBe("BEGIN");
|
|
expect(trimmed[trimmed.length - 1]).toBe("COMMIT");
|
|
});
|
|
|
|
it("emits an UPDATE on transactions for each mapped v2 id", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111), makeRow(24, 1121)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const txUpdates = fake.calls.filter((c) =>
|
|
/UPDATE transactions SET category_id/i.test(c.sql),
|
|
);
|
|
expect(txUpdates.length).toBe(2);
|
|
expect(txUpdates[0].params).toEqual([1111, 22]);
|
|
expect(txUpdates[1].params).toEqual([1121, 24]);
|
|
});
|
|
|
|
it("rewrites budget_entries and budget_template_entries", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const budgetEntries = fake.calls.filter((c) =>
|
|
/UPDATE budget_entries SET category_id/i.test(c.sql),
|
|
);
|
|
const templateEntries = fake.calls.filter((c) =>
|
|
/UPDATE budget_template_entries SET category_id/i.test(c.sql),
|
|
);
|
|
expect(budgetEntries.length).toBe(1);
|
|
expect(templateEntries.length).toBe(1);
|
|
});
|
|
|
|
it("rewrites keywords and suppliers", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const kwUpdates = fake.calls.filter((c) =>
|
|
/UPDATE keywords SET category_id/i.test(c.sql),
|
|
);
|
|
const supUpdates = fake.calls.filter((c) =>
|
|
/UPDATE suppliers SET category_id/i.test(c.sql),
|
|
);
|
|
expect(kwUpdates.length).toBe(1);
|
|
expect(supUpdates.length).toBe(1);
|
|
});
|
|
|
|
it("inserts all v1 taxonomy rows (INSERT OR IGNORE)", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const v1Inserts = fake.calls.filter((c) =>
|
|
/INSERT OR IGNORE INTO categories/i.test(c.sql),
|
|
);
|
|
// The v1 taxonomy has >100 rows; a low bound is safe as a regression
|
|
// signal without coupling to the exact count.
|
|
expect(v1Inserts.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
it("soft-deletes v2 seeded categories (is_active=0) and the 1..6 structural parents", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const deactivateSeeded = fake.calls.filter(
|
|
(c) =>
|
|
/UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
|
|
(c.params?.[0] as number) === 22,
|
|
);
|
|
const deactivateParents = fake.calls.filter((c) =>
|
|
/WHERE id IN \(1, 2, 3, 4, 5, 6\)/i.test(c.sql),
|
|
);
|
|
expect(deactivateSeeded.length).toBe(1);
|
|
expect(deactivateParents.length).toBe(1);
|
|
});
|
|
|
|
it("writes categories_schema_version=v1 and a journal entry to user_preferences", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const prefWrites = fake.calls.filter((c) =>
|
|
/INSERT INTO user_preferences/i.test(c.sql),
|
|
);
|
|
expect(prefWrites.length).toBe(2);
|
|
expect(prefWrites[0].params?.[0]).toBe("categories_schema_version");
|
|
expect(prefWrites[1].params?.[0]).toBe("last_categories_migration");
|
|
// Journal is serialized JSON; sniff a stable key to avoid snapshot churn.
|
|
const journalJson = prefWrites[1].params?.[1] as string;
|
|
expect(journalJson).toContain('"timestamp"');
|
|
expect(journalJson).toContain('"backupPath"');
|
|
expect(journalJson).toContain(FAKE_BACKUP.path);
|
|
});
|
|
|
|
it("ignores rows whose v1TargetId is null (never emits UPDATE for them)", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111), makeRow(73, null)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const txUpdates = fake.calls.filter((c) =>
|
|
/UPDATE transactions SET category_id/i.test(c.sql),
|
|
);
|
|
// Only one mapping (22 → 1111) was resolved; the unresolved row is
|
|
// skipped inside buildMappingFromRows.
|
|
expect(txUpdates.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom categories (preserved)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("applyMigration — preserved custom categories", () => {
|
|
it("creates the 'Catégories personnalisées (migration)' parent when preserved is non-empty", async () => {
|
|
const outcome = await applyMigration(
|
|
makePlan([makeRow(22, 1111)], [makeRow(9001, null)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
expect(outcome.succeeded).toBe(true);
|
|
const parentInserts = fake.calls.filter(
|
|
(c) =>
|
|
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
|
|
(c.params?.[0] as number) === 2000,
|
|
);
|
|
expect(parentInserts.length).toBe(1);
|
|
expect(parentInserts[0].params?.[4]).toBe(
|
|
"categoriesSeed.migration.customParent",
|
|
);
|
|
});
|
|
|
|
it("does NOT create the custom parent when preserved is empty", async () => {
|
|
await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
const parentInserts = fake.calls.filter(
|
|
(c) =>
|
|
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
|
|
(c.params?.[0] as number) === 2000,
|
|
);
|
|
expect(parentInserts.length).toBe(0);
|
|
});
|
|
|
|
it("re-parents each preserved v2 category under the new parent id (2000)", async () => {
|
|
await applyMigration(
|
|
makePlan(
|
|
[makeRow(22, 1111)],
|
|
[makeRow(9001, null), makeRow(9002, null), makeRow(9003, null)],
|
|
),
|
|
FAKE_BACKUP,
|
|
);
|
|
const reparent = fake.calls.filter((c) =>
|
|
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
|
|
);
|
|
expect(reparent.length).toBe(3);
|
|
for (const call of reparent) {
|
|
expect(call.params?.[0]).toBe(2000);
|
|
}
|
|
expect(reparent.map((c) => c.params?.[1])).toEqual([9001, 9002, 9003]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rollback on SQL failure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("applyMigration — rollback on SQL failure", () => {
|
|
it("emits ROLLBACK when an UPDATE throws mid-run", async () => {
|
|
fake.failAt = {
|
|
sql: /UPDATE transactions SET category_id/i,
|
|
error: "fk_violation",
|
|
};
|
|
const outcome = await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
expect(outcome.succeeded).toBe(false);
|
|
expect(outcome.error).toMatch(/fk_violation/);
|
|
const upper = fake.calls.map((c) => c.sql.trim().toUpperCase());
|
|
expect(upper).toContain("ROLLBACK");
|
|
expect(upper).not.toContain("COMMIT");
|
|
});
|
|
|
|
it("does not clobber the outcome.error even when ROLLBACK itself fails", async () => {
|
|
// First throw on transactions UPDATE; the subsequent ROLLBACK must not
|
|
// overwrite the original error.
|
|
let seen = 0;
|
|
fake.execute.mockImplementation(async (sql: string) => {
|
|
fake.calls.push({ sql });
|
|
seen++;
|
|
if (/UPDATE transactions SET category_id/i.test(sql)) {
|
|
throw new Error("original_sql_error");
|
|
}
|
|
if (/^ROLLBACK/i.test(sql.trim())) {
|
|
throw new Error("rollback_also_failed");
|
|
}
|
|
return { rowsAffected: 1 };
|
|
});
|
|
const outcome = await applyMigration(
|
|
makePlan([makeRow(22, 1111)]),
|
|
FAKE_BACKUP,
|
|
);
|
|
expect(outcome.succeeded).toBe(false);
|
|
expect(outcome.error).toMatch(/original_sql_error/);
|
|
expect(seen).toBeGreaterThan(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// markSchemaVersionV1 — helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("markSchemaVersionV1", () => {
|
|
it("writes an upsert into user_preferences with key=categories_schema_version", async () => {
|
|
await markSchemaVersionV1();
|
|
const prefWrite = fake.calls.find((c) =>
|
|
/INSERT INTO user_preferences/i.test(c.sql),
|
|
);
|
|
expect(prefWrite).toBeDefined();
|
|
expect(prefWrite!.params?.[0]).toBe("categories_schema_version");
|
|
expect(prefWrite!.params?.[1]).toBe("v1");
|
|
});
|
|
});
|