import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import { writable } from 'svelte/store'; import DiscordPage from '../../src/routes/settings/integrations/discord/+page.svelte'; import type { DiscordAdminState, DiscordSettings } from '../../src/lib/discord.js'; const page = writable({ data: { user: { role: 'admin' } } }); vi.mock('$app/stores', () => ({ page: { subscribe: (run: Parameters[0]) => page.subscribe(run) } })); let component: ReturnType | undefined; beforeEach(() => { vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); }); afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); const serverName = 't5_zombie_theater_1'; const guildId = '12345678901234567'; const channelId = '23456789012345678'; function snapshot(): DiscordAdminState { return { settings: { enabled: false, guildId, channels: { t5: channelId, t6: '' }, excludedServers: [], showPlayers: false, showAddresses: false, leaderboardEnabled: false }, credentials: { applicationId: '34567890123456789', publicKeyConfigured: true, botTokenConfigured: true }, status: { lastAttemptAt: null, lastSuccessAt: null, lastError: null, boards: [], activeSubscriptions: 0, pendingNotifications: 0 }, servers: [ { name: serverName, game: 't5', label: 'Kino der Toten · Lobby 1', map: 'Kino der Toten' } ], preview: { t5: [ { name: serverName, game: 't5', label: 'Kino der Toten · Lobby 1', map: 'Kino der Toten', mapKey: 't5:zombie_theater', state: 'empty', players: 0, capacity: 4, round: null, observedAt: '2026-10-18T20:00:00.000Z', playerNames: [], connection: null } ], t6: [] } }; } const text = (node: Element) => node.textContent?.replace(/\s+/g, ' ').trim(); const findButton = (label: string) => [...document.querySelectorAll('button')].find((node) => text(node) === label); const button = (label: string) => { const node = findButton(label); expect(node, `Missing button: ${label}`).toBeDefined(); return node!; }; const checkbox = (label: string) => { const node = [...document.querySelectorAll('label')].find((node) => text(node)?.includes(label) ); const input = node?.querySelector('input[type="checkbox"]'); expect(input, `Missing checkbox: ${label}`).toBeTruthy(); return input!; }; const field = (label: string) => { const node = [...document.querySelectorAll('label')].find((node) => text(node) === label)!; const input = document.getElementById(node.htmlFor) as HTMLInputElement; expect(input?.tagName).toBe('INPUT'); return input; }; function enter(label: string, value: string) { const input = field(label); input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); } function toggle(label: string) { checkbox(label).click(); flushSync(); } async function show(role = 'admin') { page.set({ data: { user: { role } } }); component = mount(DiscordPage, { target: document.body }); flushSync(); await vi.waitFor(() => { flushSync(); expect(document.querySelector('form')).not.toBeNull(); }); } it('includes servers by default, saves an exclusion without credentials, and restores it on discard', async () => { const initial = snapshot(); initial.preview.t5[0].capacity = null; let submitted: DiscordSettings | undefined; const fetch = vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === 'PUT') { submitted = JSON.parse(String(init.body)) as DiscordSettings; return Response.json({ ...initial, settings: submitted }); } return Response.json(initial); }); vi.stubGlobal('fetch', fetch); await show(); expect(checkbox('Enable Discord companion').checked).toBe(false); expect(checkbox(serverName).checked).toBe(true); expect(checkbox('Show player names').checked).toBe(false); expect(checkbox('Show public server connection addresses').checked).toBe(false); expect(checkbox('Publish fleet leaderboards').checked).toBe(false); expect(document.body.textContent).toContain('including excluded servers'); expect(document.body.textContent).toContain('1 server included.'); expect(document.querySelector('.preview-server')?.textContent?.replace(/\s+/g, ' ')).toContain( '0 / 4 players' ); toggle('Enable Discord companion'); toggle(serverName); toggle('Show player names'); toggle('Publish fleet leaderboards'); enter('Discord server ID', '45678901234567890'); expect(button('Register commands').disabled).toBe(true); button('Save changes').click(); await vi.waitFor(() => expect(document.body.textContent).toContain('Settings saved.')); expect(submitted).toEqual({ ...initial.settings, enabled: true, guildId: '45678901234567890', excludedServers: [serverName], showPlayers: true, leaderboardEnabled: true }); expect(fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1); expect(fetch.mock.calls.every(([url]) => url === '/api/integrations/discord')).toBe(true); expect(button('Save changes').disabled).toBe(true); toggle('Show player names'); toggle(serverName); button('Discard').click(); flushSync(); expect(checkbox('Show player names').checked).toBe(true); expect(checkbox(serverName).checked).toBe(false); expect(button('Save changes').disabled).toBe(true); }); it('keeps Super Viewer controls read-only and rejects a directly submitted mutation', async () => { const fetch = vi.fn(async (_url: string, _init?: RequestInit) => Response.json(snapshot())); vi.stubGlobal('fetch', fetch); await show('super_viewer'); expect(document.body.textContent).toContain('You have read-only access.'); for (const group of document.querySelectorAll('fieldset')) expect(group.disabled).toBe(true); expect(findButton('Save changes')).toBeUndefined(); expect(findButton('Register commands')).toBeUndefined(); document .querySelector('form')! .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); flushSync(); expect(fetch).toHaveBeenCalledTimes(1); button('Refresh status').click(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); expect(fetch.mock.calls.every((args) => !(args[1] as RequestInit | undefined)?.method)).toBe( true ); }); it('automatically includes new servers on polling while retaining draft exclusions and recovering from errors', async () => { vi.useFakeTimers(); const initial = snapshot(); const updated = snapshot(); updated.status.activeSubscriptions = 3; const newServer = 't6_zm_tomb_1'; updated.servers.push({ name: newServer, game: 't6', label: 'Origins · Lobby 1', map: 'Origins' }); const fetch = vi .fn() .mockResolvedValueOnce(Response.json(initial)) .mockRejectedValueOnce(new Error('Discord status unavailable.')) .mockResolvedValueOnce(Response.json(updated)); vi.stubGlobal('fetch', fetch); await show(); toggle(serverName); enter('Discord server ID', '45678901234567890'); await vi.advanceTimersByTimeAsync(30_000); flushSync(); expect(document.querySelector('[role="alert"]')?.textContent).toContain( 'Discord status unavailable.' ); expect(document.body.textContent).toContain('Kino der Toten · Lobby 1'); expect(checkbox(serverName).checked).toBe(false); expect(field('Discord server ID').value).toBe('45678901234567890'); await vi.advanceTimersByTimeAsync(60_000); await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull()); expect(checkbox(serverName).checked).toBe(false); expect(checkbox(newServer).checked).toBe(true); expect(document.body.textContent).toContain('1 server included.'); expect(field('Discord server ID').value).toBe('45678901234567890'); expect(button('Save changes').disabled).toBe(false); expect(document.body.textContent).toContain('Unsaved changes'); }); it('retains exclusions for removed servers until the administrator explicitly removes them', async () => { const initial = snapshot(); const removedServer = 't5_zombie_cosmodrome_1'; initial.settings.excludedServers = [removedServer]; const writes: DiscordSettings[] = []; vi.stubGlobal( 'fetch', vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === 'PUT') { const settings = JSON.parse(String(init.body)) as DiscordSettings; writes.push(settings); return Response.json({ ...initial, settings }); } return Response.json(initial); }) ); await show(); expect(checkbox(removedServer).checked).toBe(false); expect(document.body.textContent).toContain('Excluded if this server returns.'); expect(document.body.textContent).toContain('1 server included.'); toggle('Show player names'); button('Save changes').click(); await vi.waitFor(() => expect(button('Save changes').disabled).toBe(true)); expect(writes[0].excludedServers).toEqual([removedServer]); toggle(removedServer); button('Save changes').click(); await vi.waitFor(() => expect(writes).toHaveLength(2)); expect(writes[1].excludedServers).toEqual([]); expect(document.body.textContent).not.toContain('Excluded servers no longer in the fleet'); }); it('registers commands only on an explicit action and retains its error through successful refreshes', async () => { const fetch = vi.fn(async (url: string) => url.endsWith('/register') ? Response.json({ error: 'Command registration denied.' }, { status: 502 }) : Response.json(snapshot()) ); vi.stubGlobal('fetch', fetch); await show(); expect(fetch.mock.calls.some(([url]) => url.endsWith('/register'))).toBe(false); button('Register commands').click(); await vi.waitFor(() => expect(document.body.textContent).toContain('Command registration denied.') ); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3)); expect(fetch.mock.calls[1]).toEqual(['/api/integrations/discord/register', { method: 'POST' }]); button('Refresh status').click(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4)); expect(document.querySelector('[role="alert"]')?.textContent).toContain( 'Command registration denied.' ); });