import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClient, ApiError } from './api';
import type { TriggerGroup } from '../types';
describe('ApiClient', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.removeItem('xfesm.locale');
});
it('uses same-origin credentials and correlation headers', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ data: { playersOnline: 3 } }), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient('/api/v1/');
await expect(client.request<{ playersOnline: number }>('/status')).resolves.toEqual({ playersOnline: 3 });
expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/status');
expect(options?.credentials).toBe('same-origin');
expect(new Headers(options?.headers).get('X-XFESM-Request-ID')).toBeTruthy();
});
it('adds CSRF and idempotency headers to mutations', async () => {
sessionStorage.setItem('xfesm.csrf', 'csrf-token');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ id: 'op-1' }), {
status: 202,
headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient();
await client.executeConsole('save-all flush', 'maintenance');
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get('X-CSRF-Token')).toBe('csrf-token');
expect(headers.get('Idempotency-Key')).toBeTruthy();
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ command: 'save-all flush', reason: 'maintenance' }));
});
it('posts visual trigger programs without converting them to script', async () => {
const program = {
event: { type: 'player.join', configuration: {} },
conditionMode: 'all' as const,
conditions: [{ field: 'player.name', operator: 'matches', value: '\\p{L}+' }],
actions: [{ type: 'broadcast', parameters: { message: 'Hello' } }],
};
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
valid: true, ...program,
}), { status: 200, headers: { 'content-type': 'application/json' } }));
await new ApiClient().validateVisualTrigger(program);
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/triggers/validate');
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify(program));
});
it('retries an ambiguous trigger timeout once with the same idempotency key', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify({
title: 'Operation still pending', status: 504,
}), { status: 504, headers: { 'content-type': 'application/problem+json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
status: 201, headers: { 'content-type': 'application/json' },
}));
await expect(new ApiClient().createTriggerGroup({
name: 'Scheduled notices', description: '', enabled: true,
})).resolves.toEqual({ groups: [], totalTriggers: 0 });
expect(fetchMock).toHaveBeenCalledTimes(2);
const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
expect(keys[0]).toBeTruthy();
expect(keys[1]).toBe(keys[0]);
});
it('preserves the trigger operation key across reauthentication', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 428 }), {
status: 428, headers: { 'content-type': 'application/problem+json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient();
const reauthenticate = vi.fn().mockResolvedValue(undefined);
client.setReauthenticationHandler(reauthenticate);
await client.createTriggerGroup({ name: 'Join flow', description: '', enabled: true });
expect(reauthenticate).toHaveBeenCalledOnce();
const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
expect(keys[1]).toBe(keys[0]);
});
it('retries a trigger transport failure once with the same idempotency key', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new TypeError('connection reset'))
.mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
await new ApiClient().createTriggerGroup({ name: 'Events', description: '', enabled: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
expect(keys[1]).toBe(keys[0]);
});
it('does not retry ambiguous failures for mutations that are not explicitly idempotent', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'));
await expect(new ApiClient().login('owner', 'password')).rejects.toBeInstanceOf(ApiError);
expect(fetchMock).toHaveBeenCalledOnce();
});
it('surfaces RFC 7807 problem details', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
title: 'Denied', status: 403, detail: 'Recent authentication is required.', requestId: 'req-7',
}), { status: 403, headers: { 'content-type': 'application/problem+json' } }));
const client = new ApiClient();
const error = await client.settings().catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ status: 403, message: 'Recent authentication is required.' });
});
it('shows Chinese-only safe API errors when the UI locale is Chinese', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
title: 'Forbidden', status: 403, detail: 'Recent authentication is required.', requestId: 'req-zh',
}), { status: 403, headers: { 'content-type': 'application/problem+json' } }));
const error = await new ApiClient().settings().catch((cause: unknown) => cause);
expect(error).toMatchObject({
status: 403,
message: '没有执行此操作的权限(请求 ID:req-zh)',
});
});
it('selects one language from bilingual operation status messages', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
kind: 'settings.update', state: 'SUCCEEDED', message: 'Settings applied / 设置已生效',
}), { status: 200, headers: { 'content-type': 'application/json' } }));
await expect(new ApiClient().request<{ message: string }>('settings-test'))
.resolves.toMatchObject({ message: '设置已生效' });
});
it('normalizes bare arrays into paged results', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify([{ uuid: 'u1', name: 'Alex', online: true }]), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient();
await expect(client.players()).resolves.toEqual({ items: [{ uuid: 'u1', name: 'Alex', online: true }] });
});
it('loads the live server and trigger command catalog for policy rules', async () => {
const response = { commands: [{
id: 'minecraft:welcome', root: 'welcome', usages: ['/welcome <target>'],
aliases: ['welcome'], source: 'trigger' as const, triggerId: 'trigger-1', arguments: ['target'],
}] };
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await expect(new ApiClient().policyCommands()).resolves.toEqual(response);
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/policies/commands');
expect(fetchMock.mock.calls[0][1]?.method).toBe('GET');
});
it('uses the revisioned single-slot inventory contract without raw editing', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
id: 'inventory-op', state: 'SUCCEEDED', revision: 8, diffs: [],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
const client = new ApiClient();
await client.updatePlayerInventory('player/id', 'ender-chest', 7, {
slot: 3,
itemId: 'minecraft:diamond',
count: 4,
structuredData: {},
opaqueDataPresent: false,
opaqueDataFingerprint: '',
}, 'Owner approved replacement');
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/players/player%2Fid/ender-chest');
expect(fetchMock.mock.calls[0][1]?.method).toBe('PATCH');
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({
expectedRevision: 7,
reason: 'Owner approved replacement',
rawEditingRequested: false,
updates: [{ slot: 3, itemId: 'minecraft:diamond', count: 4, structuredData: {} }],
}));
});
it('sends only editable group fields and the expected revision when updating a trigger group', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
groups: [], totalTriggers: 0,
}), { status: 200, headers: { 'content-type': 'application/json' } }));
const group: TriggerGroup = {
id: 'group/id', name: 'Automations', description: 'Managed group', enabled: false, revision: 7,
createdBy: 'owner', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
migrated: true, triggers: [],
};
await new ApiClient().updateTriggerGroup(group);
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/trigger-groups/group%2Fid');
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({
name: 'Automations', description: 'Managed group', enabled: false, expectedRevision: 7,
}));
});
it('uses the server authentication contract for setup, TOTP, and reauthentication', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.endsWith('/auth/totp/enrollment')) {
return new Response(JSON.stringify({ secret: 'SECRET', provisioningUri: 'otpauth://totp/x', expiresAt: '2026-01-01T00:00:00Z' }), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (path.endsWith('/auth/totp/confirm')) {
return new Response(JSON.stringify({ recoveryCodes: ['one'] }), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (path.endsWith('/auth/reauthenticate')) return new Response(null, { status: 204 });
return new Response(JSON.stringify({ authenticated: true }), { status: 201, headers: { 'content-type': 'application/json' } });
});
const client = new ApiClient();
await client.setup('bootstrap', 'owner', 'strong-password');
await client.login('owner', 'strong-password', '123456');
await client.login('owner', 'strong-password', 'recover-code', true);
await client.beginTotpEnrollment('strong-password');
await client.confirmTotpEnrollment('123456');
await client.reauthenticate('strong-password', '654321');
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/setup',
'/api/v1/auth/login',
'/api/v1/auth/login',
'/api/v1/auth/totp/enrollment',
'/api/v1/auth/totp/confirm',
'/api/v1/auth/reauthenticate',
]);
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ bootstrapToken: 'bootstrap', username: 'owner', password: 'strong-password' }));
expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ username: 'owner', password: 'strong-password', totp: '123456' }));
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ username: 'owner', password: 'strong-password', recoveryCode: 'recover-code' }));
expect(fetchMock.mock.calls[5][1]?.body).toBe(JSON.stringify({ password: 'strong-password', totp: '654321' }));
});
it('uses the account-management REST contract', async () => {
const account = { id: 'account/id', username: 'helper', role: 'helper' as const, totpEnabled: false, disabled: false, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null };
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
if (init?.method === 'DELETE') return new Response(null, { status: 204 });
return new Response(JSON.stringify(init?.method === 'GET' || !init?.method ? [account] : account), { status: init?.method === 'POST' ? 201 : 200, headers: { 'content-type': 'application/json' } });
});
const client = new ApiClient();
await client.accounts();
await client.createAccount('helper', 'initial-password', 'helper');
await client.updateAccount('account/id', { role: 'moderator', disabled: true });
await client.deleteAccount('account/id');
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/accounts', '/api/v1/accounts', '/api/v1/accounts/account%2Fid', '/api/v1/accounts/account%2Fid',
]);
expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ username: 'helper', password: 'initial-password', role: 'helper' }));
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ role: 'moderator', disabled: true }));
expect(fetchMock.mock.calls[3][1]?.method).toBe('DELETE');
});
it('keeps EventSource alive for browser-managed reconnects and closes it on cleanup', () => {
class TestEventSource {
static latest?: TestEventSource;
readonly close = vi.fn();
readonly eventTypes: string[] = [];
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent<string>) => void) | null = null;
constructor(readonly url: string, readonly options: EventSourceInit) {
TestEventSource.latest = this;
}
addEventListener(type: string, _listener: EventListenerOrEventListenerObject): void { this.eventTypes.push(type); }
}
vi.stubGlobal('EventSource', TestEventSource);
const onState = vi.fn();
const unsubscribe = new ApiClient().subscribe(vi.fn(), onState);
const source = TestEventSource.latest!;
source.onerror?.(new Event('error'));
expect(onState).toHaveBeenCalledWith('error');
expect(source.eventTypes).toContain('triggers-changed');
expect(source.close).not.toHaveBeenCalled();
unsubscribe();
expect(source.close).toHaveBeenCalledOnce();
});
});
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClient, ApiError } from './api';
import type { TriggerGroup } from '../types';
describe('ApiClient', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.removeItem('xfesm.locale');
});
it('uses same-origin credentials and correlation headers', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ data: { playersOnline: 3 } }), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient('/api/v1/');
await expect(client.request<{ playersOnline: number }>('/status')).resolves.toEqual({ playersOnline: 3 });
expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/status');
expect(options?.credentials).toBe('same-origin');
expect(new Headers(options?.headers).get('X-XFESM-Request-ID')).toBeTruthy();
});
it('adds CSRF and idempotency headers to mutations', async () => {
sessionStorage.setItem('xfesm.csrf', 'csrf-token');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ id: 'op-1' }), {
status: 202,
headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient();
await client.executeConsole('save-all flush', 'maintenance');
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get('X-CSRF-Token')).toBe('csrf-token');
expect(headers.get('Idempotency-Key')).toBeTruthy();
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ command: 'save-all flush', reason: 'maintenance' }));
});
it('posts visual trigger programs without converting them to script', async () => {
const program = {
event: { type: 'player.join', configuration: {} },
conditionMode: 'all' as const,
conditions: [{ field: 'player.name', operator: 'matches', value: '\\p{L}+' }],
actions: [{ type: 'broadcast', parameters: { message: 'Hello' } }],
};
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
valid: true, ...program,
}), { status: 200, headers: { 'content-type': 'application/json' } }));
await new ApiClient().validateVisualTrigger(program);
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/triggers/validate');
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify(program));
});
it('retries an ambiguous trigger timeout once with the same idempotency key', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify({
title: 'Operation still pending', status: 504,
}), { status: 504, headers: { 'content-type': 'application/problem+json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
status: 201, headers: { 'content-type': 'application/json' },
}));
await expect(new ApiClient().createTriggerGroup({
name: 'Scheduled notices', description: '', enabled: true,
})).resolves.toEqual({ groups: [], totalTriggers: 0 });
expect(fetchMock).toHaveBeenCalledTimes(2);
const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
expect(keys[0]).toBeTruthy();
expect(keys[1]).toBe(keys[0]);
});
it('preserves the trigger operation key across reauthentication', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 428 }), {
status: 428, headers: { 'content-type': 'application/problem+json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient();
const reauthenticate = vi.fn().mockResolvedValue(undefined);
client.setReauthenticationHandler(reauthenticate);
await client.createTriggerGroup({ name: 'Join flow', description: '', enabled: true });
expect(reauthenticate).toHaveBeenCalledOnce();
const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
expect(keys[1]).toBe(keys[0]);
});
it('retries a trigger transport failure once with the same idempotency key', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new TypeError('connection reset'))
.mockResolvedValueOnce(new Response(JSON.stringify({ groups: [], totalTriggers: 0 }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
await new ApiClient().createTriggerGroup({ name: 'Events', description: '', enabled: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
const keys = fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'));
expect(keys[1]).toBe(keys[0]);
});
it('does not retry ambiguous failures for mutations that are not explicitly idempotent', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'));
await expect(new ApiClient().login('owner', 'password')).rejects.toBeInstanceOf(ApiError);
expect(fetchMock).toHaveBeenCalledOnce();
});
it('surfaces RFC 7807 problem details', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
title: 'Denied', status: 403, detail: 'Recent authentication is required.', requestId: 'req-7',
}), { status: 403, headers: { 'content-type': 'application/problem+json' } }));
const client = new ApiClient();
const error = await client.settings().catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ status: 403, message: 'Recent authentication is required.' });
});
it('shows Chinese-only safe API errors when the UI locale is Chinese', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
title: 'Forbidden', status: 403, detail: 'Recent authentication is required.', requestId: 'req-zh',
}), { status: 403, headers: { 'content-type': 'application/problem+json' } }));
const error = await new ApiClient().settings().catch((cause: unknown) => cause);
expect(error).toMatchObject({
status: 403,
message: '没有执行此操作的权限(请求 ID:req-zh)',
});
});
it('selects one language from bilingual operation status messages', async () => {
localStorage.setItem('xfesm.locale', 'zh-CN');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
kind: 'settings.update', state: 'SUCCEEDED', message: 'Settings applied / 设置已生效',
}), { status: 200, headers: { 'content-type': 'application/json' } }));
await expect(new ApiClient().request<{ message: string }>('settings-test'))
.resolves.toMatchObject({ message: '设置已生效' });
});
it('normalizes bare arrays into paged results', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify([{ uuid: 'u1', name: 'Alex', online: true }]), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const client = new ApiClient();
await expect(client.players()).resolves.toEqual({ items: [{ uuid: 'u1', name: 'Alex', online: true }] });
});
it('loads the live server and trigger command catalog for policy rules', async () => {
const response = { commands: [{
id: 'minecraft:welcome', root: 'welcome', usages: ['/welcome <target>'],
aliases: ['welcome'], source: 'trigger' as const, triggerId: 'trigger-1', arguments: ['target'],
}] };
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await expect(new ApiClient().policyCommands()).resolves.toEqual(response);
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/policies/commands');
expect(fetchMock.mock.calls[0][1]?.method).toBe('GET');
});
it('uses the revisioned single-slot inventory contract without raw editing', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
id: 'inventory-op', state: 'SUCCEEDED', revision: 8, diffs: [],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
const client = new ApiClient();
await client.updatePlayerInventory('player/id', 'ender-chest', 7, {
slot: 3,
itemId: 'minecraft:diamond',
count: 4,
structuredData: {},
opaqueDataPresent: false,
opaqueDataFingerprint: '',
}, 'Owner approved replacement');
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/players/player%2Fid/ender-chest');
expect(fetchMock.mock.calls[0][1]?.method).toBe('PATCH');
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({
expectedRevision: 7,
reason: 'Owner approved replacement',
rawEditingRequested: false,
updates: [{ slot: 3, itemId: 'minecraft:diamond', count: 4, structuredData: {} }],
}));
});
it('sends only editable group fields and the expected revision when updating a trigger group', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
groups: [], totalTriggers: 0,
}), { status: 200, headers: { 'content-type': 'application/json' } }));
const group: TriggerGroup = {
id: 'group/id', name: 'Automations', description: 'Managed group', enabled: false, revision: 7,
createdBy: 'owner', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
migrated: true, triggers: [],
};
await new ApiClient().updateTriggerGroup(group);
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/trigger-groups/group%2Fid');
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({
name: 'Automations', description: 'Managed group', enabled: false, expectedRevision: 7,
}));
});
it('uses the server authentication contract for setup, TOTP, and reauthentication', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.endsWith('/auth/totp/enrollment')) {
return new Response(JSON.stringify({ secret: 'SECRET', provisioningUri: 'otpauth://totp/x', expiresAt: '2026-01-01T00:00:00Z' }), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (path.endsWith('/auth/totp/confirm')) {
return new Response(JSON.stringify({ recoveryCodes: ['one'] }), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (path.endsWith('/auth/reauthenticate')) return new Response(null, { status: 204 });
return new Response(JSON.stringify({ authenticated: true }), { status: 201, headers: { 'content-type': 'application/json' } });
});
const client = new ApiClient();
await client.setup('bootstrap', 'owner', 'strong-password');
await client.login('owner', 'strong-password', '123456');
await client.login('owner', 'strong-password', 'recover-code', true);
await client.beginTotpEnrollment('strong-password');
await client.confirmTotpEnrollment('123456');
await client.reauthenticate('strong-password', '654321');
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/setup',
'/api/v1/auth/login',
'/api/v1/auth/login',
'/api/v1/auth/totp/enrollment',
'/api/v1/auth/totp/confirm',
'/api/v1/auth/reauthenticate',
]);
expect(fetchMock.mock.calls[0][1]?.body).toBe(JSON.stringify({ bootstrapToken: 'bootstrap', username: 'owner', password: 'strong-password' }));
expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ username: 'owner', password: 'strong-password', totp: '123456' }));
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ username: 'owner', password: 'strong-password', recoveryCode: 'recover-code' }));
expect(fetchMock.mock.calls[5][1]?.body).toBe(JSON.stringify({ password: 'strong-password', totp: '654321' }));
});
it('uses the account-management REST contract', async () => {
const account = { id: 'account/id', username: 'helper', role: 'helper' as const, totpEnabled: false, disabled: false, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null };
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
if (init?.method === 'DELETE') return new Response(null, { status: 204 });
return new Response(JSON.stringify(init?.method === 'GET' || !init?.method ? [account] : account), { status: init?.method === 'POST' ? 201 : 200, headers: { 'content-type': 'application/json' } });
});
const client = new ApiClient();
await client.accounts();
await client.createAccount('helper', 'initial-password', 'helper');
await client.updateAccount('account/id', { role: 'moderator', disabled: true });
await client.deleteAccount('account/id');
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/accounts', '/api/v1/accounts', '/api/v1/accounts/account%2Fid', '/api/v1/accounts/account%2Fid',
]);
expect(fetchMock.mock.calls[1][1]?.body).toBe(JSON.stringify({ username: 'helper', password: 'initial-password', role: 'helper' }));
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ role: 'moderator', disabled: true }));
expect(fetchMock.mock.calls[3][1]?.method).toBe('DELETE');
});
it('keeps EventSource alive for browser-managed reconnects and closes it on cleanup', () => {
class TestEventSource {
static latest?: TestEventSource;
readonly close = vi.fn();
readonly eventTypes: string[] = [];
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent<string>) => void) | null = null;
constructor(readonly url: string, readonly options: EventSourceInit) {
TestEventSource.latest = this;
}
addEventListener(type: string, _listener: EventListenerOrEventListenerObject): void { this.eventTypes.push(type); }
}
vi.stubGlobal('EventSource', TestEventSource);
const onState = vi.fn();
const unsubscribe = new ApiClient().subscribe(vi.fn(), onState);
const source = TestEventSource.latest!;
source.onerror?.(new Event('error'));
expect(onState).toHaveBeenCalledWith('error');
expect(source.eventTypes).toContain('triggers-changed');
expect(source.close).not.toHaveBeenCalled();
unsubscribe();
expect(source.close).toHaveBeenCalledOnce();
});
});