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: '' }, publishedServers: ['kino'] }); 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.publishedServers.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(), publishedServers: [] }); }); 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(); }); });