This commit is contained in:
Bastian Wagner
2026-06-12 11:08:04 +02:00
parent c40cb030f6
commit d5595f11cd
6 changed files with 86 additions and 536 deletions

View File

@@ -1,170 +1,71 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { UserList } from '../list-templates/list-template.types';
import { ListsService } from '../lists/lists.service';
import { AssistantService } from './assistant.service';
describe('AssistantService', () => {
const originalFetch = global.fetch;
const originalApiKey = process.env.MISTRAL_API_KEY;
let listsService: Pick<ListsService, 'listLists' | 'createList' | 'addItem'>;
const originalAgentId = process.env.MISTRAL_AGENT_ID;
let service: AssistantService;
beforeEach(() => {
process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify';
global.fetch = jest.fn();
listsService = {
listLists: jest.fn(),
createList: jest.fn(),
addItem: jest.fn(),
};
service = new AssistantService(listsService as ListsService);
service = new AssistantService();
});
afterEach(() => {
global.fetch = originalFetch;
process.env.MISTRAL_API_KEY = originalApiKey;
delete process.env.MISTRAL_MODEL;
process.env.MISTRAL_AGENT_ID = originalAgentId;
});
it('returns a plain assistant response without tool calls', async () => {
it('forwards messages to the configured Mistral agent', async () => {
mockMistralResponse({
choices: [{ message: { content: 'Klar, ich helfe dir.' } }],
choices: [
{
message: {
content: 'Ich habe den Listify-Connector verwendet.',
},
},
],
});
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Hallo' }],
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' },
],
stream: false,
response_format: {
type: 'text',
},
}),
}),
);
expect(result).toEqual({
message: { role: 'assistant', content: 'Klar, ich helfe dir.' },
message: {
role: 'assistant',
content: 'Ich habe den Listify-Connector verwendet.',
},
actions: [],
});
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('executes create_list tool calls and returns the final assistant response', async () => {
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
const completedList = list({
id: 'list-1',
name: 'Sommerurlaub',
items: ['Pass'],
});
jest.mocked(listsService.createList).mockResolvedValue(createdList);
jest.mocked(listsService.addItem).mockResolvedValue(completedList);
mockMistralResponse(
{
choices: [
{
message: {
content: '',
tool_calls: [
{
id: 'call-1',
type: 'function',
function: {
name: 'create_list',
arguments: JSON.stringify({
name: 'Sommerurlaub',
kind: 'packing',
items: [{ title: 'Pass' }],
}),
},
},
],
},
},
],
},
{
choices: [
{
message: {
content: 'Ich habe die Packliste Sommerurlaub erstellt.',
},
},
],
},
);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Erstelle eine Packliste' }],
});
expect(listsService.createList).toHaveBeenCalledWith('user-1', {
name: 'Sommerurlaub',
description: undefined,
kind: 'packing',
});
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
title: 'Pass',
notes: undefined,
quantity: undefined,
required: undefined,
});
expect(result.actions).toEqual([
{
type: 'list.created',
listId: 'list-1',
list: completedList,
},
]);
expect(result.message.content).toBe(
'Ich habe die Packliste Sommerurlaub erstellt.',
);
});
it('executes add_list_item tool calls', async () => {
const updatedList = list({
id: 'list-1',
name: 'Einkauf',
items: ['Milch'],
});
jest.mocked(listsService.addItem).mockResolvedValue(updatedList);
mockMistralResponse(
{
choices: [
{
message: {
content: '',
tool_calls: [
{
id: 'call-1',
type: 'function',
function: {
name: 'add_list_item',
arguments: JSON.stringify({
listId: 'list-1',
title: 'Milch',
quantity: 2,
}),
},
},
],
},
},
],
},
{
choices: [{ message: { content: 'Milch wurde hinzugefuegt.' } }],
},
);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Fuege Milch hinzu' }],
});
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
title: 'Milch',
notes: undefined,
quantity: 2,
required: undefined,
});
expect(result.actions[0]).toEqual({
type: 'list.item_added',
listId: 'list-1',
itemTitle: 'Milch',
list: updatedList,
});
});
it('fails clearly when the api key is missing', async () => {
@@ -176,45 +77,21 @@ describe('AssistantService', () => {
}),
).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(...responses: object[]): void {
jest.mocked(global.fetch).mockImplementation(async () => {
const response = responses.shift() ?? responses[responses.length - 1];
return {
ok: true,
json: async () => response,
} as Response;
});
}
function list(options: {
id: string;
name: string;
items?: string[];
}): UserList {
return {
id: options.id,
ownerId: 'user-1',
accessRole: 'owner',
name: options.name,
kind: 'custom',
items: (options.items ?? []).map((title, position) => ({
id: `item-${position}`,
title,
required: true,
checked: false,
position,
createdAt: now(),
updatedAt: now(),
})),
collaborators: [],
createdAt: now(),
updatedAt: now(),
};
}
function now(): string {
return new Date(0).toISOString();
function mockMistralResponse(response: object): void {
jest.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => response,
} as Response);
}