Simplify game files to automatic T5 and T6 uploads

This commit is contained in:
2026-10-04 12:23:36 -04:00
parent 482e27e735
commit 68323c0baa
19 changed files with 588 additions and 232 deletions
+82 -5
View File
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
import { serveGameFile } from '../../src/lib/server/game-files/http.js';
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
@@ -22,12 +22,87 @@ beforeEach(async () => {
credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential;
});
afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); }
function begin() { return createLibraryImport({ hostId, game: 't6' }); }
async function published() {
const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
return publishImport(hostId, version.id);
}
function legacyLibrary(id: string, game: 't5' | 't6') {
state.handle!.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)')
.run(id, `User named ${id}`, game, 'Original description', '2025-01-01T00:00:00.000Z');
}
function legacyVersion(id: string, libraryId: string, createdAt: string, publishedAt: string) {
state.handle!.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume,manifest_sha256)
VALUES(?,?,?,'Legacy version','[]','ready',?,?,?,'','','checksum')`)
.run(id, libraryId, `User label ${id}`, createdAt, publishedAt, join(directory, id));
}
describe('fixed game file slots', () => {
it('always lists exactly T5 and T6, including before any files are uploaded', () => {
expect(listLibraries()).toEqual([
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] }
]);
expect(latestPublishedLibraries()).toEqual([]);
expect(newestReadyLibrary('t5')).toBeNull();
expect(newestReadyLibrary('t6')).toBeNull();
});
it('unifies old named libraries by game without rewriting published versions or their host references', () => {
listLibraries();
legacyLibrary('first-t6-library', 't6');
legacyLibrary('other-t6-library', 't6');
legacyLibrary('t5-library', 't5');
legacyVersion('old-t6', 'first-t6-library', '2025-01-03', '2025-01-04');
legacyVersion('new-t6', 'other-t6-library', '2025-01-01', '2025-01-05');
legacyVersion('t5-files', 't5-library', '2025-01-01', '2025-01-02');
updateHostLibraryCaches(hostId, [
{ libraryVersionId: 'old-t6', status: 'ready', manifestSha256: 'checksum' },
{ libraryVersionId: 'new-t6', status: 'ready', manifestSha256: 'checksum' }
]);
const before = getLibraryVersion('old-t6');
const slots = listLibraries();
expect(slots.map(library => library.id)).toEqual(['t5', 't6']);
expect(slots[1].versions.map(version => version.id)).toEqual(['old-t6', 'new-t6']);
expect(slots.map(library => library.latestVersionId)).toEqual(['t5-files', 'new-t6']);
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['t5-files', 'new-t6']);
expect(newestReadyLibrary('t6')?.id).toBe('new-t6');
const overview = libraryOverview().libraries[1];
expect(overview.versions.find(version => version.id === 'old-t6')?.caches[0].status).toBe('outdated');
expect(overview.versions.find(version => version.id === 'new-t6')?.caches[0].status).toBe('ready');
expect(getLibraryVersion('old-t6')).toEqual(before);
expect(requireReadyLibrary(hostId, 'old-t6')).toMatchObject({
libraryId: 'first-t6-library', version: 'User label old-t6', storageLocation: join(directory, 'old-t6')
});
expect(begin().libraryId).toBe('first-t6-library');
expect(listLibraries()[1].latestVersionId).toBe('new-t6');
});
it('breaks publication-time ties by creation time and then insertion order across old libraries', () => {
listLibraries();
legacyLibrary('first-library', 't6');
legacyLibrary('second-library', 't6');
legacyVersion('first', 'first-library', '2025-01-01', '2025-01-05');
legacyVersion('second', 'second-library', '2025-01-02', '2025-01-05');
legacyVersion('third', 'first-library', '2025-01-02', '2025-01-05');
legacyVersion('later-created', 'second-library', '2025-01-04', '2025-01-04');
expect(newestReadyLibrary('t6')?.id).toBe('third');
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['third']);
expect(listLibraries()[1].latestVersionId).toBe('third');
});
it('ignores user-supplied library metadata when importing from hosts', () => {
const first = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
const next = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
expect(first.description).toBe('');
expect(first.version).not.toBe('1');
expect(next.version).not.toBe(first.version);
expect(first.libraryId).toBe(next.libraryId);
});
});
describe('game-file import lifecycle', () => {
it('publishes verified filesystem content and permanently pins manifest metadata', async () => {
const initial = begin();
@@ -42,8 +117,10 @@ describe('game-file import lifecycle', () => {
expect((await publishImport(hostId, ready.id)).id).toBe(ready.id);
await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/);
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/);
expect(begin('2').id).not.toBe(ready.id);
const next = begin();
expect(next.id).not.toBe(ready.id);
expect(next.version).not.toBe(ready.version);
expect(next.libraryId).toBe(ready.libraryId);
});
it('restricts an import to its source host and rejects manifest mutation', () => {
const version = begin();
@@ -55,7 +132,7 @@ describe('game-file import lifecycle', () => {
it('surfaces durable agent failures while preserving historical versions', () => {
const version = begin();
reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' });
expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
expect(listLibraries().find(library => library.game === 't6')?.versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
});
it('returns a pollable publication status while large files are verified asynchronously', async () => {
const version = begin(); setImportManifest(hostId, version.id, [file]);