import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiHardwareApiError, bindAiHardwareDevice, createAiHardwareAgent, getAiHardwareAgentConfiguration, getAiHardwareAssignment, getAiHardwareOverview, getAiHardwareConfigurationCatalog, 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('strictly reads the safe configuration catalog and encodes the TTS dependency', async () => { const catalog = { schema_version: 1, models: [{ model_type: 'TTS', model_id: 'TTS:model', model_name: '云端语音', supports_function_call: null, }], voices: [{ tts_model_id: 'TTS:model', voice_id: 'voice-1', voice_name: '小夏', languages: ['zh-CN'], is_clone: false, }], }; hostApiFetchMock.mockResolvedValue({ success: true, data: catalog }); await expect(getAiHardwareConfigurationCatalog('TTS:model')).resolves.toEqual(catalog); expect(hostApiFetchMock).toHaveBeenCalledWith( '/api/works/ai-hardware/catalog?tts_model_id=TTS%3Amodel', undefined, ); }); it('rejects unsafe or expanded catalog DTOs', async () => { hostApiFetchMock.mockResolvedValue({ success: true, data: { schema_version: 1, models: [{ model_type: 'TTS', model_id: 'TTS_model', model_name: '语音', supports_function_call: null, config_json: '{"api_key":"secret"}', }], voices: [], } }); await expect(getAiHardwareConfigurationCatalog()).rejects.toMatchObject({ code: 'AI_HARDWARE_INVALID_RESPONSE', }); expect(JSON.stringify(await getAiHardwareConfigurationCatalog().catch((error) => error))) .not.toContain('secret'); }); 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>({ 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>({ 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(); }); });