Files
electric-cherry/tests/unit/database-migrations.test.ts

309 lines
15 KiB
TypeScript

import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
it('initializes every schema on first open and runs no migration SQL on later opens', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
const handle = db();
expect(handle.pragma('user_version', { simple: true })).toBe(databaseVersion);
for (const table of [
'servers',
'native_instances',
'ec_hosts',
'auth_users',
'game_file_versions'
])
expect(handle.prepare('SELECT name FROM sqlite_master WHERE name=?').get(table)).toEqual({
name: table
});
handle.exec(
"INSERT INTO servers(name,last_reply_snippet) VALUES('saved','sanitized diagnostic')"
);
const schemaVersion = handle.pragma('schema_version', { simple: true });
closeDb();
const exec = vi.spyOn(Database.prototype, 'exec');
const prepare = vi.spyOn(Database.prototype, 'prepare');
const reopened = db();
expect(exec).not.toHaveBeenCalled();
expect(prepare).not.toHaveBeenCalled();
expect(reopened.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(reopened.prepare('SELECT name,last_reply_snippet FROM servers').get()).toEqual({
name: 'saved',
last_reply_snippet: 'sanitized diagnostic'
});
expect(reopened.pragma('foreign_key_check')).toEqual([]);
});
it('adopts a fully migrated unversioned database without replaying destructive backfills', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
const handle = db();
handle.exec(`INSERT INTO servers(name,desired_state,last_reply_snippet) VALUES('saved','stopped','keep');
INSERT INTO file_blobs(sha256,size,content) VALUES('hash',1,X'01');
INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'saved','test.gsc');
INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'hash',1,'hash');
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state) VALUES(1,1,1,'bundle','running');
INSERT INTO beta_test_plans(beta_id,plan,outcome) VALUES(1,'{"requirements":[]}','active');`);
// All pre-versioning releases used 0; their sentinels are compatibility inputs.
handle.pragma('user_version = 0');
closeDb();
expect(db().pragma('user_version', { simple: true })).toBe(databaseVersion);
expect(db().prepare('SELECT desired_state,last_reply_snippet FROM servers').get()).toEqual({
desired_state: 'stopped',
last_reply_snippet: 'keep'
});
expect(db().prepare('SELECT state FROM betas').get()).toEqual({ state: 'running' });
expect(db().prepare('SELECT plan FROM beta_test_plans').get()).toEqual({
plan: '{"requirements":[]}'
});
expect(db().pragma('foreign_key_check')).toEqual([]);
});
it('closes a failed startup handle and resumes from the last committed version', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { migrations, databaseVersion } = await import('../../src/lib/server/db/migrations.js');
const hosts = migrations.find(
(migration) => migration.name === 'hosts and saved command files'
)!;
const run = hosts.run;
const failure = vi.spyOn(hosts, 'run').mockImplementation((handle) => {
run(handle);
throw new Error('interrupted host upgrade');
});
expect(db).toThrow('interrupted host upgrade');
const inspect = new Database(fixture.dbPath);
try {
expect(inspect.pragma('user_version', { simple: true })).toBe(hosts.version - 1);
expect(
inspect.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_hosts'").get()
).toBeUndefined();
} finally {
inspect.close();
}
failure.mockRestore();
const completed = migrations
.slice(0, hosts.version - 1)
.map((migration) => vi.spyOn(migration, 'run'));
expect(db().pragma('user_version', { simple: true })).toBe(databaseVersion);
for (const migration of completed) expect(migration).not.toHaveBeenCalled();
expect(db().pragma('foreign_key_check')).toEqual([]);
});
it('keeps the legacy snapshot safety guard and rolls back an unsafe first upgrade', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
handle.exec(`INSERT INTO file_blobs(sha256,size,content) VALUES('hash',1,X'01');
INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'saved','test.gsc');
INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'hash',1,'hash');
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state) VALUES(1,1,1,'bundle','running');
DELETE FROM settings WHERE key='snapshots_v1';`);
handle.pragma('user_version = 0');
closeDb();
expect(db).toThrow('close active beta 1 before upgrading');
const inspect = new Database(fixture.dbPath);
try {
expect(inspect.pragma('user_version', { simple: true })).toBe(0);
expect(inspect.prepare('SELECT state FROM betas').get()).toEqual({ state: 'running' });
// The join backfill precedes the guard, but must roll back with it.
expect(inspect.prepare('SELECT * FROM beta_scripts').all()).toEqual([]);
expect(inspect.pragma('foreign_key_check')).toEqual([]);
} finally {
inspect.close();
}
});
it('rejects a database from a newer application without changing its version or schema', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
db().pragma(`user_version = ${databaseVersion + 1}`);
const schemaVersion = db().pragma('schema_version', { simple: true });
closeDb();
expect(db).toThrow('not supported by this application');
const inspect = new Database(fixture.dbPath);
try {
expect(inspect.pragma('user_version', { simple: true })).toBe(databaseVersion + 1);
expect(inspect.pragma('schema_version', { simple: true })).toBe(schemaVersion);
} finally {
inspect.close();
}
});
it('adds policy report invalidation when upgrading a database at version 12', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const table of ['autoscaling_policies', 'settings'])
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
handle.pragma('user_version = 12');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
const before = revision();
upgraded
.prepare(
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
)
.run();
expect(revision()).toBeGreaterThan(before);
const paused = revision();
upgraded.prepare("INSERT INTO settings(key,value) VALUES('unrelated-setting','1')").run();
expect(revision()).toBe(paused);
upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run();
expect(revision()).toBeGreaterThan(paused);
});
it('adds match report invalidation on upgrade without invalidating for score heartbeats', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
handle.pragma('user_version = 13');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
const before = revision();
upgraded.exec(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES(1,'removed','t5','zombie_moon','t5:zombie_moon','Moon',
'2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','start');`);
expect(revision()).toBeGreaterThan(before);
const started = revision();
upgraded.exec(`INSERT INTO leaderboard_match_players (match_id,guid,first_seen_at,last_seen_at)
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
expect(revision()).toBeGreaterThan(started);
const joined = revision();
upgraded.exec(`UPDATE leaderboard_matches SET highest_round=2,last_event='heartbeat',
last_received_at='2026-10-10T12:00:30.000Z' WHERE id=1;
UPDATE leaderboard_match_players SET kills=10,present=0 WHERE match_id=1;`);
expect(revision()).toBe(joined);
upgraded.exec("UPDATE leaderboard_matches SET map_name='Custom Moon' WHERE id=1");
expect(revision()).toBeGreaterThan(joined);
const renamed = revision();
upgraded.exec('DELETE FROM leaderboard_matches WHERE id=1');
expect(revision()).toBeGreaterThan(renamed);
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
});
it('upgrades player report invalidation from version 15 for participation clocks and durable identities', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { installMetricsMatchRevisions } =
await import('../../src/lib/server/reports/revisions.js');
const handle = db();
handle.exec(
'DROP TRIGGER report_metrics_leaderboard_match_players_UPDATE; DROP INDEX idx_leaderboard_players_guid_first_seen'
);
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_leaderboard_players_${action}`);
installMetricsMatchRevisions(handle);
handle.pragma('user_version = 15');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
upgraded.exec(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','test');
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
let before = revision();
upgraded.exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T13:00:00.000Z'");
expect(revision()).toBeGreaterThan(before);
before = revision();
upgraded.exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES('t5','player','Player','2026-10-10T12:00:00.000Z','2026-10-10T13:00:00.000Z')`);
expect(revision()).toBeGreaterThan(before);
before = revision();
upgraded.exec(
"UPDATE leaderboard_players SET name='Renamed',last_seen_at='2026-10-10T14:00:00.000Z'"
);
expect(revision()).toBe(before);
upgraded.exec("UPDATE leaderboard_players SET first_seen_at='2026-10-09T12:00:00.000Z'");
expect(revision()).toBeGreaterThan(before);
expect(
upgraded
.prepare(
"SELECT name FROM sqlite_master WHERE name='idx_leaderboard_players_guid_first_seen'"
)
.get()
).toBeDefined();
});
it('adds provisioning report invalidation at version 14 without invalidating for progress updates', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const table of ['server_deployments', 'provisioning_sources'])
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
handle.pragma('user_version = 14');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
const before = revision();
upgraded.exec(`INSERT INTO server_deployments
(id,server,action,state,stage,started_at,triggered_by)
VALUES('operation','t5_zombie_moon_1','create','waiting','Queued',
'2026-10-10T12:00:00.000Z','test')`);
expect(revision()).toBe(before);
upgraded.exec(`INSERT INTO provisioning_sources(operation_id,kind,source_id,server)
VALUES('operation','autoscaling','policy','t5_zombie_moon_1')`);
expect(revision()).toBeGreaterThan(before);
const linked = revision();
upgraded.exec(
"UPDATE server_deployments SET stage='Still waiting',observed_at='2026-10-10T12:00:15.000Z'"
);
expect(revision()).toBe(linked);
upgraded.exec(
"UPDATE server_deployments SET state='done',finished_at='2026-10-10T12:01:12.000Z'"
);
expect(revision()).toBeGreaterThan(linked);
const completed = revision();
upgraded.exec('DELETE FROM server_deployments');
expect(revision()).toBeGreaterThan(completed);
});
it('adds the generation evidence boundary to a version 23 database without changing instance identity', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
handle.exec(
"INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES('instance','s','host',2,'lib','image',1,'2026-10-14T00:00:00Z')"
);
handle.exec('ALTER TABLE native_instances DROP COLUMN generation_reconciled_at');
handle.pragma('user_version = 23');
closeDb();
expect(
db().prepare('SELECT id,generation,generation_reconciled_at FROM native_instances').get()
).toEqual({ id: 'instance', generation: 2, generation_reconciled_at: null });
expect(db().pragma('foreign_key_check')).toEqual([]);
});