Files
electric-cherry/tests/client/discord-settings.test.ts
T
2026-10-18 16:55:49 -04:00

230 lines
8.8 KiB
TypeScript

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<typeof page.subscribe>[0]) => page.subscribe(run) }
}));
let component: ReturnType<typeof mount> | 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: '' },
publishedServers: [],
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: [], 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<HTMLInputElement>('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('saves explicit publication choices without credentials and restores saved values on discard', async () => {
const initial = snapshot();
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(false);
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 servers outside the selection above');
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',
publishedServers: [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(true);
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('preserves publication edits and the last snapshot across polling failures and recovery', async () => {
vi.useFakeTimers();
const initial = snapshot();
const updated = snapshot();
updated.status.activeSubscriptions = 3;
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(true);
expect(field('Discord server ID').value).toBe('45678901234567890');
button('Retry loading').click();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull());
expect(checkbox(serverName).checked).toBe(true);
expect(field('Discord server ID').value).toBe('45678901234567890');
expect(button('Save changes').disabled).toBe(false);
expect(document.body.textContent).toContain('Unsaved changes');
});
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.'
);
});