418 lines
17 KiB
TypeScript
418 lines
17 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
defaultDiscordSettings,
|
|
type DiscordMessage,
|
|
type DiscordPublicServer
|
|
} from '../../src/lib/discord.js';
|
|
import { migrateDiscord } from '../../src/lib/server/discord/schema.js';
|
|
import { DiscordStore } from '../../src/lib/server/discord/store.js';
|
|
import {
|
|
DiscordDeliveryError,
|
|
DiscordRest,
|
|
type DiscordTransport
|
|
} from '../../src/lib/server/discord/transport.js';
|
|
import { DiscordWorker } from '../../src/lib/server/discord/worker.js';
|
|
|
|
const guildId = '12345678901234567';
|
|
const channelId = '23456789012345678';
|
|
const userId = '34567890123456789';
|
|
const messageId = '45678901234567890';
|
|
const dmId = '56789012345678901';
|
|
let handle: Database.Database;
|
|
let store: DiscordStore;
|
|
let now: number;
|
|
let server: DiscordPublicServer;
|
|
const message = (content: string): DiscordMessage => ({ content, allowed_mentions: { parse: [] } });
|
|
const watchInput = {
|
|
guildId,
|
|
userId,
|
|
game: 't5' as const,
|
|
mapKey: 't5:zombie_theater',
|
|
mode: 'empty' as const
|
|
};
|
|
|
|
beforeEach(() => {
|
|
handle = new Database(':memory:');
|
|
migrateDiscord(handle);
|
|
now = Date.parse('2026-10-18T20:00:00Z');
|
|
store = new DiscordStore(handle, () => now);
|
|
store.saveSettings({
|
|
...defaultDiscordSettings,
|
|
enabled: true,
|
|
guildId,
|
|
channels: { t5: channelId, t6: '' },
|
|
excludedServers: []
|
|
});
|
|
server = {
|
|
name: 'kino',
|
|
game: 't5',
|
|
label: 'Kino lobby',
|
|
map: 'Kino der Toten',
|
|
mapKey: watchInput.mapKey,
|
|
state: 'empty',
|
|
players: 0,
|
|
capacity: 4,
|
|
round: 1,
|
|
observedAt: new Date(now).toISOString(),
|
|
playerNames: [],
|
|
connection: null
|
|
};
|
|
});
|
|
afterEach(() => {
|
|
handle.close();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function worker(rest: DiscordTransport, render = () => [message('Public board')]) {
|
|
return new DiscordWorker({
|
|
store,
|
|
transport: () => rest,
|
|
fleet: (settings) => (!settings.excludedServers.includes(server.name) ? [server] : []),
|
|
render,
|
|
now: () => now
|
|
});
|
|
}
|
|
function transport(handler?: (method: string, path: string, body?: unknown) => unknown) {
|
|
const request = vi.fn(async (method: string, path: string, body?: unknown) => {
|
|
if (handler) {
|
|
const result = await handler(method, path, body);
|
|
if (result !== undefined) return result;
|
|
}
|
|
if (path === `/channels/${channelId}`) return { guild_id: guildId, type: 0 };
|
|
if (path === '/users/@me/channels') return { id: dmId };
|
|
return { id: messageId };
|
|
});
|
|
return { request, rest: { request } as DiscordTransport };
|
|
}
|
|
function advance() {
|
|
now += 30_000;
|
|
server.observedAt = new Date(now).toISOString();
|
|
}
|
|
|
|
describe('Discord delivery storage', () => {
|
|
it('retains interaction replay claims after cancellation and database reopen, then prunes expiry', () => {
|
|
const interactionId = '67890123456789012';
|
|
expect(store.claimInteraction(interactionId)).toBe(true);
|
|
const watch = store.subscribe(watchInput);
|
|
store.cancel(watch.id, guildId, userId);
|
|
const serialized = handle.serialize();
|
|
handle.close();
|
|
handle = new Database(serialized);
|
|
store = new DiscordStore(handle, () => now);
|
|
expect(store.claimInteraction(interactionId)).toBe(false);
|
|
expect(store.subscriptions(userId, guildId)).toHaveLength(0);
|
|
now += 15 * 60_000;
|
|
expect(store.claimInteraction(interactionId)).toBe(true);
|
|
expect(() => store.claimInteraction('not-a-snowflake')).toThrow(
|
|
'Invalid Discord interaction'
|
|
);
|
|
});
|
|
|
|
it('replays the migration and persists settings, board IDs, and opt-in watches', () => {
|
|
const watch = store.subscribe(watchInput);
|
|
store.reconcileBoards([{ game: 't5', guildId, channelId, messages: [message('Board')] }]);
|
|
store.delivered(store.jobs()[0], messageId);
|
|
const serialized = handle.serialize();
|
|
handle.close();
|
|
handle = new Database(serialized);
|
|
const schemaVersion = handle.pragma('schema_version', { simple: true });
|
|
migrateDiscord(handle);
|
|
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
|
store = new DiscordStore(handle, () => now);
|
|
expect(store.settings().guildId).toBe(guildId);
|
|
expect(store.boards()[0].messageId).toBe(messageId);
|
|
expect(store.subscriptions(userId, guildId)[0].id).toBe(watch.id);
|
|
});
|
|
|
|
it('deduplicates watches, bounds each member, and scopes cancellation to the owner and guild', () => {
|
|
const watch = store.subscribe(watchInput);
|
|
expect(store.subscribe(watchInput)).toEqual(watch);
|
|
for (let i = 1; i < 10; i++) store.subscribe({ ...watchInput, mapKey: `t5:map_${i}` });
|
|
expect(() => store.subscribe({ ...watchInput, mapKey: 't5:eleventh' })).toThrow(
|
|
'10 active'
|
|
);
|
|
expect(store.cancel(watch.id, guildId, '99999999999999999')).toBe(false);
|
|
expect(store.cancel(watch.id, '99999999999999999', userId)).toBe(false);
|
|
expect(store.cancel(watch.id, guildId, userId)).toBe(true);
|
|
expect(store.subscriptions(userId, guildId)).toHaveLength(9);
|
|
});
|
|
|
|
it('expires watches and their pending notifications, and bounds durable work atomically', () => {
|
|
const watch = store.subscribe(watchInput);
|
|
store.queueAlert(store.subscription(watch.id)!, message('Alert'));
|
|
expect(store.jobs()).toHaveLength(1);
|
|
now += 24 * 60 * 60_000;
|
|
store.expireSubscriptions();
|
|
expect(store.subscription(watch.id)?.state).toBe('expired');
|
|
expect(store.jobs()).toHaveLength(0);
|
|
expect(() =>
|
|
store.reconcileBoards([
|
|
{
|
|
game: 't5',
|
|
guildId,
|
|
channelId,
|
|
messages: Array.from({ length: 501 }, (_, i) => message(String(i)))
|
|
}
|
|
])
|
|
).toThrow('queue is full');
|
|
expect(store.boards()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Discord board worker', () => {
|
|
it('retires the original guild board before rejecting a replacement in the wrong guild', async () => {
|
|
const { rest, request } = transport();
|
|
const instance = worker(rest);
|
|
await instance.tick();
|
|
const original = store.boards()[0];
|
|
expect(original.guildId).toBe(guildId);
|
|
store.saveSettings({ ...store.settings(), guildId: '99999999999999999' });
|
|
// The message retains its original guild until the deletion is reconciled.
|
|
expect(store.board(original.id)?.guildId).toBe(guildId);
|
|
request.mockClear();
|
|
await instance.tick();
|
|
expect(request.mock.calls.map(([method]) => method)).toEqual(['DELETE', 'GET']);
|
|
expect(request.mock.calls[0][1]).toBe(`/channels/${channelId}/messages/${messageId}`);
|
|
expect(store.board(original.id)).toBeUndefined();
|
|
expect(store.boards()).toEqual([
|
|
expect.objectContaining({ guildId: '99999999999999999', messageId: null })
|
|
]);
|
|
expect(store.status().lastError).toContain('does not belong');
|
|
});
|
|
|
|
it('creates split boards once, survives a worker restart, and skips unchanged edits', async () => {
|
|
const { rest, request } = transport();
|
|
const render = () => [message('Page 1'), message('Page 2')];
|
|
await worker(rest, render).tick();
|
|
expect(request.mock.calls.filter(([method]) => method === 'POST')).toHaveLength(2);
|
|
expect(store.boards()).toHaveLength(2);
|
|
request.mockClear();
|
|
await worker(rest, render).tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
await worker(rest, () => [message('Changed page')]).tick();
|
|
expect(request.mock.calls.some(([method]) => method === 'PATCH')).toBe(true);
|
|
expect(request.mock.calls.some(([method]) => method === 'DELETE')).toBe(true);
|
|
expect(store.boards()).toHaveLength(1);
|
|
});
|
|
|
|
it('recreates deleted messages with a new nonce while preserving other board pages', async () => {
|
|
let missing = false;
|
|
const { rest, request } = transport((method, path) => {
|
|
if (missing && method === 'PATCH' && path.includes('/messages/'))
|
|
throw new DiscordDeliveryError('Missing message.', 404, null, 10008);
|
|
});
|
|
await worker(rest).tick();
|
|
const firstNonce = (
|
|
request.mock.calls.find(([method]) => method === 'POST')![2] as { nonce: string }
|
|
).nonce;
|
|
missing = true;
|
|
request.mockClear();
|
|
await worker(rest, () => [message('Updated')]).tick();
|
|
const secondNonce = (
|
|
request.mock.calls.find(([method]) => method === 'POST')![2] as { nonce: string }
|
|
).nonce;
|
|
expect(secondNonce).not.toBe(firstNonce);
|
|
expect(store.boards()[0].messageId).toBe(messageId);
|
|
expect(store.jobs()).toHaveLength(0);
|
|
});
|
|
|
|
it('detects unchanged messages deleted externally during periodic verification', async () => {
|
|
let missing = false;
|
|
const { rest, request } = transport((method, path) => {
|
|
if (missing && method === 'GET' && path.includes('/messages/'))
|
|
throw new DiscordDeliveryError('Missing message.', 404, null, 10008);
|
|
});
|
|
const instance = worker(rest);
|
|
await instance.tick();
|
|
now += 16 * 60_000;
|
|
missing = true;
|
|
await instance.tick();
|
|
expect(store.boards()[0].messageId).toBeNull();
|
|
request.mockClear();
|
|
await instance.tick();
|
|
expect(request.mock.calls.some(([method]) => method === 'POST')).toBe(true);
|
|
});
|
|
|
|
it('refuses channels outside the allowed guild and pauses permission failures until retry', async () => {
|
|
const { rest, request } = transport((method, path) =>
|
|
method === 'GET' && path === `/channels/${channelId}`
|
|
? { guild_id: '99999999999999999', type: 0 }
|
|
: undefined
|
|
);
|
|
const instance = worker(rest);
|
|
await instance.tick();
|
|
expect(request.mock.calls.some(([method]) => method === 'POST')).toBe(false);
|
|
expect(store.status().lastError).toContain('does not belong');
|
|
request.mockClear();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
store.retryFailed();
|
|
await instance.tick();
|
|
expect(request).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('checks settings again after channel lookup and removes board data when disabled', async () => {
|
|
const { rest, request } = transport();
|
|
const instance = worker(rest);
|
|
await instance.tick();
|
|
store.saveSettings({ ...store.settings(), enabled: false });
|
|
request.mockClear();
|
|
await instance.tick();
|
|
expect(request.mock.calls.map(([method]) => method)).toEqual(['DELETE']);
|
|
expect(store.boards()).toHaveLength(0);
|
|
store.saveSettings({ ...store.settings(), enabled: true });
|
|
const disabling = transport((method) => {
|
|
if (method === 'GET') store.saveSettings({ ...store.settings(), enabled: false });
|
|
});
|
|
await worker(disabling.rest).tick();
|
|
expect(disabling.request.mock.calls.some(([method]) => method === 'POST')).toBe(false);
|
|
});
|
|
|
|
it('persists rate-limit cooldowns without blocking the tick or creating overlapping runs', async () => {
|
|
let limited = true;
|
|
const { rest, request } = transport((method) => {
|
|
if (method === 'POST' && limited)
|
|
throw new DiscordDeliveryError('Rate limited.', 429, now + 120_000);
|
|
});
|
|
const instance = worker(rest);
|
|
expect(instance.tick()).toBe(instance.tick());
|
|
await instance.tick();
|
|
request.mockClear();
|
|
advance();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
now += 120_000;
|
|
limited = false;
|
|
await instance.tick();
|
|
expect(store.boards()[0].messageId).toBe(messageId);
|
|
});
|
|
});
|
|
|
|
describe('Discord one-time availability notifications', () => {
|
|
it('requires stable fresh observations and delivers only one private DM without mentions', async () => {
|
|
const watch = store.subscribe(watchInput);
|
|
const { rest, request } = transport();
|
|
const instance = worker(rest, () => []);
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
advance();
|
|
await instance.tick();
|
|
expect(request.mock.calls.map(([, path]) => path)).toEqual([
|
|
'/users/@me/channels',
|
|
`/channels/${dmId}/messages`
|
|
]);
|
|
expect(request.mock.calls[1][2]).toMatchObject({
|
|
allowed_mentions: { parse: [] },
|
|
enforce_nonce: true
|
|
});
|
|
expect(store.subscription(watch.id)?.state).toBe('delivered');
|
|
request.mockClear();
|
|
advance();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not notify on stale data or unknown capacity for a space watch', async () => {
|
|
store.subscribe({ ...watchInput, mode: 'space' });
|
|
const { rest, request } = transport();
|
|
const instance = worker(rest, () => []);
|
|
server.capacity = null;
|
|
await instance.tick();
|
|
advance();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
server.capacity = 4;
|
|
server.state = 'unknown';
|
|
advance();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
server.state = 'empty';
|
|
server.observedAt = new Date(now - 100_000).toISOString();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('withdraws queued alerts if a server is unpublished while the DM channel opens', async () => {
|
|
const watch = store.subscribe(watchInput);
|
|
const { rest, request } = transport((method, path) => {
|
|
if (path === '/users/@me/channels')
|
|
store.saveSettings({ ...store.settings(), excludedServers: [server.name] });
|
|
});
|
|
const instance = worker(rest, () => []);
|
|
await instance.tick();
|
|
advance();
|
|
await instance.tick();
|
|
expect(request.mock.calls).toHaveLength(1);
|
|
expect(store.subscription(watch.id)?.state).toBe('active');
|
|
expect(store.jobs()).toHaveLength(0);
|
|
});
|
|
|
|
it('stops retrying forbidden DMs and exposes the failure in delivery health', async () => {
|
|
const watch = store.subscribe(watchInput);
|
|
const { rest, request } = transport((method, path) => {
|
|
if (path.includes('/messages'))
|
|
throw new DiscordDeliveryError('Recipient does not accept direct messages.', 403);
|
|
});
|
|
const instance = worker(rest, () => []);
|
|
await instance.tick();
|
|
advance();
|
|
await instance.tick();
|
|
expect(store.subscription(watch.id)?.state).toBe('failed');
|
|
expect(store.status().lastError).toContain('direct messages');
|
|
request.mockClear();
|
|
advance();
|
|
await instance.tick();
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Discord REST transport', () => {
|
|
it('honors global retry_after and route reset headers without network calls during cooldown', async () => {
|
|
const fetcher = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ retry_after: 12.5, global: true }), { status: 429 })
|
|
);
|
|
const rest = new DiscordRest('private-token', fetcher, () => now);
|
|
await expect(
|
|
rest.request('POST', `/channels/${channelId}/messages`, {})
|
|
).rejects.toMatchObject({ status: 429, retryAt: now + 12_500 });
|
|
await expect(rest.request('GET', `/channels/${dmId}`)).rejects.toMatchObject({
|
|
status: 429
|
|
});
|
|
expect(fetcher).toHaveBeenCalledOnce();
|
|
now += 13_000;
|
|
fetcher.mockImplementation(
|
|
async () =>
|
|
new Response('{"id":"123"}', {
|
|
headers: {
|
|
'X-RateLimit-Remaining': '0',
|
|
'X-RateLimit-Reset-After': '3',
|
|
'X-RateLimit-Bucket': 'messages'
|
|
}
|
|
})
|
|
);
|
|
await rest.request('POST', `/channels/${channelId}/messages`, {});
|
|
await expect(
|
|
rest.request('POST', `/channels/${channelId}/messages`, {})
|
|
).rejects.toMatchObject({ retryAt: now + 3000 });
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('never exposes response bodies or tokens and stops HTTP requests after invalid credentials', async () => {
|
|
const fetcher = vi.fn(
|
|
async () => new Response('{"message":"private response data"}', { status: 401 })
|
|
);
|
|
const rest = new DiscordRest('private-token', fetcher, () => now);
|
|
await expect(rest.request('GET', `/channels/${channelId}`)).rejects.toThrow(
|
|
'credentials were rejected'
|
|
);
|
|
await expect(rest.request('GET', `/channels/${channelId}`)).rejects.not.toThrow('private');
|
|
expect(fetcher).toHaveBeenCalledOnce();
|
|
await expect(rest.request('GET', 'https://attacker.invalid')).rejects.toThrow(
|
|
'Invalid Discord API path'
|
|
);
|
|
expect(fetcher).toHaveBeenCalledOnce();
|
|
});
|
|
});
|