initial commit

This commit is contained in:
SowinskiBraeden committed 2026-07-05 16:54:59 -07:00
commit ce8f719a05
106 files changed
+24584

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { LogIngestionService } from '../src/modules/reforger-logs/ingestion/ingestion-service.js';
import { createLogger } from '../src/lib/logger.js';
import { FakeLogSource, InMemoryIngestionStore } from './helpers/in-memory-ingestion-store.js';
const SERVER_ID = 'srv-1';
const PROVIDER_ID = 'ptero-1';
const LOG_PATH = '/profile/logs/console.log';
const HEADER = 'Log started 2026-07-04 10:00:00\n';
function connect(time: string, num: number, name: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} (10.0.0.${num}:5000) connected'\n`;
}
function guid(time: string, num: number, name: string, id: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} - GUID: ${id}'\n`;
}
function disconnect(time: string, num: number, name: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} disconnected'\n`;
}
function kill(time: string, victim: string, killer: string, friendly = false): string {
return `${time} SCRIPT : ServerAdminTools | Event serveradmintools_player_killed | player: ${victim}, instigator: ${killer}, friendly: ${friendly ? 'true' : 'false'}\n`;
}
describe('LogIngestionService', () => {
let store: InMemoryIngestionStore;
let source: FakeLogSource;
let service: LogIngestionService;
beforeEach(() => {
store = new InMemoryIngestionStore();
source = new FakeLogSource();
service = new LogIngestionService(source, store, createLogger('silent'), {
maxDownloadBytes: 2 * 1024 * 1024,
});
});
const sync = () => service.sync(SERVER_ID, PROVIDER_ID, LOG_PATH);
it('creates players, events, and open sessions from connect lines', async () => {
source.content =
HEADER +
connect('10:05:00.000', 1, 'Braeden') +
guid('10:05:01.000', 1, 'Braeden', 'aabbccdd11223344');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(store.players).toHaveLength(1);
expect(store.players[0]!.externalPlayerId).toBe('aabbccdd11223344');
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
expect(result.cursorReset).toBe(false);
});
it('closes sessions with duration on disconnect', async () => {
source.content =
HEADER + connect('10:00:10.000', 1, 'Braeden') + disconnect('10:42:10.000', 1, 'Braeden');
await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
const session = store.sessions[0]!;
expect(session.durationSeconds).toBe(42 * 60);
});
it('tracks multiple simultaneous players', async () => {
source.content =
HEADER +
connect('10:01:00.000', 1, 'Alpha') +
connect('10:02:00.000', 2, 'Bravo') +
connect('10:03:00.000', 3, 'Charlie') +
disconnect('10:30:00.000', 2, 'Bravo');
await sync();
expect(store.players).toHaveLength(3);
const open = store.openSessions(SERVER_ID);
expect(open).toHaveLength(2);
const openNames = open.map((s) => store.players.find((p) => p.id === s.playerId)!.displayName);
expect(openNames.sort()).toEqual(['Alpha', 'Charlie']);
});
it('stores ServerAdminTools killfeed events', async () => {
source.content = HEADER + kill('10:10:00.000', 'Victim', 'Killer');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(store.events[0]!.eventType).toBe('player_killed');
expect(store.events[0]!.payload).toMatchObject({
killerName: 'Killer',
victimName: 'Victim',
friendly: false,
distanceMeters: null,
weapon: null,
});
expect(store.players.map((player) => player.displayName).sort()).toEqual(['Killer', 'Victim']);
});
it('does not duplicate events when the same content is synced twice', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
// Force a cursor reset by clearing the cursor: same lines get re-read.
store.cursors.clear();
const second = await sync();
expect(second.createdEvents).toBe(0);
expect(store.events).toHaveLength(1);
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
});
it('processes only appended content on subsequent syncs', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
const first = await sync();
source.content += disconnect('10:45:00.000', 1, 'Braeden');
const second = await sync();
expect(first.createdEvents).toBe(1);
expect(second.createdEvents).toBe(1);
expect(second.cursorReset).toBe(false);
expect(second.processedLines).toBe(1);
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
});
it('carries a partial trailing line across syncs and parses it once complete', async () => {
const full = connect('10:05:00.000', 1, 'Braeden');
source.content = HEADER + full.slice(0, 40); // mid-line
const first = await sync();
expect(first.createdEvents).toBe(0);
source.content = HEADER + full;
const second = await sync();
expect(second.createdEvents).toBe(1);
expect(store.events[0]!.eventType).toBe('player_connected');
});
it('handles log rotation: resets the cursor and ingests the new file without duplicates', async () => {
source.content =
HEADER + connect('10:05:00.000', 1, 'Braeden') + disconnect('11:00:00.000', 1, 'Braeden');
await sync();
expect(store.events).toHaveLength(2);
// Rotation: much smaller replacement file with a fresh header.
source.content = 'Log started 2026-07-04 11:30:00\n' + connect('11:31:00.000', 1, 'Sable');
const afterRotation = await sync();
expect(afterRotation.cursorReset).toBe(true);
expect(afterRotation.createdEvents).toBe(1);
expect(store.events).toHaveLength(3);
});
it('bounds the first sync of a huge file instead of importing full history', async () => {
let old = HEADER;
for (let i = 0; i < 30_000; i += 1) {
old += `09:00:00.000 SCRIPT : filler line ${i} ${'x'.repeat(20)}\n`;
}
source.content = old + connect('10:05:00.000', 1, 'Braeden');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(result.processedLines).toBeLessThan(30_000);
});
it('closes orphaned sessions when a server start is detected', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
source.content += '11:00:00.000 DEFAULT : Game successfully created.\n';
const result = await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
expect(store.sessions[0]!.disconnectReason).toBe('server_restart');
expect(store.events.map((e) => e.eventType)).toContain('server_restart_detected');
expect(result.updatedSessions).toBe(1);
});
it('closes a stale session when the same player reconnects without a disconnect', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
source.content += connect('12:00:00.000', 4, 'Braeden');
await sync();
const open = store.openSessions(SERVER_ID);
expect(open).toHaveLength(1);
expect(open[0]!.connectedAt.toISOString()).toBe('2026-07-04T12:00:00.000Z');
const closed = store.sessions.find((s) => s.disconnectedAt !== null)!;
expect(closed.disconnectReason).toBe('missed_disconnect');
});
it('records a sanitized error on the cursor when the download fails', async () => {
source.failWith = new Error('connect ETIMEDOUT 10.1.2.3:443 with apiKey=secret123');
await expect(sync()).rejects.toThrow();
const cursor = await store.getCursor(SERVER_ID, LOG_PATH);
expect(cursor?.lastErrorAt).toBeInstanceOf(Date);
expect(cursor?.lastErrorMessage).not.toContain('secret123');
expect(cursor?.lastSuccessfulSyncAt).toBeNull();
// Recovery: the next successful sync clears the error.
source.failWith = null;
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
const recovered = await store.getCursor(SERVER_ID, LOG_PATH);
expect(recovered?.lastErrorAt).toBeNull();
expect(recovered?.lastSuccessfulSyncAt).toBeInstanceOf(Date);
});
it('keeps one player when the logs emit both identityId and BattlEye GUID', async () => {
// Real logs emit BACKEND Authenticated (uuid) then the BE GUID line.
source.content =
HEADER +
'10:04:59.941 BACKEND : Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=Braeden\n' +
connect('10:05:00.000', 1, 'Braeden') +
guid('10:05:01.000', 1, 'Braeden', '8f1ec46b6979b3a3590e62aa8b757a68');
await sync();
expect(store.players).toHaveLength(1);
// First identity wins; the GUID does not split the player.
expect(store.players[0]!.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756');
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
});
it('merges identity lines that arrive in a later sync via open-session name match', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
source.content += guid('10:05:02.000', 1, 'Braeden', 'deadbeef00000001');
await sync();
expect(store.players).toHaveLength(1);
expect(store.players[0]!.externalPlayerId).toBe('deadbeef00000001');
});
});