import { describe, expect, it } from "vitest"; import Database from "better-sqlite3"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; describe("数据库迁移", () => { it("从 0000 旧库升级时保留记录并幂等应用新字段", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-migration-")); const previousDataDir = process.env.TALLYNOTE_DATA_DIR; process.env.TALLYNOTE_DATA_DIR = dataDir; process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997"; process.env.TALLYNOTE_COOKIE_SECURE = "false"; let migrated: ReturnType | undefined; try { const config = loadConfig(); prepareDataDirectories(config); const legacy = new Database(config.dbPath); legacy.exec(readFileSync(path.join(config.migrationsDir, "0000_initial.sql"), "utf8")); legacy.exec("CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT"); legacy.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES ('0000_initial.sql', ?)").run(Date.now()); legacy.prepare(` INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at) VALUES ('legacy-admin', 'legacy', 'legacy', '旧管理员', 'hash', 'active', 0, 1, 1, ?) `).run(Date.now()); legacy.prepare(` INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES ('00000000-0000-4000-8000-000000000099', ?, 100, '旧账目', 'unreimbursed', 1, ?, 'legacy-admin', ?, 'legacy-admin') `).run(Date.now(), Date.now(), Date.now()); legacy.close(); migrated = openDatabase(config); const columns = migrated.sqlite.prepare("PRAGMA table_info(expenses)").all() as Array<{ name: string }>; expect(columns.some((column) => column.name === "invoice_missing_reason")).toBe(true); expect(migrated.sqlite.prepare("SELECT name FROM schema_migrations ORDER BY name").all()).toEqual([ { name: "0000_initial.sql" }, { name: "0001_invoice_missing_reason.sql" }, { name: "0002_update_jobs.sql" }, { name: "0003_update_job_ownership.sql" }, { name: "0004_update_download_apply.sql" }, { name: "0005_update_progress.sql" }, ]); const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>; expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation", "downloaded_bytes", "download_started_at", "download_speed_bps"])); expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null }); migrated.sqlite.close(); migrated = openDatabase(config); expect(migrated.sqlite.prepare("SELECT COUNT(*) AS count FROM schema_migrations WHERE name='0001_invoice_missing_reason.sql'").get()).toEqual({ count: 1 }); } finally { migrated?.sqlite.close(); if (previousDataDir === undefined) delete process.env.TALLYNOTE_DATA_DIR; else process.env.TALLYNOTE_DATA_DIR = previousDataDir; rmSync(dataDir, { recursive: true, force: true }); } }); });