Simpl-Resultat/src/services/categoryMigrationService.test.ts
le king fu 8cb7e53698
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
fix(db): serialize DB access to fix "database is locked"; live-update log console
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>
2026-06-30 16:50:39 -04:00

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