Files
electric-cherry/tests/unit/database-migrations.test.ts
T
2026-10-10 14:48:52 -04:00

170 lines
7.8 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);
});