Files
TREK/server/tests/e2e/assistant.e2e.test.ts
T
Maurice 3c3a228737 fix(assistant): lint, types and tests for the CI round
- agent loop and decisions without await in loops: steps as recursion,
  calls and applied changes in order through one promise chain, the
  independent reads and claims in parallel; part mapping in its own helpers
- place card, header and weather strip without refs or components created
  in render; long hooks and components split; state reset during render
- trip notes only in a turn's tool session, never an undo's; map view
  reported only while the assistant can run
- place strip scrolls through the strip's own smooth scroll, no physical side
- tests follow the raised limits and the new card fields; toasts read through
  a mocked useToast instead of a window global; baselines lowered where the
  counts fell
2026-10-21 22:29:58 +02:00

980 lines
43 KiB
TypeScript

/**
* Trip assistant e2e: the whole app (buildApp, every global guard, the Zod
* pipe, the response contracts, the real MCP registry) with an instance
* connection of kind `custom` pointing at a fake OpenAI-compatible server on
* loopback. Nothing is mocked: the fake server is the provider, scripted per
* case, and it records what the assistant sent it.
*
* Covered: the gates before the stream, a text turn, a read tool, a change
* held, applied (with its broadcast) and continued, a discarded one, the
* all-or-nothing decisions, undo and its guard, one turn per user and stop,
* a provider failure, threads and their deletion with the trip, the daily
* limit on decisions, an approved change the turn never got to run, a
* booking file attached in the chat (the extractor, an external binary, is
* stubbed on the booking import), the cards a search and a weather lookup
* make (the place search and the forecast are stubbed on their services), a
* found place suggested for a day without the model, mentions, and an answer
* shared in the trip chat.
*/
import { buildApp } from '../../src/bootstrap';
import { AssistantThreads } from '../../src/db/entities/AssistantThreads.entity';
import { AssistantToolCalls } from '../../src/db/entities/AssistantToolCalls.entity';
import { CollabMessages } from '../../src/db/entities/CollabMessages.entity';
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { Reservations } from '../../src/db/entities/Reservations.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TURNS_PER_MINUTE } from '../../src/nest/assistant/assistant-tools.constants';
import { AssistantToolsService } from '../../src/nest/assistant/assistant-tools.service';
import { BookingImportService } from '../../src/nest/booking-import/booking-import.service';
import { MapsService } from '../../src/nest/maps/maps.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { WeatherService } from '../../src/nest/weather/weather.service';
import { makePlace } from '../helpers/factories/places';
import { countRows, deleteRows, findRow, updateRows } from '../helpers/factories/rows';
import { setAddonEnabled, setCollabFeature } from '../helpers/factories/settings';
import { makeTrip, readTripDays } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { AssistantMessagePart, UiStreamChunk } from '@trek/shared';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// ── The fake provider ─────────────────────────────────────────────────────────
interface ChatBody {
model: string;
messages: Array<{ role: string; content: string | null; tool_call_id?: string; tool_calls?: unknown[] }>;
tools?: Array<{ function: { name: string; parameters: { properties?: Record<string, unknown> } } }>;
}
type Reply = { chunks: object[] } | { status: number };
type Step = (body: ChatBody) => Reply | Promise<Reply>;
const provider = { requests: [] as ChatBody[], script: [] as Step[] };
const say =
(text: string): Step =>
() => ({
chunks: [
{ choices: [{ index: 0, delta: { content: text } }] },
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
{ choices: [], usage: { prompt_tokens: 12, completion_tokens: 4 } },
],
});
const callTools =
(...calls: Array<{ id: string; name: string; args: Record<string, unknown> }>): Step =>
() => ({
chunks: [
{
choices: [
{
index: 0,
delta: {
tool_calls: calls.map((call, index) => ({
index,
id: call.id,
type: 'function',
function: { name: call.name, arguments: JSON.stringify(call.args) },
})),
},
},
],
},
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
],
});
async function readBody(req: IncomingMessage): Promise<string> {
let text = '';
for await (const chunk of req) text += String(chunk);
return text;
}
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method === 'GET' && req.url?.startsWith('/api/tags')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ models: [{ name: 'fake-model', size: 1 }] }));
return;
}
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
const body = JSON.parse(await readBody(req)) as ChatBody;
provider.requests.push(body);
const reply = await (provider.script.shift() ?? say('Nothing more.'))(body);
if (res.destroyed) return;
if ('status' in reply) {
res.writeHead(reply.status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: { type: 'server_error' } }));
return;
}
res.writeHead(200, { 'content-type': 'text/event-stream' });
for (const chunk of reply.chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.end('data: [DONE]\n\n');
}
// ── The app ──────────────────────────────────────────────────────────────────
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let broadcasts: RealtimeSpies;
let adminId: number;
async function connectInstance(): Promise<void> {
const { port } = fake.address() as AddressInfo;
const res = await request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
}
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
nestApp = await buildApp();
orm = nestApp.get(MikroORM);
broadcasts = spyOnRealtime(nestApp);
await setAddonEnabled(orm, 'ai_assistant', true);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
await connectInstance();
});
beforeEach(async () => {
await resetRateLimits(nestApp);
provider.requests = [];
provider.script = [];
broadcasts.broadcast.mockClear();
});
afterAll(async () => {
await nestApp.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
// ── Helpers ──────────────────────────────────────────────────────────────────
const CTX = { locale: 'en', timeZone: 'Europe/Berlin' };
/** The request with its whole answer read as text, a stream included. */
function asText(req: request.Test): request.Test {
return req.buffer(true).parse((res, done) => {
let text = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => (text += chunk));
res.on('end', () => done(null, text));
});
}
interface Answer {
status: number;
chunks: UiStreamChunk[];
json: Record<string, unknown> | null;
}
async function post(path: string, userId: number, body: object): Promise<Answer> {
const res = await asText(request(nestApp.getHttpServer()).post(path).set('Cookie', sessionCookie(userId)).send(body));
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
}
function parseAnswer(status: number, contentType: string, text: string): Answer {
if (!contentType.includes('text/event-stream')) {
return { status, chunks: [], json: text ? (JSON.parse(text) as Record<string, unknown>) : null };
}
const events = text
.split('\n\n')
.filter((event) => event.startsWith('data: '))
.map((event) => event.slice('data: '.length));
expect(events.at(-1)).toBe('[DONE]');
return {
status,
chunks: events.slice(0, -1).map((event) => JSON.parse(event) as UiStreamChunk),
json: null,
};
}
/** A file attached in the chat, as the client uploads it (multipart, the context as text fields). */
async function upload(path: string, userId: number, fileName: string): Promise<Answer> {
const req = request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.field('locale', CTX.locale)
.field('timeZone', CTX.timeZone)
.attach('file', Buffer.from('%PDF-1.4\n'), { filename: fileName, contentType: 'application/pdf' });
const res = await asText(req);
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
}
function get(path: string, userId: number) {
return request(nestApp.getHttpServer()).get(path).set('Cookie', sessionCookie(userId));
}
async function travelerWithTrip() {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Kyoto', start_date: '2026-06-11', end_date: '2026-06-12' });
await makePlace(orm, trip.id, { name: 'Kinkaku-ji', category: null });
const base = `/api/trips/${trip.id}/assistant`;
return { userId: user.id, tripId: trip.id, base };
}
const ofType = <T extends UiStreamChunk['type']>(chunks: UiStreamChunk[], type: T) =>
chunks.filter((chunk): chunk is Extract<UiStreamChunk, { type: T }> => chunk.type === type);
/** A chat turn that proposes one new place, and the id the change is held under. */
async function proposePlace(t: { userId: number; base: string }, name: string) {
provider.script = [callTools({ id: `call_${name}`, name: 'create_place', args: { name } })];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: `Add ${name}` });
const [held] = ofType(turn.chunks, 'tool-approval-request');
const [thread] = ofType(turn.chunks, 'data-trek-thread');
return { turn, toolCallId: held.toolCallId, threadId: thread.data.threadId };
}
/** What the stubbed place search finds: a Google place, an OpenStreetMap one, and one without a position. */
const FOUND = [
{
google_place_id: 'ChIJginkaku',
name: 'Ginkaku-ji',
address: '2 Ginkakujicho',
lat: 35.027,
lng: 135.7982,
rating: 4.6,
website: 'https://example.com/ginkaku',
phone: '+81 75 771 5725',
types: ['tourist_attraction', 'place_of_worship'],
source: 'google',
},
{
google_place_id: null,
osm_id: 'node:42',
name: 'Tea house of mail@example.com',
lat: 35.01,
lng: 135.76,
category: 'cafe',
source: 'openstreetmap',
},
{ name: 'Nowhere' },
];
/** A turn that searches places and looks up the weather; the search and the forecast are stubbed. */
async function searchTurn(t: { userId: number; base: string }) {
const search = vi
.spyOn(nestApp.get(MapsService), 'searchPlaces')
.mockResolvedValue({ places: FOUND, source: 'google' });
const forecast = vi.spyOn(nestApp.get(WeatherService), 'get').mockResolvedValue({
temp: 21,
temp_max: 24,
temp_min: 15,
main: 'Clear',
description: 'clear sky',
type: 'forecast',
precipitation_probability_max: 40,
});
provider.script = [
callTools(
{ id: 'call_find', name: 'search_place', args: { query: 'temples', locationBias: { lat: 35.03, lng: 135.73 } } },
{ id: 'call_sky', name: 'get_weather', args: { lat: 35.03, lng: 135.73, date: '2026-06-11' } },
),
say('Two temples are worth it.'),
];
try {
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Temples nearby?' });
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
return { turn, threadId, messageId: ofType(turn.chunks, 'start')[0].messageId };
} finally {
search.mockRestore();
forecast.mockRestore();
}
}
function addFound(path: string, userId: number, body: object) {
return request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.send({ ...CTX, ...body });
}
// ── Cases ────────────────────────────────────────────────────────────────────
describe('Trip assistant e2e', () => {
it('ASTE2E-001: the addon gate, auth and the trip come before anything else', async () => {
const t = await travelerWithTrip();
const { user: stranger } = await makeUser(orm);
await setAddonEnabled(orm, 'ai_assistant', false);
expect((await request(nestApp.getHttpServer()).get(`${t.base}/threads`)).status).toBe(404);
await setAddonEnabled(orm, 'ai_assistant', true);
expect((await request(nestApp.getHttpServer()).get(`${t.base}/threads`)).status).toBe(401);
expect((await get(`${t.base}/threads`, stranger.id)).status).toBe(404);
const refused = await post(`${t.base}/chat`, stranger.id, { ...CTX, threadId: null, text: 'hi' });
expect(refused.status).toBe(404);
expect((await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: '' })).status).toBe(400);
expect(provider.requests).toHaveLength(0);
});
it('ASTE2E-002: without a connection a turn is refused as JSON with its code', async () => {
const t = await travelerWithTrip();
expect(
(await request(nestApp.getHttpServer()).delete('/api/admin/ai/connection').set('Cookie', sessionCookie(adminId)))
.status,
).toBe(200);
try {
const refused = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'hi' });
expect(refused).toMatchObject({ status: 409, json: { code: 'not_connected' } });
} finally {
await connectInstance();
}
});
it('ASTE2E-003: a text turn opens a thread, streams the answer and keeps it', async () => {
const t = await travelerWithTrip();
provider.script = [say('Kyoto in June is lovely.')];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'How is Kyoto in June?' });
expect(turn.status).toBe(200);
expect(turn.chunks.map((chunk) => chunk.type)).toEqual([
'start',
'data-trek-thread',
'start-step',
'text-start',
'text-delta',
'text-end',
'finish-step',
'data-trek-quota',
'finish',
]);
const [thread] = ofType(turn.chunks, 'data-trek-thread');
expect(thread.data.title).toBe('How is Kyoto in June?');
expect(ofType(turn.chunks, 'data-trek-quota')[0].data).toMatchObject({ limit: 50 });
expect(provider.requests[0].messages[0].content).toContain('<trip_card>');
expect(provider.requests[0].messages[0].content).toContain('Kinkaku-ji');
const list = await get(`${t.base}/threads`, t.userId);
expect(list.status).toBe(200);
expect(list.body.threads).toEqual([
expect.objectContaining({ id: thread.data.threadId, title: 'How is Kyoto in June?' }),
]);
const shown = await get(`${t.base}/threads/${thread.data.threadId}`, t.userId);
expect(shown.status).toBe(200);
expect(shown.body.messages.map((m: { role: string; status: string }) => [m.role, m.status])).toEqual([
['user', 'complete'],
['assistant', 'complete'],
]);
expect(shown.body.messages[1].id).toBe(ofType(turn.chunks, 'start')[0].messageId);
expect(shown.body.messages[1].parts).toEqual([{ type: 'text', text: 'Kyoto in June is lovely.' }]);
});
it('ASTE2E-004: a read runs at once on the trip, and the model never sees the trip id or the denied tools', async () => {
const t = await travelerWithTrip();
provider.script = [callTools({ id: 'call_r', name: 'list_places', args: {} }), say('You have Kinkaku-ji.')];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'What is planned?' });
const [output] = ofType(turn.chunks, 'tool-output-available');
expect(JSON.stringify(output.output)).toContain('Kinkaku-ji');
const tools = provider.requests[0].tools ?? [];
const names = tools.map((tool) => tool.function.name);
expect(names).toContain('list_places');
for (const denied of ['delete_place', 'delete_trip', 'import_places_from_url', 'create_share_link']) {
expect(names).not.toContain(denied);
}
for (const tool of tools) expect(Object.keys(tool.function.parameters.properties ?? {})).not.toContain('tripId');
const toolAnswer = provider.requests[1].messages.find((message) => message.role === 'tool');
expect(toolAnswer).toMatchObject({ tool_call_id: 'call_r' });
expect(toolAnswer?.content).toContain('Kinkaku-ji');
});
it('ASTE2E-005: a change is held, applied with its stored arguments, broadcast, and the turn goes on', async () => {
const t = await travelerWithTrip();
const { turn, toolCallId, threadId } = await proposePlace(t, 'Ryoan-ji');
expect(turn.chunks.at(-1)).toEqual({ type: 'finish', finishReason: 'tool-calls' });
expect(ofType(turn.chunks, 'data-trek-change')[0].data).toMatchObject({
toolCallId,
verbKey: 'addPlace',
subject: 'Ryoan-ji',
});
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Ryoan-ji' })).toBeNull();
const waiting = await get(`${t.base}/threads/${threadId}`, t.userId);
expect(waiting.body.messages[1].status).toBe('awaiting_approval');
provider.script = [say('Added Ryoan-ji.')];
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(decided.status).toBe(200);
expect(ofType(decided.chunks, 'start')[0].messageId).toBe(ofType(turn.chunks, 'start')[0].messageId);
const [applied] = ofType(decided.chunks, 'data-trek-applied');
expect(applied.data).toMatchObject({ toolCallId, entity: 'place', dayId: null });
expect(applied.data.undoUntil).toEqual(expect.any(String));
const place = await findRow(orm, Places, { trip: t.tripId, name: 'Ryoan-ji' });
expect(place).not.toBeNull();
expect(applied.data.ids).toEqual([place?.id]);
expect(broadcasts.broadcast).toHaveBeenCalledWith(t.tripId, 'place:created', expect.anything());
expect(decided.chunks.at(-1)).toEqual({ type: 'finish', finishReason: 'stop' });
const toolAnswer = provider.requests.at(-1)?.messages.find((message) => message.role === 'tool');
expect(toolAnswer).toMatchObject({ tool_call_id: 'call_Ryoan-ji' });
const after = await get(`${t.base}/threads/${threadId}`, t.userId);
expect(after.body.messages).toHaveLength(2);
expect(after.body.messages[1].status).toBe('complete');
const again = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(again.status).toBe(409);
expect(await countRows(orm, Places, { trip: t.tripId, name: 'Ryoan-ji' })).toBe(1);
});
it('ASTE2E-006: a discarded change does not run, and the model is told', async () => {
const t = await travelerWithTrip();
const { toolCallId, threadId } = await proposePlace(t, 'Nijo Castle');
provider.script = [say('Fine, left it out.')];
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: false }],
});
expect(ofType(decided.chunks, 'tool-output-denied')).toEqual([{ type: 'tool-output-denied', toolCallId }]);
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Nijo Castle' })).toBeNull();
expect(provider.requests.at(-1)?.messages.find((message) => message.role === 'tool')?.content).toContain(
'discarded',
);
});
it('ASTE2E-007: decisions name every waiting change, in a thread of the user', async () => {
const t = await travelerWithTrip();
const { threadId } = await proposePlace(t, 'Ginkaku-ji');
const partial = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId: 'not-a-waiting-change', approved: true }],
});
expect(partial).toMatchObject({ status: 422, json: { code: 'generic' } });
const missing = await post(`${t.base}/threads/999999/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId: 'x', approved: true }],
});
expect(missing).toMatchObject({ status: 404, json: { code: 'not_found' } });
provider.script = [say('Moving on.')];
const next = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId, text: 'Never mind' });
expect(next.status).toBe(200);
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
const cancelled = shown.body.messages[1].parts.find((part: { type: string }) => part.type === 'tool-call');
expect(cancelled.approval).toMatchObject({ resolution: 'cancelled' });
expect(provider.requests.at(-1)?.messages.find((message) => message.role === 'tool')?.content).toContain(
'moved on',
);
});
it('ASTE2E-008: an applied change is undone once, and not after it was changed', async () => {
const t = await travelerWithTrip();
const first = await proposePlace(t, 'Tenryu-ji');
await post(`${t.base}/threads/${first.threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId: first.toolCallId, approved: true }],
});
const undone = await request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/${first.toolCallId}/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({});
expect(undone.status).toBe(200);
expect(undone.body).toEqual({ undone: true });
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Tenryu-ji' })).toBeNull();
const twice = await request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/${first.toolCallId}/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({});
expect(twice.status).toBe(409);
const shown = await get(`${t.base}/threads/${first.threadId}`, t.userId);
const receipt = shown.body.messages[1].parts.find((part: { type: string }) => part.type === 'data-trek-applied');
expect(receipt.data.undoUntil).toBeNull();
const second = await proposePlace(t, 'Daitoku-ji');
await post(`${t.base}/threads/${second.threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId: second.toolCallId, approved: true }],
});
await updateRows(
orm,
Places,
{ trip: t.tripId, name: 'Daitoku-ji' },
{ notes: 'Edited by hand', updated_at: '2099-01-01 00:00:00' },
);
const stale = await request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/${second.toolCallId}/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({});
expect(stale.status).toBe(409);
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Daitoku-ji' })).not.toBeNull();
const { user: stranger } = await makeUser(orm);
const foreign = await request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/${second.toolCallId}/undo`)
.set('Cookie', sessionCookie(stranger.id))
.send({});
expect(foreign.status).toBe(404);
});
it('ASTE2E-009: one turn per user at a time; stop ends the running one', async () => {
const t = await travelerWithTrip();
let arrived!: () => void;
const reached = new Promise<void>((resolve) => (arrived = resolve));
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
provider.script = [
async (body) => {
arrived();
await gate;
return say('too late')(body);
},
];
const running = post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Slow one' });
await reached;
const busy = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Second window' });
expect(busy).toMatchObject({ status: 409, json: { code: 'busy' } });
const stopped = await request(nestApp.getHttpServer())
.post(`${t.base}/stop`)
.set('Cookie', sessionCookie(t.userId))
.send({});
expect(stopped.status).toBe(200);
const first = await running;
expect(first.chunks.at(-1)).toEqual({ type: 'abort' });
release();
});
it('ASTE2E-010: a provider failure ends the stream with its code', async () => {
const t = await travelerWithTrip();
provider.script = [() => ({ status: 503 })];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'hi' });
expect(turn.status).toBe(200);
// The day's count goes out before the end, so it is read apart from it.
const ending = turn.chunks.filter((chunk) => chunk.type !== 'data-trek-quota');
expect(ending.slice(-2)).toEqual([
{ type: 'error', errorText: 'provider_unavailable' },
{ type: 'finish', finishReason: 'error' },
]);
});
it('ASTE2E-011: turns are rate limited per user', async () => {
const t = await travelerWithTrip();
const responses = await Promise.all(
Array.from({ length: TURNS_PER_MINUTE + 1 }, () =>
request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/missing/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({}),
),
);
const statuses = responses.map((res) => res.status);
expect(statuses.filter((status) => status === 404)).toHaveLength(TURNS_PER_MINUTE);
const limited = responses.filter((res) => res.status === 429);
expect(limited).toHaveLength(1);
// TREK's own limit, told apart from a provider's `rate_limited`.
expect((limited[0]?.body as { code?: unknown }).code).toBe('too_many_requests');
});
it('ASTE2E-012: a thread is deleted by its owner, and all go with the trip', async () => {
const t = await travelerWithTrip();
provider.script = [say('one'), say('two')];
const a = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'First' });
await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Second' });
const threadId = ofType(a.chunks, 'data-trek-thread')[0].data.threadId;
const removed = await request(nestApp.getHttpServer())
.delete(`${t.base}/threads/${threadId}`)
.set('Cookie', sessionCookie(t.userId));
expect(removed.status).toBe(200);
expect((await get(`${t.base}/threads/${threadId}`, t.userId)).status).toBe(404);
expect(await countRows(orm, AssistantThreads, { trip: t.tripId })).toBe(1);
await deleteRows(orm, Trips, { id: t.tripId });
expect(await countRows(orm, AssistantThreads, { trip: t.tripId })).toBe(0);
});
it('ASTE2E-013: a decision on the instance connection is held to the daily limit', async () => {
const t = await travelerWithTrip();
const { toolCallId, threadId } = await proposePlace(t, 'Sanjusangen-do');
const settings = await get('/api/admin/ai/settings', adminId);
const save = (body: object) =>
request(nestApp.getHttpServer()).put('/api/admin/ai/settings').set('Cookie', sessionCookie(adminId)).send(body);
expect((await save({ ...settings.body, dailyTurns: 1 })).status).toBe(200);
try {
const refused = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(refused).toMatchObject({ status: 429, json: { code: 'daily_limit' } });
expect(await findRow(orm, AssistantToolCalls, { id: toolCallId })).toMatchObject({
state: 'approval_requested',
});
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Sanjusangen-do' })).toBeNull();
} finally {
expect((await save(settings.body)).status).toBe(200);
}
});
it('ASTE2E-014: an approved change the turn never ran reads as failed, not as applied', async () => {
const t = await travelerWithTrip();
const { toolCallId, threadId } = await proposePlace(t, 'Kiyomizu-dera');
const open = vi
.spyOn(nestApp.get(AssistantToolsService), 'open')
.mockRejectedValueOnce(new Error('tool session down'));
try {
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(decided.chunks.slice(-2)).toEqual([
{ type: 'error', errorText: 'generic' },
{ type: 'finish', finishReason: 'error' },
]);
} finally {
open.mockRestore();
}
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Kiyomizu-dera' })).toBeNull();
expect(await findRow(orm, AssistantToolCalls, { id: toolCallId })).toMatchObject({ state: 'failed' });
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
const change = shown.body.messages[1].parts.find((part: { type: string }) => part.type === 'tool-call');
expect(change).toMatchObject({ isError: true, approval: { approved: true } });
expect(shown.body.messages[1].status).toBe('complete');
const again = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(again.status).toBe(409);
});
it('ASTE2E-015: an attached file is refused as JSON before the stream when nothing reads it', async () => {
const t = await travelerWithTrip();
const bookingImport = nestApp.get(BookingImportService);
const extractor = vi.spyOn(bookingImport, 'isAvailable').mockReturnValue(false);
const ai = vi.spyOn(bookingImport, 'aiAvailable').mockResolvedValue(false);
try {
const refused = await upload(`${t.base}/attachments`, t.userId, 'ticket.pdf');
expect(refused).toMatchObject({ status: 503, json: { code: 'import_unavailable' } });
const wrongType = await upload(`${t.base}/attachments`, t.userId, 'ticket.exe');
expect(wrongType).toMatchObject({ status: 400, json: { code: 'generic' } });
} finally {
extractor.mockRestore();
ai.mockRestore();
}
expect(await countRows(orm, AssistantThreads, { trip: t.tripId })).toBe(0);
});
it('ASTE2E-016: the bookings of an attached file are held as cards, applied, and the model is told', async () => {
const t = await travelerWithTrip();
const bookingImport = nestApp.get(BookingImportService);
const extractor = vi.spyOn(bookingImport, 'isAvailable').mockReturnValue(true);
const ai = vi.spyOn(bookingImport, 'aiAvailable').mockResolvedValue(false);
const preview = vi.spyOn(bookingImport, 'preview').mockResolvedValue({
items: [
{
type: 'event',
title: 'Gion Corner show',
reservation_time: '2026-06-11T19:00',
confirmation_number: 'GC-42',
source: { fileName: 'ticket.pdf', index: 0 },
},
],
warnings: [],
});
let threadId: number;
let toolCallId: string;
try {
const turn = await upload(`${t.base}/attachments`, t.userId, 'ticket.pdf');
expect(turn.status).toBe(200);
expect(turn.chunks.map((chunk) => chunk.type)).toEqual([
'start',
'data-trek-thread',
'start-step',
'tool-input-start',
'tool-input-available',
'tool-output-available',
'tool-input-start',
'tool-input-available',
'tool-approval-request',
'data-trek-change',
'finish-step',
'finish',
]);
expect(ofType(turn.chunks, 'tool-input-start')[0].toolName).toBe('read_booking_file');
expect(ofType(turn.chunks, 'data-trek-change')[0].data).toMatchObject({
verbKey: 'importBooking',
subject: 'Gion Corner show',
undoable: false,
});
const file = expect.objectContaining({ originalname: 'ticket.pdf' });
expect(preview).toHaveBeenCalledWith([file], 'no-ai', t.userId);
threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
toolCallId = ofType(turn.chunks, 'tool-approval-request')[0].toolCallId;
} finally {
extractor.mockRestore();
ai.mockRestore();
preview.mockRestore();
}
expect(provider.requests).toHaveLength(0);
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
expect(shown.body.messages[0].parts).toEqual([
{ type: 'attachment', name: 'ticket.pdf', mime: 'application/pdf', size: 9 },
]);
expect(shown.body.messages[1].status).toBe('awaiting_approval');
provider.script = [say('The show is on your trip.')];
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
const [applied] = ofType(decided.chunks, 'data-trek-applied');
expect(applied.data).toMatchObject({ toolCallId, entity: 'reservation', undoUntil: null });
const booked = await findRow(orm, Reservations, { trip: t.tripId, title: 'Gion Corner show' });
expect(booked).not.toBeNull();
expect(applied.data.ids).toEqual([booked?.id]);
const sent = provider.requests[0].messages.map((message) => String(message.content ?? '')).join('\n');
expect(sent).toContain('The user attached the file "ticket.pdf"');
expect(sent).toContain('"Gion Corner show" was added to the trip');
expect(provider.requests[0].tools?.map((tool) => tool.function.name)).not.toContain('import_booking');
});
it('ASTE2E-017: a search and a weather lookup stream where they look and come back as cards, kept with the answer', async () => {
const t = await travelerWithTrip();
const [day] = await readTripDays(orm, t.tripId);
const { turn, threadId } = await searchTurn(t);
expect(turn.chunks.map((chunk) => chunk.type)).toEqual([
'start',
'data-trek-thread',
'start-step',
'data-trek-activity',
'tool-input-start',
'tool-input-available',
'tool-output-available',
'data-trek-places',
'data-trek-activity',
'tool-input-start',
'tool-input-available',
'tool-output-available',
'data-trek-weather',
'finish-step',
'start-step',
'text-start',
'text-delta',
'text-end',
'finish-step',
'data-trek-quota',
'finish',
]);
const [looking] = ofType(turn.chunks, 'data-trek-activity');
expect(looking).toEqual({
type: 'data-trek-activity',
transient: true,
data: {
toolCallId: 'call_find',
label: 'temples',
area: { type: 'point', lat: 35.03, lng: 135.73, radiusM: 50_000 },
},
});
const [found] = ofType(turn.chunks, 'data-trek-places');
expect(found.data.more).toBe(0);
expect(found.data.places).toEqual([
expect.objectContaining({
ref: 'call_find:0',
name: 'Ginkaku-ji',
kind: 'sight',
rating: 4.6,
openNow: null,
googlePlaceId: 'ChIJginkaku',
photoKey: 'ChIJginkaku',
inTripPlaceId: null,
}),
expect.objectContaining({
ref: 'call_find:1',
name: 'Tea house of [hidden]',
kind: 'drinks',
osmId: 'node:42',
photoKey: 'coords:35.010000,135.760000',
}),
]);
expect(JSON.stringify(found.data)).not.toMatch(/example\.com|\+81|https?:/);
expect(ofType(turn.chunks, 'data-trek-weather')[0].data.days).toEqual([
{ dayId: day.id, date: '2026-06-11', main: 'Clear', tempMax: 24, tempMin: 15, rainPct: 40, kind: 'forecast' },
]);
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
const kept = (shown.body.messages[1].parts as AssistantMessagePart[]).map((part) => part.type);
expect(kept).toContain('data-trek-places');
expect(kept).toContain('data-trek-weather');
expect(kept).not.toContain('data-trek-activity');
});
it('ASTE2E-018: a found place is suggested for a day without the model, applied without a turn, and undone', async () => {
const t = await travelerWithTrip();
const [day] = await readTripDays(orm, t.tripId);
const { threadId, messageId } = await searchTurn(t);
const asked = provider.requests.length;
const path = `${t.base}/threads/${threadId}/found-places`;
const res = await addFound(path, t.userId, { ref: 'call_find:0', dayId: day.id });
expect(res.status).toBe(200);
expect(res.body.message).toMatchObject({ id: messageId, status: 'awaiting_approval' });
const parts = res.body.message.parts as AssistantMessagePart[];
const card = parts.find((part) => part.type === 'data-trek-change');
expect(card?.type === 'data-trek-change' && card.data).toMatchObject({
verbKey: 'addPlaceToDay',
subject: 'Ginkaku-ji',
dayId: day.id,
effect: { kind: 'add', dayId: day.id, place: { name: 'Ginkaku-ji', lat: 35.027, lng: 135.7982 } },
});
const toolCallId = card?.type === 'data-trek-change' ? card.data.toolCallId : '';
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(decided.chunks.map((chunk) => chunk.type)).toEqual([
'start',
'tool-output-available',
'data-trek-applied',
'finish',
]);
expect(ofType(decided.chunks, 'start')[0].messageId).toBe(messageId);
expect(decided.chunks.at(-1)).toEqual({ type: 'finish', finishReason: 'stop' });
expect(provider.requests).toHaveLength(asked);
const place = await findRow(orm, Places, { trip: t.tripId, name: 'Ginkaku-ji' });
expect(place).toMatchObject({ google_place_id: 'ChIJginkaku' });
expect(await countRows(orm, DayAssignments, { day: day.id, place: place?.id })).toBe(1);
expect(broadcasts.broadcast).toHaveBeenCalledWith(t.tripId, 'assignment:created', expect.anything());
const undone = await request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/${toolCallId}/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({});
expect(undone.status).toBe(200);
expect(await findRow(orm, Places, { trip: t.tripId, name: 'Ginkaku-ji' })).toBeNull();
provider.script = [say('Noted.')];
await post(`${t.base}/chat`, t.userId, { ...CTX, threadId, text: 'Thanks' });
const sent = provider.requests
.at(-1)
?.messages.map((message) => String(message.content ?? ''))
.join('\n');
expect(sent).toContain(`"Ginkaku-ji" was added to day ${day.id}.`);
});
it('ASTE2E-019: a found place must be in the thread, for a day of the trip, with nothing else waiting', async () => {
const t = await travelerWithTrip();
const [day] = await readTripDays(orm, t.tripId);
const other = await makeTrip(orm, t.userId, { start_date: '2026-07-01', end_date: '2026-07-01' });
const [foreignDay] = await readTripDays(orm, other.id);
const { threadId } = await searchTurn(t);
const path = `${t.base}/threads/${threadId}/found-places`;
const missing = await addFound(`${t.base}/threads/999999/found-places`, t.userId, {
ref: 'call_find:0',
dayId: day.id,
});
expect(missing.status).toBe(404);
const unknown = await addFound(path, t.userId, { ref: 'call_find:7', dayId: day.id });
expect(unknown).toMatchObject({ status: 400, body: { code: 'generic' } });
const foreign = await addFound(path, t.userId, { ref: 'call_find:0', dayId: foreignDay.id });
expect(foreign).toMatchObject({ status: 400, body: { code: 'generic' } });
expect((await addFound(path, t.userId, { ref: 'call_find:0' })).status).toBe(400);
expect((await addFound(path, t.userId, { ref: 'call_find:0', dayId: day.id })).status).toBe(200);
const twice = await addFound(path, t.userId, { ref: 'call_find:1', dayId: day.id });
expect(twice).toMatchObject({ status: 409, body: { code: 'decide_first' } });
expect(await countRows(orm, AssistantToolCalls, { thread: threadId })).toBe(1);
expect(provider.requests).toHaveLength(2);
});
it('ASTE2E-020: mentions reach the model as the trip names them, and the message keeps the checked ones', async () => {
const t = await travelerWithTrip();
const [day] = await readTripDays(orm, t.tripId);
const place = await findRow(orm, Places, { trip: t.tripId, name: 'Kinkaku-ji' });
const other = await makeTrip(orm, t.userId);
const elsewhere = await makePlace(orm, other.id, { name: 'Elsewhere' });
provider.script = [say('Sure.')];
const turn = await post(`${t.base}/chat`, t.userId, {
...CTX,
threadId: null,
text: 'Is @Kinkaku-ji open then?',
mentions: [
{ kind: 'place', id: place?.id },
{ kind: 'day', id: day.id },
{ kind: 'place', id: elsewhere.id },
],
});
expect(turn.status).toBe(200);
const context = String(provider.requests[0].messages[0].content);
expect(context).toContain('The user names these in the message');
expect(context).toContain(`- place ${place?.id}: Kinkaku-ji`);
expect(context).toContain(`- day ${day.id} (2026-06-11)`);
expect(context).not.toContain('Elsewhere');
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
expect(shown.body.messages[0].parts).toEqual([
{ type: 'text', text: 'Is @Kinkaku-ji open then?' },
{
type: 'data-trek-mentions',
data: {
items: [
{ kind: 'place', id: place?.id, label: 'Kinkaku-ji' },
{ kind: 'day', id: day.id, label: expect.stringContaining('11') },
],
},
},
]);
});
it('ASTE2E-021: an answer is shared in the trip chat as from the assistant, with the chat on and the right to write', async () => {
const t = await travelerWithTrip();
await setAddonEnabled(orm, 'collab', true);
provider.script = [say('Kyoto in June is lovely.')];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'How is June?' });
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
const messageId = ofType(turn.chunks, 'start')[0].messageId;
const share = (id: string) =>
request(nestApp.getHttpServer())
.post(`${t.base}/threads/${threadId}/messages/${id}/share`)
.set('Cookie', sessionCookie(t.userId))
.set('X-Socket-Id', 'sock-1')
.send({ text: ' Kyoto in June is lovely. ' });
const shared = await share(messageId);
expect(shared.status).toBe(201);
expect(shared.body.message).toMatchObject({
text: 'Kyoto in June is lovely.',
origin: 'assistant',
user_id: t.userId,
});
expect(broadcasts.broadcast).toHaveBeenCalledWith(
String(t.tripId),
'collab:message:created',
{ message: expect.objectContaining({ origin: 'assistant' }) },
'sock-1',
);
const listed = await get(`/api/trips/${t.tripId}/collab/messages`, t.userId);
expect(listed.body.messages).toEqual([
expect.objectContaining({ id: shared.body.message.id, origin: 'assistant' }),
]);
const question = `msg-${Number(messageId.slice('msg-'.length)) - 1}`;
expect((await share(question)).status).toBe(404);
expect((await share('not-a-message')).status).toBe(404);
const permission = vi.spyOn(nestApp.get(PermissionsService), 'checkPermission').mockResolvedValueOnce(false);
try {
expect((await share(messageId)).status).toBe(403);
} finally {
permission.mockRestore();
}
await setCollabFeature(orm, 'chat', false);
try {
expect(await share(messageId)).toMatchObject({ status: 403, body: { error: 'Collab chat is off' } });
} finally {
await setCollabFeature(orm, 'chat', true);
}
expect(await countRows(orm, CollabMessages, { trip: t.tripId })).toBe(1);
});
});