import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { ContextualChatEntry } from '../../src/lib/game-chat.js'; import type { TailResult } from '../../src/lib/server/fleet/logs.js'; const SERVER = 't5_zombie_theater_1'; const OTHER_SERVER = 't6_zombie_town_1'; let fixture: Fixture; let baseline: number; beforeEach(async () => { vi.resetModules(); vi.useFakeTimers({ toFake: ['Date'] }); baseline = Date.now() - 600_000; fixture = await newFixture([SERVER, OTHER_SERVER]); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { db } = await import('../../src/lib/server/db/index.js'); await seedServerRegistry(); db() .prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'") .run(new Date(baseline - 120_000).toISOString()); }); afterEach(async () => { vi.restoreAllMocks(); vi.useRealTimers(); await fixture.cleanup(); }); function tail(message: string): TailResult { const line = `1:00 say;abc;0;Player;${message}`; return { lines: [line], size: Buffer.byteLength(line) + 1, mtime: new Date(baseline + 60_000).toISOString(), truncated: false }; } function messages(body: { entries: ContextualChatEntry[] }): string[] { return body.entries.flatMap((entry) => entry.type === 'message' ? [entry.message.message] : [] ); } function advance(milliseconds: number) { vi.setSystemTime(Date.now() + milliseconds); } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function setup() { const logs = await import('../../src/lib/server/fleet/logs.js'); const read = vi.spyOn(logs, 'readLogChunk').mockResolvedValue(tail('first')); const { GET } = await import('../../src/routes/api/servers/[name]/chat/+server.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js'); return { read, db: db(), collectFleetChat, get: (server = SERVER) => GET({ params: { name: server } }) }; } async function addInstance() { const { createHost } = await import('../../src/lib/server/hosts/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const host = createHost({ name: 'Chat host' }); db() .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('chat-instance',?,?,'library','image',28960,?)` ) .run(SERVER, host.id, new Date(baseline).toISOString()); } describe('server chat refresh sharing', () => { it('serves worker state to repeated viewers and still collects after players leave', async () => { const s = await setup(); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(1); expect(messages(await (await s.get()).json())).toEqual(['first']); const responses = await Promise.all(Array.from({ length: 5 }, () => s.get())); for (const response of responses) expect(messages(await response.json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(1); s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER); s.read.mockResolvedValue(tail('last message')); await s.collectFleetChat(); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(2); expect(messages(await (await s.get()).json())).toEqual(['last message']); expect(s.read).toHaveBeenCalledTimes(2); }); it('refreshes exactly when the completed read becomes fifteen seconds old', async () => { const s = await setup(); expect(messages(await (await s.get()).json())).toEqual(['first']); s.read.mockResolvedValue(tail('second message')); advance(14_999); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(1); advance(1); const responses = await Promise.all([s.get(), s.get(), s.get()]); for (const response of responses) expect(messages(await response.json())).toEqual(['second message']); expect(s.read).toHaveBeenCalledTimes(2); }); it('shares slow worker reads and starts the cooldown when they complete', async () => { const s = await setup(); const slow = deferred(); s.read.mockImplementationOnce(() => slow.promise); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); const collection = s.collectFleetChat(); const first = s.get(); advance(60_000); const second = s.get(); expect(s.read).toHaveBeenCalledTimes(1); slow.resolve(tail('slow message')); await collection; for (const response of await Promise.all([first, second])) expect(messages(await response.json())).toEqual(['slow message']); advance(14_999); expect(messages(await (await s.get()).json())).toEqual(['slow message']); expect(s.read).toHaveBeenCalledTimes(1); advance(1); await s.get(); expect(s.read).toHaveBeenCalledTimes(2); }); it('caches missing logs until the next allowed attempt', async () => { const s = await setup(); s.read.mockResolvedValueOnce(null); const missing = { ok: true, entries: [], missing: true }; expect(await (await s.get()).json()).toEqual(missing); advance(14_999); for (const response of await Promise.all([s.get(), s.get()])) expect(await response.json()).toEqual(missing); expect(s.read).toHaveBeenCalledTimes(1); advance(1); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(2); }); it('backs off failed reads and retries without leaking the original error', async () => { const s = await setup(); s.read.mockRejectedValueOnce(new Error('read failed at /private/server/log')); vi.spyOn(console, 'error').mockImplementation(() => {}); const first = await s.get(); expect(first.status).toBe(500); expect(await first.json()).toEqual({ ok: false, reason: 'Could not read server chat' }); advance(14_999); for (const response of await Promise.all([s.get(), s.get()])) { expect(response.status).toBe(500); expect(await response.json()).toEqual({ ok: false, reason: 'Could not read server chat' }); } expect(s.read).toHaveBeenCalledTimes(1); advance(1); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(2); }); it('keeps each server and every replacement identity on its own cooldown', async () => { const s = await setup(); await addInstance(); expect(messages(await (await s.get()).json())).toEqual(['first']); s.read.mockResolvedValue(tail('other server')); expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(2); s.db .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(SERVER); s.read.mockResolvedValue(tail('new generation')); expect(messages(await (await s.get()).json())).toEqual(['new generation']); s.db .prepare("UPDATE native_instances SET id='replacement-instance' WHERE server=?") .run(SERVER); s.read.mockResolvedValue(tail('new instance')); expect(messages(await (await s.get()).json())).toEqual(['new instance']); s.db .prepare('UPDATE servers SET created_at=? WHERE name=?') .run(new Date(baseline - 60_000).toISOString(), SERVER); s.read.mockResolvedValue(tail('new registration')); expect(messages(await (await s.get()).json())).toEqual(['new registration']); expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']); expect(s.read).toHaveBeenCalledTimes(5); }); it('does not let an old in-flight generation overwrite its replacement cache', async () => { const s = await setup(); await addInstance(); const oldRead = deferred(); s.read.mockImplementationOnce(() => oldRead.promise); const oldResponse = s.get(); expect(s.read).toHaveBeenCalledTimes(1); s.db .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(SERVER); s.read.mockResolvedValue(tail('replacement')); expect(messages(await (await s.get()).json())).toEqual(['replacement']); advance(10_000); oldRead.resolve(tail('old generation')); expect(await (await oldResponse).json()).toEqual({ ok: true, entries: [], missing: true }); expect(messages(await (await s.get()).json())).toEqual(['replacement']); expect(s.read).toHaveBeenCalledTimes(2); advance(5_000); s.read.mockResolvedValue(tail('replacement refreshed')); expect(messages(await (await s.get()).json())).toEqual(['replacement refreshed']); expect(s.read).toHaveBeenCalledTimes(3); }); });