Files
TREK/server/tests/unit/nest/addon-guard.test.ts
T
Maurice 26df56d76c feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat
The three AI addons share one tile in the integration column; a dialog holds
the connection, the assistant's limits and AI parsing, which now reads with
the assistant's connection unless an install keeps its own. Existing parsing
configs stay where they are and keep winning.

The chat sees the map on screen, offers next questions under an answer,
shows its live step in the window head, previews waiting changes inside the
day list, checks whether a day still works, folds a multi-day plan into a
week grid, keeps per-trip notes and can estimate costs. An empty or cut-off
answer gets one push instead of ending silently, and the assistant's own
step and size limits are raised.
2026-10-21 22:29:57 +02:00

137 lines
6.0 KiB
TypeScript

/**
* The addon route gate.
*
* Three hand-written guards (collections, airtrail, journey) did the same thing
* and differed only in the addon id they read and the word leading the 404. They
* are one guard plus a @RequireAddon decorator now.
*
* The last describe matters as much as the first: with the addon id in a
* decorator, nothing in a controller's own tests notices if the decorator is
* dropped or points at the wrong addon. These assertions are what keeps the
* three route groups gated, and gated on the right thing.
*/
import { ADDON_IDS } from '../../../src/addons';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { RequireAddon, REQUIRE_ADDON, type RequireAddonMeta } from '../../../src/nest/addons/require-addon.decorator';
import { CollectionsController } from '../../../src/nest/collections/collections.controller';
import { AirtrailController } from '../../../src/nest/integrations/airtrail.controller';
import { JourneyController } from '../../../src/nest/journey/journey.controller';
import { ReservationImportController } from '../../../src/nest/reservation-import/reservation-import.controller';
import { HttpException } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { describe, it, expect, vi } from 'vitest';
/** A context whose handler/class carry the decorator under test. */
function ctxFor(handler: object, cls: object = class {}): ExecutionContext {
return {
getHandler: () => handler,
getClass: () => cls,
} as unknown as ExecutionContext;
}
function addons(enabled: boolean): AddonsService {
return { isAddonEnabled: vi.fn(() => enabled) } as unknown as AddonsService;
}
async function thrown(fn: () => unknown) {
try {
await fn();
return undefined;
} catch (err) {
return err instanceof HttpException ? { status: err.getStatus(), body: err.getResponse() } : err;
}
}
/** Apply @RequireAddon to a throwaway class the way a controller would. */
function decorated(addonId: string | readonly string[], label: string) {
class Gated {}
RequireAddon(addonId, label)(Gated);
return Gated;
}
describe('AddonGuard', () => {
it('ADDON-GUARD-001: 404s with the decorator label when the addon is disabled', async () => {
const cls = decorated(ADDON_IDS.JOURNEY, 'Journey');
const guard = new AddonGuard(addons(false), new Reflector());
expect(await thrown(() => guard.canActivate(ctxFor(() => {}, cls)))).toEqual({
status: 404,
body: { error: 'Journey addon is not enabled' },
});
});
it('ADDON-GUARD-002: passes when the addon is enabled, asking for the declared id', async () => {
const cls = decorated(ADDON_IDS.COLLECTIONS, 'Collections');
const svc = addons(true);
const guard = new AddonGuard(svc, new Reflector());
expect(await guard.canActivate(ctxFor(() => {}, cls))).toBe(true);
expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.COLLECTIONS);
});
it('ADDON-GUARD-007: a list of addons opens the route while any one of them is on', async () => {
const cls = decorated([ADDON_IDS.AI_ASSISTANT, ADDON_IDS.LLM_PARSING], 'AI assistant');
const isAddonEnabled = vi.fn(async (id: string) => id === ADDON_IDS.LLM_PARSING);
const guard = new AddonGuard({ isAddonEnabled } as unknown as AddonsService, new Reflector());
expect(await guard.canActivate(ctxFor(() => {}, cls))).toBe(true);
isAddonEnabled.mockResolvedValue(false);
expect(await thrown(() => guard.canActivate(ctxFor(() => {}, cls)))).toEqual({
status: 404,
body: { error: 'AI assistant addon is not enabled' },
});
});
it('ADDON-GUARD-003: is a no-op on a route that never asked for an addon', async () => {
const svc = addons(false);
const guard = new AddonGuard(svc, new Reflector());
expect(await guard.canActivate(ctxFor(() => {}, class {}))).toBe(true);
expect(svc.isAddonEnabled).not.toHaveBeenCalled();
});
it('ADDON-GUARD-004: a handler-level decorator overrides the controller-level one', async () => {
const cls = decorated(ADDON_IDS.JOURNEY, 'Journey');
const handler = () => {};
RequireAddon(ADDON_IDS.AIRTRAIL, 'AirTrail')(handler);
const svc = addons(false);
expect(await thrown(() => new AddonGuard(svc, new Reflector()).canActivate(ctxFor(handler, cls)))).toEqual({
status: 404,
body: { error: 'AirTrail addon is not enabled' },
});
expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.AIRTRAIL);
});
});
describe('the gated route groups still declare their addon', () => {
const metaOf = (cls: object): RequireAddonMeta | undefined => Reflect.getMetadata(REQUIRE_ADDON, cls);
const guardsOf = (cls: object): unknown[] => Reflect.getMetadata('__guards__', cls) ?? [];
it.each([
['JourneyController', JourneyController, ADDON_IDS.JOURNEY, 'Journey'],
['CollectionsController', CollectionsController, ADDON_IDS.COLLECTIONS, 'Collections'],
['AirtrailController', AirtrailController, ADDON_IDS.AIRTRAIL, 'AirTrail'],
])('ADDON-GUARD-005: %s gates on the right addon, with AddonGuard first', (_name, cls, addonId, label) => {
expect(metaOf(cls)).toEqual({ addonId, label });
// Order is the contract: a disabled addon has to answer 404 to anonymous
// callers, so the addon check must run before the 401.
expect(guardsOf(cls)[0]).toBe(AddonGuard);
});
it('ADDON-GUARD-006: the merged import controller gates AirTrail per handler, not per class', () => {
// A class-level requirement here would 404 the four file-upload routes
// whenever the AirTrail addon happens to be off. AddonGuard still runs first
// in the chain, so the 404 keeps beating the 401.
expect(metaOf(ReservationImportController)).toBeUndefined();
expect(Reflect.getMetadata(REQUIRE_ADDON, ReservationImportController.prototype.importAirtrail)).toEqual({
addonId: ADDON_IDS.AIRTRAIL,
label: 'AirTrail',
});
expect(guardsOf(ReservationImportController)[0]).toBe(AddonGuard);
});
});