import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import initSqlJs from 'sql.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { openGameDb, persist } from './client'; import * as schema from './schema'; // The v0.6.0 DDL — used to build a "legacy" DB file for the migration test. const LEGACY_DDL = ` CREATE TABLE planes ( id TEXT PRIMARY KEY NOT NULL, model TEXT NOT NULL, status TEXT NOT NULL, fuel REAL NOT NULL, max_fuel REAL NOT NULL, location_icao TEXT NOT NULL, contract_id TEXT, lat REAL NOT NULL, lon REAL NOT NULL ); CREATE TABLE contracts ( id TEXT PRIMARY KEY NOT NULL, origin_icao TEXT NOT NULL, dest_icao TEXT NOT NULL, reward REAL NOT NULL, cargo_type TEXT NOT NULL, cargo_count INTEGER NOT NULL, deadline_sim_time INTEGER NOT NULL, status TEXT NOT NULL, plane_id TEXT, created_sim_time INTEGER NOT NULL ); CREATE TABLE account ( cash REAL NOT NULL, reputation REAL NOT NULL ); CREATE TABLE sim_state ( clock_ms INTEGER NOT NULL, time_scale REAL NOT NULL, paused INTEGER NOT NULL ); `; let dir: string; beforeAll(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-db-')); }); afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); describe('openGameDb', () => { it('seeds a fresh DB file on first open and persists it to disk', async () => { const file = path.join(dir, 'fresh.db'); const inst = await openGameDb(file); const planes = await inst.db.select().from(schema.planes); expect(planes).toHaveLength(1); expect(planes[0].id).toBe('plane-starter'); expect(planes[0].status).toBe('available'); expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV expect(planes[0].fuel).toBe(planes[0].maxFuel); const contracts = await inst.db.select().from(schema.contracts); expect(contracts.length).toBeGreaterThanOrEqual(8); expect(contracts.every((c) => c.status === 'open')).toBe(true); const account = (await inst.db.select().from(schema.account))[0]; expect(account?.cash).toBeGreaterThan(0); const sim = (await inst.db.select().from(schema.simState))[0]; expect(sim?.paused).toBe(false); expect(sim?.timeScale).toBe(1); // The seed was durably written: the file exists and is non-empty. const st = await fs.stat(file); expect(st.size).toBeGreaterThan(0); }); it('does not re-seed an existing DB — state survives reopen', async () => { const file = path.join(dir, 'reopen.db'); const first = await openGameDb(file); const contracts1 = (await first.db.select().from(schema.contracts)).map((c) => c.id).sort(); const account1 = (await first.db.select().from(schema.account))[0]; // Simulate a process restart: brand-new instance over the same file. const second = await openGameDb(file); const contracts2 = (await second.db.select().from(schema.contracts)).map((c) => c.id).sort(); expect(contracts2).toEqual(contracts1); expect((await second.db.select().from(schema.account))[0]).toEqual(account1); }); it('persist() durably saves writes made after open', async () => { const file = path.join(dir, 'write.db'); const inst = await openGameDb(file); await inst.db.update(schema.account).set({ cash: 12345 }); await persist(inst); // A fresh instance over the same file sees the update. const reloaded = await openGameDb(file); expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345); }); it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => { // Build a legacy file with the v0.6.0 schema (no departure_sim_time / last_real_tick_ms). const SQL = await initSqlJs(); const raw = new SQL.Database(); raw.exec(LEGACY_DDL); raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`); raw.exec(`INSERT INTO account VALUES (20000, 10)`); raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)'); const file = path.join(dir, 'legacy.db'); await fs.writeFile(file, Buffer.from(raw.export())); const inst = await openGameDb(file); // Not re-seeded (account row exists)… expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); // …but the new columns exist and are usable… const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane?.departureSimTime ?? null).toBeNull(); const sim = (await inst.db.select().from(schema.simState))[0]; expect(sim?.clockMs).toBe(1_750_000_000_000); // …and the ALTER-default 0 was fixed up to a real timestamp so the first // tick-on-read resumes cleanly instead of taking one clamped catch-up jump. expect(sim?.lastRealTickMs).toBeGreaterThan(0); }); });