mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
55c6bad295
- A card for a tool without one of its own names what it works on: the place of an assignment, the day, the note, the booking; its arguments sit under the labels the other cards use, a new order reads as the names in order. 28 frequent tools carry their own action name in every language. - The server's own steps without a card still refuse, as before. - Tests: the rate limit is asked one request after another, the mention list closes on the caret move a browser reports, the place factory hands its category over as a plain id, eleven files formatted.
981 lines
43 KiB
TypeScript
981 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 a denied tool', 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_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();
|
|
// One after another, as a user would; a burst of requests at once only tests the test server.
|
|
const undoOnce = () =>
|
|
request(nestApp.getHttpServer())
|
|
.post(`${t.base}/tool-calls/missing/undo`)
|
|
.set('Cookie', sessionCookie(t.userId))
|
|
.send({});
|
|
const responses = await Array.from({ length: TURNS_PER_MINUTE + 1 }).reduce<
|
|
Promise<Array<{ status: number; body: unknown }>>
|
|
>(async (done, _unused) => [...(await done), await undoOnce()], Promise.resolve([]));
|
|
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);
|
|
});
|
|
});
|