271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
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('123456', '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();
|
||
});
|
||
});
|