Files
makelore/tests/unit/ai-hardware-api.test.ts

271 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AiHardwareApiError,
bindAiHardwareDevice,
createAiHardwareAgent,
getAiHardwareAgentConfiguration,
getAiHardwareAssignment,
getAiHardwareOverview,
updateAiHardwareAgentConfiguration,
updateAiHardwareAssignment,
createAiHardwareOperationId,
recoverAiHardwareCredential,
} from '@/lib/ai-hardware';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
const agent = { id: 'agent-1', name: 'Tutor', config_revision: 0 };
const device = { id: 'device-1', agent_id: 'agent-1', assignment_revision: 0 };
const configuration = {
...agent,
system_prompt: null,
lang_code: null,
language: null,
asr_model_id: null,
vad_model_id: null,
llm_model_id: null,
slm_model_id: null,
vllm_model_id: null,
tts_model_id: null,
tts_voice_id: null,
tts_language: null,
tts_volume: null,
tts_rate: null,
tts_pitch: null,
mem_model_id: null,
intent_model_id: null,
chat_history_conf: null,
};
describe('AI hardware renderer API', () => {
beforeEach(() => hostApiFetchMock.mockReset());
it('strictly reads overview and local public DTOs', async () => {
hostApiFetchMock.mockResolvedValue({
success: true,
data: { status: 'active', agents: [agent], devices: [device] },
});
await expect(getAiHardwareOverview()).resolves.toEqual({
status: 'active', agents: [agent], devices: [device],
});
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/ai-hardware', undefined);
});
it('sends create and bind business bodies without sensitive headers', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: agent })
.mockResolvedValueOnce({ success: true, data: device });
await createAiHardwareAgent('Tutor', { operationId: '11111111-1111-4111-8111-111111111111' });
await bindAiHardwareDevice('123456', 'agent-1', { operationId: '22222222-2222-4222-8222-222222222222' });
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/works/ai-hardware/agents', {
method: 'POST', body: JSON.stringify({ agent_name: 'Tutor', client_operation_id: '11111111-1111-4111-8111-111111111111' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/works/ai-hardware/device-bindings', {
method: 'POST', body: JSON.stringify({ activation_code: '123456', agent_id: 'agent-1', client_operation_id: '22222222-2222-4222-8222-222222222222' }),
});
for (const [, init] of hostApiFetchMock.mock.calls) {
expect(init).not.toHaveProperty('headers');
expect(JSON.stringify(init)).not.toMatch(/authorization|idempotency|if-match|token/i);
}
});
it('returns a reusable operation id on retryable mutation errors', async () => {
hostApiFetchMock.mockResolvedValue({
success: false, status: 504, code: 'AI_HARDWARE_TIMEOUT', error: 'hidden', retryable: true,
operation_id: '33333333-3333-4333-8333-333333333333',
});
const error = await createAiHardwareAgent('Tutor', {
operationId: '33333333-3333-4333-8333-333333333333',
}).catch((value: unknown) => value);
expect(error).toMatchObject({ operationId: '33333333-3333-4333-8333-333333333333', retryable: true });
expect(createAiHardwareOperationId()).toMatch(/^[0-9a-f-]{36}$/i);
});
it('wraps pre-envelope transport failures with the reusable operation id', async () => {
hostApiFetchMock.mockResolvedValue(undefined);
const operationId = '77777777-7777-4777-8777-777777777777';
let error: AiHardwareApiError | undefined;
try {
await updateAiHardwareAssignment('device-1', 0, 'agent-2', { operationId });
} catch (value) {
error = value as AiHardwareApiError;
}
expect(error).toBeDefined();
expect({
name: error.name,
status: error.status,
code: error.code,
retryable: error.retryable,
operationId: error.operationId,
message: error.message,
}).toEqual({
name: 'AiHardwareApiError', status: 502, code: 'AI_HARDWARE_INVALID_RESPONSE',
retryable: false, operationId, message: 'AI hardware service returned an invalid response',
});
expect(error.message).toBe('AI hardware service returned an invalid response');
expect(hostApiFetchMock.mock.calls[0][0]).toBe(
'/api/works/ai-hardware/devices/device-1/agent-assignment',
);
expect((hostApiFetchMock.mock.calls[0][1] as RequestInit).body).toContain(
`"client_operation_id":"${operationId}"`,
);
});
it('requests credential recovery with a reusable operation identity', async () => {
hostApiFetchMock.mockResolvedValue({ success: true, data: {
status: 'active', agents: [agent], devices: [device],
} });
const operationId = '88888888-8888-4888-8888-888888888888';
await expect(recoverAiHardwareCredential({ operationId })).resolves.toEqual({
status: 'active', agents: [agent], devices: [device],
});
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/ai-hardware/credential-recovery',
{ method: 'POST', body: JSON.stringify({ client_operation_id: operationId }) },
);
});
it('reads the stable credential recovery unavailable error', async () => {
hostApiFetchMock.mockResolvedValue({
success: false,
status: 409,
code: 'ai_hardware_credential_recovery_unavailable',
error: 'ignored Main text',
retryable: false,
operation_id: '99999999-9999-4999-8999-999999999999',
});
const error = await recoverAiHardwareCredential({
operationId: '99999999-9999-4999-8999-999999999999',
}).catch((value: unknown) => value);
expect(error).toMatchObject({
code: 'ai_hardware_credential_recovery_unavailable',
message: 'AI hardware credential recovery is not currently available',
operationId: '99999999-9999-4999-8999-999999999999',
});
});
it('returns Main-owned numeric revisions from versioned reads', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: configuration, revision: 0 })
.mockResolvedValueOnce({ success: true, data: device, revision: 0 });
await expect(getAiHardwareAgentConfiguration('agent/1')).resolves.toEqual({
data: configuration, revision: 0,
});
await expect(getAiHardwareAssignment('device/1')).resolves.toEqual({ data: device, revision: 0 });
expect(hostApiFetchMock.mock.calls[0][0]).toBe('/api/works/ai-hardware/agents/agent%2F1');
expect(hostApiFetchMock.mock.calls[1][0]).toBe(
'/api/works/ai-hardware/devices/device%2F1/agent-assignment',
);
});
it('passes revision as business JSON while Main owns concurrency headers', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: configuration, revision: 1 })
.mockResolvedValueOnce({
success: true,
data: { ...device, agent_id: 'agent-2', assignment_revision: 1 },
revision: 1,
});
await updateAiHardwareAgentConfiguration('agent-1', 0, {
agent_name: 'New Tutor', clear_fields: ['system_prompt'],
}, { operationId: '44444444-4444-4444-8444-444444444444' });
await updateAiHardwareAssignment('device-1', 0, 'agent-2', { operationId: '55555555-5555-4555-8555-555555555555' });
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/works/ai-hardware/agents/agent-1', {
method: 'PATCH',
body: JSON.stringify({ revision: 0, agent_name: 'New Tutor', clear_fields: ['system_prompt'], client_operation_id: '44444444-4444-4444-8444-444444444444' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
2,
'/api/works/ai-hardware/devices/device-1/agent-assignment',
{ method: 'PUT', body: JSON.stringify({ revision: 0, agent_id: 'agent-2', client_operation_id: '55555555-5555-4555-8555-555555555555' }) },
);
});
it('rejects malformed success payloads instead of exposing unknown fields', async () => {
hostApiFetchMock.mockResolvedValue({
success: true,
data: { ...agent, token: 'must-not-pass' },
});
await expect(createAiHardwareAgent('Tutor')).rejects.toMatchObject<Partial<AiHardwareApiError>>({
name: 'AiHardwareApiError',
status: 502,
code: 'AI_HARDWARE_INVALID_RESPONSE',
});
});
it('exposes only stable sanitized Main error fields', async () => {
hostApiFetchMock.mockResolvedValue({
success: false,
status: 409,
code: 'ai_hardware_revision_conflict',
error: 'AI hardware state changed; refresh and retry',
retryable: true,
retry_after_seconds: 2,
});
await expect(getAiHardwareOverview()).rejects.toMatchObject<Partial<AiHardwareApiError>>({
name: 'AiHardwareApiError',
status: 409,
code: 'ai_hardware_revision_conflict',
retryable: true,
retryAfterSeconds: 2,
message: 'AI hardware state changed; refresh and retry',
});
});
it('does not leak unknown raw error detail', async () => {
hostApiFetchMock.mockResolvedValue({
success: false,
status: 'bad',
code: '../unsafe',
error: { token: 'private-token' },
detail: 'upstream internals',
});
const error = await getAiHardwareOverview().catch((value: unknown) => value);
expect(error).toMatchObject({
status: 502,
code: 'AI_HARDWARE_INVALID_RESPONSE',
});
expect(JSON.stringify(error)).not.toContain('private-token');
expect(String(error)).not.toContain('upstream internals');
});
it('rejects null updates and invalid clear fields before making a request', async () => {
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
system_prompt: null,
} as never)).rejects.toThrow('clear_fields');
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
clear_fields: ['agent_name'],
} as never)).rejects.toThrow('Invalid clear_fields');
expect(hostApiFetchMock).not.toHaveBeenCalled();
});
it('validates create, bind, and numeric update constraints before Main', async () => {
await expect(createAiHardwareAgent(' ')).rejects.toThrow('agent_name');
await expect(bindAiHardwareDevice('', 'agent-1')).rejects.toThrow('activation_code');
await expect(bindAiHardwareDevice('123456', '')).rejects.toThrow('agent_id');
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
tts_rate: 101,
})).rejects.toThrow('tts_rate');
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
chat_history_conf: 3,
})).rejects.toThrow('chat_history_conf');
expect(hostApiFetchMock).not.toHaveBeenCalled();
});
});