Files
TREK/server/tests/helpers/mcp-test-controllers.ts
T
Maurice ba7d55f999 Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
2026-08-10 19:54:46 +02:00

180 lines
12 KiB
TypeScript

import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp';
import { db } from '../../src/db/database';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
import { AtlasMcp } from '../../src/nest/atlas/atlas.mcp';
import { AtlasService } from '../../src/nest/atlas/atlas.service';
import { AuthService } from '../../src/nest/auth/auth.service';
import { BudgetMcp } from '../../src/nest/budget/budget.mcp';
import { BudgetService } from '../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
import { CategoriesMcp } from '../../src/nest/categories/categories.mcp';
import { CategoriesService } from '../../src/nest/categories/categories.service';
import { CollabMcp } from '../../src/nest/collab/collab.mcp';
import { CollabService } from '../../src/nest/collab/collab.service';
import { CollectionsMcp } from '../../src/nest/collections/collections.mcp';
import { CollectionsService } from '../../src/nest/collections/collections.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { DayNotesMcp } from '../../src/nest/day-notes/day-notes.mcp';
import { DayNotesService } from '../../src/nest/day-notes/day-notes.service';
import { DaysMcp } from '../../src/nest/days/days.mcp';
import { DaysService } from '../../src/nest/days/days.service';
import { MapsMcp } from '../../src/nest/maps/maps.mcp';
import { WeatherMcp } from '../../src/nest/weather/weather.mcp';
import { WeatherService } from '../../src/nest/weather/weather.service';
import { AirportsMcp } from '../../src/nest/airports/airports.mcp';
import { AuthMcp } from '../../src/nest/auth/auth.mcp';
import { MapsService } from '../../src/nest/maps/maps.service';
import { NotificationsMcp } from '../../src/nest/notifications/notifications.mcp';
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
import { PackingMcp } from '../../src/nest/packing/packing.mcp';
import { PackingService } from '../../src/nest/packing/packing.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { PlacesMcp } from '../../src/nest/places/places.mcp';
import { PlacesService } from '../../src/nest/places/places.service';
import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp';
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
import { TagsMcp } from '../../src/nest/tags/tags.mcp';
import { TagsService } from '../../src/nest/tags/tags.service';
import { SettingsService } from '../../src/nest/settings/settings.service';
import { ShareMcp } from '../../src/nest/share/share.mcp';
import { ShareService } from '../../src/nest/share/share.service';
import { TodoMcp } from '../../src/nest/todo/todo.mcp';
import { TodoService } from '../../src/nest/todo/todo.service';
import { TransitMcp } from '../../src/nest/transit/transit.mcp';
import { TransitService } from '../../src/nest/transit/transit.service';
import { FilesService } from '../../src/nest/files/files.service';
import { TripsMcp } from '../../src/nest/trips/trips.mcp';
import { TripsService } from '../../src/nest/trips/trips.service';
import { VacayMcp } from '../../src/nest/vacay/vacay.mcp';
import { VacayService } from '../../src/nest/vacay/vacay.service';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
import { JourneyMcp } from '../../src/nest/journey/journey.mcp';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service';
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
import { CalendarService } from '../../src/nest/calendar/calendar.service';
import { AccommodationsService } from '../../src/nest/accommodations/accommodations.service';
import { AccommodationsMcp } from '../../src/nest/accommodations/accommodations.mcp';
import { TripMembersService } from '../../src/nest/trip-members/trip-members.service';
import { TripReadModelService } from '../../src/nest/trip-read-model/trip-read-model.service';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { notificationsStub } from './notifications';
import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
* for the no-Nest MCP harness. One line per migrated domain — add the new
* @McpController instance here when a domain moves off the legacy registrar
* fan-out. Constructing against the `db` Proxy keeps per-file vi.mock's of
* src/db/database flowing through (same pattern as todo.bridge.ts).
*/
export function createMcpTestRegistry(): McpRegistry {
const dbService = new DatabaseService(db);
const permissionsService = new PermissionsService(dbService);
// Same argument list as auth.bridge.ts. AtlasService used to sit in third
// place; when getTravelStats moved onto AtlasService itself the edge was
// dropped and four collaborators took its place, but this call site kept the
// old shape, so `membership` and `webauthn` held the wrong objects and
// userCleanup/mailer/tokens were undefined.
const authService = new AuthService(
dbService,
permissionsService,
new TripMembershipService(dbService),
new WebauthnConfigService(dbService),
new UserCleanupService(dbService),
new MailerService(dbService),
new EphemeralTokenService(),
);
const realtimeService = new RealtimeService();
const queryHelpersService = new QueryHelpersService(dbService);
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService);
const exchangeRatesService = new ExchangeRatesService();
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService);
const todoService = new TodoService(dbService, permissionsService, realtimeService);
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub());
// Exactly one instance, shared by maps, places and share: its stampede guard
// and its on-disk set only work if all three readers see the same maps.
const placePhotoCache = new PlacePhotoCacheService(dbService, new RuntimeEnvService());
const mapsService = new MapsService(dbService, placePhotoCache);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService));
// The last three were previously omitted, which left them `undefined` at
// runtime — silently fine while nothing called them, a TypeError the moment
// the journey skeleton hooks landed on the place write paths. tsconfig only
// includes `src`, so nothing here is typechecked; pass them for real.
const placesService = new PlacesService(
dbService, permissionsService, realtimeService, mapsService, queryHelpersService,
new UnsplashService(dbService, new RuntimeEnvService()),
placePhotoCache,
journeyDomain,
);
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub());
const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService);
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService), permissionsService, realtimeService, notificationsStub());
const tripsService = new TripsService(
dbService,
reservationsService,
daysService,
permissionsService,
budgetService,
new VacayService(dbService, realtimeService, notificationsStub()),
realtimeService,
new UnsplashService(dbService, new RuntimeEnvService()),
);
const readModelService = new TripReadModelService(
dbService, membersService, daysService, accommodationsService, budgetService,
packingService, reservationsService, collabService, placesService, todoService,
new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService()),
);
const calendarService = new CalendarService(dbService, reservationsService);
// The nine addon-gated surfaces read their toggle off an injected service now
// rather than off addons.bridge's own instance, so the harness has to supply
// one — against the same test DB, which is what makes the `when:` gates
// answer truthfully here instead of against the process-wide singleton.
const addonsService = new AddonsService(dbService);
// One instance, two consumers: AssignmentsMcp and, since it stopped reaching
// through assignments.bridge, ReservationsMcp.
const assignmentsService = new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain);
return createTestRegistry(
[
new TagsMcp(new TagsService(dbService), authService),
new CategoriesMcp(new CategoriesService(dbService)),
// The weather and airport tools left the legacy mapsWeather registrar.
new WeatherMcp(new WeatherService()),
new AirportsMcp(),
new AuthMcp(),
new TodoMcp(todoService, authService, addonsService),
new PackingMcp(packingService, authService, addonsService),
new BudgetMcp(budgetService, exchangeRatesService, dbService, authService, addonsService),
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService),
new DaysMcp(daysService, authService),
new AccommodationsMcp(accommodationsService, dbService, placesService, authService),
new AssignmentsMcp(assignmentsService, daysService, authService),
new CollabMcp(collabService, authService, addonsService),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub()), dbService, authService, addonsService),
new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService),
new AtlasMcp(new AtlasService(dbService), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain), addonsService, authService),
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
);
}