import { ServiceUnavailableException } from '@nestjs/common'; import { AssistantService } from './assistant.service'; describe('AssistantService', () => { const connectorSystemMessage = [ 'Die eingeloggte Listify userId ist "user-1".', 'Der listify Connector ist nicht usergebunden. Sende bei jedem listify Tool-Call das Argument userId exakt mit dieser userId.', 'Benutze fuer Listify-Daten immer den listify Connector.', 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', ].join(' '); const originalFetch = global.fetch; const originalApiKey = process.env.MISTRAL_API_KEY; const originalModel = process.env.MISTRAL_MODEL; let chatLogsRepository: { create: jest.Mock; find: jest.Mock; save: jest.Mock; }; let listRealtimeService: { publishSnapshot: jest.Mock; }; let listsService: { listLists: jest.Mock; addItem: jest.Mock; }; let service: AssistantService; beforeEach(() => { process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_MODEL = 'mistral-large-test'; global.fetch = jest.fn(); chatLogsRepository = { create: jest.fn((input) => input), find: jest.fn(), save: jest.fn(async (input) => input), }; listRealtimeService = { publishSnapshot: jest.fn(), }; listsService = { listLists: jest.fn(), addItem: jest.fn(), }; listsService.listLists.mockResolvedValue([]); service = new AssistantService( chatLogsRepository as never, listRealtimeService as never, listsService as never, ); }); afterEach(() => { global.fetch = originalFetch; process.env.MISTRAL_API_KEY = originalApiKey; process.env.MISTRAL_MODEL = originalModel; }); it('starts a Mistral conversation with the configured model', 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: 'Hallo' }, ], }); expect(global.fetch).toHaveBeenCalledWith( 'https://api.mistral.ai/v1/conversations', expect.objectContaining({ method: 'POST', headers: { Authorization: 'Bearer test-key', 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'mistral-large-test', inputs: [ { role: 'assistant', content: 'Hallo' }, { role: 'user', content: 'Hallo' }, ], instructions: connectorSystemMessage, tools: [ { type: 'connector', connector_id: 'listify', }, ], stream: false, }), }), ); 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/conversations', agentId: null, statusCode: 200, requestPayload: expect.objectContaining({ model: 'mistral-large-test', inputs: [ { role: 'assistant', content: 'Hallo' }, { role: 'user', content: 'Hallo' }, ], instructions: connectorSystemMessage, tools: [{ type: 'connector', connector_id: 'listify' }], }), responsePayload: providerResponse, assistantContent: 'Ich habe den Listify-Connector verwendet.', errorMessage: null, }), ); }); it('extracts assistant content and created list actions from conversation outputs', async () => { const createdList = { id: 'list-1', ownerId: 'user-1', accessRole: 'owner', name: 'Einkauf', kind: 'shopping', items: [], collaborators: [], createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }; const providerResponse = { conversation_id: 'conv-1', outputs: [ { type: 'tool.execution', tool_name: 'listify_create_list', result: { list: createdList }, }, { type: 'message.output', role: 'assistant', content: 'Ich habe die Liste Einkauf angelegt.', }, ], }; mockMistralResponse(providerResponse); const result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], }); expect(result).toEqual({ message: { role: 'assistant', content: 'Ich habe die Liste Einkauf angelegt.', }, actions: [ { type: 'list.created', listId: 'list-1', list: createdList, }, ], }); expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( 'user-1', createdList, ); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ responsePayload: providerResponse, assistantContent: 'Ich habe die Liste Einkauf angelegt.', }), ); }); it('answers open list read requests locally without calling Mistral', async () => { listsService.listLists.mockResolvedValue([ { 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', }, { id: 'item-2', title: 'Brot', required: true, checked: true, position: 1, 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', }, { id: 'list-2', ownerId: 'user-1', accessRole: 'owner', name: 'Fertig', kind: 'todo', items: [ { id: 'item-3', title: 'Done', required: true, checked: true, 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 result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Zeige mir meine offenen Listen' }], }); expect(global.fetch).not.toHaveBeenCalled(); expect(result.message.content).toContain('Du hast 1 offene Liste'); expect(result.message.content).toContain('**Einkauf**'); expect(result.message.content).toContain('Milch'); expect(result.message.content).not.toContain('Fertig'); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', provider: 'listify', endpoint: 'local:listLists', agentId: null, statusCode: 200, assistantContent: expect.stringContaining('Einkauf'), }), ); }); 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: 'Was ist hier wichtig?' }], 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 contextContent = payload.instructions; expect(contextContent).toContain('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(contextContent).toContain(connectorSystemMessage); }); it('adds list items locally when the user writes onto the current list', async () => { const updatedList = { id: 'list-1', ownerId: 'user-1', accessRole: 'owner', name: 'Einkauf', kind: 'shopping', items: [ { id: 'item-1', title: 'Brot', 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', }; listsService.addItem.mockResolvedValue(updatedList); const result = 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', accessRole: 'owner', name: 'Einkauf', kind: 'shopping', items: [], collaborators: [], createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }, }, }); expect(global.fetch).not.toHaveBeenCalled(); expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', { title: 'Brot', }); expect(result).toEqual({ message: { role: 'assistant', content: 'Ich habe **Brot** zu **Einkauf** hinzugefuegt.', }, actions: [ { type: 'list.item_added', listId: 'list-1', itemTitle: 'Brot', list: updatedList, }, ], }); expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( 'user-1', updatedList, ); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ provider: 'listify', endpoint: 'local:addItem', requestPayload: expect.objectContaining({ intent: 'list.add_item', listId: 'list-1', itemTitle: 'Brot', }), }), ); }); 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 conversation 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('extracts created list actions from raw tool JSON content', async () => { const createdList = { 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 = { choices: [ { messages: [ { role: 'assistant', content: '', tool_calls: [ { id: 'call-1', type: 'function', function: { name: 'listify_create_list', arguments: '{"name": "Einkauf"}', }, }, ], }, { role: 'tool', content: [ { type: 'text', text: JSON.stringify({ list: createdList }), }, ], tool_call_id: 'call-1', }, { role: 'assistant', content: JSON.stringify({ list: createdList }), }, ], }, ], }; mockMistralResponse(providerResponse); const result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], }); expect(result).toEqual({ message: { role: 'assistant', content: 'Ich habe die Liste **Einkauf** angelegt.', }, actions: [ { type: 'list.created', listId: 'list-1', list: createdList, }, ], }); expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( 'user-1', createdList, ); }); it('treats unknown list tool results as created lists for creation requests', async () => { const createdList = { id: 'list-1', ownerId: 'user-1', accessRole: 'owner', name: 'Einkauf', kind: 'shopping', items: [], collaborators: [], createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }; const providerResponse = { choices: [ { messages: [ { role: 'tool', content: [ { type: 'text', text: JSON.stringify({ list: createdList }), }, ], }, { role: 'assistant', content: 'Erledigt.', }, ], }, ], }; mockMistralResponse(providerResponse); const result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], }); expect(result.actions).toEqual([ { type: 'list.created', listId: 'list-1', list: createdList, }, ]); expect(result.message.content).toBe('Erledigt.'); expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( 'user-1', createdList, ); }); it('detects lists created through the connector when the provider omits tool details', async () => { const createdList = { id: 'list-1', ownerId: 'user-1', accessRole: 'owner', name: 'Einkauf', kind: 'shopping', items: [], collaborators: [], createdAt: '2026-06-12T00:00:00.000Z', updatedAt: '2026-06-12T00:00:00.000Z', }; listsService.listLists .mockResolvedValueOnce([]) .mockResolvedValueOnce([createdList]); mockMistralResponse({ choices: [ { message: { content: 'Ich habe die Liste angelegt.', }, }, ], }); const result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], }); expect(result).toEqual({ message: { role: 'assistant', content: 'Ich habe die Liste angelegt.', }, actions: [ { type: 'list.created', listId: 'list-1', list: createdList, }, ], }); expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( 'user-1', createdList, ); }); it('returns a clear message when list creation did not use the connector', async () => { mockMistralResponse({ choices: [ { message: { content: JSON.stringify({ name: 'Einkauf', items: [{ title: 'Milch' }], }), }, }, ], }); const result = await service.chat('user-1', { messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], }); expect(result).toEqual({ message: { role: 'assistant', content: 'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.', }, actions: [], }); expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled(); }); 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); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', statusCode: null, responsePayload: null, assistantContent: null, errorMessage: 'Mistral API key is not configured.', }), ); }); it('lists the latest chat logs for the current user', async () => { chatLogsRepository.find.mockResolvedValue([ { id: 'log-1', userId: 'user-1', provider: 'mistral', endpoint: 'https://api.mistral.ai/v1/conversations', agentId: null, statusCode: 502, durationMs: 123, requestPayload: { inputs: [] }, responsePayload: { message: 'connector failed' }, assistantContent: null, errorMessage: 'Mistral conversation request failed.', createdAt: new Date('2026-06-24T08:00:00.000Z'), }, ]); const logs = await service.listChatLogs('user-1'); expect(chatLogsRepository.find).toHaveBeenCalledWith({ where: { userId: 'user-1' }, order: { createdAt: 'DESC' }, take: 50, }); expect(logs).toEqual([ { id: 'log-1', provider: 'mistral', endpoint: 'https://api.mistral.ai/v1/conversations', agentId: null, statusCode: 502, durationMs: 123, requestPayload: { inputs: [] }, responsePayload: { message: 'connector failed' }, assistantContent: null, errorMessage: 'Mistral conversation request failed.', createdAt: '2026-06-24T08:00:00.000Z', }, ]); }); }); 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(): { inputs: Array<{ role: string; content: string }>; instructions: 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 { inputs: Array<{ role: string; content: string }>; instructions: string; }; }