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>
135 lines
7.3 KiB
TypeScript
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');
|
|
});
|
|
});
|