chore: wire TripsService into the plugin RPC host and retire the legacy tripService

Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md.
This commit is contained in:
jubnl
2026-07-29 01:53:54 +02:00
parent 530c7cb005
commit 287dceb532
18 changed files with 179 additions and 1370 deletions
+25 -4
View File
@@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
collab, vacay, reservations, day, permissions, audit — see the migration
recipe below.
collab, vacay, reservations, day, permissions, audit, budget, trip — see the
migration recipe below.
## Cross-cutting Foundation pieces
@@ -203,9 +203,30 @@ MCP class) and BookingImportService inject `BudgetService`; a 4-export
trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its
last consumers, and the controller adopted `budget.dto.ts` — all nine
allow-list entries removed).
tripService followed (the 1121-line Wave-4 hub — the biggest fold — moved into
the wrapper `TripsService`: TRIP_SELECT + list/create/get, the
`generateDays` two-phase renumber engine, the updateTrip date-shift
transaction, the member/guest lifecycle (#973/#1362), the ICS export with its
module-scoped tz-validity cache, `copyTripById` and `getTripSummary`; its six
bridge imports became injected services (CollabService + VacayService joined
the constructor); the 10-tool trips registrar + 3 `resources.ts` trip
resources + the trip-summary prompt moved to `trips.mcp.ts` — the first
`@Prompt` use, with the fire-once static-token deprecation notice now riding
the `registry.attach` ctx — and the 3 share-link tools it carried moved to
`share.mcp.ts` on the `canShareTrips` predicate (delete_trip and the
canReadTrips reads are predicates too — the broadened legacy gates have no
declarative equivalent); FeedsService and the plugin host inject
`TripsService` (its 20th constructor dep); a 3-export `trips.bridge.ts`
serves the legacy prompts registrar and budget.mcp.ts's owner/member seam
(injecting there would need a forwardRef'd TripsModule↔BudgetModule cycle);
`todo.bridge.ts`, `share.bridge.ts`, `collab.bridge.ts` and `vacay.bridge.ts`
were deleted with their last consumers and the unused days/budget bridge
exports pruned; the controller adopted `trips.dto.ts` — all seven allow-list
entries removed).
Repeat these steps per
service (next up: **tripService** — the budget fold was its unblock, per the
dependency-honest order in `migration-graph.md`). This is a
service (next up: the **mapsService → transitService → placeService** chain —
or the notifications fan-in — per the dependency-honest order in
`migration-graph.md`). This is a
**pure relocation** — byte-identical
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it
+10 -17
View File
@@ -7,15 +7,16 @@ import { ExchangeRatesService } from './exchange-rates.service';
/**
* Non-Nest entry point for the budget domain — for code running OUTSIDE the
* Nest container (the legacy services/tripService.ts and
* services/userCleanupService.ts, and the legacy update_trip / create_transport
* registrars in src/mcp/tools/trips.ts and src/mcp/tools/transports.ts; the
* budget MCP tools and resources moved to the DI-discovered budget.mcp.ts, and
* the plugin RPC host injects BudgetService via PluginHostDepsFactory).
* Exports only the legacy services/budgetService names still consumed outside
* the container, 1:1, so repointing a consumer is an import-path-only diff.
* Inside the container, inject BudgetService instead. Delete this file when
* tripService/userCleanupService and the legacy MCP registrars migrate.
* Nest container (services/userCleanupService.ts and the legacy
* create_transport registrar in src/mcp/tools/transports.ts; the budget MCP
* tools and resources moved to the DI-discovered budget.mcp.ts, the plugin
* RPC host injects BudgetService via PluginHostDepsFactory, and the trip
* domain folded into the DI-native TripsService, which injects BudgetService —
* its bridge exports were pruned with it). Exports only the legacy
* services/budgetService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject BudgetService instead. Delete this file when
* userCleanupService and the transports registrar migrate.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton, and ExchangeRatesService keeps its FX
@@ -24,18 +25,10 @@ import { ExchangeRatesService } from './exchange-rates.service';
*/
const budget = new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService(), new RealtimeService());
export function listBudgetItems(tripId: string | number) {
return budget.listBudgetItems(tripId);
}
export function removeUserFromBudgetItems(userId: number): void {
budget.removeUserFromBudgetItems(userId);
}
export function rebaseTripCurrency(tripId: string | number, newCurrency: string | null | undefined): Promise<void> {
return budget.rebaseTripCurrency(tripId, newCurrency);
}
export function linkBudgetItemToReservation(
tripId: string | number,
reservationId: number,
-33
View File
@@ -1,33 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CollabService } from './collab.service';
import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the collab domain — for code running OUTSIDE the
* Nest container (the legacy trip-summary builder in src/services/tripService.ts
* and the legacy get_trip_summary registrar in src/mcp/tools/trips.ts; the
* collab MCP tools and resources moved to the DI-discovered collab.mcp.ts, and
* the plugin RPC host injects CollabService via PluginHostDepsFactory). Exports
* only the legacy services/collabService names still consumed outside the
* container, 1:1, so repointing a consumer is an import-path-only diff. Inside
* the container, inject CollabService instead. Delete this file when
* tripService and the legacy MCP trips registrar migrate.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const collab = new CollabService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService());
export function listNotes(tripId: string | number) {
return collab.listNotes(tripId);
}
export function listPolls(tripId: string | number) {
return collab.listPolls(tripId);
}
export function countMessages(tripId: string | number): number {
return collab.countMessages(tripId);
}
+3 -2
View File
@@ -58,8 +58,9 @@ export interface LinkPreviewResult {
* run in db.transaction(), getFormattedNoteById is trip-scoped and null-safe,
* votePoll rejects non-integer indexes, and linkPreview absorbs malformed URLs
* instead of throwing.
* Non-Nest consumers (legacy tripService, the legacy MCP trips registrar) go
* through collab.bridge.ts instead of importing this class directly.
* All consumers are in-container since the trip fold (TripsService and
* TripsMcp inject this class); collab.bridge.ts was deleted with its last
* outside-container consumers.
*/
@Injectable()
export class CollabService {
+10 -30
View File
@@ -1,27 +1,26 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { DaysService, addDays } from './days.service';
import { DaysService } from './days.service';
import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the day domain — for code running OUTSIDE the
* Nest container (the legacy services/tripService.ts and the legacy
* transit/transports MCP registrars in src/mcp/tools/; the day MCP tools and
* resources moved to the DI-discovered days.mcp.ts, and the plugin RPC host
* injects DaysService via PluginHostDepsFactory). Exports only the legacy
* services/dayService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject DaysService instead. Delete this file when tripService and the
* transit/transports registrars migrate.
* Nest container (the legacy transit/transports MCP registrars in
* src/mcp/tools/; the day MCP tools and resources moved to the DI-discovered
* days.mcp.ts, the plugin RPC host injects DaysService via
* PluginHostDepsFactory, and the trip domain folded into the DI-native
* TripsService, which injects DaysService — its bridge exports were pruned
* with it). Exports only the legacy services/dayService names still consumed
* outside the container, 1:1, so repointing a consumer is an import-path-only
* diff. Inside the container, inject DaysService instead. Delete this file
* when the transit/transports registrars migrate.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const days = new DaysService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService());
export { addDays };
export function getDay(id: string | number, tripId: string | number) {
return days.getDay(id, tripId);
}
@@ -29,22 +28,3 @@ export function getDay(id: string | number, tripId: string | number) {
export function listDays(tripId: string | number) {
return days.list(tripId);
}
export function listAccommodations(tripId: string | number) {
return days.listAccommodations(tripId);
}
export function restampReservationDates(
tripId: string | number,
oldDateById: Map<number, string | null>,
newDateById: Map<number, string | null>,
): void {
days.restampReservationDates(tripId, oldDateById, newDateById);
}
export function resyncAccommodationDays(
tripId: string | number,
prevDateByDayId: Map<number, string | null>,
): void {
days.resyncAccommodationDays(tripId, prevDateByDayId);
}
+15 -7
View File
@@ -36,10 +36,11 @@ DI-only service and the legacy function module was deleted, but the wiring
layer didn't inject — so a module-level shim re-exported the legacy names over
a hand-built instance. Option A deleted `tags.bridge.ts` and
`categories.bridge.ts` (the plugin host was their only consumer).
**`todo.bridge.ts` survives** for exactly one remaining consumer that genuinely
runs outside the container: the legacy `get_trip_summary` registrar in
`src/mcp/tools/trips.ts`. Delete it when that registrar migrates to the
DI-discovered MCP registry. New bridges should only be added for genuinely
`todo.bridge.ts`, `share.bridge.ts`, `collab.bridge.ts` and `vacay.bridge.ts`
followed with the 2026-07 trip fold — their last consumers (the legacy
`src/mcp/tools/trips.ts` registrar and the legacy tripService itself) migrated
into the DI-discovered `trips.mcp.ts`/`share.mcp.ts` and the DI-native
`TripsService`. New bridges should only be added for genuinely
non-Nest consumers (legacy MCP registrars, scheduler, websocket) — the plugin
host no longer needs them, ever.
@@ -118,8 +119,12 @@ host's audit trail is the separate `plugin-audit.ts`); exchangeRateService
swapped in 2026-07 with the budget-domain fold — its `getRates` import became
the injected `ExchangeRatesService`; budgetService swapped in 2026-07 with the
budget migration — its `listBudgetItems` import became the already-injected
`BudgetService`, so no constructor change at all; the remaining factory
imports are all Wave-4+ domains: trips, places, journeys, atlas, collections).
`BudgetService`, so no constructor change at all; tripService swapped in
2026-07 with the trip fold — its six imported symbols (`listTrips`,
`updateTrip`, `createTrip`, `removeMember`, `NotFoundError`, `ValidationError`)
became the injected `TripsService`, with the error classes now imported from
`nest/trips/trips.service`; the remaining factory
imports are all Wave-4+/5 domains: places, journeys, atlas, collections).
### Test impact (as landed)
@@ -127,7 +132,10 @@ imports are all Wave-4+ domains: trips, places, journeys, atlas, collections).
`PluginRpcHost` directly with hand-built `HostDeps`).
- `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was
`create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor
stubs (sixteen stubs as of the 2026-07 exchange-rates fold); the ~22
stubs (sixteen stubs as of the 2026-07 exchange-rates fold, plus `tripsStub`
with the 2026-07 trip fold — the tripService path mock's sentinel behaviors
moved onto the stub, its throws now built from the real
`nest/trips/trips.service` error classes); the ~22
legacy-service path mocks shrank by one with the 2026-07 budget migration
(the budgetService satisfy-the-import mock died with the legacy file — the
`budgetStub` gained `listBudgetItems` for the two swapped closures); a
@@ -14,7 +14,7 @@ import fsMod from 'node:fs';
import pathMod from 'node:path';
import { randomUUID } from 'node:crypto';
import { PermissionsService } from '../../permissions/permissions.service';
import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService';
import { TripsService, NotFoundError, ValidationError } from '../../trips/trips.service';
import { createPlace, updatePlace, deletePlace } from '../../../services/placeService';
import { AddonsService } from '../../addons/addons.service';
import { isDemoEmail } from '../../../services/demo';
@@ -125,7 +125,7 @@ export interface PluginCallRouter {
*
* DI-native domain services (budget, exchange-rates, reservations, tags,
* categories, todo, packing, day-notes, assignments, oauth, files, collab,
* vacay, the LLM config resolver) and
* vacay, trips, the LLM config resolver) and
* the DatabaseService (all inline SQL + the trip-access helper) are
* constructor-injected; legacy `services/*` domains stay plain function
* imports until their own migration lands (DI-MIGRATION.md), at which point
@@ -153,6 +153,7 @@ export class PluginHostDepsFactory {
private readonly exchangeRates: ExchangeRatesService,
private readonly addons: AddonsService,
private readonly realtime: RealtimeService,
private readonly trips: TripsService,
) {}
/**
@@ -347,7 +348,7 @@ export class PluginHostDepsFactory {
// transfer is a separate, deliberate action, not a member-management side effect.
const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
removeTripMemberSvc(tripId, targetUserId);
this.trips.removeMember(tripId, targetUserId);
return { removed: true };
},
// --- Host-mediated notifications. Recipient resolution + channel fan-out +
@@ -431,7 +432,7 @@ export class PluginHostDepsFactory {
// Cross-trip: every accessible trip's budget items (membership predicate is
// baked into listTrips). Reuses the hydrated list so members/payers come too.
listCostsForUser: (userId) => {
const trips = listTrips(userId, null) as Array<{ id: number }>;
const trips = this.trips.list(userId, null) as Array<{ id: number }>;
return trips.flatMap((t) => this.budget.listBudgetItems(t.id));
},
// Reuses BudgetService.create (frozen FX + members/payers), then broadcasts
@@ -528,7 +529,7 @@ export class PluginHostDepsFactory {
},
createTripForUser: (userId, input) => {
try {
const result = createTrip(userId, input as unknown as Parameters<typeof createTrip>[1]);
const result = this.trips.create(userId, input as unknown as Parameters<TripsService['create']>[1]);
return result.trip;
} catch (e) {
if (e instanceof ValidationError) throw new BadParams(e.message);
@@ -552,7 +553,9 @@ export class PluginHostDepsFactory {
}
const u = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
try {
const result = updateTrip(tripId, userId, input as Parameters<typeof updateTrip>[2], u?.role ?? 'user');
// The no-rebase updateTrip core — parity with the legacy host path,
// which never re-anchored the budget currency.
const result = this.trips.updateTrip(tripId, userId, input as Parameters<TripsService['updateTrip']>[2], u?.role ?? 'user');
this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip });
return result.updatedTrip;
} catch (e) {
@@ -564,9 +567,9 @@ export class PluginHostDepsFactory {
// --- Cross-trip reads. The membership predicate is baked into listTrips, so a
// plugin only ever sees the acting user's own trips/reservations (no raw
// cross-tenant SELECT). Reuses the same hydrated services as the REST paths. ---
listTripsForUser: (userId) => listTrips(userId, null),
listTripsForUser: (userId) => this.trips.list(userId, null),
listReservationsForUser: (userId) => {
const trips = listTrips(userId, null) as Array<{ id: number }>;
const trips = this.trips.list(userId, null) as Array<{ id: number }>;
return trips.flatMap((t) => this.reservations.list(String(t.id)));
},
// --- Trip-scoped hydrated reads (membership already checked by tripRead). Same
+2 -1
View File
@@ -36,6 +36,7 @@ import { LlmParseModule } from '../llm-parse/llm-parse.module';
import { FilesModule } from '../files/files.module';
import { CollabModule } from '../collab/collab.module';
import { VacayModule } from '../vacay/vacay.module';
import { TripsModule } from '../trips/trips.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
import { AddonsModule } from '../addons/addons.module';
@@ -50,7 +51,7 @@ import { AddonsModule } from '../addons/addons.module';
@Module({
// The DI-native domain services the plugin host wiring injects
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule, AuditModule, AddonsModule],
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
-28
View File
@@ -1,28 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { VacayService } from './vacay.service';
/**
* Non-Nest entry point for the vacay domain — for code running OUTSIDE the
* Nest container (the legacy services/tripService.ts, which shifts a trip
* owner's vacation entries when the trip window moves; the vacay MCP tools and
* resources moved to the DI-discovered vacay.mcp.ts, and the plugin RPC host
* injects VacayService via PluginHostDepsFactory). Exports only the legacy
* services/vacayService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject VacayService instead. Delete this file when tripService migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const vacay = new VacayService(new DatabaseService(db), new RealtimeService());
export function shiftOwnerEntriesForTripWindow(
ownerId: number,
oldStart: string,
oldEnd: string,
newStart: string
): void {
return vacay.shiftOwnerEntriesForTripWindow(ownerId, oldStart, oldEnd, newStart);
}
+3 -2
View File
@@ -155,8 +155,9 @@ function windowEndYear(end: string): number {
* an AbortSignal timeout and the nager.at responses are ok-checked;
* applyHolidayCalendars honors the cache TTL; addYear no longer swallows real
* errors; the holiday cache is instance state instead of a module-level map.
* The only consumer outside the Nest container is the legacy tripService,
* which goes through vacay.bridge.ts instead of importing this class.
* All consumers are in-container since the trip fold (TripsService injects
* this class); vacay.bridge.ts was deleted with its last outside-container
* consumer.
*/
@Injectable()
export class VacayService {
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -18,6 +18,7 @@ import { SettingsService } from '../../src/nest/settings/settings.service';
import { FilesService } from '../../src/nest/files/files.service';
import { CollabService } from '../../src/nest/collab/collab.service';
import { VacayService } from '../../src/nest/vacay/vacay.service';
import { TripsService } from '../../src/nest/trips/trips.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { AuditService } from '../../src/nest/audit/audit.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
@@ -35,26 +36,34 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
const realtime = new RealtimeService();
const budget = new BudgetService(dbs, permissions, exchangeRates, realtime);
const addons = new AddonsService(dbs);
const todos = new TodoService(dbs, permissions, realtime);
const packing = new PackingService(dbs, permissions, realtime);
const files = new FilesService(dbs, permissions, realtime);
const reservations = new ReservationsService(dbs, permissions, budget, realtime);
const collab = new CollabService(dbs, permissions, realtime);
const vacay = new VacayService(dbs, realtime);
const days = new DaysService(dbs, permissions, realtime);
return new PluginHostDepsFactory(
budget,
new ReservationsService(dbs, permissions, budget, realtime),
reservations,
new TagsService(dbs),
new CategoriesService(dbs),
new TodoService(dbs, permissions, realtime),
new PackingService(dbs, permissions, realtime),
todos,
packing,
new PluginOAuthService(dbs),
new DayNotesService(dbs, permissions, realtime),
new AssignmentsService(dbs, permissions, realtime),
new LlmConfigResolver(new SettingsService(dbs), dbs, addons),
dbs,
new FilesService(dbs, permissions, realtime),
new CollabService(dbs, permissions, realtime),
new VacayService(dbs, realtime),
new DaysService(dbs, permissions, realtime),
files,
collab,
vacay,
days,
permissions,
exchangeRates,
addons,
realtime,
new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime),
);
}
+4 -31
View File
@@ -1,7 +1,7 @@
/**
* Unit tests for the DI-native CollabService — COLLAB-SVC-001 to COLLAB-SVC-038
* (001–030 moved 1:1 from the legacy tests/unit/services/collabService.test.ts;
* 031–033 pin the collab.bridge delegation; 034–038 pin the post-migration
* 031–033 (collab.bridge delegation) died with the bridge; 034–038 pin the post-migration
* hardening: transactional writes, trip-scoped getFormattedNoteById, the
* integer vote guard and malformed-URL absorption). Covers votePoll edge
* cases, listMessages pagination, deleteMessage ownership, updateNote partial
@@ -61,7 +61,6 @@ import { avatarUrl } from '../../../src/services/avatarUrl';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
import { listNotes as bridgeListNotes, listPolls as bridgeListPolls, countMessages as bridgeCountMessages } from '../../../src/nest/collab/collab.bridge';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
@@ -406,35 +405,9 @@ describe('linkPreview', () => {
});
});
// ── collab.bridge delegation (out-of-container consumers) ─────────────────────
describe('collab.bridge', () => {
it('COLLAB-SVC-031: listNotes delegates to CollabService over the shared db', () => {
const { user1, trip } = setup();
svc.createNote(trip.id, user1.id, { title: 'Bridged note' });
const notes = bridgeListNotes(trip.id);
expect(notes).toHaveLength(1);
expect(notes[0].title).toBe('Bridged note');
});
it('COLLAB-SVC-032: listPolls delegates to CollabService over the shared db', () => {
const { user1, trip } = setup();
svc.createPoll(trip.id, user1.id, { question: 'Bridged?', options: ['A', 'B'] });
const polls = bridgeListPolls(trip.id);
expect(polls).toHaveLength(1);
expect(polls[0]!.question).toBe('Bridged?');
});
it('COLLAB-SVC-033: countMessages delegates to CollabService over the shared db', () => {
const { user1, trip } = setup();
svc.createMessage(trip.id, user1.id, 'one');
svc.createMessage(trip.id, user1.id, 'two');
expect(bridgeCountMessages(trip.id)).toBe(2);
});
});
// COLLAB-SVC-031..033 (collab.bridge delegation) were deleted with the bridge —
// its last consumers (the legacy tripService and the legacy get_trip_summary
// registrar) migrated into the DI-native TripsService/TripsMcp.
// ── Post-migration hardening (transactions, scoping, guards) ──────────────────
+9 -11
View File
@@ -52,13 +52,7 @@ import { createUser, createTrip, createDay, createPlace, createDayAssignment, cr
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DaysService, addDays } from '../../../src/nest/days/days.service';
import {
getDay as bridgeGetDay,
listDays as bridgeListDays,
listAccommodations as bridgeListAccommodations,
restampReservationDates as bridgeRestampReservationDates,
resyncAccommodationDays as bridgeResyncAccommodationDays,
} from '../../../src/nest/days/days.bridge';
import { getDay as bridgeGetDay, listDays as bridgeListDays } from '../../../src/nest/days/days.bridge';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
@@ -406,6 +400,10 @@ describe('deleteAccommodation', () => {
});
// ── days.bridge delegation (out-of-container consumers) ───────────────────────
// The listAccommodations / restampReservationDates / resyncAccommodationDays /
// addDays bridge exports were pruned when their last outside-container
// consumer (legacy tripService) folded into the DI-native TripsService —
// 029/031/032 pin the same behavior on the service.
describe('days.bridge', () => {
it('DAY-SVC-027 — getDay delegates to DaysService.getDay', () => {
@@ -425,13 +423,13 @@ describe('days.bridge', () => {
expect(Array.isArray(result.days[0].assignments)).toBe(true);
});
it('DAY-SVC-029 — listAccommodations delegates to DaysService.listAccommodations', () => {
it('DAY-SVC-029 — listAccommodations returns the hydrated stays', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Ryokan' });
createDayAccommodation(testDb, trip.id, place.id, day.id, day.id);
const rows = bridgeListAccommodations(trip.id) as { place_name: string }[];
const rows = svc.listAccommodations(trip.id) as { place_name: string }[];
expect(rows).toHaveLength(1);
expect(rows[0].place_name).toBe('Ryokan');
});
@@ -450,7 +448,7 @@ describe('days.bridge', () => {
'INSERT INTO reservations (trip_id, day_id, title, reservation_time) VALUES (?, ?, ?, ?)'
).run(trip.id, day.id, 'Dinner', '2026-01-01T19:00');
bridgeRestampReservationDates(
svc.restampReservationDates(
trip.id,
new Map([[day.id, '2026-01-01']]),
new Map([[day.id, '2026-01-05']]),
@@ -475,7 +473,7 @@ describe('days.bridge', () => {
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-01-01', d2.id);
testDb.prepare('UPDATE days SET day_number = 0 WHERE id = ?').run(d2.id);
bridgeResyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']]));
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']]));
const row = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accom.id) as { start_day_id: number; end_day_id: number };
expect(row.start_day_id).toBe(d2.id);
+3 -17
View File
@@ -35,14 +35,14 @@ import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { shiftOwnerEntriesForTripWindow } from '../../../src/nest/vacay/vacay.bridge';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
// VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy
// tests/unit/services/vacayService.test.ts (the named-function imports became
// method calls on a directly constructed VacayService; the legacy
// updateUserYearSettings is the class's updateYearSettings). VACAY-SVC-067
// pins the vacay.bridge delegation for the one outside-container consumer.
// updateUserYearSettings is the class's updateYearSettings).
// VACAY-SVC-067 (vacay.bridge delegation) died with the bridge — its only
// consumer, the legacy tripService, folded into the DI-native TripsService.
const svc = new VacayService(new DatabaseService(testDb), new RealtimeService());
// ── Lifecycle ─────────────────────────────────────────────────────────────────
@@ -1367,20 +1367,6 @@ describe('getSharedCalendars', () => {
});
});
// ── vacay.bridge delegation ───────────────────────────────────────────────────
describe('vacay.bridge', () => {
it('VACAY-SVC-067: shiftOwnerEntriesForTripWindow shifts the owner entries through the bridge', () => {
const { user, plan } = setupUserWithPlan();
svc.toggleEntry(user.id, plan.id, '2025-05-10', 1);
shiftOwnerEntriesForTripWindow(user.id, '2025-05-08', '2025-05-12', '2025-05-15');
const dates = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date').all(plan.id);
expect(dates).toEqual([{ date: '2025-05-17' }]);
});
});
// ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ──────
describe('quirk fixes', () => {
@@ -66,27 +66,10 @@ const budgetStub = {
const checkPermission = vi.fn((..._a: any[]) => true as boolean);
const permissionsStub = { checkPermission } as unknown as PermissionsService;
// The core write services are delegated to; mock them so the host deps'
// wiring + error branches run without the full core schema. The error classes must
// be defined INSIDE the factory (vi.mock is hoisted above module-scope code).
vi.mock('../../../src/services/tripService', () => {
class NotFoundError extends Error {}
class ValidationError extends Error {}
return {
updateTrip: (tripId: number, _u: number, input: Record<string, unknown>) => {
if (input.title === 'boom') throw new ValidationError('bad dates');
if (input.title === 'gone') throw new NotFoundError('no trip');
if (input.title === 'crash') throw new Error('unexpected');
return { updatedTrip: { id: tripId, ...input } };
},
createTrip: (userId: number, input: Record<string, unknown>) => {
if (input.title === 'boom') throw new ValidationError('bad dates');
return { trip: { id: 99, user_id: userId, ...input }, tripId: 99, reminderDays: 3 };
},
listTrips: () => [{ id: 1 }],
NotFoundError, ValidationError,
};
});
// Trips are a constructor-injected stub since the trip fold (same behaviors as
// the old services/tripService path mock). The throws use the REAL error
// classes from trips.service so the factory's instanceof mapping keeps
// matching — see the imports below the mocks.
// Exchange rates are a constructor-injected stub since the budget-domain fold
// (was a path mock of the deleted services/exchangeRateService — same behavior).
const exchangeRatesStub = {
@@ -96,6 +79,9 @@ vi.mock('../../../src/services/placeService', () => ({
createPlace: vi.fn((tid: string, body: Record<string, unknown>) => ({ id: 10, trip_id: Number(tid), ...body })),
updatePlace: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })),
deletePlace: vi.fn((_tid: string, pid: string) => pid !== '99'),
// trips.service (loaded for real since the trip fold) imports listPlaces for
// its offline bundle — the mock must carry every named export it pulls.
listPlaces: vi.fn(() => []),
}));
// Days are a constructor-injected stub (same behaviors as the old path mock,
// mapped onto the DaysService method names).
@@ -320,12 +306,27 @@ import type { VacayService } from '../../../src/nest/vacay/vacay.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
// The factory under test, wired exactly like PluginsModule does — but with the
// DI-native domain services replaced by the stubs above. The shim keeps the
// ~45 historical call sites unchanged and supplies a default no-op router.
const addonsStub = { isAddonEnabled } as unknown as import('../../../src/nest/addons/addons.service').AddonsService;
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService());
const tripsStub = {
updateTrip: (tripId: number, _u: number, input: Record<string, unknown>) => {
if (input.title === 'boom') throw new ValidationError('bad dates');
if (input.title === 'gone') throw new NotFoundError('no trip');
if (input.title === 'crash') throw new Error('unexpected');
return { updatedTrip: { id: tripId, ...input } };
},
create: (userId: number, input: Record<string, unknown>) => {
if (input.title === 'boom') throw new ValidationError('bad dates');
return { trip: { id: 99, user_id: userId, ...input }, tripId: 99, reminderDays: 3 };
},
list: () => [{ id: 1 }],
removeMember: vi.fn(),
} as unknown as import('../../../src/nest/trips/trips.service').TripsService;
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);