Files
SnapOtter/tests/integration/migrate-from-sqlite.test.ts
T
SnapOtter f950dac792 test: add launch-readiness guards and fix zh-CN/pt-BR i18n fallback (phase 4a)
Four launch gates for v2.0.0:

1. Catalog integrity (catalog-integrity.test.ts): asserts every TOOLS entry
   is fully wired end to end (API route + frontend registry + display mode +
   process fn or REGISTRY_EXEMPT). Count checked dynamically against
   TOOLS.length. All 157 tools pass.

2. i18n cross-locale parity (i18n-parity.test.ts): asserts every locale in
   SUPPORTED_LOCALES has the same key set as en.ts. Found and fixed a real
   bug: zh-CN and pt-BR exported only a camelCase named export (zhCN, ptBR)
   with no default export, so loadTranslations silently fell back to English
   for Chinese Simplified and Brazilian Portuguese users. Fixed by adding
   export default to both files. All 20 non-en locales now pass parity.

3. Cross-modality smoke (cross-modality-smoke.test.ts): one fast tool per
   modality (rotate/image, mute-video/video, convert-audio/audio,
   rotate-pdf/document, csv-json/data) plus an auth gate. Tools needing
   ffmpeg or qpdf are gated with skipIf. Ship/no-ship signal.

4. Migration launch gate: extended migrate-from-sqlite.test.ts with a
   representative 1.x SQLite database (3 users, 3 teams, 3 settings,
   2 roles, 2 sessions, 2 API keys, 2 pipelines, 4 jobs, 4 audit entries,
   4 user files) covering boolean/timestamp/JSON/NULL type conversions,
   column remapping (input_files->input_refs, progress real->jsonb), and
   multi-row round-trip verification. 9 new test cases.

Parity: 13260 passed, 0 dropped.
2026-06-20 02:38:30 +08:00

607 lines
24 KiB
TypeScript

import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { db } from "../../apps/api/src/db/index.js";
import { migrateFromSqlite } from "../../apps/api/src/db/migrate-from-sqlite.js";
function buildFixtureSqlite(path: string): void {
const s = new Database(path);
s.exec(`
CREATE TABLE users (id text PRIMARY KEY, username text NOT NULL, password_hash text,
role text NOT NULL DEFAULT 'user', team text NOT NULL DEFAULT 'Default',
must_change_password integer NOT NULL DEFAULT 1, auth_provider text NOT NULL DEFAULT 'local',
external_id text, email text, created_at integer NOT NULL, updated_at integer NOT NULL,
analytics_enabled integer, analytics_consent_shown_at integer, analytics_consent_remind_at integer);
CREATE TABLE teams (id text PRIMARY KEY, name text NOT NULL, created_at integer NOT NULL);
CREATE TABLE settings ("key" text PRIMARY KEY, value text NOT NULL, updated_at integer NOT NULL);
CREATE TABLE roles (id text PRIMARY KEY, name text NOT NULL, description text NOT NULL DEFAULT '',
permissions text NOT NULL, is_builtin integer NOT NULL DEFAULT 0, created_by text,
created_at integer NOT NULL, updated_at integer NOT NULL);
CREATE TABLE sessions (id text PRIMARY KEY, user_id text NOT NULL, expires_at integer NOT NULL,
id_token text, created_at integer NOT NULL);
CREATE TABLE api_keys (id text PRIMARY KEY, user_id text NOT NULL, key_hash text NOT NULL,
key_prefix text, name text NOT NULL DEFAULT 'Default API Key', permissions text,
created_at integer NOT NULL, last_used_at integer, expires_at integer);
CREATE TABLE pipelines (id text PRIMARY KEY, user_id text, name text NOT NULL, description text,
steps text NOT NULL, created_at integer NOT NULL);
CREATE TABLE jobs (id text PRIMARY KEY, type text NOT NULL, status text NOT NULL DEFAULT 'queued',
progress real NOT NULL DEFAULT 0, input_files text NOT NULL, output_path text, settings text,
error text, created_at integer NOT NULL, completed_at integer);
CREATE TABLE audit_log (id text PRIMARY KEY, actor_id text, actor_username text NOT NULL,
action text NOT NULL, target_type text, target_id text, details text, ip_address text,
created_at integer NOT NULL);
CREATE TABLE user_files (id text PRIMARY KEY, user_id text, original_name text NOT NULL,
stored_name text NOT NULL, mime_type text NOT NULL, size integer NOT NULL, width integer,
height integer, version integer NOT NULL DEFAULT 1, parent_id text, tool_chain text,
created_at integer NOT NULL);
`);
const now = 1750000000; // seconds epoch, as 1.x stored
s.prepare(
"INSERT INTO users (id, username, password_hash, must_change_password, created_at, updated_at, analytics_enabled, analytics_consent_shown_at) VALUES (?,?,?,?,?,?,?,?)",
).run("u1", "alice", "hash", 0, now, now, 1, null);
s.prepare("INSERT INTO teams (id, name, created_at) VALUES (?,?,?)").run("t1", "Legal", now);
s.prepare('INSERT INTO settings ("key", value, updated_at) VALUES (?,?,?)').run(
"cookieSecret",
"not-json-value",
now,
);
s.prepare(
"INSERT INTO roles (id, name, permissions, is_builtin, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
).run("r1", "auditor", '["audit:read"]', 1, "u1", now, now);
s.prepare("INSERT INTO pipelines (id, user_id, name, steps, created_at) VALUES (?,?,?,?,?)").run(
"p1",
"u1",
"shrink",
'[{"toolId":"compress","settings":{"quality":70}}]',
now,
);
s.prepare(
"INSERT INTO jobs (id, type, status, progress, input_files, created_at, completed_at) VALUES (?,?,?,?,?,?,?)",
).run("j1", "batch", "completed", 1, '["a.png"]', now, null);
s.prepare(
"INSERT INTO jobs (id, type, status, progress, input_files, output_path, error, created_at) VALUES (?,?,?,?,?,?,?,?)",
).run("j2", "single", "failed", 0.5, "[]", "/out/result.png", "Something broke", now);
s.prepare(
"INSERT INTO audit_log (id, actor_username, action, details, created_at) VALUES (?,?,?,?,?)",
).run("al1", "alice", "login", null, now);
s.prepare(
"INSERT INTO user_files (id, user_id, original_name, stored_name, mime_type, size, version, tool_chain, created_at) VALUES (?,?,?,?,?,?,?,?,?)",
).run("uf1", "u1", "photo.png", "abc123.png", "image/png", 1024, 1, null, now);
s.close();
}
describe("migrate-from-sqlite", () => {
const dir = mkdtempSync(join(tmpdir(), "snapotter-migrator-"));
const sqlitePath = join(dir, "snapotter-1x.db");
beforeAll(async () => {
buildFixtureSqlite(sqlitePath);
// simulate empty 2.0 target: wipe all rows the suite DB may have
await db.execute(
sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`,
);
});
afterAll(async () => {
await db.execute(
sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`,
);
});
it("copies all rows with converted types", async () => {
const result = await migrateFromSqlite(sqlitePath, { force: false });
expect(result.tables.users).toBe(1);
expect(result.tables.pipelines).toBe(1);
const [user] = (await db.execute(sql`SELECT * FROM users WHERE id = 'u1'`)).rows;
expect(user.username).toBe("alice");
expect(user.must_change_password).toBe(false); // 0 became boolean false
expect(user.analytics_enabled).toBe(true); // 1 became boolean true
expect(user.analytics_consent_shown_at).toBeNull(); // explicit NULL preserved
expect(new Date(user.created_at as string).getTime()).toBe(1750000000 * 1000); // seconds became timestamptz
const [pipeline] = (await db.execute(sql`SELECT * FROM pipelines WHERE id = 'p1'`)).rows;
expect((pipeline.steps as Array<{ toolId: string }>)[0].toolId).toBe("compress"); // text JSON became jsonb
const [setting] = (await db.execute(sql`SELECT * FROM settings WHERE key = 'cookieSecret'`))
.rows;
expect(setting.value).toBe("not-json-value"); // settings.value stayed text, untouched
const [job] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j1'`)).rows;
expect(job.completed_at).toBeNull(); // explicit NULL preserved
// 1.x progress (real 1.0) became jsonb {percent: 100}
expect(job.progress).toEqual({ percent: 100 });
// 1.x input_files became input_refs (empty array, dead paths discarded)
expect(job.input_refs).toEqual([]);
// 1.x error NULL preserved as null
expect(job.error).toBeNull();
// Verify j2: error text became jsonb, progress 0.5 became {percent: 50}, output_path became output_refs
const [job2] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j2'`)).rows;
expect(job2.progress).toEqual({ percent: 50 });
expect(job2.error).toEqual({ message: "Something broke" });
expect(job2.input_refs).toEqual([]);
expect(job2.output_refs).toEqual([]);
// audit_log with NULL details
const alRows = (await db.execute(sql`SELECT * FROM audit_log WHERE id = 'al1'`)).rows;
expect(alRows).toHaveLength(1);
expect(alRows[0].details).toBeNull();
expect(result.tables.audit_log).toBe(1);
// user_files with NULL tool_chain
const ufRows = (await db.execute(sql`SELECT * FROM user_files WHERE id = 'uf1'`)).rows;
expect(ufRows).toHaveLength(1);
expect(ufRows[0].tool_chain).toBeNull();
expect(result.tables.user_files).toBe(1);
});
it("refuses a non-empty target without force", async () => {
await expect(migrateFromSqlite(sqlitePath, { force: false })).rejects.toThrow(/non-empty/i);
});
it("force on populated target fails on PK collision and rolls back", async () => {
// The first test already inserted rows; forcing again should hit a PK/unique violation
await expect(migrateFromSqlite(sqlitePath, { force: true })).rejects.toThrow();
// Rollback must leave previous data intact
const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`);
expect(rows[0].n).toBe(1);
});
});
/**
* Representative 1.x import: a more realistic SQLite database with multiple
* rows, diverse type-conversion edge cases (booleans, timestamps, JSON
* columns, NULLs), and all 10 tables populated.
*/
describe("migrate-from-sqlite (representative 1.x database)", () => {
const reprDir = mkdtempSync(join(tmpdir(), "snapotter-migrator-repr-"));
const reprPath = join(reprDir, "snapotter-1x-representative.db");
function buildRepresentativeSqlite(path: string): void {
const s = new Database(path);
s.exec(`
CREATE TABLE users (id text PRIMARY KEY, username text NOT NULL, password_hash text,
role text NOT NULL DEFAULT 'user', team text NOT NULL DEFAULT 'Default',
must_change_password integer NOT NULL DEFAULT 1, auth_provider text NOT NULL DEFAULT 'local',
external_id text, email text, created_at integer NOT NULL, updated_at integer NOT NULL,
analytics_enabled integer, analytics_consent_shown_at integer, analytics_consent_remind_at integer);
CREATE TABLE teams (id text PRIMARY KEY, name text NOT NULL, created_at integer NOT NULL);
CREATE TABLE settings ("key" text PRIMARY KEY, value text NOT NULL, updated_at integer NOT NULL);
CREATE TABLE roles (id text PRIMARY KEY, name text NOT NULL, description text NOT NULL DEFAULT '',
permissions text NOT NULL, is_builtin integer NOT NULL DEFAULT 0, created_by text,
created_at integer NOT NULL, updated_at integer NOT NULL);
CREATE TABLE sessions (id text PRIMARY KEY, user_id text NOT NULL, expires_at integer NOT NULL,
id_token text, created_at integer NOT NULL);
CREATE TABLE api_keys (id text PRIMARY KEY, user_id text NOT NULL, key_hash text NOT NULL,
key_prefix text, name text NOT NULL DEFAULT 'Default API Key', permissions text,
created_at integer NOT NULL, last_used_at integer, expires_at integer);
CREATE TABLE pipelines (id text PRIMARY KEY, user_id text, name text NOT NULL, description text,
steps text NOT NULL, created_at integer NOT NULL);
CREATE TABLE jobs (id text PRIMARY KEY, type text NOT NULL, status text NOT NULL DEFAULT 'queued',
progress real NOT NULL DEFAULT 0, input_files text NOT NULL, output_path text, settings text,
error text, created_at integer NOT NULL, completed_at integer);
CREATE TABLE audit_log (id text PRIMARY KEY, actor_id text, actor_username text NOT NULL,
action text NOT NULL, target_type text, target_id text, details text, ip_address text,
created_at integer NOT NULL);
CREATE TABLE user_files (id text PRIMARY KEY, user_id text, original_name text NOT NULL,
stored_name text NOT NULL, mime_type text NOT NULL, size integer NOT NULL, width integer,
height integer, version integer NOT NULL DEFAULT 1, parent_id text, tool_chain text,
created_at integer NOT NULL);
`);
const t1 = 1748000000; // epoch seconds
const t2 = 1748100000;
const t3 = 1748200000;
// ── Users: multiple users with diverse boolean/null combos ──
const insU = s.prepare(
`INSERT INTO users (id, username, password_hash, role, team, must_change_password,
auth_provider, external_id, email, created_at, updated_at,
analytics_enabled, analytics_consent_shown_at, analytics_consent_remind_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
);
insU.run(
"u-admin",
"admin",
"scrypt-hash-1",
"admin",
"Default",
0,
"local",
null,
"admin@example.com",
t1,
t1,
1,
t1,
null,
);
insU.run(
"u-editor",
"editor",
"scrypt-hash-2",
"editor",
"Design",
1,
"local",
null,
null,
t2,
t2,
0,
null,
null,
);
insU.run(
"u-oidc",
"sso-user",
null,
"user",
"Default",
0,
"oidc",
"ext-id-123",
"sso@corp.com",
t3,
t3,
null,
null,
t3,
);
// ── Teams ──
const insT = s.prepare("INSERT INTO teams (id, name, created_at) VALUES (?,?,?)");
insT.run("tm-1", "Default", t1);
insT.run("tm-2", "Design", t1);
insT.run("tm-3", "Engineering", t2);
// ── Settings: plain string, JSON-like string, numeric string ──
const insS = s.prepare('INSERT INTO settings ("key", value, updated_at) VALUES (?,?,?)');
insS.run("cookieSecret", "super-secret-value", t1);
insS.run("siteName", "My SnapOtter", t1);
insS.run("maxUploadSize", "50", t2);
// ── Roles: builtin and custom ──
const insR = s.prepare(
`INSERT INTO roles (id, name, description, permissions, is_builtin, created_by,
created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
);
insR.run("r-admin", "admin", "Full access", '["*"]', 1, null, t1, t1);
insR.run(
"r-custom",
"reviewer",
"Can view audit logs",
'["audit:read","files:read"]',
0,
"u-admin",
t2,
t2,
);
// ── Sessions ──
const insSes = s.prepare(
"INSERT INTO sessions (id, user_id, expires_at, id_token, created_at) VALUES (?,?,?,?,?)",
);
insSes.run("ses-1", "u-admin", t3, null, t1);
insSes.run("ses-2", "u-oidc", t3, "eyJhbGciOiJSUzI1NiJ9.fake-jwt-token", t2);
// ── API Keys: with and without permissions/expiry ──
const insK = s.prepare(
`INSERT INTO api_keys (id, user_id, key_hash, key_prefix, name, permissions,
created_at, last_used_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)`,
);
insK.run("ak-1", "u-admin", "hash-abc", "si_abc", "Admin Key", '["*"]', t1, t2, null);
insK.run("ak-2", "u-editor", "hash-def", "si_def", "Read-Only", '["files:read"]', t2, null, t3);
// ── Pipelines: single-step and multi-step ──
const insP = s.prepare(
`INSERT INTO pipelines (id, user_id, name, description, steps, created_at)
VALUES (?,?,?,?,?,?)`,
);
insP.run(
"p-1",
"u-admin",
"Quick Shrink",
"Compress to 70%",
'[{"toolId":"compress","settings":{"quality":70}}]',
t1,
);
insP.run(
"p-2",
"u-editor",
"Full Process",
null,
'[{"toolId":"resize","settings":{"width":800}},{"toolId":"compress","settings":{"quality":80}}]',
t2,
);
// ── Jobs: diverse statuses, progress, errors, timestamps ──
const insJ = s.prepare(
`INSERT INTO jobs (id, type, status, progress, input_files, output_path, settings,
error, created_at, completed_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
);
insJ.run(
"j-done",
"single",
"completed",
1.0,
'["uploads/photo.png"]',
"/out/photo_compressed.png",
'{"quality":70}',
null,
t1,
t2,
);
insJ.run(
"j-fail",
"single",
"failed",
0.33,
"[]",
null,
'{"width":99999}',
"Out of memory",
t1,
t1,
);
insJ.run(
"j-queue",
"batch",
"queued",
0.0,
'["a.png","b.png","c.png"]',
null,
null,
null,
t2,
null,
);
insJ.run("j-null", "single", "completed", 1.0, '["x.jpg"]', "/out/x.jpg", null, null, t3, t3);
// ── Audit log: various actions with and without details/target ──
const insA = s.prepare(
`INSERT INTO audit_log (id, actor_id, actor_username, action, target_type,
target_id, details, ip_address, created_at) VALUES (?,?,?,?,?,?,?,?,?)`,
);
insA.run("al-1", "u-admin", "admin", "login", null, null, null, "192.168.1.1", t1);
insA.run(
"al-2",
"u-admin",
"admin",
"settings.update",
"setting",
"siteName",
'{"key":"siteName","oldValue":"SnapOtter","newValue":"My SnapOtter"}',
"192.168.1.1",
t1,
);
insA.run("al-3", "u-editor", "editor", "file.upload", "file", "uf-1", null, "10.0.0.5", t2);
insA.run(
"al-4",
null,
"system",
"user.create",
"user",
"u-oidc",
'{"provider":"oidc"}',
null,
t3,
);
// ── User files: various types, dimensions, tool_chain, parent ──
const insUF = s.prepare(
`INSERT INTO user_files (id, user_id, original_name, stored_name, mime_type,
size, width, height, version, parent_id, tool_chain, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
);
insUF.run(
"uf-1",
"u-editor",
"photo.png",
"abc123.png",
"image/png",
204800,
1920,
1080,
1,
null,
null,
t1,
);
insUF.run(
"uf-2",
"u-editor",
"photo_compressed.png",
"def456.png",
"image/png",
102400,
1920,
1080,
2,
"uf-1",
'["compress"]',
t2,
);
insUF.run(
"uf-3",
"u-admin",
"report.pdf",
"ghi789.pdf",
"application/pdf",
512000,
null,
null,
1,
null,
null,
t2,
);
insUF.run(
"uf-4",
"u-oidc",
"meeting.mp4",
"jkl012.mp4",
"video/mp4",
10485760,
null,
null,
1,
null,
'["trim-video","compress-video"]',
t3,
);
s.close();
}
beforeAll(async () => {
buildRepresentativeSqlite(reprPath);
await db.execute(
sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`,
);
});
afterAll(async () => {
await db.execute(
sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`,
);
});
it("imports all tables with correct row counts", async () => {
const result = await migrateFromSqlite(reprPath, { force: false });
expect(result.tables.users).toBe(3);
expect(result.tables.teams).toBe(3);
expect(result.tables.settings).toBe(3);
expect(result.tables.roles).toBe(2);
expect(result.tables.sessions).toBe(2);
expect(result.tables.api_keys).toBe(2);
expect(result.tables.pipelines).toBe(2);
expect(result.tables.jobs).toBe(4);
expect(result.tables.audit_log).toBe(4);
expect(result.tables.user_files).toBe(4);
});
it("boolean conversions: 0 -> false, 1 -> true, NULL -> null", async () => {
const users = (await db.execute(sql`SELECT * FROM users ORDER BY id`)).rows;
// u-admin: must_change_password=0 -> false, analytics_enabled=1 -> true
const admin = users.find((u) => u.id === "u-admin");
expect(admin?.must_change_password).toBe(false);
expect(admin?.analytics_enabled).toBe(true);
// u-editor: must_change_password=1 -> true, analytics_enabled=0 -> false
const editor = users.find((u) => u.id === "u-editor");
expect(editor?.must_change_password).toBe(true);
expect(editor?.analytics_enabled).toBe(false);
// u-oidc: analytics_enabled=NULL -> null
const oidc = users.find((u) => u.id === "u-oidc");
expect(oidc?.analytics_enabled).toBeNull();
});
it("timestamp conversions: epoch seconds -> timestamptz", async () => {
const [admin] = (await db.execute(sql`SELECT * FROM users WHERE id = 'u-admin'`)).rows;
expect(new Date(admin.created_at as string).getTime()).toBe(1748000000 * 1000);
// NULL timestamps stay null
expect(admin.analytics_consent_remind_at).toBeNull();
// Non-null timestamp
const [oidc] = (await db.execute(sql`SELECT * FROM users WHERE id = 'u-oidc'`)).rows;
expect(new Date(oidc.analytics_consent_remind_at as string).getTime()).toBe(1748200000 * 1000);
});
it("JSON column conversions: text -> jsonb", async () => {
// Pipelines: steps text -> jsonb array
const [p1] = (await db.execute(sql`SELECT * FROM pipelines WHERE id = 'p-1'`)).rows;
const steps1 = p1.steps as Array<{ toolId: string }>;
expect(steps1).toHaveLength(1);
expect(steps1[0].toolId).toBe("compress");
// Multi-step pipeline
const [p2] = (await db.execute(sql`SELECT * FROM pipelines WHERE id = 'p-2'`)).rows;
const steps2 = p2.steps as Array<{ toolId: string }>;
expect(steps2).toHaveLength(2);
expect(steps2[0].toolId).toBe("resize");
expect(steps2[1].toolId).toBe("compress");
// Roles: permissions text -> jsonb array
const [rAdmin] = (await db.execute(sql`SELECT * FROM roles WHERE id = 'r-admin'`)).rows;
expect(rAdmin.permissions).toEqual(["*"]);
const [rCustom] = (await db.execute(sql`SELECT * FROM roles WHERE id = 'r-custom'`)).rows;
expect(rCustom.permissions).toEqual(["audit:read", "files:read"]);
// API keys: permissions
const [ak1] = (await db.execute(sql`SELECT * FROM api_keys WHERE id = 'ak-1'`)).rows;
expect(ak1.permissions).toEqual(["*"]);
});
it("jobs: progress/error/input/output column remapping", async () => {
// Completed: progress 1.0 -> {percent: 100}, error null
const [jDone] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-done'`)).rows;
expect(jDone.progress).toEqual({ percent: 100 });
expect(jDone.error).toBeNull();
expect(jDone.input_refs).toEqual([]);
expect(jDone.output_refs).toEqual([]);
expect(jDone.settings).toEqual({ quality: 70 });
expect(jDone.completed_at).not.toBeNull();
// Failed: progress 0.33 -> {percent: 33}, error text -> {message}
const [jFail] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-fail'`)).rows;
expect(jFail.progress).toEqual({ percent: 33 });
expect(jFail.error).toEqual({ message: "Out of memory" });
// Queued: progress 0 -> {percent: 0}, null settings, null completed_at
const [jQueue] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-queue'`)).rows;
expect(jQueue.progress).toEqual({ percent: 0 });
expect(jQueue.settings).toBeNull();
expect(jQueue.completed_at).toBeNull();
// Null settings round-trips
const [jNull] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-null'`)).rows;
expect(jNull.settings).toBeNull();
expect(jNull.completed_at).not.toBeNull();
});
it("audit_log: details null and JSON both handled", async () => {
const rows = (await db.execute(sql`SELECT * FROM audit_log ORDER BY id`)).rows;
expect(rows).toHaveLength(4);
const al1 = rows.find((r) => r.id === "al-1");
expect(al1?.details).toBeNull();
const al2 = rows.find((r) => r.id === "al-2");
expect(al2?.details).toEqual({
key: "siteName",
oldValue: "SnapOtter",
newValue: "My SnapOtter",
});
const al4 = rows.find((r) => r.id === "al-4");
expect(al4?.actor_id).toBeNull();
expect(al4?.details).toEqual({ provider: "oidc" });
});
it("user_files: tool_chain null and JSON, width/height null", async () => {
const rows = (await db.execute(sql`SELECT * FROM user_files ORDER BY id`)).rows;
expect(rows).toHaveLength(4);
const uf1 = rows.find((r) => r.id === "uf-1");
expect(uf1?.tool_chain).toBeNull();
expect(uf1?.width).toBe(1920);
expect(uf1?.height).toBe(1080);
const uf2 = rows.find((r) => r.id === "uf-2");
expect(uf2?.tool_chain).toEqual(["compress"]);
expect(uf2?.parent_id).toBe("uf-1");
expect(uf2?.version).toBe(2);
const uf3 = rows.find((r) => r.id === "uf-3");
expect(uf3?.width).toBeNull();
expect(uf3?.height).toBeNull();
const uf4 = rows.find((r) => r.id === "uf-4");
expect(uf4?.tool_chain).toEqual(["trim-video", "compress-video"]);
});
it("settings: plain string values preserved as-is", async () => {
const rows = (await db.execute(sql`SELECT * FROM settings ORDER BY key`)).rows;
expect(rows).toHaveLength(3);
const cookie = rows.find((r) => r.key === "cookieSecret");
expect(cookie?.value).toBe("super-secret-value");
const maxUpload = rows.find((r) => r.key === "maxUploadSize");
expect(maxUpload?.value).toBe("50");
});
it("sessions: id_token null and non-null", async () => {
const [ses1] = (await db.execute(sql`SELECT * FROM sessions WHERE id = 'ses-1'`)).rows;
expect(ses1.id_token).toBeNull();
const [ses2] = (await db.execute(sql`SELECT * FROM sessions WHERE id = 'ses-2'`)).rows;
expect(ses2.id_token).toBe("eyJhbGciOiJSUzI1NiJ9.fake-jwt-token");
});
});