Files
electric-cherry/tests/unit/discord-delivery.test.ts
T
2026-10-18 17:23:50 -04:00

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();
});
});