import { ServiceUnavailableException } from '@nestjs/common'; import { AssistantService } from './assistant.service'; describe('AssistantService', () => { const originalFetch = global.fetch; const originalApiKey = process.env.MISTRAL_API_KEY; const originalAgentId = process.env.MISTRAL_AGENT_ID; let chatLogsRepository: { create: jest.Mock; save: jest.Mock; }; let listRealtimeService: { publishSnapshot: jest.Mock; }; let service: AssistantService; beforeEach(() => { process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_AGENT_ID = 'agent-listify'; global.fetch = jest.fn(); chatLogsRepository = { create: jest.fn((input) => input), save: jest.fn(async (input) => input), }; listRealtimeService = { publishSnapshot: jest.fn(), }; service = new AssistantService( chatLogsRepository as never, listRealtimeService as never, ); }); afterEach(() => { global.fetch = originalFetch; process.env.MISTRAL_API_KEY = originalApiKey; process.env.MISTRAL_AGENT_ID = originalAgentId; }); it('forwards messages to the configured Mistral agent', async () => { const providerResponse = { choices: [ { message: { content: 'Ich habe den Listify-Connector verwendet.', }, }, ], usage: { prompt_tokens: 12, completion_tokens: 8, }, }; mockMistralResponse(providerResponse); const result = await service.chat('user-1', { messages: [ { role: 'assistant', content: 'Hallo' }, { role: 'user', content: 'Erstelle eine Liste' }, ], }); expect(global.fetch).toHaveBeenCalledWith( 'https://api.mistral.ai/v1/agents/completions', expect.objectContaining({ method: 'POST', headers: { Authorization: 'Bearer test-key', 'Content-Type': 'application/json', }, body: JSON.stringify({ agent_id: 'agent-listify', messages: [ { role: 'assistant', content: 'Hallo' }, { role: 'user', content: 'Erstelle eine Liste' }, { role: 'system', content: 'benutze immer den listify connector' }, ], tools: [ { type: 'connector', connector_id: 'listify', }, ], stream: false, response_format: { type: 'text', }, }), }), ); expect(result).toEqual({ message: { role: 'assistant', content: 'Ich habe den Listify-Connector verwendet.', }, actions: [], }); expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled(); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', provider: 'mistral', endpoint: 'https://api.mistral.ai/v1/agents/completions', agentId: 'agent-listify', statusCode: 200, requestPayload: expect.objectContaining({ agent_id: 'agent-listify', tools: [{ type: 'connector', connector_id: 'listify' }], }), responsePayload: providerResponse, assistantContent: 'Ich habe den Listify-Connector verwendet.', errorMessage: null, }), ); }); it('adds a normalized list context system message when context is present', async () => { const providerResponse = { choices: [ { message: { content: 'Ich nutze diese Liste.', }, }, ], }; mockMistralResponse(providerResponse); await service.chat('user-1', { messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }], context: { page: 'list_detail', route: '/lists/list-1', list: { id: 'list-1', ownerId: 'user-1', ownerName: 'Ada', ownerEmail: 'ada@example.com', accessRole: 'owner', name: 'Einkauf', description: 'Wochenende', kind: 'shopping', items: [ { id: 'item-1', title: 'Milch', notes: '1,5 Prozent', quantity: 2, required: true, checked: false, checkedByUserId: 'user-2', checkedByName: 'Grace', position: 0, createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }, ], collaborators: [ { id: 'user-2', email: 'grace@example.com', role: 'collaborator', }, ], createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }, }, }); const payload = getMistralRequestPayload(); const contextMessage = payload.messages.at(-2); const contextContent = contextMessage?.content ?? ''; expect(contextMessage).toEqual( expect.objectContaining({ role: 'system', content: expect.stringContaining('Aktueller Listify-Kontext:'), }), ); expect(contextContent).toContain( 'Der User befindet sich auf einer Listendetailseite.', ); expect(contextContent).toContain('Route: /lists/list-1'); expect(contextContent).toContain( 'Offene Liste: Einkauf (ID: list-1, Typ: shopping)', ); expect(contextContent).toContain( '- Milch (ID: item-1, Menge: 2, Pflicht: ja, Erledigt: nein, Notizen: 1,5 Prozent)', ); expect(contextContent).toContain( 'bezieht sich das auf die Liste mit ID list-1.', ); expect(contextContent).not.toContain('ada@example.com'); expect(contextContent).not.toContain('grace@example.com'); expect(contextContent).not.toContain('checkedByUserId'); expect(payload.messages.at(-1)).toEqual({ role: 'system', content: 'benutze immer den listify connector', }); }); it('logs full failed provider responses before throwing', async () => { const providerResponse = { message: 'connector failed', details: { connector_id: 'listify' }, }; mockMistralResponse(providerResponse, false, 502); await expect( service.chat('user-1', { messages: [{ role: 'user', content: 'Hallo' }], }), ).rejects.toThrow(ServiceUnavailableException); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', statusCode: 502, responsePayload: providerResponse, assistantContent: null, errorMessage: 'Mistral agent request failed.', }), ); }); it('extracts the final assistant message from multi-completion responses', async () => { const updatedList = { id: 'list-1', ownerId: 'user-1', accessRole: 'owner', name: 'Einkauf', kind: 'shopping', items: [ { id: 'item-1', title: 'Milch', required: true, checked: false, position: 0, createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }, ], collaborators: [], createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }; const providerResponse = { id: 'chatcmpl-test', object: 'chat.multi_completion', choices: [ { messages: [ { role: 'assistant', index: 0, content: '', tool_calls: [ { id: 'call-1', type: 'function', function: { name: 'listify_add_list_item', arguments: '{"listId": "list-1", "title": "Milch"}', }, }, ], }, { role: 'tool', index: 1, content: [{ type: 'text', text: '{"lists":[]}' }], tool_call_id: 'call-1', metadata: { mcp_meta: { structuredContent: { list: updatedList, }, }, }, }, { role: 'assistant', index: 2, content: 'Hier sind deine bestehenden Listen.', }, ], finish_reason: 'stop', }, ], }; mockMistralResponse(providerResponse); const result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Welche Listen habe ich?' }], }); expect(result.message.content).toBe('Hier sind deine bestehenden Listen.'); expect(result.actions).toEqual([ { type: 'list.item_added', listId: 'list-1', itemTitle: 'Milch', list: updatedList, }, ]); expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( 'user-1', updatedList, ); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ responsePayload: providerResponse, assistantContent: 'Hier sind deine bestehenden Listen.', }), ); }); it('fails clearly when the api key is missing', async () => { delete process.env.MISTRAL_API_KEY; await expect( service.chat('user-1', { messages: [{ role: 'user', content: 'Hallo' }], }), ).rejects.toThrow(ServiceUnavailableException); }); it('fails clearly when the agent id is missing', async () => { delete process.env.MISTRAL_AGENT_ID; await expect( service.chat('user-1', { messages: [{ role: 'user', content: 'Hallo' }], }), ).rejects.toThrow(ServiceUnavailableException); }); }); function mockMistralResponse( response: object, ok = true, status = 200, ): void { jest.mocked(global.fetch).mockResolvedValue({ ok, status, text: async () => JSON.stringify(response), } as Response); } function getMistralRequestPayload(): { messages: Array<{ role: string; content: string }>; } { const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; const body = init?.body; if (typeof body !== 'string') { throw new Error('Expected Mistral request body to be JSON.'); } return JSON.parse(body) as { messages: Array<{ role: string; content: string }>; }; }