Files
listify/listify-api/src/assistant/assistant.service.spec.ts
Bastian Wagner 22c93f9ca1 chat
2026-06-13 15:41:57 +02:00

367 lines
10 KiB
TypeScript

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 }>;
};
}