test(server): MCP suites run on the booted container and unit suites take services from their module

createTestModule (tests/helpers/test-module.ts) builds a Nest testing module of the domain modules under test over the suite's test db, with a FakeRealtimeService and provider overrides for the collaborators a suite controls. bootTestApp (tests/helpers/test-app.ts) boots AppModule once per file, with recording realtime, fixture storage and an offline geocoder, and the MCP harness takes McpRegistryService from it inside a request context instead of hand-wiring roughly eighty services. The days, day removal and day notes service suites take their subjects from DaysModule and DayNotesModule.
This commit is contained in:
Maurice
2026-10-10 18:38:00 +02:00
parent fa0edfd714
commit 6ce15157a1
16 changed files with 303 additions and 653 deletions
+2 -2
View File
@@ -16,11 +16,11 @@
"no-misleading-character-class": 1
},
"tests": {
"@typescript-eslint/no-explicit-any": 1571,
"@typescript-eslint/no-explicit-any": 1569,
"@typescript-eslint/no-non-null-assertion": 1757,
"@typescript-eslint/no-require-imports": 25,
"@typescript-eslint/no-unsafe-function-type": 7,
"@typescript-eslint/no-unused-vars": 101,
"@typescript-eslint/no-unused-vars": 96,
"no-await-in-loop": 459,
"no-console": 18,
"no-empty": 2,
-1
View File
@@ -1009,7 +1009,6 @@
"tests/helpers/journey-repos.ts",
"tests/helpers/journey-share-repos.ts",
"tests/helpers/mcp-harness.ts",
"tests/helpers/mcp-test-controllers.ts",
"tests/helpers/migration-step.ts",
"tests/helpers/notifications-repos.ts",
"tests/helpers/notifications.ts",
+14 -5
View File
@@ -15,7 +15,10 @@ import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { registerTools } from '../../src/mcp/tools';
import type { McpAttachOptions } from '../../src/nest-mcp';
import { MikroORM } from '@mikro-orm/core';
import { withRequestContext } from '../../src/nest/database/request-context';
import { createMcpTestRegistry } from './mcp-test-controllers';
import { bootTestApp } from './test-app';
import type { RealtimeService } from '../../src/nest/realtime/realtime.service';
export interface McpHarness {
@@ -62,11 +65,17 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
if (withTools) {
// In production the transport service passes its injected
// McpRegistryService to registerTools; the harness has no Nest app, so it
// builds the same registry by hand (see mcp-test-controllers.ts).
// registerTools' own ctx construction stays exercised.
await registerTools(await createMcpTestRegistry(realtime), server, userId, scopes ?? null, isStaticToken, getDeprecationNotice, undefined, dynamicTools);
// The registry is the booted container's McpRegistryService, as the
// transport service hands it to registerTools. In production every /mcp
// request runs inside the request context the ORM middleware forks; the
// harness has no HTTP request, so it opens one for the attach and for every
// call through the registry's `around` seam, as the transport's requests do.
const orm = (await bootTestApp(realtime)).get(MikroORM);
const registry = await createMcpTestRegistry(realtime);
const around = (_info: { kind: string; name: string }, call: () => unknown): unknown => withRequestContext(orm, call);
await withRequestContext(orm, () =>
registerTools(registry, server, userId, scopes ?? null, isStaticToken, getDeprecationNotice, undefined, dynamicTools, around),
);
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+16 -506
View File
@@ -1,510 +1,20 @@
import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { db } from '../../src/db/database';
import { trekDemoToolGate, trekMcpAccessPolicy, trekMcpErrorMapper, 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 { 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 { DemoService } from '../../src/nest/common/demo.service';
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
import { CollectionsMcp } from '../../src/nest/collections/collections.mcp';
import { CollectionsService } from '../../src/nest/collections/collections.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 { DayRemovalService } from '../../src/nest/days/day-removal.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 { buildMapsService } from './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 { PackingWritesService } from '../../src/nest/packing/packing-writes.service';
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 { buildPlaceImportService } from './place-import';
import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp';
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
import { ReservationsReadService } from '../../src/nest/reservations/reservations-read.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 { SettingsMcp } from '../../src/nest/settings/settings.mcp';
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 { GoogleTransitProvider } from '../../src/nest/transit/google-transit.provider';
import { TransitService } from '../../src/nest/transit/transit.service';
import { transitConfig } from '../../src/nest/app-config/tokens';
import { FilesService } from '../../src/nest/files/files.service';
import { FilesMcp } from '../../src/nest/files/files.mcp';
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 { McpToolGuardsService } from '../../src/nest/mcp-shared/mcp-tool-guards.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 { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photo-registration.service';
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekPhotoCacheMeta } from '../../src/db/entities/TrekPhotoCacheMeta.entity';
import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
import { CalendarService } from '../../src/nest/calendar/calendar.service';
import { FeedsMcp } from '../../src/nest/feeds/feeds.mcp';
import { FeedsService } from '../../src/nest/feeds/feeds.service';
import { TripInviteMcp } from '../../src/nest/trip-invite/trip-invite.mcp';
import { TripInviteService } from '../../src/nest/trip-invite/trip-invite.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 { TripPromptsMcp } from '../../src/nest/trips/trip-prompts.mcp';
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 { createTestAddonsService } from './test-addons';
import { RoadtripMcp } from '../../src/nest/roadtrip/roadtrip.mcp';
import { RoadtripPreferencesMcp } from '../../src/nest/roadtrip/roadtrip-preferences.mcp';
import { RoadtripPreferencesService } from '../../src/nest/roadtrip/roadtrip-preferences.service';
import { RoadtripService } from '../../src/nest/roadtrip/roadtrip.service';
import { notificationsStub } from './notifications';
import { EphemeralTokenService } from '../../src/nest/auth-core/ephemeral-token.service';
import { AllowedFileTypesService } from '../../src/nest/files/allowed-file-types.service';
import { MemoriesMcp } from '../../src/nest/memories/memories.mcp';
import { ImmichService } from '../../src/nest/memories/immich.service';
import { SynologyService } from '../../src/nest/memories/synology.service';
import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service';
import { PhotoCaptureBackfillService } from '../../src/nest/memories/photo-capture-backfill.service';
import { JourneyPhotoCaptureService } from '../../src/nest/journey/journey-photo-capture.service';
import { PhotoResolverService } from '../../src/nest/memories/photo-resolver.service';
import { ThumbnailService } from '../../src/nest/memories/thumbnail.service';
import { TrekPhotoCacheService } from '../../src/nest/memories/trek-photo-cache.service';
import { PhotoProviderRegistry } from '../../src/nest/memories/photo-provider.registry';
import { ImmichPhotoProvider } from '../../src/nest/memories/providers/immich.provider';
import { SynologyPhotoProvider } from '../../src/nest/memories/providers/synology.provider';
import { AuditService } from '../../src/nest/audit/audit.service';
import { makeStorageFixture } from './storage-fixture';
// No plugin supervisor in this harness, so PluginHooks is built over an inert runtime
// and the warnings tool answers empty by default; the trip-warnings suite spies on
// PluginHooks.prototype to play the provider fan-out.
import { TripWarningsMcp } from '../../src/nest/plugins/contributions/trip-warnings.mcp';
import { PluginSearchMcp } from '../../src/nest/plugins/contributions/plugin-search.mcp';
import { PluginPoisMcp } from '../../src/nest/plugins/contributions/plugin-pois.mcp';
import { PluginPoisService } from '../../src/nest/plugins/contributions/plugin-pois.service';
import { PluginHooks } from '../../src/nest/plugins/plugin-hooks.service';
import type { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service';
import { AirtrailMcp } from '../../src/nest/integrations/airtrail.mcp';
import { AirtrailService } from '../../src/nest/integrations/airtrail.service';
import { AirtrailClient } from '../../src/nest/integrations/airtrail.client';
import { AirtrailImportService } from '../../src/nest/integrations/airtrail-import.service';
import { ReservationImportMcp } from '../../src/nest/reservation-import/reservation-import.mcp';
import { HelpMcp } from '../../src/nest/help/help.mcp';
import { AddonsMcp } from '../../src/nest/addons/addons.mcp';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo, createTestSettingsRepo,
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
createTestTripMembersRepo, createTestTripInviteTokensRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestRoadtripViasRepo, createTestRoadtripDayTracksRepo,
createTestPlaceDetailsCacheRepo,
createTestRoadtripPreferencesRepo, createTestRoadtripDayBoundariesRepo,
createTestReservationsRepo,
createTestReservationEndpointsRepo,
createTestReservationTravelersRepo,
createTestReservationDayPositionsRepo,
createTestDayAccommodationsRepo,
createTestUsersRepo,
createTestUserImmichRepo,
createTestUserSynologyRepo,
createTestUserAirtrailRepo,
createTestCollectionsRepo, createTestCollectionMembersRepo, createTestCollectionLabelsRepo,
createTestCollectionPlacesRepo, createTestCollectionPlaceRatingsRepo,
sharedTestOrm,
} from './test-uow';
import { createTestOrm } from './test-orm';
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
import { budgetRepoArgs } from './budget-repos';
import { createTestShareTokensRepo, shareServiceRepoArgs, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from './share-repos';
import {
createTestCollabMessageReactionsRepo, createTestCollabNotesRepo, createTestCollabPollsRepo,
createTestCollabPollVotesRepo, createTestCollabLinksRepo, createTestCollabMessagesRepo,
} from './collab-repos';
import {
createTestPackingItemsRepo, createTestPackingItemContributorsRepo, createTestPackingBagsRepo,
createTestPackingCategoryAssigneesRepo, createTestPackingTemplatesRepo, createTestPackingTemplateCategoriesRepo,
createTestPackingTemplateItemsRepo,
} from './packing-repos';
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from './todo-repos';
import {
createTestSchoolHolidayCountriesRepo, createTestSchoolHolidayRegionsRepo,
createTestSchoolHolidayPeriodsRepo, createTestVacayHolidayCalendarsRepo,
} from './school-holidays-repos';
import {
createTestVacayPlansRepo, createTestVacayPlanMembersRepo, createTestVacayYearsRepo, createTestVacayUserYearsRepo,
createTestVacayUserColorsRepo, createTestVacayEntriesRepo, createTestVacayCompanyHolidaysRepo,
createTestVacaySharesRepo, createTestVacayUserSettingsRepo,
} from './vacay-repos';
import {
createTestBucketListRepo, createTestHiddenCountriesRepo, createTestHiddenRegionsRepo,
createTestVisitedCountriesRepo, createTestVisitedRegionsRepo, createTestPlaceRegionsRepo,
} from './atlas-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from './journey-repos';
import { createTestJourneyShareTokensRepo } from './journey-share-repos';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { noGoogleQuota } from './google-quota';
import { createTestToursRepo } from './tours-repos';
import { createTestBudgetSettlementsRepo } from './budget-repos';
import { MaintenanceRepository } from '../../src/db/repositories/MaintenanceRepository';
import { TripAccessService } from '../../src/nest/trip-membership/trip-access.service';
import { McpRegistryService, type McpRegistry } from '../../src/nest-mcp';
import type { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { bootTestApp } from './test-app';
/**
* 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).
* The MCP registry the MCP harness attaches, taken from the booted
* application container (tests/helpers/test-app.ts): the same
* McpRegistryService production builds, with every @McpController the
* container discovers and the policy AppModule hands McpModule.forRoot (the
* access policy, the demo tool gate, the error mapper).
*
* It used to be a hand-wired copy of that graph, one positional constructor
* call per service, which every new dependency broke. Booting the container
* once per file costs about what building the copy did per harness.
*
* Pass the suite's FakeRealtimeService to assert on broadcasts.
*/
export async function createMcpTestRegistry(realtimeService: RealtimeService = new RealtimeService()): Promise<McpRegistry> {
// Plan 3c Task 0b / Plan 4 Task 4: `mcpOrm` gives every repository below
// its `EntityManager` — `canAccessTrip`/`isOwner`/`rosterUserIds`/
// `getPlaceWithTags` resolve `TripsRepository`/`TripMembersRepository`/
// `PlacesRepository` through it directly now; `DatabaseService` is gone.
const mcpOrm = await createTestOrm(db);
// DemoService answers the registry's demo gate (trekDemoToolGate) below.
const demoService = new DemoService(new RuntimeEnvService(), mcpOrm.em);
const generalStorage = makeStorageFixture('').storage;
const appSettings = (await createTestOrm(db)).repo(AppSettings);
const auditLogRepo = mcpOrm.repo(AuditLog);
const usersRepo = mcpOrm.repo(Users);
const permissionsService = new PermissionsService(await createTestAppSettingsRepo(db), await createTestUnitOfWork(db));
// Plan 4 Task 1 constructor-ripple: the trip `user_id`/user `role` reads
// moved off `DatabaseService` onto `TripsRepository`/`UsersRepository`.
const guards = new McpToolGuardsService(mcpOrm.repo(Trips), usersRepo, permissionsService, realtimeService);
const exchangeRatesService = new ExchangeRatesService();
const budgetService = new BudgetService(permissionsService, exchangeRatesService, realtimeService, await createTestUnitOfWork(db), ...(await budgetRepoArgs(db)));
const queryHelpersService = new QueryHelpersService(await createTestTagsRepo(db), await createTestPlaceRatingsRepo(db), await createTestAssignmentParticipantsRepo(db));
const daysService = new DaysService(
permissionsService,
realtimeService,
queryHelpersService,
await createTestUnitOfWork(db),
await createTestDaysRepo(db),
await createTestDayAssignmentsRepo(db),
await createTestDayNotesRepo(db),
await createTestTripsRepo(db),
await createTestReservationsRepo(db),
await createTestReservationEndpointsRepo(db),
await createTestDayAccommodationsRepo(db),
await createTestRoadtripViasRepo(db),
await createTestRoadtripDayBoundariesRepo(db),
);
const todoService = new TodoService(permissionsService, realtimeService, await createTestUnitOfWork(db), await createTestTodoItemsRepo(db), await createTestTodoCategoryAssigneesRepo(db), new TripAccessService(await createTestTripsRepo(db)), await createTestTripMembersRepo(db));
const packingService = new PackingService(
permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(db),
await createTestPackingItemsRepo(db), await createTestPackingItemContributorsRepo(db), await createTestPackingBagsRepo(db),
await createTestPackingCategoryAssigneesRepo(db), await createTestPackingTemplatesRepo(db), await createTestPackingTemplateCategoriesRepo(db),
await createTestPackingTemplateItemsRepo(db), await createTestTripsRepo(db), await createTestTripMembersRepo(db),
);
const collabService = new CollabService(
permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(db),
await createTestCollabMessageReactionsRepo(db), await createTestCollabNotesRepo(db), await createTestCollabPollsRepo(db),
await createTestCollabPollVotesRepo(db), await createTestCollabLinksRepo(db), await createTestCollabMessagesRepo(db),
await createTestTripsRepo(db),
);
// 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(makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(db), await createTestPlacesRepo(db), await createTestCollectionPlacesRepo(db));
const mapsService = buildMapsService(placePhotoCache, appSettings, usersRepo, await createTestPlaceDetailsCacheRepo(db), await createTestPlacesRepo(db), noGoogleQuota);
const journeyDomain = new JourneyDomainService(
realtimeService, new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), await createTestJourneyPhotosRepo(db)), await createTestUnitOfWork(db),
// Plan 3g Task 1 — the constructor-ripple fix (R9): four journey-owned
// repositories + the already-shared `TripsRepository` (AP1/`getTitle`).
await createTestJourneysRepo(db), await createTestJourneyContributorsRepo(db),
await createTestJourneyTripsRepo(db), await createTestJourneyEntriesRepo(db),
await createTestTripsRepo(db),
// Plan 3g Task 2 — a genuine follow-up constructor-ripple (git status
// confirmed this file clean before editing, per the task brief's own
// allowance): `JourneyPhotosRepository`/`JourneyEntryPhotosRepository`
// (the photos surface, JG19/JG87-116) + `PlacesRepository` (JG44's
// `findRaw`, already imported below for other MCP controllers).
await createTestJourneyPhotosRepo(db), await createTestJourneyEntryPhotosRepo(db),
await createTestPlacesRepo(db),
);
// 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.tests.json
// covers `tests` now and CI runs it (npm run typecheck:tests), so a missed
// dependency fails the build — pass them for real regardless of the gate.
// One instance, four consumers: AssignmentsMcp, ReservationsMcp, PlacesMcp and
// AccommodationsService, which writes the day stop a booked night implies.
const assignmentsService = new AssignmentsService(
new TripAccessService(await createTestTripsRepo(db)), permissionsService, realtimeService, queryHelpersService, journeyDomain, await createTestUnitOfWork(db),
await createTestDayAssignmentsRepo(db),
await createTestAssignmentParticipantsRepo(db),
await createTestDaysRepo(db),
await createTestPlacesRepo(db),
await createTestTripMembersRepo(db),
await createTestRoadtripViasRepo(db),
await createTestToursRepo(db),
);
const accommodationsService = new AccommodationsService(
permissionsService, realtimeService, assignmentsService, await createTestUnitOfWork(db),
new TripAccessService(await createTestTripsRepo(db)),
await createTestDayAccommodationsRepo(db),
await createTestDayAssignmentsRepo(db),
await createTestPlacesRepo(db),
await createTestDaysRepo(db),
await createTestRoadtripViasRepo(db),
await createTestReservationsRepo(db),
await createTestBudgetItemsRepo(db),
);
// Built after it: deleting a place cancels the nights booked at it through this one.
const placesService = new PlacesService(
permissionsService, realtimeService, mapsService, queryHelpersService,
new UnsplashService(appSettings, usersRepo, new RuntimeEnvService(), generalStorage),
placePhotoCache,
journeyDomain,
generalStorage,
accommodationsService, await createTestUnitOfWork(db),
await createTestPlacesRepo(db),
await createTestTagsRepo(db),
await createTestPlaceRatingsRepo(db),
await createTestTripMembersRepo(db),
await createTestDayAssignmentsRepo(db),
await createTestCategoriesRepo(db),
await createTestTripsRepo(db),
await createTestBudgetItemsRepo(db),
await createTestCollectionPlacesRepo(db),
buildPlaceImportService(),
);
// Built after it: a hotel booking writes the stay's day stop through this one.
const reservationsService = new ReservationsService(permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(db), await createTestReservationEndpointsRepo(db), await createTestReservationTravelersRepo(db)), accommodationsService, await createTestUnitOfWork(db), await createTestReservationsRepo(db), await createTestReservationEndpointsRepo(db), await createTestReservationTravelersRepo(db), await createTestReservationDayPositionsRepo(db), await createTestDayAccommodationsRepo(db), await createTestDaysRepo(db), await createTestPlacesRepo(db), await createTestDayAssignmentsRepo(db), await createTestTripMembersRepo(db), await createTestUsersRepo(db), await createTestTripsRepo(db), await createTestBudgetItemsRepo(db));
// Deleting a day cancels the stays on it through the same accommodations service.
const dayRemovalService = new DayRemovalService(
daysService, accommodationsService, assignmentsService, await createTestUnitOfWork(db),
await createTestDaysRepo(db), await createTestDayAccommodationsRepo(db), await createTestRoadtripDayBoundariesRepo(db), await createTestTripsRepo(db),
);
const membersService = new TripMembersService(budgetService, new UserCleanupService(new MaintenanceRepository(mcpOrm.em), budgetService, await createTestUnitOfWork(db), usersRepo, await createTestTripMembersRepo(db), await createTestBudgetItemsRepo(db), await createTestBudgetSettlementsRepo(db), await createTestJourneyShareTokensRepo(db), await createTestJourneysRepo(db), await createTestJourneyEntriesRepo(db), await createTestJourneyContributorsRepo(db), await createTestShareTokensRepo(db), await createTestPluginsRepo(db), await createTestPluginUserErasureQueueRepo(db)), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(db), await createTestTripsRepo(db), await createTestTripMembersRepo(db), usersRepo);
const tripsService = new TripsService(
reservationsService,
daysService,
permissionsService,
budgetService,
new VacayService(
await createTestVacayPlansRepo(db), await createTestVacayPlanMembersRepo(db),
await createTestVacayYearsRepo(db), await createTestVacayUserYearsRepo(db),
await createTestVacayUserColorsRepo(db), await createTestVacayEntriesRepo(db),
await createTestVacayCompanyHolidaysRepo(db), await createTestVacayHolidayCalendarsRepo(db),
await createTestVacaySharesRepo(db), await createTestVacayUserSettingsRepo(db),
await createTestSchoolHolidayRegionsRepo(db),
realtimeService, notificationsStub(), await createTestUnitOfWork(db),
),
realtimeService,
new UnsplashService(appSettings, usersRepo, new RuntimeEnvService(), generalStorage),
generalStorage,
await createTestUnitOfWork(db),
mcpOrm.em,
new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)),
);
// Plan 3e Task 1 (files): FilesService now also takes uow + the repositories
// its R2 transactions and R12 cross-object trip-scoping guard need. Built
// once and shared by both consumers below (readModelService, FilesMcp)
// rather than reconstructed twice with the same seven-repository tail.
const filesService = new FilesService(
new TripAccessService(await createTestTripsRepo(db)), permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em,
await createTestUnitOfWork(db),
await createTestTripFilesRepo(db),
await createTestFileLinksRepo(db),
await createTestReservationsRepo(db),
await createTestPlacesRepo(db),
await createTestDayAssignmentsRepo(db),
await createTestBudgetItemsRepo(db),
);
const readModelService = new TripReadModelService(
await createTestTripsRepo(db), membersService, daysService, accommodationsService, budgetService,
packingService, reservationsService, collabService, placesService, todoService,
filesService,
);
const calendarService = new CalendarService(
reservationsService,
await createTestTripsRepo(db), await createTestDaysRepo(db),
await createTestDayNotesRepo(db), await createTestReservationsRepo(db),
);
// 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 = await createTestAddonsService(db);
// The two photo providers, shared by MemoriesMcp (which browses them) and by
// the capture backfill JourneyMcp schedules after a provider photo is attached
// (which asks them when and where it was taken). Built for real rather than
// stubbed: an empty provider registry would make the backfill answer "unknown
// provider" for every id and hide a wiring mistake behind a caught error.
const memoriesAccess = new MemoriesAccessService(
mcpOrm.repo(TripPhotos), mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripAlbumLinks), mcpOrm.repo(Trips),
// Plan 3g Task 4 constructor-ripple: MA1/MA2/MA6's journey half.
await createTestJourneysRepo(db), await createTestJourneyContributorsRepo(db), await createTestJourneyPhotosRepo(db),
);
const immichService = new ImmichService(new AuditService(auditLogRepo, usersRepo), memoriesAccess, generalStorage, await createTestUserImmichRepo(db), await createTestUnitOfWork(db));
const synologyService = new SynologyService(memoriesAccess, notificationsStub(), await createTestUserSynologyRepo(db));
const trekPhotos = new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), await createTestJourneyPhotosRepo(db));
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, mcpOrm.repo(TrekPhotos)), new TrekPhotoCacheService(mcpOrm.repo(TrekPhotoCacheMeta), generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
return createTestRegistry(
[
new TagsMcp(new TagsService(await createTestTagsRepo(db))),
new CategoriesMcp(new CategoriesService(await createTestCategoriesRepo(db)), new RuntimeEnvService(), guards),
// The weather and airport tools left the legacy mapsWeather registrar.
new WeatherMcp(new WeatherService()),
new AirportsMcp(),
new AuthMcp(),
new TodoMcp(todoService, addonsService, guards),
new PackingMcp(packingService, addonsService, guards, new PackingWritesService(packingService, permissionsService)),
new BudgetMcp(budgetService, exchangeRatesService, new RuntimeEnvService(), new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)), addonsService, guards, await createTestUnitOfWork(db), await createTestPlacesRepo(db), await createTestTripsRepo(db), await createTestTripMembersRepo(db)),
new ReservationsMcp(reservationsService, daysService, budgetService, assignmentsService, guards),
new DayNotesMcp(new DayNotesService(new TripAccessService(await createTestTripsRepo(db)), permissionsService, realtimeService, await createTestDayNotesRepo(db), await createTestDaysRepo(db)), guards),
new DaysMcp(daysService, guards, dayRemovalService),
new RoadtripMcp(
new RoadtripService(
realtimeService,
await createTestUnitOfWork(db),
await createTestDaysRepo(db),
await createTestPlacesRepo(db),
await createTestRoadtripViasRepo(db),
await createTestRoadtripDayTracksRepo(db),
),
new TripAccessService(await createTestTripsRepo(db)),
guards,
addonsService,
),
new RoadtripPreferencesMcp(
new RoadtripPreferencesService(realtimeService, await createTestUnitOfWork(db), await createTestRoadtripPreferencesRepo(db)),
addonsService, new TripAccessService(await createTestTripsRepo(db)), guards,
),
new FilesMcp(filesService, guards, new AllowedFileTypesService(appSettings)),
new AccommodationsMcp(accommodationsService, placesService, guards, await createTestUnitOfWork(db)),
new AssignmentsMcp(assignmentsService, daysService, guards),
new CollabMcp(collabService, addonsService, guards),
new VacayMcp(new VacayService(
await createTestVacayPlansRepo(db), await createTestVacayPlanMembersRepo(db),
await createTestVacayYearsRepo(db), await createTestVacayUserYearsRepo(db),
await createTestVacayUserColorsRepo(db), await createTestVacayEntriesRepo(db),
await createTestVacayCompanyHolidaysRepo(db), await createTestVacayHolidayCalendarsRepo(db),
await createTestVacaySharesRepo(db), await createTestVacayUserSettingsRepo(db),
await createTestSchoolHolidayRegionsRepo(db),
realtimeService, notificationsStub(), await createTestUnitOfWork(db),
), usersRepo, addonsService),
new SchoolHolidaysMcp(
new SchoolHolidaysService(
await createTestSchoolHolidayCountriesRepo(db),
await createTestSchoolHolidayRegionsRepo(db),
await createTestSchoolHolidayPeriodsRepo(db),
await createTestVacayHolidayCalendarsRepo(db),
await createTestUnitOfWork(db),
),
guards,
),
new TripsMcp(tripsService, todoService, collabService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(db), ...(await shareServiceRepoArgs(db))), guards),
new FeedsMcp(new FeedsService(await createTestTripsRepo(db), usersRepo, calendarService), await createTestTripsRepo(db), new RuntimeEnvService(), guards),
new TripInviteMcp(new TripInviteService(new TripAccessService(await createTestTripsRepo(db)), permissionsService, new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)), await createTestUnitOfWork(db), await createTestTripInviteTokensRepo(db)), new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo)),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, new TripAccessService(await createTestTripsRepo(db)), journeyDomain, assignmentsService, guards, await createTestUnitOfWork(db)),
new CollectionsMcp(
new CollectionsService(
permissionsService, realtimeService, notificationsStub(), generalStorage, await createTestUnitOfWork(db),
// Plan 3h Task 1 — the constructor-ripple fix: collections part A's
// own repositories, first cut, plus the already-DONE
// `CategoriesRepository` (CL21 reuse).
await createTestCollectionsRepo(db), await createTestCollectionMembersRepo(db),
await createTestCollectionLabelsRepo(db), await createTestCategoriesRepo(db),
// Plan 3h Task 2 — part B's own repositories: `DatabaseService`
// dropped entirely (this service's LAST use of it — savePlace
// onward is now 100% off `this.db`), the trip/place/tag/user
// repositories AP1-AP6's `TripsRepository.findAccessible` calls
// and the cross-domain writes need, injected directly.
await createTestCollectionPlacesRepo(db), await createTestCollectionPlaceRatingsRepo(db),
await createTestTripsRepo(db), await createTestTripMembersRepo(db),
await createTestPlacesRepo(db), await createTestPlaceRatingsRepo(db),
await createTestTagsRepo(db), usersRepo,
buildPlaceImportService(),
),
// Plan 3h Task 1 — `CollectionsMcp`'s own constructor-ripple fix:
// `DatabaseService` dropped (CL89's only use), `UsersRepository`
// (`findUsernameEmail`, UM11's precedent) added in its place.
usersRepo, addonsService,
),
new TransitMcp(new TransitService(new GoogleTransitProvider(appSettings, usersRepo, noGoogleQuota), transitConfig()), daysService, reservationsService, new TripAccessService(await createTestTripsRepo(db)), guards),
new AtlasMcp(new AtlasService(
await createTestBucketListRepo(db), await createTestHiddenCountriesRepo(db),
await createTestHiddenRegionsRepo(db), await createTestVisitedCountriesRepo(db),
await createTestVisitedRegionsRepo(db), await createTestPlaceRegionsRepo(db),
await createTestTripsRepo(db), await createTestPlacesRepo(db),
await createTestReservationEndpointsRepo(db), await createTestUnitOfWork(db), (await sharedTestOrm(db)).orm,
), addonsService),
new JourneyMcp(journeyDomain, new JourneyShareService(
journeyDomain,
new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)),
// Plan 3g Task 3 — the constructor-ripple fix: `JourneyShareTokensRepository`
// (JS1-JS15) + the already-shared `JourneysRepository` (JS8/JS12).
await createTestJourneyShareTokensRepo(db), await createTestJourneysRepo(db),
// task-5-fix-brief constructor-ripple: `UnitOfWork` (L1) + `JourneyPhotosRepository` (L2).
await createTestUnitOfWork(db), await createTestJourneyPhotosRepo(db),
// Plan 4 Task 8b constructor-ripple: `JourneyEntriesRepository` (JS13)
// + `JourneyEntryPhotosRepository` (JS14), relocated off
// `JourneyShareTokensRepository`'s own fallback stub.
await createTestJourneyEntriesRepo(db), await createTestJourneyEntryPhotosRepo(db),
), addonsService, new JourneyPhotoCaptureService(captureBackfill, journeyDomain, { reverseGeocode: async () => ({ name: null, address: null }) } as never, mcpOrm.orm)),
new MemoriesMcp(immichService, synologyService, addonsService, mcpOrm.repo(PhotoProviders)),
new NotificationsMcp(await makeNotificationsService(db, realtimeService)),
new AirtrailMcp(new AirtrailService(await createTestUserAirtrailRepo(db), new AuditService(auditLogRepo, usersRepo), new AirtrailClient()), addonsService),
new ReservationImportMcp(new AirtrailImportService(
await createTestReservationsRepo(db), await createTestReservationEndpointsRepo(db), await createTestDaysRepo(db),
realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(await createTestUserAirtrailRepo(db), new AuditService(auditLogRepo, usersRepo), new AirtrailClient()),
await createTestUnitOfWork(db),
), new TripAccessService(await createTestTripsRepo(db)), guards, addonsService),
new SettingsMcp(new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db))),
new HelpMcp(), new AddonsMcp(addonsService),
new TripWarningsMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), new TripAccessService(await createTestTripsRepo(db))),
new PluginSearchMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService)),
new PluginPoisMcp(new PluginPoisService(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), await createTestPluginsRepo(db))),
],
{
accessPolicy: trekMcpAccessPolicy,
validateAccess: trekMcpValidateAccess,
// The same demo gate AppModule hands McpModule.forRoot.
toolGate: trekDemoToolGate((id) => demoService.isDemoUserId(id)),
errorMapper: trekMcpErrorMapper,
},
);
export async function createMcpTestRegistry(realtime?: RealtimeService): Promise<McpRegistry> {
return (await bootTestApp(realtime)).get(McpRegistryService);
}
+1 -2
View File
@@ -28,8 +28,7 @@ import {
* factories, bound to the raw better-sqlite3 handle the same way every other
* converted domain's test helper does. The helper's own OUTER signature
* (`db`, `realtime`) is unchanged in shape, so every caller that only goes
* through this function (`mcp-test-controllers.ts`, `plugin-host.ts`, both
* test-only) needs no edit of its own beyond passing the raw handle (Plan 4
* through this function needs no edit of its own beyond passing the raw handle (Plan 4
* Task 4 dropped the `DatabaseService` wrapper — the handle was always all
* this helper read off it).
*
+81
View File
@@ -0,0 +1,81 @@
/**
* The whole application container, booted once per test file for suites that
* drive a cross-domain surface (the MCP tools, the plugin RPC host) rather
* than one domain.
*
* It is AppModule itself, over the `src/db/database` handle the suite mocks
* with a snapshot db, so every service, controller and discovery registry is
* the one production builds. Suites used to rebuild that graph by hand with
* positional constructor calls, which every new dependency broke; now a
* dependency a service gains reaches these suites through its module.
*
* What the container would otherwise reach outside the process is replaced,
* the same stand-ins the hand-built graphs used:
* - RealtimeService: the suite's FakeRealtimeService (or a fresh one), so
* broadcasts are recorded instead of delivered;
* - the reverse geocoder behind the journey photo capture, which would ask
* Nominatim for every attached provider photo;
* - StorageService: a real local driver in a throwaway directory
* (makeStorageFixture), so nothing a suite uploads lands in server/uploads.
*
* The container is built on the first call and handed out again for the same
* RealtimeService, so a suite that opens a harness per case boots once. Lifecycle
* hooks run (discovery fills the MCP and plugin registries in onModuleInit);
* the HTTP server and the websocket gateway are never bound, since no app is
* created on top of the module.
*/
import { AppModule } from '../../src/nest/app.module';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { JourneyPhotoCaptureService } from '../../src/nest/journey/journey-photo-capture.service';
import type { MapsService } from '../../src/nest/maps/maps.service';
import { PhotoCaptureBackfillService } from '../../src/nest/memories/photo-capture-backfill.service';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { StorageService } from '../../src/nest/storage/storage.service';
import { FakeRealtimeService } from './fake-realtime';
import { makeStorageFixture } from './storage-fixture';
import { MikroORM } from '@mikro-orm/core';
import { Test, type TestingModule } from '@nestjs/testing';
/** The geocoder the capture asks for a place name: nothing known, without leaving the process. */
const offlineGeocoder = {
reverseGeocode: async () => ({ name: null, address: null }),
} as unknown as MapsService;
const booted = new Map<RealtimeService, Promise<TestingModule>>();
async function boot(realtime: RealtimeService): Promise<TestingModule> {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(StorageService)
.useValue(makeStorageFixture('').storage)
.overrideProvider(JourneyPhotoCaptureService)
.useFactory({
factory: (backfill: PhotoCaptureBackfillService, journey: JourneyDomainService, orm: MikroORM) =>
new JourneyPhotoCaptureService(backfill, journey, offlineGeocoder, orm),
inject: [PhotoCaptureBackfillService, JourneyDomainService, MikroORM],
})
.compile();
await moduleRef.init();
return moduleRef;
}
/**
* The booted container for this test file. Pass the suite's FakeRealtimeService
* to assert on broadcasts; without one, the file shares a container whose fake
* nobody reads.
*/
export function bootTestApp(realtime?: RealtimeService): Promise<TestingModule> {
const key = realtime ?? defaultRealtime();
const existing = booted.get(key);
if (existing !== undefined) return existing;
const pending = boot(key);
booted.set(key, pending);
return pending;
}
let sharedRealtime: FakeRealtimeService | undefined;
function defaultRealtime(): FakeRealtimeService {
sharedRealtime ??= new FakeRealtimeService();
return sharedRealtime;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* A Nest testing module for unit suites: the domain modules under test over
* the suite's snapshot test db, with the collaborators the suite wants to
* control replaced by provider overrides.
*
* A suite takes its subject out of the container instead of calling its
* constructor, so a service that gains a dependency changes its module, not
* every suite that builds it (lint:test-new-service holds the hand-built rest):
*
* const t = await createTestModule({
* db: testDb,
* imports: [TodoModule],
* overrides: [{ provide: PermissionsService, useValue: { checkPermission: () => true } }],
* });
* const todos = t.get(TodoService);
* ...
* expect(t.realtime.broadcastMock).toHaveBeenCalledWith(tripId, 'todo:created', ...);
* afterAll(() => t.close());
*
* Every module gets the request-scoped ORM and the UnitOfWork bound to `db`
* (the same pair the e2e suites mount) and the global RealtimeModule, whose
* RealtimeService is a FakeRealtimeService unless the suite hands in its own.
*/
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { FakeRealtimeService } from './fake-realtime';
import { createTestMikroOrmModule } from './test-orm';
import { TestUnitOfWorkModule } from './test-uow';
import type { DynamicModule, ForwardReference, INestApplicationContext, Provider, Type } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type Database from 'better-sqlite3';
type ModuleImport = Type<unknown> | DynamicModule | Promise<DynamicModule> | ForwardReference;
/** A provider the suite replaces: by value, by class or by factory, as Nest's overrideProvider takes it. */
export type TestOverride =
| { provide: unknown; useValue: unknown }
| { provide: unknown; useClass: Type<unknown> }
| { provide: unknown; useFactory: (...args: never[]) => unknown; inject?: unknown[] };
export interface TestModuleOptions<R extends RealtimeService = FakeRealtimeService> {
/** The suite's test db (a snapshot copy, usually through the `src/db/database` mock). */
db: Database.Database;
/** The domain modules under test. */
imports: ModuleImport[];
/** Extra providers next to them, for a provider no module lists. */
providers?: Provider[];
/** Collaborators the suite controls. Applied after the realtime override, so one here wins. */
overrides?: TestOverride[];
/** The RealtimeService every module gets. Defaults to a fresh FakeRealtimeService. */
realtime?: R;
/** Run the lifecycle hooks (onModuleInit and friends). Off by default: most subjects need none. */
init?: boolean;
}
export interface TestModule<R extends RealtimeService = FakeRealtimeService> {
moduleRef: TestingModule;
/** The RealtimeService the container resolved: the fake unless the suite chose otherwise. */
realtime: R;
/** A provider out of the container, looked up across modules. */
get: INestApplicationContext['get'];
close: () => Promise<void>;
}
export async function createTestModule<R extends RealtimeService = FakeRealtimeService>(
options: TestModuleOptions<R>,
): Promise<TestModule<R>> {
const realtime = options.realtime ?? (new FakeRealtimeService() as RealtimeService as R);
let builder = Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(options.db),
// No HTTP request forks a context here, so the global one is allowed.
await createTestMikroOrmModule(options.db, { allowGlobalContext: true }),
RealtimeModule,
...options.imports,
],
providers: options.providers ?? [],
})
.overrideProvider(RealtimeService)
.useValue(realtime);
for (const override of options.overrides ?? []) {
const target = builder.overrideProvider(override.provide);
if ('useValue' in override) builder = target.useValue(override.useValue);
else if ('useClass' in override) builder = target.useClass(override.useClass);
else builder = target.useFactory({ factory: override.useFactory, inject: override.inject as never[] });
}
const moduleRef = await builder.compile();
if (options.init) await moduleRef.init();
return {
moduleRef,
realtime,
get: moduleRef.get.bind(moduleRef) as INestApplicationContext['get'],
close: () => moduleRef.close(),
};
}
+10 -1
View File
@@ -106,12 +106,21 @@ export async function createTestOrm(
// The spread also carries production's `extensions` (Migrator, SeedManager),
// `migrations` and `seeder` settings into the harness; only the driver, the
// database name, the entity list and discovery noise are overridden.
export function createTestMikroOrmModule(db: Database.Database): DynamicModule | Promise<DynamicModule> {
//
// `allowGlobalContext` is for a unit suite that calls services straight out of
// the container (createTestModule): there is no request to fork a context for,
// so the repositories read through the global EntityManager, exactly as the
// hand-built ones over createTestOrm() do.
export function createTestMikroOrmModule(
db: Database.Database,
options: { allowGlobalContext?: boolean } = {},
): DynamicModule | Promise<DynamicModule> {
return MikroOrmModule.forRoot({
...mikroOrmConfig,
entities: [...ALL_ENTITIES],
driver: createBoundSqliteDriver(() => db),
dbName: ':memory:',
discovery: { warnWhenNoEntities: false },
...(options.allowGlobalContext ? { allowGlobalContext: true } : {}),
});
}
+14 -4
View File
@@ -2,8 +2,8 @@
* The demo-mode write block, held for every registered MCP tool at once.
*
* The block used to be the first line of each write tool; it now runs in the
* registry (trekDemoToolGate, wired into McpModule.forRoot and into the MCP test
* registry). This suite attaches the real registry to a capturing server and
* registry (trekDemoToolGate, wired into McpModule.forRoot, which the MCP test
* registry is taken from). This suite attaches the real registry to a capturing server and
* calls every write tool as the demo account: each one has to answer with the
* canned refusal before its handler runs. Every read-only tool has to pass the
* gate. Besides that it pins the two markers the gate relies on to each other,
@@ -14,9 +14,12 @@ import { ADDON_IDS } from '../../../src/addons';
import { db as testDb } from '../../../src/db/database';
import { isDemoGatedTool, trekDemoToolGate } from '../../../src/mcp/nest-mcp-policy';
import type { McpContext, McpRegistry, McpRegistryListing } from '../../../src/nest-mcp';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { createUser } from '../../helpers/factories';
import { createMcpTestRegistry } from '../../helpers/mcp-test-controllers';
import { bootTestApp } from '../../helpers/test-app';
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { MikroORM } from '@mikro-orm/core';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
@@ -47,20 +50,27 @@ async function captureTools(registry: McpRegistry, ctx: McpContext): Promise<Map
) => {
// Arguments as a client would send nothing: the gate answers before
// the handler ever reads them.
tools.set(name, { annotations: config.annotations, call: async (args = {}) => cb(args, {}) });
tools.set(name, {
annotations: config.annotations,
call: async (args = {}) => withRequestContext(orm, () => cb(args, {})),
});
},
registerResource: () => undefined,
registerPrompt: () => undefined,
} as unknown as McpServer;
await registry.attach(server, ctx);
// The attach reads the addon toggles, which production does inside the /mcp
// request's ORM context; the calls open their own below.
await withRequestContext(orm, () => registry.attach(server, ctx));
return tools;
}
let registry: McpRegistry;
let orm: MikroORM;
let toolListings: McpRegistryListing[];
beforeAll(async () => {
registry = await createMcpTestRegistry();
orm = (await bootTestApp()).get(MikroORM);
toolListings = registry.list().filter((entry) => entry.kind === 'tool');
});
+3 -4
View File
@@ -19,10 +19,9 @@ vi.mock('../../../src/db/database', async () => {
});
const { fixture } = vi.hoisted(() => ({ fixture: {} as { root: string } }));
// Each createMcpHarness() builds a fresh registry, and the helper hands every one
// of those its own mkdtemp root, so bytes written by a test would land in a
// directory the next registry never looks at. Pin one shared fixture instead,
// which is what lets read_trip_file return a file this suite wrote.
// The booted container's StorageService is a makeStorageFixture('') over its own
// mkdtemp root (tests/helpers/test-app.ts). Pin one shared fixture and remember
// its root, which is what lets read_trip_file return a file this suite wrote.
vi.mock('../../helpers/storage-fixture', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../helpers/storage-fixture')>();
const shared = actual.makeStorageFixture('');
+2 -2
View File
@@ -3,8 +3,8 @@
* (list_categories moved to tools-categories.test.ts with the CategoriesMcp migration.)
*
* Since the place DI fold these run through the decorator-driven PlacesMcp in
* src/nest/places/places.mcp.ts — the harness attaches it via the hand-wired
* registry in tests/helpers/mcp-test-controllers.ts, so the assertions below
* src/nest/places/places.mcp.ts — the harness attaches it through the booted
* container's registry (tests/helpers/mcp-test-controllers.ts), so the assertions below
* exercise the @Tool/@ResourceTemplate path instead of the deleted registrar.
*/
import { DomainError } from '../../../src/nest/common/domain-error';
@@ -12,7 +12,10 @@ vi.mock('../../../src/db/database', async () => {
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../../src/nest/weather/weather.impl', () => ({
// The rest of the module stays real: the booted container's WeatherService
// starts and stops its cache sweep through it.
vi.mock('../../../src/nest/weather/weather.impl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/weather/weather.impl')>()),
getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }),
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
}));
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Unit tests for MCP vacay tools (vacay addon-gated, VacayMcp — DI-discovered
* via the hand-built test registry in tests/helpers/mcp-test-controllers.ts):
* through the booted container's registry, tests/helpers/mcp-test-controllers.ts):
* get_vacay_plan, get_vacay_year_settings, update_vacay_plan, set_vacay_color,
* list_vacay_years, add_vacay_year, delete_vacay_year,
* get_vacay_entries, toggle_vacay_entry, toggle_company_holiday,
@@ -28,31 +28,27 @@ import { createUser, createTrip, createDay, addTripMember } from '../../helpers/
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
import type { DayNote } from '../../../src/types';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDayNotesRepo, createTestDaysRepo, createTestTripsRepo } from '../../helpers/test-uow';
import { sharedTestOrm } from '../../helpers/test-uow';
import { findRow } from '../../helpers/factories/rows';
import { DayNotes } from '../../../src/db/entities/DayNotes.entity';
import { TripAccessService } from '../../../src/nest/trip-membership/trip-access.service';
import { DayNotesModule } from '../../../src/nest/day-notes/day-notes.module';
import { createTestModule, type TestModule } from '../../helpers/test-module';
// The service comes out of its own module, so a dependency it gains reaches
// this suite through DayNotesModule rather than through a constructor call here.
let t: TestModule;
let svc: DayNotesService;
beforeAll(async () => {
// Plan 4 Task 2 — DayNotesService's own canAccessTrip delegate is now
// TripsRepository.findAccessible, in the same constructor slot.
svc = new DayNotesService(
new TripAccessService(await createTestTripsRepo(testDb)),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
await createTestDayNotesRepo(testDb),
await createTestDaysRepo(testDb),
);
t = await createTestModule({ db: testDb, imports: [DayNotesModule] });
svc = t.get(DayNotesService);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
@@ -273,8 +269,12 @@ describe('remove', () => {
describe('DayNotesService.canEdit', () => {
it('DAYNOTE-SVC-090 asks for day_edit and flags a non-owner as shared', async () => {
const checkPermission = vi.fn(() => true);
const permissions = { checkPermission } as unknown as PermissionsService;
const withStub = new DayNotesService(new TripAccessService(await createTestTripsRepo(testDb)), permissions, new RealtimeService(), await createTestDayNotesRepo(testDb), await createTestDaysRepo(testDb));
const stubbed = await createTestModule({
db: testDb,
imports: [DayNotesModule],
overrides: [{ provide: PermissionsService, useValue: { checkPermission } }],
});
const withStub = stubbed.get(DayNotesService);
const trip = { id: 1, user_id: 1 } as never;
expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true);
@@ -287,5 +287,6 @@ describe('DayNotesService.canEdit', () => {
checkPermission.mockReturnValue(false);
expect(await withStub.canEdit(trip, { id: 2, role: 'user' } as never)).toBe(false);
await stubbed.close();
});
});
@@ -18,30 +18,13 @@ vi.mock('../../../src/db/database', async () => {
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { DaysModule } from '../../../src/nest/days/days.module';
import { DayRemovalService, DayDeleteError, LAST_DAY_MESSAGE, type DayRemoval } from '../../../src/nest/days/day-removal.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { AccommodationsService, type MirrorSender } from '../../../src/nest/accommodations/accommodations.service';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
createTestTripMembersRepo, createTestRoadtripViasRepo, createTestDayAccommodationsRepo, createTestReservationsRepo,
createTestReservationEndpointsRepo, createTestDayNotesRepo, createTestRoadtripDayBoundariesRepo,
sharedTestOrm, createTestTripsRepo,
} from '../../helpers/test-uow';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { createTestToursRepo } from '../../helpers/tours-repos';
import { sharedTestOrm } from '../../helpers/test-uow';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import type { EntityClass, FilterQuery } from '@mikro-orm/core';
import { countRows, deleteRows, findRow, findRows, insertRow } from '../../helpers/factories/rows';
import { makeReservation } from '../../helpers/factories/reservations';
@@ -56,63 +39,39 @@ import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoi
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { legacyBoundIntegerText } from '../../../src/nest/common/row-id';
import { TripAccessService } from '../../../src/nest/trip-membership/trip-access.service';
let days: DaysService;
let journey: JourneyDomainService;
let assignments: AssignmentsService;
let accommodations: AccommodationsService;
let removal: DayRemovalService;
const modules: TestModule[] = [];
/**
* The days domain out of its module over the test connection, optionally with
* the accommodations service replaced. Every collaborator is the one DaysModule
* wires, so a dependency DayRemovalService gains needs no edit here.
*/
async function daysModule(acc?: AccommodationsService): Promise<TestModule> {
const t = await createTestModule({
db: testDb,
imports: [DaysModule],
overrides: acc ? [{ provide: AccommodationsService, useValue: acc }] : [],
});
modules.push(t);
return t;
}
/** A DayRemovalService over the test connection, with the accommodations service given. */
async function removalWith(acc: AccommodationsService): Promise<DayRemovalService> {
return new DayRemovalService(
days, acc, assignments, await createTestUnitOfWork(testDb),
await createTestDaysRepo(testDb), await createTestDayAccommodationsRepo(testDb),
await createTestRoadtripDayBoundariesRepo(testDb), await createTestTripsRepo(testDb),
);
return (await daysModule(acc)).get(DayRemovalService);
}
beforeAll(async () => {
const permissions = new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb));
const realtime = new RealtimeService();
const queryHelpers = new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb));
const t = await sharedTestOrm(testDb);
days = new DaysService(
permissions, realtime, queryHelpers, await createTestUnitOfWork(testDb),
await createTestDaysRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestDayNotesRepo(testDb),
await createTestTripsRepo(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb),
await createTestDayAccommodationsRepo(testDb), await createTestRoadtripViasRepo(testDb), await createTestRoadtripDayBoundariesRepo(testDb),
);
journey = new JourneyDomainService(
realtime, new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb),
await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb),
await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb),
);
assignments = new AssignmentsService(
new TripAccessService(await createTestTripsRepo(testDb)), permissions, realtime, queryHelpers, journey,
await createTestUnitOfWork(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestAssignmentParticipantsRepo(testDb),
await createTestDaysRepo(testDb),
await createTestPlacesRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestToursRepo(testDb),
);
accommodations = new AccommodationsService(
permissions, realtime, assignments, await createTestUnitOfWork(testDb),
new TripAccessService(await createTestTripsRepo(testDb)),
await createTestDayAccommodationsRepo(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestPlacesRepo(testDb),
await createTestDaysRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestReservationsRepo(testDb),
await createTestBudgetItemsRepo(testDb),
);
removal = await removalWith(accommodations);
const t = await daysModule();
days = t.get(DaysService);
journey = t.get(JourneyDomainService);
accommodations = t.get(AccommodationsService);
removal = t.get(DayRemovalService);
});
beforeEach(() => {
@@ -123,7 +82,8 @@ afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
afterAll(async () => {
await Promise.all(modules.splice(0).map((t) => t.close()));
testDb.close();
});
+16 -42
View File
@@ -81,39 +81,27 @@ import { DaysService, DayReorderError, DayAppendError, NO_DATES_MESSAGE, addDays
// container. The assertions stayed; they point at the service now.
const bridgeGetDay = async (id: string | number, tripId: string | number) => await svc.getDay(id, tripId);
const bridgeListDays = async (tripId: string | number) => await svc.list(tripId);
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { makeAccommodationsService } from '../../helpers/accommodations-service';
import { DaysModule } from '../../../src/nest/days/days.module';
import { createTestModule, type TestModule, type TestOverride } from '../../helpers/test-module';
import type { Day } from '../../../src/types';
import { legacyBoundIntegerText } from '../../../src/nest/common/row-id';
import {
createTestUnitOfWork, createTestAppSettingsRepo,
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
createTestReservationsRepo,
createTestReservationEndpointsRepo,
createTestDayAccommodationsRepo,
createTestRoadtripViasRepo,
createTestRoadtripDayBoundariesRepo,
} from '../../helpers/test-uow';
// The service comes out of DaysModule, so a dependency it gains reaches this
// suite through the module rather than through a constructor call here.
const modules: TestModule[] = [];
async function daysModule(overrides: TestOverride[] = []): Promise<TestModule> {
const t = await createTestModule({ db: testDb, imports: [DaysModule], overrides });
modules.push(t);
return t;
}
let svc: DaysService;
beforeAll(async () => {
svc = new DaysService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
await createTestUnitOfWork(testDb),
await createTestDaysRepo(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestDayNotesRepo(testDb),
await createTestTripsRepo(testDb),
await createTestReservationsRepo(testDb),
await createTestReservationEndpointsRepo(testDb),
await createTestDayAccommodationsRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestRoadtripDayBoundariesRepo(testDb),
);
svc = (await daysModule()).get(DaysService);
});
afterAll(async () => {
await Promise.all(modules.splice(0).map((t) => t.close()));
});
let accommodations: Awaited<ReturnType<typeof makeAccommodationsService>>;
beforeAll(async () => {
@@ -963,21 +951,7 @@ describe('DaysService.canEdit', () => {
it('DAY-SVC-090 asks for day_edit and flags a non-owner as shared', async () => {
const checkPermission = vi.fn(() => true);
const permissions = { checkPermission } as unknown as PermissionsService;
const withStub = new DaysService(
permissions,
new RealtimeService(),
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
await createTestUnitOfWork(testDb),
await createTestDaysRepo(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestDayNotesRepo(testDb),
await createTestTripsRepo(testDb),
await createTestReservationsRepo(testDb),
await createTestReservationEndpointsRepo(testDb),
await createTestDayAccommodationsRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestRoadtripDayBoundariesRepo(testDb),
);
const withStub = (await daysModule([{ provide: PermissionsService, useValue: permissions }])).get(DaysService);
const trip = { id: 1, user_id: 1 } as never;
expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true);