From 079064fdcda0cfc08a7cdb43911131cb0e51c863 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 11 Aug 2026 20:28:49 +0200 Subject: [PATCH] refactor(server): delete trips.bridge via leaf membership reads and prompt relocation --- server/src/nest/budget/budget.mcp.ts | 83 ++++---------- server/src/nest/budget/budget.module.ts | 3 +- server/src/nest/budget/costs.rpc.ts | 11 +- .../notifications/notifications.instance.ts | 4 +- server/src/nest/packing/packing.mcp.ts | 47 +------- server/src/nest/realtime/realtime.service.ts | 2 +- .../trip-membership.service.ts | 44 +++++++- server/src/nest/trips/trip-prompts.mcp.ts | 103 ++++++++++++++++++ server/src/nest/trips/trips.bridge.ts | 103 ------------------ server/src/nest/trips/trips.module.ts | 3 +- server/tests/helpers/mcp-test-controllers.ts | 4 +- server/tests/helpers/plugin-host.ts | 2 +- server/tests/helpers/rpc-host-deps.ts | 2 +- server/tests/unit/budget/costs.rpc.test.ts | 9 +- server/tests/unit/mcp/tools-prompts.test.ts | 23 ++-- server/tests/unit/nest/places.service.test.ts | 2 +- .../trip-membership.service.test.ts | 39 +++++++ .../unit/nest/trip-read-model.service.test.ts | 2 +- server/tests/unit/nest/trips.service.test.ts | 28 +---- .../unit/plugins/addon-gates.rpc.test.ts | 4 +- 20 files changed, 249 insertions(+), 269 deletions(-) create mode 100644 server/src/nest/trips/trip-prompts.mcp.ts delete mode 100644 server/src/nest/trips/trips.bridge.ts diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 7f7430bcb..8177c0f72 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -1,5 +1,5 @@ import { - McpController, Prompt, Tool, ResourceTemplate, type McpContext, + McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, @@ -9,7 +9,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { isDemoUserId } from '../common/demo-write'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; -import { getTripOwner, listMembers, getTripSummary } from '../trips/trips.bridge'; +import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { DatabaseService } from '../database/database.service'; import { BudgetService } from './budget.service'; import { ExchangeRatesService } from './exchange-rates.service'; @@ -30,20 +30,6 @@ function parseId(value: string | string[]): number | null { return Number.isInteger(n) && n > 0 ? n : null; } -/** - * Resolve the equal-split participants for a new budget item. When member_ids is - * omitted, default to the whole trip (owner + all members), deduped — reproducing - * the client's own create flow (CostsPanel seeds participants from all members). - * An explicit empty array means "planning-only, no split" and is passed through. - */ -function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined { - if (member_ids !== undefined) return member_ids; - const owner = getTripOwner(tripId); - if (!owner) return undefined; - const { members } = listMembers(tripId, owner.user_id); - return Array.from(new Set([owner.user_id, ...members.map(m => m.id)])); -} - /** * Budget MCP surface — ported from the legacy registrars: the eleven tools * from src/mcp/tools/budget.ts and the trek://trips/{tripId}/budget, @@ -71,6 +57,7 @@ export class BudgetMcp { private readonly exchangeRates: ExchangeRatesService, private readonly db: DatabaseService, private readonly env: RuntimeEnvService, + private readonly membership: TripMembershipService, readonly addons: AddonsService, ) {} @@ -79,6 +66,21 @@ export class BudgetMcp { return isDemoUserId(this.env, this.db, userId); } + /** + * Resolve the equal-split participants for a new budget item. When member_ids + * is omitted, default to the whole trip (owner + all members), deduped — + * reproducing the client's own create flow (CostsPanel seeds participants + * from all members). An explicit empty array means "planning-only, no split" + * and is passed through. Reads ride the leaf TripMembershipService — the + * hydrated member services all live in modules that import this one. + */ + private resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined { + if (member_ids !== undefined) return member_ids; + const ownerId = this.membership.getOwnerId(tripId); + if (ownerId === null) return undefined; + return Array.from(new Set([ownerId, ...this.membership.listMemberUserIds(tripId)])); + } + // --- BUDGET --- @Tool({ @@ -109,7 +111,7 @@ export class BudgetMcp { if (this.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); - const members = resolveMemberIds(tripId, member_ids); + const members = this.resolveMemberIds(tripId, member_ids); const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, note }; // Freeze the live FX rate at entry time so a settled position isn't re-opened // when live rates drift (#1445) — same as the REST create path. @@ -206,7 +208,7 @@ export class BudgetMcp { if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Omitted userIds → default to the whole trip, matching create_budget_item. - const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined); + const members = (userIds && userIds.length > 0) ? userIds : this.resolveMemberIds(tripId, undefined); try { const item = this.db.transaction(() => { const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members }); @@ -475,46 +477,7 @@ export class BudgetMcp { }; } - /** - * Moved 1:1 from the legacy registrar src/mcp/tools/prompts.ts. The - * registration-time `if (isAddonEnabled(BUDGET))` became the same `when` gate - * the tools above use, and the trip lookup keeps going through trips.bridge — - * injecting TripsService here would close a TripsModule/BudgetModule cycle. - */ - @Prompt({ - name: 'budget-overview', - title: 'Budget Overview', - description: 'Get a formatted budget summary for a trip', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID'), - }, - when: budgetAddonOn, - }) - async budgetOverviewPrompt({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.budget.verifyTripAccess(tripId, ctx.userId)) { - return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; - } - const summary = getTripSummary(tripId, ctx.userId); - if (!summary) { - return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; - } - const { trip, budget } = summary; - const currency = trip?.currency || 'EUR'; - const byCategory = (budget?.items || []).reduce((acc: Record, item: { category?: string; total_price?: number }) => { - const cat = item.category || 'Uncategorized'; - acc[cat] = (acc[cat] || 0) + (item.total_price || 0); - return acc; - }, {} as Record); - const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0); - const lines = Object.entries(byCategory) - .sort(([, a], [, b]) => b - a) - .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) - .join('\n'); - const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); - const perPerson = (total / memberCount).toFixed(2); - return { - description: `Budget overview for "${trip?.title || tripId}"`, - messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], - }; - } + // The budget-overview prompt moved to trips/trip-prompts.mcp.ts: it reads the + // whole-trip summary, and the read model lives above this module (the fold + // that deleted trips.bridge). } diff --git a/server/src/nest/budget/budget.module.ts b/server/src/nest/budget/budget.module.ts index 659e18c8d..7dfa974ad 100644 --- a/server/src/nest/budget/budget.module.ts +++ b/server/src/nest/budget/budget.module.ts @@ -9,6 +9,7 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AppConfigModule } from '../app-config/app-config.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { AddonsModule } from '../addons/addons.module'; /** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule. @@ -19,7 +20,7 @@ import { AddonsModule } from '../addons/addons.module'; * AppConfigModule is @Global in the app graph; the explicit import keeps the * partial e2e TestingModules resolving RuntimeEnvService. */ @Module({ - imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AddonsModule], + imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule], controllers: [BudgetController], providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc], // For in-container consumers (CostsRpc, TripsService, diff --git a/server/src/nest/budget/costs.rpc.ts b/server/src/nest/budget/costs.rpc.ts index 1559ee550..0aebef480 100644 --- a/server/src/nest/budget/costs.rpc.ts +++ b/server/src/nest/budget/costs.rpc.ts @@ -6,7 +6,7 @@ import { num, schemaMessage } from '../plugins/host/rpc-params'; import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; -import { listTripsForUser } from '../trips/trips.bridge'; +import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { ADDON_IDS } from '../../addons'; import { BudgetService } from './budget.service'; @@ -33,6 +33,7 @@ export class CostsRpc { private readonly db: DatabaseService, private readonly realtime: RealtimeService, private readonly guards: PluginGuards, + private readonly membership: TripMembershipService, ) {} @PluginMethod('costs.getByTrip', { permission: 'db:read:costs' }) @@ -49,9 +50,11 @@ export class CostsRpc { // the same way tripRead refuses one. if (ctx.actingUserId === undefined) throw new ForbiddenResource('cost reads require an authenticated user context'); this.requireBudgetAddon(); - // Through the bridge, not by injection: see the note on listTripsForUser. - const trips = listTripsForUser(ctx.actingUserId); - return trips.flatMap((t) => this.budget.listBudgetItems(t.id)); + // The leaf membership read, not TripsService.list: TripsModule imports this + // one, so injecting TripsService here would close a cycle. Same id set, + // same newest-first order. + const tripIds = this.membership.listAccessibleTripIds(ctx.actingUserId); + return tripIds.flatMap((id) => this.budget.listBudgetItems(id)); } @PluginMethod('costs.create', { permission: 'db:write:costs' }) diff --git a/server/src/nest/notifications/notifications.instance.ts b/server/src/nest/notifications/notifications.instance.ts index c0b874c5a..938e262df 100644 --- a/server/src/nest/notifications/notifications.instance.ts +++ b/server/src/nest/notifications/notifications.instance.ts @@ -9,8 +9,8 @@ import { NotificationsService } from './notifications.service'; /** * The out-of-container NotificationsService, for the bridges that hand-build - * their collaborators (trips.bridge and airtrail.bridge — the permanent - * cycle-dodges; this file lives exactly as long as either does). The reminder + * their collaborators (airtrail.bridge is the last one — trips.bridge died + * 2026-08-11; this file lives exactly as long as it does). The reminder * crons that used to reach it through a dedicated send bridge inject the * container singleton now (ReminderJobsService); that bridge is gone. * diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index c49bbe197..9df1e3e7a 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -1,5 +1,5 @@ import { - McpController, Prompt, Tool, ResourceTemplate, type McpContext, + McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, @@ -9,10 +9,6 @@ import { AuthService } from '../auth/auth.service'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied, isAdminUser, adminRequired } from '../../mcp/tools/_shared'; import { PackingService } from './packing.service'; -// trips.bridge, not an injected TripsService: TripsModule imports PackingModule, -// so injecting it here would close a module cycle. Same documented in-container -// exception as atlas.mcp.ts, and the legacy registrar reached for it the same way. -import { getTripSummary } from '../trips/trips.bridge'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; @@ -473,42 +469,7 @@ export class PackingMcp { }; } - /** - * Moved 1:1 from the legacy registrar src/mcp/tools/prompts.ts. The - * registration-time `if (isAddonEnabled(PACKING))` became the same `when` - * gate the tools above use. - */ - @Prompt({ - name: 'packing-list', - title: 'Packing List', - description: 'Get a formatted packing checklist for a trip', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID'), - }, - when: packingAddonOn, - }) - async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) { - return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; - } - // Hide other members' private items (#858) from the requesting user. - const items = this.packing.listItems(tripId, ctx.userId); - if (!items.length) { - return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] }; - } - const grouped = items.reduce((acc: Record, item: { category?: string }) => { - const cat = item.category || 'General'; - if (!acc[cat]) acc[cat] = []; - acc[cat].push(item); - return acc; - }, {}); - const lines = Object.entries(grouped).map(([cat, catItems]) => - `## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` - ).join('\n\n'); - const { trip } = getTripSummary(tripId, ctx.userId) || {}; - return { - description: `Packing list for "${trip?.title || tripId}"`, - messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], - }; - } + // The packing-list prompt moved to trips/trip-prompts.mcp.ts: it reads the + // whole-trip summary for the title, and the read model lives above this + // module (the fold that deleted trips.bridge). } diff --git a/server/src/nest/realtime/realtime.service.ts b/server/src/nest/realtime/realtime.service.ts index 852ac97f6..5ff37275a 100644 --- a/server/src/nest/realtime/realtime.service.ts +++ b/server/src/nest/realtime/realtime.service.ts @@ -22,7 +22,7 @@ import { broadcast, broadcastToUser, getOnlineUserIds } from '../../websocket'; * method wasn't asked for — many test mocks provide `broadcast` only. * * The service is deliberately dependency-free so module-level bridge code - * (the trips.bridge.ts pattern) can construct it with a bare `new RealtimeService()`. + * (the airtrail.bridge.ts pattern) can construct it with a bare `new RealtimeService()`. */ @Injectable() export class RealtimeService { diff --git a/server/src/nest/trip-membership/trip-membership.service.ts b/server/src/nest/trip-membership/trip-membership.service.ts index cabfc0a39..a1880a22c 100644 --- a/server/src/nest/trip-membership/trip-membership.service.ts +++ b/server/src/nest/trip-membership/trip-membership.service.ts @@ -2,15 +2,51 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; /** - * Adding an existing user to a trip as a member. Its own domain rather than a - * method on auth or trip-invite: both of those call it, as does OIDC and the - * plugin RPC host, and folding it into either would put AuthModule and - * TripInviteModule in a cycle. + * Adding an existing user to a trip as a member, plus the leaf membership + * READS. Its own domain rather than a method on auth or trip-invite: both of + * those call it, as does OIDC and the plugin RPC host, and folding it into + * either would put AuthModule and TripInviteModule in a cycle. + * + * The reads exist for the same reason: BudgetMcp and CostsRpc need "who is on + * this trip" / "which trips can this user see", but every service that owns + * the hydrated answer (TripsService, TripMembersService, TripReadModelService) + * lives in a module that imports the budget domain, so injecting one there + * closes a real cycle. This module imports nothing, so it is the one place + * those id-level reads can live — the fold that deleted trips.bridge. */ @Injectable() export class TripMembershipService { constructor(private readonly db: DatabaseService) {} + /** The trip owner's user id, or null when the trip does not exist. */ + getOwnerId(tripId: string | number): number | null { + const row = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId); + return row ? row.user_id : null; + } + + /** Member user ids (owner excluded), in added_at order like listMembers. */ + listMemberUserIds(tripId: string | number): number[] { + return this.db + .all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC', tripId) + .map((r) => r.user_id); + } + + /** + * Ids of every trip the user owns or is a member of, newest first — the id + * half of TripsService.list(userId, null), same WHERE and ORDER BY. + */ + listAccessibleTripIds(userId: number): number[] { + return this.db + .prepare(` + SELECT t.id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) + ORDER BY t.created_at DESC + `) + .all({ userId }) + .map((r) => (r as { id: number }).id); + } + /** * Add an existing user to a trip as a member, by user id. * diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts new file mode 100644 index 000000000..8fab9e7e1 --- /dev/null +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -0,0 +1,103 @@ +import { McpController, Prompt, type McpContext } from '@trek/nest-mcp'; +import { z } from 'zod'; +import { ADDON_IDS } from '../../addons'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { PackingService } from '../packing/packing.service'; +import { TripReadModelService } from '../trip-read-model/trip-read-model.service'; +import { TripsService } from './trips.service'; + +const budgetAddonOn = addonGate(ADDON_IDS.BUDGET); +const packingAddonOn = addonGate(ADDON_IDS.PACKING); + +/** + * The two cross-domain prompts, relocated from budget.mcp.ts / packing.mcp.ts + * (where the 1:1 port from src/mcp/tools/prompts.ts had left them). Both read + * the whole-trip summary, and the read model lives ABOVE the budget/packing + * modules — TripReadModelModule imports both — so hosting them down there + * forced the trips.bridge seam. Here in the trips domain the summary is an + * injection, and that bridge is deleted. Names, gates and payloads are + * unchanged; the budget prompt's access check swaps + * BudgetService.verifyTripAccess for TripsService.canAccessTrip, both of which + * are the same DatabaseService.canAccessTrip call. + */ +@McpController() +export class TripPromptsMcp { + constructor( + private readonly trips: TripsService, + private readonly readModel: TripReadModelService, + private readonly packing: PackingService, + readonly addons: AddonsService, + ) {} + + @Prompt({ + name: 'budget-overview', + title: 'Budget Overview', + description: 'Get a formatted budget summary for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + when: budgetAddonOn, + }) + async budgetOverviewPrompt({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.trips.canAccessTrip(tripId, ctx.userId)) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; + } + const summary = this.readModel.getTripSummary(tripId, ctx.userId); + if (!summary) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; + } + const { trip, budget } = summary; + const currency = trip?.currency || 'EUR'; + const byCategory = (budget?.items || []).reduce((acc: Record, item: { category?: string; total_price?: number }) => { + const cat = item.category || 'Uncategorized'; + acc[cat] = (acc[cat] || 0) + (item.total_price || 0); + return acc; + }, {} as Record); + const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0); + const lines = Object.entries(byCategory) + .sort(([, a], [, b]) => b - a) + .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) + .join('\n'); + const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); + const perPerson = (total / memberCount).toFixed(2); + return { + description: `Budget overview for "${trip?.title || tripId}"`, + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], + }; + } + + @Prompt({ + name: 'packing-list', + title: 'Packing List', + description: 'Get a formatted packing checklist for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + when: packingAddonOn, + }) + async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.packing.verifyTripAccess(tripId, ctx.userId)) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; + } + // Hide other members' private items (#858) from the requesting user. + const items = this.packing.listItems(tripId, ctx.userId); + if (!items.length) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] }; + } + const grouped = items.reduce((acc: Record, item: { category?: string }) => { + const cat = item.category || 'General'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(item); + return acc; + }, {}); + const lines = Object.entries(grouped).map(([cat, catItems]) => + `## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` + ).join('\n\n'); + const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {}; + return { + description: `Packing list for "${trip?.title || tripId}"`, + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], + }; + } +} diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts deleted file mode 100644 index a345c137b..000000000 --- a/server/src/nest/trips/trips.bridge.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { db } from '../../db/database'; -import { JourneyDomainService } from '../journey/journey-domain.service'; -import { TrekPhotosRepository } from '../photos/trek-photos.repository'; -import { DatabaseService } from '../database/database.service'; -import { RealtimeService } from '../realtime/realtime.service'; -import { PermissionsService } from '../permissions/permissions.service'; -import { QueryHelpersService } from '../query-helpers/query-helpers.service'; -import { UnsplashService } from '../unsplash/unsplash.service'; -import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; -import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { TripsService } from './trips.service'; -import { TodoService } from '../todo/todo.service'; -import { PackingService } from '../packing/packing.service'; -import { FilesService } from '../files/files.service'; -import { ReservationsService } from '../reservations/reservations.service'; -import { DaysService } from '../days/days.service'; -import { BudgetService } from '../budget/budget.service'; -import { ExchangeRatesService } from '../budget/exchange-rates.service'; -import { CollabService } from '../collab/collab.service'; -import { VacayService } from '../vacay/vacay.service'; -import { PlacesService } from '../places/places.service'; -import { MapsService } from '../maps/maps.service'; -import { UserCleanupService } from '../auth/user-cleanup.service'; -import { AccommodationsService } from '../accommodations/accommodations.service'; -import { TripMembersService } from '../trip-members/trip-members.service'; -import { TripReadModelService } from '../trip-read-model/trip-read-model.service'; -import { notificationsInstance } from '../notifications/notifications.instance'; -import { EphemeralTokenService } from '../auth/ephemeral-token.service'; - -/** - * Verified-permanent cycle-dodge for the trip domain. Three in-container - * consumers cannot inject the backing services: budget.mcp.ts (getTripOwner / - * listMembers / getTripSummary), packing.mcp.ts (getTripSummary) and - * costs.rpc.ts (listTripsForUser). Every direction was checked 2026-08-11 and - * every one closes a real module cycle — TripsModule imports BudgetModule and - * PackingModule, TripReadModelModule imports both, and TripMembersModule - * imports BudgetModule, so none of TripsService, TripReadModelService or - * TripMembersService can be injected into the budget/packing surfaces without - * forwardRef. Exports only what those three consume, 1:1. Everything else - * injects the services. This file dies if the trip read model ever stops - * importing the budget/packing domains, not before. - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton. - */ -const dbs = () => new DatabaseService(db); -// One instance, not one per consumer: the in-flight dedup only works if the -// stampede guard is shared (see PlacePhotoCacheService). -const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService()); -const journeyDomain = new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())); -const budget = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); -const permissions = new PermissionsService(dbs()); -const realtime = new RealtimeService(); -const days = new DaysService(dbs(), permissions, realtime, new QueryHelpersService(dbs())); -const accommodations = new AccommodationsService(dbs(), permissions, realtime); -const reservations = new ReservationsService(dbs(), permissions, budget, realtime, notificationsInstance()); -const places = new PlacesService(dbs(), permissions, realtime, new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache, journeyDomain); -const trips = new TripsService( - dbs(), - reservations, - days, - permissions, - budget, - new VacayService(dbs(), realtime, notificationsInstance()), - realtime, - new UnsplashService(dbs(), new RuntimeEnvService()), -); -const members = new TripMembersService(dbs(), budget, new UserCleanupService(dbs(), budget), permissions, realtime, notificationsInstance()); -const readModel = new TripReadModelService( - dbs(), - members, - days, - accommodations, - budget, - new PackingService(dbs(), permissions, realtime, notificationsInstance()), - reservations, - new CollabService(dbs(), permissions, realtime, notificationsInstance()), - places, - new TodoService(dbs(), permissions, realtime), - new FilesService(dbs(), permissions, realtime, new EphemeralTokenService()), -); - -export function getTripOwner(tripId: string | number) { - return trips.getOwner(tripId); -} - -export function listMembers(tripId: string | number, tripOwnerId: number) { - return members.listMembers(tripId, tripOwnerId); -} - -export function getTripSummary(tripId: number, viewerUserId?: number) { - return readModel.getTripSummary(tripId, viewerUserId); -} - -/** - * Every trip the user can access. Used by CostsRpc for the cross-trip cost feed: - * BudgetModule cannot import TripsModule, because TripsModule already imports - * BudgetModule, and injecting it would need a forwardRef'd cycle. Same reason - * budget.mcp.ts reaches for this file. - */ -export function listTripsForUser(userId: number) { - return trips.list(userId, null) as Array<{ id: number }>; -} diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index fffd8dcc5..f6c84fcc4 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -12,6 +12,7 @@ import { AuditModule } from '../audit/audit.module'; import { PlacesModule } from '../places/places.module'; import { TripsController } from './trips.controller'; import { TripsService } from './trips.service'; +import { TripPromptsMcp } from './trip-prompts.mcp'; import { TripsRpc } from './trips.rpc'; import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; @@ -31,7 +32,7 @@ import { AddonsModule } from '../addons/addons.module'; @Module({ imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule], controllers: [TripsController], - providers: [TripsService, TripsMcp, TripsRpc], + providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc], // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). exports: [TripsService], }) diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index dfe45f6b3..c25fed79d 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -65,6 +65,7 @@ import { AccommodationsService } from '../../src/nest/accommodations/accommodati 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'; @@ -156,7 +157,7 @@ export function createMcpTestRegistry(): McpRegistry { new AuthMcp(), new TodoMcp(todoService, authService, addonsService), new PackingMcp(packingService, authService, addonsService), - new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), addonsService), + new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService), new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService), new DaysMcp(daysService, authService), @@ -165,6 +166,7 @@ export function createMcpTestRegistry(): McpRegistry { 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 TripPromptsMcp(tripsService, readModelService, packingService, addonsService), new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index f81fdd49a..19c5721e3 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -125,7 +125,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF new AccommodationsRpc(accommodations, realtime, guards), new ItineraryRpc(assignments, realtime, guards), new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards, accommodations, members), - new CostsRpc(budget, dbs, realtime, guards), + new CostsRpc(budget, dbs, realtime, guards, membership), new ReservationsRpc(reservations, realtime, guards), new CollabRpc(collab, realtime, guards), new AtlasRpc(atlas, guards), diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index bd2405720..d32590540 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -72,7 +72,7 @@ export function allRpcControllers(): object[] { new DaysRpc(anyService(), anyService(), anyService()), new ItineraryRpc(anyService(), anyService(), anyService()), new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), - new CostsRpc(anyService(), anyService(), anyService(), anyService()), + new CostsRpc(anyService(), anyService(), anyService(), anyService(), anyService()), new ReservationsRpc(anyService(), anyService(), anyService()), new AccommodationsRpc(anyService(), anyService(), anyService()), new CollabRpc(anyService(), anyService(), anyService()), diff --git a/server/tests/unit/budget/costs.rpc.test.ts b/server/tests/unit/budget/costs.rpc.test.ts index 43927ecca..c75322b4f 100644 --- a/server/tests/unit/budget/costs.rpc.test.ts +++ b/server/tests/unit/budget/costs.rpc.test.ts @@ -17,13 +17,10 @@ import type { RealtimeService } from '../../../src/nest/realtime/realtime.servic import type { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; +import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope'; import { makeDeps } from '../../helpers/rpc-host-deps'; -vi.mock('../../../src/nest/trips/trips.bridge', () => ({ - listTripsForUser: vi.fn(() => [{ id: 1 }, { id: 2 }]), -})); - const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); /** Trip 1 belongs to user 42, cost 5 sits on it, and the addon is on unless said otherwise. */ @@ -44,7 +41,9 @@ function build(opts: { addonOn?: boolean; canEdit?: boolean; missing?: boolean } { checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService, { isAddonEnabled: vi.fn(() => opts.addonOn ?? true) } as unknown as AddonsService, ); - const rpc = new CostsRpc(budget, db, realtime, guards); + // The leaf membership read replaced the deleted trips.bridge for listMine. + const membership = { listAccessibleTripIds: vi.fn(() => [1, 2]) } as unknown as TripMembershipService; + const rpc = new CostsRpc(budget, db, realtime, guards, membership); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants.length ? grants : ['db:read:costs', 'db:write:costs']), makeDeps(), createTestPluginRegistry([rpc])); return { budget, realtime, host }; diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 2bfcb81b4..4a687f5d2 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -54,12 +54,8 @@ const addonsStub = { const { mockGetTripSummary } = vi.hoisted(() => ({ mockGetTripSummary: vi.fn(), })); -// prompts.ts consumes getTripSummary via the trips bridge since the trip fold. -vi.mock('../../../src/nest/trips/trips.bridge', () => ({ - getTripSummary: mockGetTripSummary, - getTripOwner: vi.fn(), - listMembers: vi.fn(() => ({ members: [] })), -})); +// The prompts read the summary through the injected read model (readModelStub +// below wraps the same controllable mock) — trips.bridge is deleted. import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; @@ -68,6 +64,8 @@ import { createUser, createTrip, addTripMember, createPackingItem, createBudgetI import { createTestRegistry } from '@trek/nest-mcp'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; import { TripsMcp } from '../../../src/nest/trips/trips.mcp'; +import { TripPromptsMcp } from '../../../src/nest/trips/trip-prompts.mcp'; +import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { PackingMcp } from '../../../src/nest/packing/packing.mcp'; import { PackingService } from '../../../src/nest/packing/packing.service'; import { BudgetMcp } from '../../../src/nest/budget/budget.mcp'; @@ -112,18 +110,19 @@ const tripsMcp = new TripsMcp( // in-memory DB so the cases below keep asserting real rows. const promptDbs = () => new DatabaseService(testDb); const authStub = { isDemoUser: () => false } as unknown as AuthService; -const packingMcp = new PackingMcp( - new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()), - authStub, - addonsStub, -); +const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()); +const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub); const budgetMcp = new BudgetMcp( new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()), new ExchangeRatesService(), promptDbs(), new RuntimeEnvService(), + new TripMembershipService(promptDbs()), addonsStub, ); +// The packing-list / budget-overview prompts live here since the trips.bridge +// fold; the summary rides the same readModelStub the trip-summary prompt uses. +const tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub); const authMcp = new AuthMcp(); beforeAll(() => { @@ -177,7 +176,7 @@ function buildServer(userId: number, opts: { isStaticToken?: boolean } = {}): Mc const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); // Every prompt is DI-discovered now; attach them the way registerTools does in // production, including the isStaticToken flag the notice's `when` gate reads. - createTestRegistry([tripsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) + createTestRegistry([tripsMcp, tripPromptsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) .attach(server, { userId, scopes: null, isStaticToken: opts.isStaticToken ?? false }); return server; } diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 66f123b29..44d71e456 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -83,7 +83,7 @@ const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); const dbs = new DatabaseService(testDb); /** - * Same collaborator set the container hands PlacesService (see trips.bridge.ts), + * Same collaborator set the container hands PlacesService, * built once here so the two construction sites cannot drift apart again. The * journey domain is a real instance on the same in-memory DB rather than a stub: * its place hooks are fire-and-forget behind a catch, so a missing one would have diff --git a/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts b/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts index eae424bb4..5fd50335a 100644 --- a/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts +++ b/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts @@ -68,3 +68,42 @@ describe('joinTripAsMember', () => { expect(r.joined).toBe(false); }); }); + +// The leaf reads that replaced trips.bridge for BudgetMcp/CostsRpc — see the +// service docblock for why they live on this dependency-free module. +describe('leaf membership reads', () => { + const svc = () => new TripMembershipService(new DatabaseService(testDb)); + + it('TRIP-READ-001: getOwnerId answers the owner and null for a missing trip', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + expect(svc().getOwnerId(trip.id)).toBe(owner.id); + expect(svc().getOwnerId(999999)).toBeNull(); + }); + + it('TRIP-READ-002: listMemberUserIds excludes the owner and follows added_at order', () => { + const { user: owner } = createUser(testDb); + const { user: m1 } = createUser(testDb); + const { user: m2 } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-02')").run(trip.id, m2.id); + testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-01')").run(trip.id, m1.id); + expect(svc().listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]); + expect(svc().listMemberUserIds(999999)).toEqual([]); + }); + + it('TRIP-READ-003: listAccessibleTripIds unions owned and member trips, newest first', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const owned = createTrip(testDb, user.id); + const memberOf = createTrip(testDb, other.id); + const foreign = createTrip(testDb, other.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(memberOf.id, user.id); + // Distinct created_at so the ORDER BY is actually asserted, not assumed. + testDb.prepare("UPDATE trips SET created_at = '2026-01-01' WHERE id = ?").run(owned.id); + testDb.prepare("UPDATE trips SET created_at = '2026-01-02' WHERE id = ?").run(memberOf.id); + const ids = svc().listAccessibleTripIds(user.id); + expect(ids).toEqual([memberOf.id, owned.id]); + expect(ids).not.toContain(foreign.id); + }); +}); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 7b189c37c..9c12dd82f 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -80,7 +80,7 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos const dbs = () => new DatabaseService(testDb); const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); -// One shared cache instance, as in trips.bridge.ts: the in-flight dedup in +// One shared cache instance (the PlacePhotoCacheService rule): the in-flight dedup in // PlacePhotoCacheService only works while both consumers hold the same object. const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService()); const placesSvc = new PlacesService( diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index a8cb5f8ea..8110f923d 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -2,8 +2,8 @@ * Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-058 * (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts; * the exportICS cases that duplicated the generateDays 010–012 numbering were - * renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pin the - * trips.bridge delegation; 042–050 cover the folded + * renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pinned + * the deleted trips.bridge and died with it; 042–050 cover the folded * summary/list/create/delete/copy SQL; 051–053 pin the post-fold quirk fixes * (transactional deletes, owner display_name)). Uses a real in-memory SQLite * DB so SQL logic is exercised faithfully. @@ -78,7 +78,6 @@ import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-pho import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; -import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import fs from 'fs'; import { notificationsStub } from '../../helpers/notifications'; @@ -89,7 +88,7 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se const dbs = () => new DatabaseService(testDb); const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs())); -// Same collaborator set the container hands PlacesService (see trips.bridge.ts). +// Same collaborator set the container hands PlacesService (see places.service.test.ts). // Only the read-model aggregation reaches into places here, but the photo cache, // Unsplash and journey domain are real instances over the same in-memory DB // rather than casts: the place hooks are fire-and-forget behind a catch, so a @@ -641,27 +640,6 @@ describe('guest members (#1362)', () => { }); }); -// ── trips.bridge delegation (coverage gate) ─────────────────────────────────── - -describe('trips.bridge delegation', () => { - it('TRIP-SVC-040: getTripOwner delegates 1:1', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(getTripOwner(trip.id)).toEqual({ user_id: user.id }); - expect(getTripOwner(99999)).toBeUndefined(); - }); - - it('TRIP-SVC-041: listMembers delegates 1:1', () => { - const { user: owner } = createUser(testDb); - const { user: member } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - addTripMember(testDb, trip.id, member.id); - const result = bridgeListMembers(trip.id, owner.id); - expect(result.owner.id).toBe(owner.id); - expect(result.members.map(m => m.id)).toEqual([member.id]); - }); -}); - // ── Folded CRUD SQL (summary / list / create / delete / copy) ───────────────── describe('folded trip CRUD', () => { diff --git a/server/tests/unit/plugins/addon-gates.rpc.test.ts b/server/tests/unit/plugins/addon-gates.rpc.test.ts index 54c17f857..136af4791 100644 --- a/server/tests/unit/plugins/addon-gates.rpc.test.ts +++ b/server/tests/unit/plugins/addon-gates.rpc.test.ts @@ -26,8 +26,6 @@ import type { AddonsService } from '../../../src/nest/addons/addons.service'; import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope'; import { makeDeps } from '../../helpers/rpc-host-deps'; -vi.mock('../../../src/nest/trips/trips.bridge', () => ({ listTripsForUser: vi.fn(() => [{ id: 1 }]) })); - const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); /** Records every service call, so "the service was never touched" is checkable. */ @@ -67,7 +65,7 @@ function build(addonOn: boolean) { new VacayRpc(spyService(calls, 'vacay'), guards), new JournalRpc(spyService(calls, 'journey'), guards), new CollectionsRpc(spyService(calls, 'collections'), guards), - new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards), + new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')), ]); const granted = new Set([ 'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal',