Capture team chat and expose moderation match archives
This commit is contained in:
@@ -51,11 +51,42 @@ describe('game chat', () => {
|
||||
'1:00 say;123;bad;Player;hello',
|
||||
'1:00 say;123;0;Player;\x15^7',
|
||||
'1:00 InitGame: say;123;0;Player;hello',
|
||||
'1:00 sayteam;123;0;Player;hello'
|
||||
'1:00 sayteam;123;bad;Player;hello',
|
||||
'1:00 sayteam;123;0;Player;\x15^7'
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps team and public chat in order with distinct channels and stable public identities', () => {
|
||||
const messages = parseGameChat([
|
||||
'1:00 say;123;0;Player;hello; café',
|
||||
'1:00 sayteam;123;0;^2Player;\x15hello; café',
|
||||
'1:01 sayteam;456;1;Teammate;reply'
|
||||
]);
|
||||
expect(messages).toEqual([
|
||||
{ time: '1:00', guid: '123', slot: 0, player: 'Player', message: 'hello; café' },
|
||||
{
|
||||
time: '1:00',
|
||||
guid: '123',
|
||||
slot: 0,
|
||||
player: 'Player',
|
||||
message: 'hello; café',
|
||||
channel: 'team'
|
||||
},
|
||||
{
|
||||
time: '1:01',
|
||||
guid: '456',
|
||||
slot: 1,
|
||||
player: 'Teammate',
|
||||
message: 'reply',
|
||||
channel: 'team'
|
||||
}
|
||||
]);
|
||||
expect(JSON.stringify(messages[0])).toBe(
|
||||
'{"time":"1:00","guid":"123","slot":0,"player":"Player","message":"hello; café"}'
|
||||
);
|
||||
});
|
||||
|
||||
it('separates matches even when elapsed time keeps increasing', () => {
|
||||
const entries = parseGameChatTimeline([
|
||||
'1200:00 InitGame: \\mapname\\zombie_theater',
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { AuthUser } from '../../src/lib/auth.js';
|
||||
import {
|
||||
matchApiParams,
|
||||
matchHistoryHref,
|
||||
readMatchQuery
|
||||
} from '../../src/lib/match-history-query.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
let handle: Database.Database;
|
||||
const server = 't5_zombie_theater_1';
|
||||
const clock = '2026-10-17T12:00:00.000Z';
|
||||
const end = '2026-10-17T12:01:00.000Z';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
handle = db();
|
||||
handle
|
||||
.prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')")
|
||||
.run(server);
|
||||
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer'])
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES(?,?,?,'unused',?,0,0)`
|
||||
)
|
||||
.run(role, role, role, role);
|
||||
handle
|
||||
.prepare(
|
||||
"INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)"
|
||||
)
|
||||
.run(clock, end);
|
||||
const insert =
|
||||
handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint)
|
||||
VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`);
|
||||
for (const [id, status, round] of [
|
||||
[1, 'completed', 1],
|
||||
[2, 'interrupted', 1],
|
||||
[3, 'active', 0],
|
||||
[4, 'end-report-missing', 2],
|
||||
[5, 'completed', 8],
|
||||
[6, 'completed', 1]
|
||||
] as const) {
|
||||
insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round);
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at)
|
||||
VALUES(?,'123',?,1,?,?,?)`
|
||||
)
|
||||
.run(id, round, status === 'completed' ? 1 : 0, clock, end);
|
||||
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
|
||||
id,
|
||||
`message-${id}`,
|
||||
1,
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: {
|
||||
time: '1:00',
|
||||
guid: '123',
|
||||
slot: 1,
|
||||
player: 'Player',
|
||||
message: `Message from match ${id}`
|
||||
},
|
||||
context: { realTime: null, matchElapsedMs: null, round: null }
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
async function user(role: string): Promise<AuthUser> {
|
||||
const { getUser } = await import('../../src/lib/server/auth/index.js');
|
||||
return getUser(role)!;
|
||||
}
|
||||
async function api(actor: AuthUser | null, scoped = false, archive = '1') {
|
||||
const url = new URL(`http://localhost/api/matches?archive=${archive}`);
|
||||
if (scoped) {
|
||||
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||
return GET({ url, params: { name: server }, locals: { user: actor } });
|
||||
}
|
||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||
return GET({ url, locals: { user: actor } });
|
||||
}
|
||||
async function page(
|
||||
actor: AuthUser | null,
|
||||
surface: 'list' | 'detail' | 'instance',
|
||||
id = '1',
|
||||
archive = '1'
|
||||
) {
|
||||
const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`);
|
||||
const setHeaders = vi.fn();
|
||||
let result;
|
||||
if (surface === 'list') {
|
||||
const { load } = await import('../../src/routes/matches/+page.server.js');
|
||||
result = load({ url, locals: { user: actor }, setHeaders });
|
||||
} else if (surface === 'detail') {
|
||||
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
|
||||
result = load({ url, params: { id }, locals: { user: actor }, setHeaders });
|
||||
} else {
|
||||
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
|
||||
result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders });
|
||||
}
|
||||
return { result, setHeaders };
|
||||
}
|
||||
|
||||
it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => {
|
||||
expect(readMatchQuery(new URLSearchParams()).archive).toBe(false);
|
||||
expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false);
|
||||
expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true);
|
||||
for (const value of ['true', 'yes', '2'])
|
||||
expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow(
|
||||
'Invalid match history filters'
|
||||
);
|
||||
const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted');
|
||||
const href = matchHistoryHref(url, { archive: '1' });
|
||||
expect(href).toContain('matchArchive=1');
|
||||
expect(href).not.toContain('matchPage');
|
||||
expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1');
|
||||
});
|
||||
|
||||
it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => {
|
||||
const { getMatchDetail, serverMatchHistory } =
|
||||
await import('../../src/lib/server/fleet/match-history.js');
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const publicBefore = getLeaderboard();
|
||||
expect(publicBefore.entries[0].gamesPlayed).toBe(1);
|
||||
expect(serverMatchHistory().total).toBe(2);
|
||||
const archive = readMatchQuery(new URLSearchParams('archive=1'));
|
||||
expect(serverMatchHistory(undefined, 25, archive).total).toBe(6);
|
||||
expect(serverMatchHistory(server, 25, archive).total).toBe(5);
|
||||
expect(
|
||||
serverMatchHistory(
|
||||
server,
|
||||
25,
|
||||
readMatchQuery(new URLSearchParams('archive=1&status=interrupted'))
|
||||
).matches.map((item) => item.id)
|
||||
).toEqual([2]);
|
||||
for (const id of [1, 2, 3, 4, 6]) {
|
||||
expect(getMatchDetail(id)).toBeNull();
|
||||
expect(getMatchDetail(id, true)?.chat).toMatchObject([
|
||||
{ message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` }
|
||||
]);
|
||||
}
|
||||
for (const id of [2, 3, 4])
|
||||
expect(getMatchDetail(id, true)).toMatchObject({
|
||||
finalRound: null,
|
||||
durationMs: null,
|
||||
endedAt: null,
|
||||
endPlayers: null,
|
||||
players: [{ presentAtEnd: false }]
|
||||
});
|
||||
expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 });
|
||||
expect(getLeaderboard()).toEqual(publicBefore);
|
||||
handle.prepare('DELETE FROM servers WHERE name=?').run(server);
|
||||
expect(getMatchDetail(2, true)?.chat).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(['admin', 'moderator', 'super_viewer'])(
|
||||
'allows %s archive access with private responses on every API and page surface',
|
||||
async (role) => {
|
||||
const actor = await user(role);
|
||||
for (const scoped of [false, true]) {
|
||||
const response = await api(actor, scoped);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect((await response.json()).total).toBe(scoped ? 5 : 6);
|
||||
}
|
||||
for (const surface of ['list', 'detail', 'instance'] as const) {
|
||||
const loaded = await page(actor, surface);
|
||||
expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
|
||||
if (surface === 'detail')
|
||||
expect(loaded.result).toMatchObject({
|
||||
match: { id: 1, status: 'completed', finalRound: 1 }
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['operator', 'viewer', null])(
|
||||
'rejects %s expanded access before revealing match existence',
|
||||
async (role) => {
|
||||
const actor = role ? await user(role) : null;
|
||||
for (const scoped of [false, true]) {
|
||||
const response = await api(actor, scoped);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
}
|
||||
for (const surface of ['list', 'detail', 'instance'] as const)
|
||||
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
|
||||
await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 });
|
||||
expect((await api(actor, false, '0')).status).toBe(200);
|
||||
await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 });
|
||||
expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
|
||||
'revalidates stale moderator locals after %s',
|
||||
async (change) => {
|
||||
const actor = await user('moderator');
|
||||
handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
|
||||
expect((await api(actor)).status).toBe(403);
|
||||
expect((await api(actor, true)).status).toBe(403);
|
||||
for (const surface of ['list', 'detail', 'instance'] as const)
|
||||
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
|
||||
}
|
||||
);
|
||||
|
||||
it('returns validation and not-found errors without silently falling back to the standard history', async () => {
|
||||
const actor = await user('moderator');
|
||||
expect((await api(actor, false, 'true')).status).toBe(400);
|
||||
for (const surface of ['list', 'detail', 'instance'] as const)
|
||||
await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 });
|
||||
for (const id of ['0', '01', '-1', '1.5', '999'])
|
||||
await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => {
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||
const entry = getMatchDetail(2, true)!.chat[0];
|
||||
const { id } = mutateModeration('moderator', {
|
||||
op: 'create-case',
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
title: 'Interrupted match report',
|
||||
evidence: { matchId: 2, messageIds: [entry.messageId] },
|
||||
idempotencyKey: randomUUID()
|
||||
});
|
||||
handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
|
||||
const evidence = moderationCaseDetail('moderator', id).case.evidence[0];
|
||||
expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2');
|
||||
});
|
||||
@@ -50,7 +50,7 @@ async function create(overrides: Record<string, unknown> = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function archive(messages: Array<{ guid?: string; message: string }>) {
|
||||
async function archive(messages: Array<{ guid?: string; message: string; channel?: 'team' }>) {
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
|
||||
saveTelemetry({
|
||||
@@ -77,7 +77,8 @@ async function archive(messages: Array<{ guid?: string; message: string }>) {
|
||||
guid: message.guid ?? '123',
|
||||
slot: message.guid ? 2 : 1,
|
||||
player: message.guid ? 'Witness' : 'Subject',
|
||||
message: message.message
|
||||
message: message.message,
|
||||
...(message.channel ? { channel: message.channel } : {})
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -155,6 +156,31 @@ it('allows a new witness reply alongside an attached subject and deduplicates la
|
||||
expect(await detail(id)).toEqual(attached);
|
||||
});
|
||||
|
||||
it('preserves team chat separately when matching evidence saved before message identifiers', async () => {
|
||||
const { matchId, entries } = await archive([
|
||||
{ message: 'Same words', channel: 'team' },
|
||||
{ message: 'Same words' }
|
||||
]);
|
||||
const { id } = await create({
|
||||
evidence: { matchId, messageIds: [entries[1].messageId] }
|
||||
});
|
||||
// A legacy public-chat snapshot has no selected IDs or channel field.
|
||||
handle
|
||||
.prepare('UPDATE moderation_evidence SET snapshot=? WHERE case_id=?')
|
||||
.run(JSON.stringify({ matchId, entry: entries[1] }), id);
|
||||
await mutate({
|
||||
op: 'attach-evidence',
|
||||
id,
|
||||
revision: 1,
|
||||
evidence: { matchId, messageIds: [entries[0].messageId] }
|
||||
});
|
||||
const attached = await detail(id);
|
||||
expect(attached.case.evidence).toHaveLength(2);
|
||||
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
|
||||
expect(snapshot.entry.message.channel).toBe('team');
|
||||
expect(snapshot.selectedMessageIds).toEqual([entries[0].messageId]);
|
||||
});
|
||||
|
||||
it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => {
|
||||
const { matchId, entries } = await archive([{ message: 'Subject statement' }]);
|
||||
const messageId = entries[0].messageId!;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import type { LogChunk, TailResult } from '../../src/lib/server/fleet/logs.js';
|
||||
|
||||
const SERVER = 't5_zombie_theater_1';
|
||||
const OTHER_SERVER = 't6_zombie_town_1';
|
||||
@@ -19,6 +19,7 @@ beforeEach(async () => {
|
||||
db()
|
||||
.prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'")
|
||||
.run(new Date(baseline - 120_000).toISOString());
|
||||
db().prepare("UPDATE servers SET container_state='stopped' WHERE name=?").run(OTHER_SERVER);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -43,6 +44,25 @@ function messages(body: { entries: ContextualChatEntry[] }): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function chunk(content: string, offset: number, size: number, reset = false): LogChunk {
|
||||
const mtime = new Date(baseline + 60_000).toISOString();
|
||||
return {
|
||||
contentBase64: Buffer.from(content).toString('base64'),
|
||||
size,
|
||||
mtime,
|
||||
truncated: false,
|
||||
reset,
|
||||
cursor: {
|
||||
fileId: 'chat-log',
|
||||
offset,
|
||||
size,
|
||||
modified: mtime,
|
||||
anchorLength: 0,
|
||||
anchorHash: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function advance(milliseconds: number) {
|
||||
vi.setSystemTime(Date.now() + milliseconds);
|
||||
}
|
||||
@@ -82,6 +102,92 @@ async function addInstance() {
|
||||
}
|
||||
|
||||
describe('server chat refresh sharing', () => {
|
||||
it('picks up surviving logs once when collection starts on an idle server', async () => {
|
||||
const s = await setup();
|
||||
await s.collectFleetChat();
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first']);
|
||||
advance(60_000);
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains all remaining chunks after players leave before anchoring messages', async () => {
|
||||
const s = await setup();
|
||||
const lines = [
|
||||
'0:30 say;abc;0;Player;first\n',
|
||||
'0:40 say;abc;0;Player;middle\n',
|
||||
'1:00 say;abc;0;Player;last\n'
|
||||
];
|
||||
const sizes = lines.map((line) => Buffer.byteLength(line));
|
||||
const total = sizes.reduce((a, b) => a + b, 0);
|
||||
s.read
|
||||
.mockResolvedValueOnce(chunk(lines[0], sizes[0], total, true))
|
||||
.mockResolvedValueOnce(chunk(lines[1], sizes[0] + sizes[1], total))
|
||||
.mockResolvedValueOnce(chunk(lines[2], total, total));
|
||||
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
|
||||
await s.collectFleetChat();
|
||||
expect(messages(await (await s.get()).json())).toEqual([]);
|
||||
s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
|
||||
await s.collectFleetChat();
|
||||
expect(messages(await (await s.get()).json())).toEqual([]);
|
||||
await s.collectFleetChat();
|
||||
expect(messages(await (await s.get()).json())).toEqual(['first', 'middle', 'last']);
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(3);
|
||||
expect(s.read.mock.calls.map((call) => call[1]?.offset)).toEqual([
|
||||
undefined,
|
||||
sizes[0],
|
||||
sizes[0] + sizes[1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds stalled partial-line retries and resumes collection when players return', async () => {
|
||||
const s = await setup();
|
||||
const partial = '1:00 say;abc;0;Player;incomplete';
|
||||
const size = Buffer.byteLength(partial);
|
||||
s.read
|
||||
.mockResolvedValueOnce(chunk(partial, size, size, true))
|
||||
.mockResolvedValue(chunk('', size, size));
|
||||
await s.collectFleetChat();
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
advance(15_000);
|
||||
await s.collectFleetChat();
|
||||
}
|
||||
expect(s.read).toHaveBeenCalledTimes(4);
|
||||
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
|
||||
s.read.mockResolvedValue(chunk('\n', size + 1, size + 1));
|
||||
await s.collectFleetChat();
|
||||
expect(messages(await (await s.get()).json())).toEqual(['incomplete']);
|
||||
expect(s.read).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('stops idle reads for missing logs and bounds retries for read failures', async () => {
|
||||
const s = await setup();
|
||||
s.read.mockResolvedValueOnce(null);
|
||||
await s.collectFleetChat();
|
||||
advance(60_000);
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(1);
|
||||
// A new registration gets its own initial attempt and retry budget.
|
||||
s.db
|
||||
.prepare('UPDATE servers SET created_at=? WHERE name=?')
|
||||
.run(new Date(baseline - 60_000).toISOString(), SERVER);
|
||||
s.read.mockRejectedValue(new Error('Unavailable source'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await s.collectFleetChat();
|
||||
await s.collectFleetChat();
|
||||
expect(s.read).toHaveBeenCalledTimes(2);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
advance(15_000);
|
||||
await s.collectFleetChat();
|
||||
}
|
||||
expect(s.read).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('server chat API', () => {
|
||||
).toEqual(['first', 'retry me']);
|
||||
});
|
||||
|
||||
it('skips empty, stopped and offline servers and collects one last time after players leave', async () => {
|
||||
it('checks idle servers once, skips stopped and offline servers and collects after players leave', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { createHost } = await import('../../src/lib/server/hosts/index.js');
|
||||
const logs = await import('../../src/lib/server/fleet/logs.js');
|
||||
@@ -147,25 +147,27 @@ describe('server chat API', () => {
|
||||
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
|
||||
db().prepare("UPDATE servers SET last_players=0,container_state='running'").run();
|
||||
await collectFleetChat();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
|
||||
await log(['60:50 say;abc;0;Player;playing'], 50);
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(read).toHaveBeenCalledTimes(3);
|
||||
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
|
||||
await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']);
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(read).toHaveBeenCalledTimes(3);
|
||||
await collectFleetChat();
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect(read).toHaveBeenCalledTimes(4);
|
||||
expect((await (await get()).json()).entries).toHaveLength(2);
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect(read).toHaveBeenCalledTimes(4);
|
||||
db()
|
||||
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
|
||||
.run(SERVER);
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect(read).toHaveBeenCalledTimes(4);
|
||||
const host = createHost({ name: 'Offline' });
|
||||
db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER);
|
||||
db()
|
||||
@@ -175,7 +177,30 @@ describe('server chat API', () => {
|
||||
)
|
||||
.run(SERVER, host.id, at(0));
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect(read).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('archives surviving idle match logs with distinct stable public and team message identities', async () => {
|
||||
await report('game_start', 0);
|
||||
await report('heartbeat', 20, 4);
|
||||
await report('game_end', 60, 4);
|
||||
await log(['60:30 say;abc;0;Player;hello', '60:30 sayteam;abc;0;Player;hello'], 30);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare('UPDATE servers SET last_players=0').run();
|
||||
const { collectFleetChat, matchChatTimeline } =
|
||||
await import('../../src/lib/server/fleet/chat.js');
|
||||
await collectFleetChat();
|
||||
const saved = matchChatTimeline(1);
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(saved[0]).toMatchObject({ message: { message: 'hello' } });
|
||||
expect(saved[0].type === 'message' && saved[0].message.channel).toBeUndefined();
|
||||
expect(saved[1]).toMatchObject({ message: { message: 'hello', channel: 'team' } });
|
||||
expect(new Set(saved.map((entry) => entry.messageId)).size).toBe(2);
|
||||
expect(saved.every((entry) => typeof entry.messageId === 'string')).toBe(true);
|
||||
advanceTime();
|
||||
await get();
|
||||
await collectFleetChat();
|
||||
expect(matchChatTimeline(1)).toEqual(saved);
|
||||
});
|
||||
|
||||
it('keeps real timestamps and historical match context stable across refreshes', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render } from 'svelte/server';
|
||||
import { writable } from 'svelte/store';
|
||||
import MatchPage from '../../src/routes/matches/[id]/+page.svelte';
|
||||
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
|
||||
import EvidenceMessages from '../../src/lib/ui/components/moderation/EvidenceMessages.svelte';
|
||||
import type { MatchDetail } from '../../src/lib/match-history.js';
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
@@ -45,6 +46,18 @@ const match: MatchDetail = {
|
||||
};
|
||||
|
||||
describe('historical match chat', () => {
|
||||
it('identifies team chat in both archived messages and preserved evidence excerpts', () => {
|
||||
const entry = match.chat[0];
|
||||
if (entry.type !== 'message') throw new Error('Expected message fixture');
|
||||
const history = [{ ...entry, message: { ...entry.message, channel: 'team' as const } }];
|
||||
expect(render(ChatPanel, { props: { history } }).body).toContain('Team chat');
|
||||
expect(render(EvidenceMessages, { props: { entries: history } }).body).toContain(
|
||||
'Team chat'
|
||||
);
|
||||
expect(render(ChatPanel, { props: { history: match.chat } }).body).not.toContain(
|
||||
'Team chat'
|
||||
);
|
||||
});
|
||||
it('renders saved messages with their context and no live controls on the match page', () => {
|
||||
const body = render(MatchPage, { props: { data: { match } } }).body;
|
||||
expect(body).toContain('Match history');
|
||||
|
||||
Reference in New Issue
Block a user