import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import Database from "better-sqlite3"; import { migrateDatabase } from "../../lib/db-migrations"; const remoteSchema = readFileSync( "tests/fixtures/remote-v4-schema.sql", "utf8", ); function remoteDb() { const db = new Database(":memory:"); db.pragma("foreign_keys = ON"); db.exec(remoteSchema); return db; } function snapshot(db: Database.Database) { return Object.fromEntries( ( db .prepare( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", ) .all() as { name: string }[] ) .filter( ({ name }) => !["managed_certificates", "node_installations"].includes(name), ) .map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]), ); } function seed(db: Database.Database) { db.exec(`INSERT INTO hy2_users (id,auth_id,username,password_hash,notes,created_at,updated_at,subscription_token,auth_secret_encrypted,traffic_limit_bytes,used_tx_bytes,used_rx_bytes) VALUES (42,'original-auth','member','test-hash','keep notes','2026-01-01','2026-01-01','test-sub-token','test-encrypted-value',4096,128,256); INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host) VALUES (17,'existing-hy2','Existing HY2','test-auth-token','http://stats.example:9999','test-stats-secret','2026-01-01','2026-01-01','existing.example'); INSERT INTO traffic_ledgers (node_id,user_id,auth_id,tx_bytes,rx_bytes,window_started_at,window_ended_at,created_at) VALUES (17,42,'original-auth',128,256,'2026-01-01','2026-01-01','2026-01-01'); INSERT INTO user_sessions (id,user_id,session_hash,expires_at,created_at,last_seen_at) VALUES ('session',42,'test-session-hash','2030-01-01','2026-01-01','2026-01-01'); INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["legacy-custom","existing-hy2"]','2026-01-01'); INSERT INTO app_settings VALUES ('proxy_groups_json','[{"id":"group-id","name":"Work","type":"select","memberSlugs":["legacy-custom"]}]','2026-01-01');`); } test("fresh database exactly matches the inspected remote v4 column definitions", () => { const fresh = new Database(":memory:"); const remote = remoteDb(); migrateDatabase(fresh); assert.equal(fresh.pragma("user_version", { simple: true }), 5); for (const { name } of remote .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[]) { assert.deepEqual( fresh.pragma(`table_info(${name})`), remote.pragma(`table_info(${name})`), ); } fresh.close(); remote.close(); }); test("remote v4: repeated migration preserves every row, ID, secret, quota, session and group", () => { const db = remoteDb(); seed(db); const source = `# legacy source stays byte-for-byte\n- name: "AnyTLS #1"\n type: anytls\n password: "test: password"\n server: hk.example.com\n port: 443\n`; db.prepare( `INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name) VALUES (18,'legacy-custom','Legacy','test-token','','test-secret','2026-01-01','2026-01-01','custom',?,'Old fallback')`, ).run(source); const before = snapshot(db); migrateDatabase(db); migrateDatabase(db); assert.deepEqual(snapshot(db), before); assert.deepEqual(db.pragma("foreign_key_check"), []); db.close(); }); for (const existing of [ [], ["node_type"], ["custom_config_yaml"], ["node_type", "custom_config_yaml"], ]) { test(`v3 upgrade with existing columns [${existing.join(", ")}] preserves data`, () => { const db = remoteDb(); seed(db); for (const field of ["node_type", "custom_config_yaml"]) if (!existing.includes(field)) db.exec(`ALTER TABLE hy2_nodes DROP COLUMN ${field}`); db.pragma("user_version = 3"); const before = snapshot(db); migrateDatabase(db); migrateDatabase(db); const after = snapshot(db); // New fields have compatible defaults; every pre-existing field is unchanged. for (const row of after.hy2_nodes as Record[]) { assert.equal(row.node_type, "hy2"); assert.equal(row.custom_config_yaml, ""); for (const field of ["node_type", "custom_config_yaml"]) if (!existing.includes(field)) delete row[field]; } assert.deepEqual(after, before); assert.equal(db.pragma("user_version", { simple: true }), 5); db.close(); }); } test("repairs interrupted v4 without duplicate-column errors", () => { const db = remoteDb(); seed(db); db.exec("ALTER TABLE hy2_nodes DROP COLUMN custom_config_yaml"); migrateDatabase(db); assert.equal( ( db.prepare("SELECT custom_config_yaml FROM hy2_nodes").get() as { custom_config_yaml: string; } ).custom_config_yaml, "", ); db.close(); }); test("failed migrations roll back all statements and future schemas are not downgraded", () => { const db = new Database(":memory:"); db.exec("CREATE TABLE hy2_users(id INTEGER)"); assert.throws(() => migrateDatabase(db)); assert.equal(db.pragma("user_version", { simple: true }), 0); assert.deepEqual( db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(), [{ name: "hy2_users" }], ); db.pragma("user_version = 6"); assert.throws(() => migrateDatabase(db), /版本/); assert.equal(db.pragma("user_version", { simple: true }), 6); db.close(); });