367 lines
10 KiB
TypeScript
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 }>;
|
|
};
|
|
}
|