Files
hope-events/backend/tests/ai.test.ts
T
joshuaandClaude Opus 5.5 311fe8b6f1 WIP 2.0.0: AI helper plumbing (status, usage, Site settings -> AI)
The site calls AI through the Hub (protocol 3b, Hub 1.6.0): src/ai
works out whether the AI buttons show (licence token first, then the
Hub, cached a minute), runs a helper call with per-helper switches and
a per-person daily limit, logs AiCall rows, and emails admins at 80%.
v2 /ai/status, /ai/usage, /ai/settings; useAiStatus, AiButton, AiTab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:25:36 +02:00

135 lines
7.3 KiB
TypeScript

// AI helpers (2.0): what the buttons do, and how a helper call goes through the Hub (§3b).
const mockCalls: Array<Record<string, unknown>> = [];
const mockSettings = new Map<string, string>();
const mockDb = {
appSetting: {
findUnique: jest.fn(async ({ where }: { where: { key: string } }) => (mockSettings.has(where.key) ? { key: where.key, value: mockSettings.get(where.key) } : null)),
upsert: jest.fn(async ({ where, update }: { where: { key: string }; update: { value: string } }) => { mockSettings.set(where.key, update.value); }),
},
aiCall: {
count: jest.fn(async () => mockCalls.filter(c => c.outcome === 'ok').length),
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => { mockCalls.push(data); return data; }),
deleteMany: jest.fn(async () => ({ count: 0 })),
},
user: { findMany: jest.fn(async () => [{ email: 'admin@test.local' }]) },
};
jest.mock('../src/config/db', () => mockDb);
const mockLicence = { connected: true, readOnly: false, state: 'active', claims: { limits: { aiMonthlyCapCents: 5000 } } as { limits: { aiMonthlyCapCents: number } } | null };
jest.mock('../src/hub/licence', () => ({ licenceStatus: jest.fn(async () => mockLicence) }));
class MockHubError extends Error {
constructor(message: string, public code: string, public statusCode: number, public data: unknown = null) { super(message); }
}
const mockPost = jest.fn();
jest.mock('../src/hub/client', () => ({ PROTOCOL_VERSION: 1, HubError: MockHubError, signedPost: (...a: unknown[]) => mockPost(...a) }));
const mockMail = jest.fn(async (_m: unknown) => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (m: unknown) => mockMail(m), emailWrapper: (s: string) => s, isSmtpConfigured: () => true }));
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn(async () => undefined) }));
import { aiStatus, runHelper, saveAiSettings, clearAiStatusCache, _internal } from '../src/ai';
const hubStatus = (over = {}) => ({ enabled: true, capReached: false, capCents: 5000, usedCents: 100, usedPercent: 2, renewsOn: '2026-12-01', ...over });
const req = { system: 'Write a friendly message.', input: 'Bring a sleeping bag.', maxTokens: 500 };
beforeEach(() => {
mockCalls.length = 0;
mockSettings.clear();
mockPost.mockReset();
mockMail.mockClear();
mockLicence.connected = true;
mockLicence.readOnly = false;
mockLicence.claims = { limits: { aiMonthlyCapCents: 5000 } };
clearAiStatusCache();
});
describe('status', () => {
test('a licence without AI never asks the Hub', async () => {
mockLicence.claims = { limits: { aiMonthlyCapCents: 0 } };
expect(await aiStatus()).toMatchObject({ enabled: false, capReached: false });
mockLicence.connected = false;
expect((await aiStatus()).enabled).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
test('asks the Hub and caches its answer for a minute', async () => {
mockPost.mockResolvedValue(hubStatus());
expect(await aiStatus()).toMatchObject({ enabled: true, available: true, usedCents: 100, helpers: { draft_event: true } });
await aiStatus();
expect(mockPost).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledWith('/api/installs/ai/status', { protocolVersion: 1 });
});
test('a Hub without AI (404) means off; an unreachable Hub keeps the buttons, marked unavailable', async () => {
mockPost.mockRejectedValueOnce(new MockHubError('Not found', 'NOT_FOUND', 404));
expect((await aiStatus()).enabled).toBe(false);
clearAiStatusCache();
mockPost.mockRejectedValueOnce(new MockHubError('down', 'HUB_UNREACHABLE', 502));
expect(await aiStatus()).toMatchObject({ enabled: true, available: false });
});
});
describe('running a helper', () => {
test('sends the request to the Hub, logs the call, returns the answer', async () => {
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'Hi all!', chargedCents: 0.31, status: hubStatus({ usedCents: 100.31 }) });
const r = await runHelper('draft_message', 'u1', req);
expect(r.output).toBe('Hi all!');
expect(r.status.usedCents).toBe(100.31);
expect(mockPost).toHaveBeenLastCalledWith('/api/installs/ai/complete', { protocolVersion: 1, helper: 'draft_message', ...req }, { timeoutMs: 90000 });
expect(mockCalls).toEqual([{ userId: 'u1', helper: 'draft_message', outcome: 'ok', chargedCents: 0.31 }]);
});
test('a helper switched off, or the cap reached, never reaches the Hub', async () => {
mockPost.mockResolvedValue(hubStatus({ capReached: true }));
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'FORBIDDEN', reason: 'AI_CAP_REACHED' });
clearAiStatusCache();
mockPost.mockResolvedValue(hubStatus());
await saveAiSettings({ helpers: { draft_message: false } }, { id: 'a1', role: 'admin' });
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_HELPER_OFF' });
expect(mockPost).not.toHaveBeenCalledWith('/api/installs/ai/complete', expect.anything(), expect.anything());
});
test('the per-person daily limit', async () => {
await saveAiSettings({ dailyLimitPerPerson: 1 }, { id: 'a1', role: 'admin' });
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'x', chargedCents: 0.1, status: hubStatus() });
await runHelper('draft_message', 'u1', req);
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'RATE_LIMITED', reason: 'AI_DAILY_LIMIT' });
});
test('the Hub refusing at the cap updates the status, so the buttons disable at once', async () => {
mockPost.mockResolvedValueOnce(hubStatus())
.mockRejectedValueOnce(new MockHubError('used up', 'AI_CAP_REACHED', 402, { code: 'AI_CAP_REACHED', status: hubStatus({ capReached: true }) }));
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_CAP_REACHED', message: expect.stringContaining('1 December') });
expect((await aiStatus()).capReached).toBe(true);
expect(mockCalls[0]).toMatchObject({ outcome: 'cap_reached', chargedCents: 0 });
});
test('admins are emailed once a month at 80%', async () => {
const at80 = hubStatus({ usedPercent: 81, usedCents: 4050 });
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValue({ output: 'x', chargedCents: 1, status: at80 });
await runHelper('draft_message', 'u1', req);
await runHelper('draft_message', 'u1', req);
expect(mockMail).toHaveBeenCalledTimes(1);
expect(mockMail.mock.calls[0]).toEqual([expect.objectContaining({ to: 'admin@test.local', subject: 'AI allowance 81% used' })]);
});
});
describe('Hub errors', () => {
test.each([
['AI_REFUSED', 'UNAVAILABLE', 'refused'],
['AI_INCOMPLETE', 'UNAVAILABLE', 'incomplete'],
['AI_OFF', 'FORBIDDEN', 'unavailable'],
['HUB_UNREACHABLE', 'UNAVAILABLE', 'unavailable'],
])('%s → %s, logged as %s', (hubCode, code, outcome) => {
const r = _internal.fromHubError({ code: hubCode, message: 'x' });
expect(r.error.code).toBe(code);
expect(r.outcome).toBe(outcome);
});
test('today starts at midnight South African time', () => {
expect(_internal.startOfToday(new Date('2026-11-10T22:30:00Z')).toISOString()).toBe('2026-11-10T22:00:00.000Z');
expect(_internal.startOfToday(new Date('2026-11-10T10:00:00Z')).toISOString()).toBe('2026-11-09T22:00:00.000Z');
});
});