Files
TREK/server/tests/unit/nest/features.controller.test.ts
T
Maurice 214984f398 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-21 22:29:56 +02:00

178 lines
7.5 KiB
TypeScript

import { ADDON_IDS } from '../../../src/addons';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { BookingImportModule } from '../../../src/nest/booking-import/booking-import.module';
import { KitineraryExtractorModule } from '../../../src/nest/booking-import/kitinerary-extractor.module';
import { KitineraryExtractorService } from '../../../src/nest/booking-import/kitinerary-extractor.service';
import { MaintenanceModule } from '../../../src/nest/database/maintenance.module';
import { FeaturesController } from '../../../src/nest/health/features.controller';
import { HealthModule } from '../../../src/nest/health/health.module';
import { ReadinessService } from '../../../src/nest/health/readiness.service';
import { expectRegisteredController, expectRegisteredProvider } from '../../helpers/module-providers';
import { describe, it, expect, vi } from 'vitest';
type Extractor = Pick<KitineraryExtractorService, 'isAvailable'>;
type Addons = { isAddonEnabled: (id: string) => boolean };
function make(available: boolean, aiEnabled: boolean) {
const extractor = { isAvailable: vi.fn(() => available) };
const addons = { isAddonEnabled: vi.fn(() => aiEnabled) };
return {
extractor,
addons,
controller: new FeaturesController(
extractor as Extractor as KitineraryExtractorService,
addons as never,
{} as never,
new ReadinessService(),
),
};
}
describe('FeaturesController (GET /api/health/features)', () => {
it('FEAT-001: reports both flags on', async () => {
const { controller } = make(true, true);
expect(await controller.features()).toEqual({ bookingImport: true, aiParsing: true, aiAssistant: true });
});
it('FEAT-002: reports both flags off', async () => {
const { controller } = make(false, false);
expect(await controller.features()).toEqual({ bookingImport: false, aiParsing: false, aiAssistant: false });
});
it('FEAT-003: the two flags are independent', async () => {
expect(await make(true, false).controller.features()).toEqual({
bookingImport: true,
aiParsing: false,
aiAssistant: false,
});
expect(await make(false, true).controller.features()).toEqual({
bookingImport: false,
aiParsing: true,
aiAssistant: true,
});
});
it('FEAT-004: aiParsing asks the addons service for the LLM parsing addon specifically', async () => {
const { controller, addons } = make(true, true);
await controller.features();
expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.LLM_PARSING);
});
it('FEAT-013: aiAssistant follows its own addon, and is off on a managed install whatever the addon says', async () => {
const { controller, addons } = make(true, true);
await controller.features();
expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.AI_ASSISTANT);
vi.stubEnv('TREK_MANAGED', 'true');
try {
expect((await controller.features()).aiAssistant).toBe(false);
} finally {
vi.unstubAllEnvs();
}
});
it('FEAT-005: bookingImport is read live, not cached at construction', async () => {
const { controller, extractor } = make(false, false);
await controller.features();
extractor.isAvailable.mockReturnValue(true);
expect((await controller.features()).bookingImport).toBe(true);
expect(extractor.isAvailable).toHaveBeenCalledTimes(2);
});
it('FEAT-006: the route lives on the health module, not on booking-import', () => {
expectRegisteredController(HealthModule, FeaturesController);
const bookingControllers = Reflect.getMetadata('controllers', BookingImportModule) as unknown[];
expect(bookingControllers).not.toContain(FeaturesController);
});
it('FEAT-007: the extractor is reachable from health without importing the booking-import domain', () => {
// The whole point of the leaf module: health depends on the probe, not on
// llm-parse/reservations/budget/maps/places behind BookingImportModule.
expectRegisteredProvider(KitineraryExtractorModule, KitineraryExtractorService);
const healthImports = Reflect.getMetadata('imports', HealthModule) as unknown[];
expect(Array.isArray(healthImports)).toBe(true);
expect(healthImports).toEqual(expect.arrayContaining([KitineraryExtractorModule, MaintenanceModule]));
expect(healthImports).not.toContain(BookingImportModule);
});
it('FEAT-008: the controller keeps its public path', () => {
expect(Reflect.getMetadata('path', FeaturesController)).toBe('api/health');
expect(Reflect.getMetadata('path', FeaturesController.prototype.features)).toBe('features');
});
it('FEAT-009: GET /api/health writes the exact probe bytes (legacy parity)', () => {
const { controller } = make(true, true);
const res = {
headers: {} as Record<string, string>,
body: undefined as unknown,
setHeader: vi.fn(function (this: { headers: Record<string, string> }, k: string, v: string) {
this.headers[k] = v;
}),
json: vi.fn(function (this: { body: unknown }, b: unknown) {
this.body = b;
}),
};
controller.health(res as never);
expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate');
expect(res.body).toEqual({ status: 'ok' });
});
describe('the readiness probe (GET /api/health/ready)', () => {
function response() {
const res = { setHeader: vi.fn(), status: vi.fn(), json: vi.fn() };
res.status.mockReturnValue(res);
return res;
}
it('FEAT-010: answers ready while the database answers, and 503 when it does not', async () => {
const execute = vi.fn().mockResolvedValue([]);
const em = { getConnection: () => ({ execute }), getContext: () => em };
const extractor = { isAvailable: vi.fn(() => true) };
const controller = new FeaturesController(
extractor as Extractor as KitineraryExtractorService,
{} as never,
new MaintenanceRepository(em as never),
new ReadinessService(),
);
const ok = response();
await controller.ready(ok as never);
expect(execute).toHaveBeenCalledWith('SELECT 1');
expect(ok.json).toHaveBeenCalledWith({ status: 'ready' });
expect(ok.status).not.toHaveBeenCalled();
execute.mockRejectedValueOnce(new Error('connection is not available (restore in progress?)'));
const down = response();
await controller.ready(down as never);
expect(down.status).toHaveBeenCalledWith(503);
expect(down.json).toHaveBeenCalledWith({ status: 'unavailable' });
});
it('FEAT-011: answers 503 once a shutdown has started, without asking the database', async () => {
const execute = vi.fn().mockResolvedValue([]);
const em = { getConnection: () => ({ execute }), getContext: () => em };
const readiness = new ReadinessService();
const controller = new FeaturesController(
{} as never,
{} as never,
new MaintenanceRepository(em as never),
readiness,
);
readiness.markDraining();
const draining = response();
await controller.ready(draining as never);
expect(draining.status).toHaveBeenCalledWith(503);
expect(draining.json).toHaveBeenCalledWith({ status: 'unavailable' });
expect(execute).not.toHaveBeenCalled();
});
it('FEAT-012: Nest closing the app marks the process as draining too', () => {
const readiness = new ReadinessService();
expect(readiness.isDraining()).toBe(false);
readiness.beforeApplicationShutdown();
expect(readiness.isDraining()).toBe(true);
});
});
});