import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import schema from '../../src/lib/server/db/schema.sql?raw'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { LaunchRecipe } from '../../src/lib/server/fleet/launch-recipes.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); describe('persisted launch recipe summaries', () => { it.each([false, true])( 'restores exact historical release labels with cached summary=%s without rewriting the recipe', async (cached) => { const { db } = await import('../../src/lib/server/db/index.js'); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { captureRecipe, summarizeRecipe, recipeSummaryForDeployment, recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const saved = createScript({ name: 'Historical script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'history.gsc', content: Buffer.from('historical bytes'), releaseVersion: '2.3.4' }); db() .prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?') .run(saved.version.id, saved.script.id); const recipe = captureRecipe('t5', 'theater'); const original = recipe.files[0]; delete original.releaseVersion; const unversioned = createScript({ name: 'Legacy script', targetRelPath: 'legacy.gsc', content: Buffer.from('historical bytes'), releaseVersion: '1.0.0' }); db() .prepare('UPDATE script_versions SET release_version = NULL WHERE id = ?') .run(unversioned.version.id); recipe.files.push( { ...original, path: 'recorded.gsc', releaseVersion: '1.8.0' }, { ...original, path: 'legacy.gsc', versionId: unversioned.version.id }, { ...original, path: 'missing.gsc', versionId: 9999 }, { ...original, path: 'mismatched.gsc', sha256: 'different' } ); const latest = uploadNewVersion( saved.script.id, Buffer.from('newer bytes'), undefined, '9.0.0' ); db() .prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?') .run(latest.id, saved.script.id); const encoded = JSON.stringify(recipe); const summary = summarizeRecipe(recipe); db() .exec(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('historical','retired-server','create','done','Complete','2025-01-01','fixture')`); db() .prepare( 'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)' ) .run('historical', encoded, cached ? JSON.stringify(summary) : null); const restored = recipeSummaryForDeployment('historical')!.recipe; expect(restored.fingerprint).toBe(summary.fingerprint); expect(restored.files.find((file) => file.path === 'history.gsc')).toMatchObject({ version: 1, releaseVersion: '2.3.4' }); expect( restored.files.find((file) => file.path === 'recorded.gsc')!.releaseVersion ).toBe('1.8.0'); expect( restored.files.find((file) => file.path === 'legacy.gsc')!.releaseVersion ).toBeNull(); for (const path of ['missing.gsc', 'mismatched.gsc']) expect(restored.files.find((file) => file.path === path)).not.toHaveProperty( 'releaseVersion' ); expect(recipeSummaryForDeployment('historical')!.recipe).toEqual(restored); expect(recipeForDeployment('historical')!.recipe).toEqual(recipe); const stored = db() .prepare( 'SELECT recipe,recipe_summary FROM server_launch_recipes WHERE deployment_id = ?' ) .get('historical') as { recipe: string; recipe_summary: string }; expect(stored.recipe).toBe(encoded); expect(JSON.parse(stored.recipe_summary)).toEqual(restored); } ); it('lazily upgrades legacy snapshots once without changing fingerprints or retained bytes', async () => { const recipe: LaunchRecipe = { format: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino der Toten', expectedMap: 'zombie_theater', capturedAt: '2025-01-01T00:00:00.000Z', image: 'plutainer:test', configFile: 'dedicated_sp.cfg', configSource: 'Defaults', files: [ { name: 'Old plugin', kind: 'plugin', path: 'legacy.dll', versionId: 1, version: 1, sha256: 'a'.repeat(64), size: 2 * 1024 ** 2, content: Buffer.alloc(2 * 1024 ** 2, 42).toString('base64') } ] }; const legacy = new Database(fixture.dbPath); const encoded = JSON.stringify(recipe, null, 2); try { legacy.exec(schema.replace(' recipe_summary TEXT,\n', '')); legacy.exec(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('legacy','retired-server','create','done','Complete','2025-01-01','fixture');`); legacy .prepare( 'INSERT INTO server_launch_recipes(deployment_id,recipe,blueprint_name) VALUES(?,?,?)' ) .run('legacy', encoded, 'Old collection'); } finally { legacy.close(); } const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { recipeSummaryForDeployment, recipeForDeployment, summarizeRecipe } = await import('../../src/lib/server/fleet/launch-recipes.js'); const expected = { recipe: summarizeRecipe(recipe), blueprintName: 'Old collection' }; expect(db().prepare('SELECT recipe_summary FROM server_launch_recipes').get()).toEqual({ recipe_summary: null }); expect(recipeSummaryForDeployment('legacy')).toEqual(expected); closeDb(); const parse = JSON.parse; const parsing = vi.spyOn(JSON, 'parse').mockImplementation((value, reviver) => { if (value.length > 1024 * 1024) throw new Error('Legacy recipe was decoded again.'); return parse(value, reviver); }); try { expect(recipeSummaryForDeployment('legacy')).toEqual(expected); expect(recipeSummaryForDeployment('missing')).toBeNull(); } finally { parsing.mockRestore(); } expect(recipeForDeployment('legacy')).toEqual({ recipe, blueprintName: 'Old collection' }); const stored = db().prepare('SELECT recipe FROM server_launch_recipes').get() as { recipe: string; }; expect(stored.recipe.length).toBeLessThan(1024); expect(JSON.parse(stored.recipe).files[0]).not.toHaveProperty('content'); }); });