230 lines
8.8 KiB
TypeScript
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.'
|
|
);
|
|
});
|