/** * The production wiring (PluginHostDepsFactory) that connects a plugin's * capability host to the real privileged modules (#plugins, M1). Verifies the * per-plugin data db is cached, a granted db:own call works through the wired * host, and trip broadcasts are force-namespaced to plugin:{id}:{event}. * DI-native domains (budget/exchange-rates/reservations/tags/categories/todo/ * packing/day-notes/days/assignments/oauth/llm-config/files/collab/vacay/ * permissions/trips/places/collections/atlas/notifications) * are constructor-injected stubs; legacy services/* domains stay path-mocked * until their own DI migration lands. */ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // A real in-memory core db so the metadata deps (inline SQL) and metaEntityTrip // resolution run for real; trip 1 is owned by user 5. canAccessTrip is stubbed so // user 5 (owner) can access trip 1 and user 6 cannot. vi.mock('../../../src/db/database', () => { const Database = require('better-sqlite3'); const d = new Database(':memory:'); d.exec(` CREATE TABLE trips (id INTEGER PRIMARY KEY, user_id INTEGER); CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id INTEGER); CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER); CREATE TABLE users (id INTEGER PRIMARY KEY, role TEXT, username TEXT, display_name TEXT, avatar TEXT, email TEXT); CREATE TABLE trip_members (trip_id INTEGER, user_id INTEGER); CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT, UNIQUE(plugin_id, entity_type, entity_id, key)); CREATE TABLE packing_items (id INTEGER PRIMARY KEY, trip_id INTEGER, is_private INTEGER, owner_id INTEGER); CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT); CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name)); `); d.prepare('INSERT INTO trips (id, user_id) VALUES (1, 5)').run(); d.prepare('INSERT INTO packing_items (id, trip_id, is_private, owner_id) VALUES (70, 1, 0, 5)').run(); // public before an update d.prepare('INSERT INTO packing_items (id, trip_id, is_private, owner_id) VALUES (71, 1, 1, 5)').run(); // private before an update d.prepare('INSERT INTO places (id, trip_id) VALUES (7, 1)').run(); d.prepare('INSERT INTO days (id, trip_id) VALUES (3, 1)').run(); d.prepare('INSERT INTO users (id, role) VALUES (5, ?)').run('trip_owner'); d.prepare('INSERT INTO users (id, role) VALUES (6, ?)').run('user'); d.prepare("INSERT INTO users (id, role, email) VALUES (77, 'user', 'demo@trek.app')").run(); // a demo-account member for the DEMO_MODE upload guard d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 6)').run(); // user 6 shares trip 1 with owner 5 d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 77)').run(); return { db: d, canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && (userId === 5 || userId === 6 || userId === 77) ? { id: 1, user_id: 5 } : undefined) }; }); vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); // Addon gate — flip per test to exercise the "addon disabled" branch of the reads. const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true as boolean) })); // DI-native services are constructor-injected into PluginHostDepsFactory below — // stub instances instead of path mocks (same behaviors as before the DI move). const budgetStub = { async create(tid: string, input: Record) { return { id: 1, trip_id: Number(tid), ...input }; }, async update(id: string, tid: string, input: Record) { return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input }; }, remove(id: string, _tid: string) { return id !== '404'; }, // The listCostsForTrip/listCostsForUser closures inject this since the budget // fold (was the legacy listBudgetItems path import). listBudgetItems: vi.fn(() => []), } as unknown as BudgetService; // Edit permission — flip per test to exercise the gates (constructor-injected // stub since the permissions DI migration). // eslint-disable-next-line @typescript-eslint/no-explicit-any const checkPermission = vi.fn((..._a: any[]) => true as boolean); const permissionsStub = { checkPermission } as unknown as PermissionsService; // 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 = { getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08, GBP: 0.85 })), } as unknown as ExchangeRatesService; // Places are a constructor-injected stub since the place fold (same behaviors // as the old services/placeService path mock, mapped onto the PlacesService // method names). `remove` is asserted by invocation order against the journey // hook, so it stays a vi.fn. const removePlaceStub = vi.fn((_tid: string, pid: string) => pid !== '99'); const placesStub = { create: vi.fn((tid: string, body: Record) => ({ id: 10, trip_id: Number(tid), ...body })), update: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), remove: removePlaceStub, // Trip-scoped read the delete path uses to reject a foreign id before the // journey hook runs — place 99 belongs to another trip. get: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), // trips.service (loaded for real since the trip fold) injects this for its // offline bundle — the stub must carry every method it calls. list: vi.fn(() => []), } as unknown as PlacesService; // Days are a constructor-injected stub (same behaviors as the old path mock, // mapped onto the DaysService method names). const daysStub = { create: vi.fn((tid: number) => ({ id: 20, trip_id: tid, assignments: [] })), getDay: vi.fn((id: number) => (id === 99 ? undefined : { id, title: null })), update: vi.fn((id: number) => ({ id, assignments: [] })), remove: vi.fn(), list: vi.fn((tid: number) => ({ days: [{ id: 3, trip_id: Number(tid), day_number: 1, assignments: [], notes_items: [] }] })), listAccommodations: vi.fn((tid: number) => [{ id: 11, trip_id: Number(tid), place_name: 'Ryokan' }]), // place 999 / day 88 don't belong to the trip validateAccommodationRefs: vi.fn((_tid: number, placeId?: number, startDayId?: number, endDayId?: number) => { const errors: { field: string; message: string }[] = []; if (placeId === 999) errors.push({ field: 'place_id', message: 'Place not found' }); if (startDayId === 88 || endDayId === 88) errors.push({ field: 'start_day_id', message: 'Start day not found' }); return errors; }), createAccommodation: vi.fn((tid: number, data: Record) => ({ id: 60, trip_id: Number(tid), ...data })), getAccommodation: vi.fn((id: number) => (Number(id) === 404 ? undefined : { id: Number(id), place_id: 7, start_day_id: 3, end_day_id: 4 })), updateAccommodation: vi.fn((id: number, _existing: unknown, fields: Record) => ({ id: Number(id), ...fields })), deleteAccommodation: vi.fn((id: number) => (Number(id) === 61 ? { linkedReservationId: 40, deletedBudgetItemId: 9 } : { linkedReservationId: null, deletedBudgetItemId: null })), } as unknown as DaysService; // Assignments is a constructor-injected stub (same behaviors as the old path mock). const assignmentsStub = { createAssignment: vi.fn((dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })), deleteAssignment: vi.fn(), dayExists: vi.fn((dayId: number) => dayId === 3), placeExists: vi.fn((placeId: number) => placeId === 7), getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id, day_id: 3 })), reconcile: vi.fn(), } as unknown as AssignmentsService; // Packing is a constructor-injected stub (same behaviors as the old path mock). const packingStub = { listItems: vi.fn((tid: number | string, userId?: number) => [{ id: 1, trip_id: tid, name: 'Socks', _uid: userId }]), // Return the item with the #858 privacy fields the host deps scope on. createItem: vi.fn((tid: number | string, input: { name: string; visibility?: string; recipient_ids?: number[] }, ownerId?: number) => { if (input.visibility === 'personal') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: [] }; if (input.visibility === 'shared') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: (input.recipient_ids || []).map((id) => ({ user_id: id })) }; return { id: 70, trip_id: Number(tid), name: input.name, is_private: 0, owner_id: ownerId }; }), // itemId 99 => a stale-write conflict result; otherwise the after-state (is_private per input). updateItem: vi.fn((tid: number | string, id: string, input: { is_private?: boolean }) => Number(id) === 99 ? { conflict: true, server: { id: 99 } } : { id: Number(id), trip_id: Number(tid), is_private: input.is_private ? 1 : 0, owner_id: 5 }, ), // The raw deleted row (owner-only for a private item, no recipients — #858). deleteItem: vi.fn((_tid: number | string, id: string) => (Number(id) === 404 ? null : Number(id) === 71 ? { id: 71, is_private: 1, owner_id: 5 } : { id: Number(id), is_private: 0 })), // Mirrors the packing_items rows seeded above: 70 = public, 71 = private (was // the inline packingItemPrivacy SQL before the DI move). getItemPrivacy: vi.fn((tid: number | string, id: number | string) => Number(tid) === 1 && Number(id) === 70 ? { is_private: 0, owner_id: 5 } : Number(tid) === 1 && Number(id) === 71 ? { is_private: 1, owner_id: 5 } : undefined), listBags: vi.fn((tid: number | string) => [{ id: 80, trip_id: Number(tid), name: 'Backpack' }]), createBag: vi.fn((tid: number | string, data: { name: string }) => ({ id: 80, trip_id: Number(tid), name: data.name })), updateBag: vi.fn((_tid: number | string, bagId: string) => (Number(bagId) === 404 ? null : { id: Number(bagId), name: 'Renamed' })), deleteBag: vi.fn((_tid: number | string, bagId: string) => Number(bagId) !== 404), setBagMembers: vi.fn((_tid: number | string, bagId: string, userIds: number[]) => (Number(bagId) === 404 ? null : userIds.map((u) => ({ user_id: u })))), } as unknown as PackingService; vi.mock('../../../src/nest/common/conflictResult', () => ({ isUpdateConflict: (r: unknown) => !!(r as { conflict?: boolean })?.conflict })); vi.mock('../../../src/nest/weather/weather.impl', () => ({ getWeather: vi.fn(async (lat: string, lng: string) => ({ lat, lng, temp: 20 })) })); const categoriesStub = { list: vi.fn(() => [{ id: 1, name: 'Food' }]) } as unknown as CategoriesService; const tagsStub = { list: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]), create: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })), getByIdAndUser: vi.fn((tagId: number, _uid: number) => (Number(tagId) === 404 ? undefined : { id: Number(tagId) })), update: vi.fn((tagId: number, name?: string) => ({ id: Number(tagId), name })), remove: vi.fn(), } as unknown as TagsService; const todoStub = { listItems: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), name: 'Pack' }]), createItem: vi.fn((tid: number, data: { name: string }) => ({ id: 90, trip_id: Number(tid), name: data.name })), updateItem: vi.fn((_tid: number, id: string) => (Number(id) === 404 ? null : { id: Number(id), name: 'Done' })), deleteItem: vi.fn((_tid: number, id: string) => Number(id) !== 404), } as unknown as TodoService; const { testFilesDir } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const osm = require('node:os') as typeof import('node:os'); // eslint-disable-next-line @typescript-eslint/no-require-imports const pm = require('node:path') as typeof import('node:path'); return { testFilesDir: pm.join(osm.tmpdir(), 'trek-crh-files-test') }; }); // The load-time constants the factory imports from files.constants — mocked so // createTripFile's mkdirSync/writeFileSync land in the tmpdir, never in the // real server/uploads/files (same redirection the old fileService path mock did). vi.mock('../../../src/nest/files/files.constants', () => ({ filesDir: testFilesDir, BLOCKED_EXTENSIONS: ['.exe', '.bat', '.sh'], })); // Files: injected stub since the fileService DI migration (same behaviors as // the old path mock). const filesStub = { listFiles: vi.fn((tid: number, trash: boolean) => [{ id: 2, trip_id: tid, trash }]), createFile: vi.fn((tid: number, file: { filename: string; originalname: string; size: number }, uploadedBy: number) => ({ id: 130, trip_id: Number(tid), ...file, uploaded_by: uploadedBy })), createFileLink: vi.fn(() => [{ file_id: 130 }]), getFileById: vi.fn((id: number) => (Number(id) === 404 ? undefined : Number(id) === 500 ? { id: 500, filename: 'huge.mp4', original_name: 'huge.mp4', mime_type: 'video/mp4', file_size: 400 * 1024 * 1024, deleted_at: null } : { id: Number(id), filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: 'application/pdf', file_size: 3, deleted_at: null, description: 'old', place_id: null, reservation_id: null })), resolveFilePath: vi.fn((filename: string) => ({ resolved: `${testFilesDir}/${filename}`, safe: filename !== 'evil' })), updateFile: vi.fn((id: number, _cur: unknown, updates: unknown) => ({ id: Number(id), ...(updates as object) })), softDeleteFile: vi.fn(), // reservation_id/place_id 999 = belongs to another trip findForeignLinkTarget: vi.fn((_tid: number, opts: { reservation_id?: number | null; place_id?: number | null }) => (Number(opts.reservation_id) === 999 ? 'reservation_id' : Number(opts.place_id) === 999 ? 'place_id' : null)), } as unknown as FilesService; // Collab: injected stub since the collabService DI migration (same behaviors // as the old path mock). const collabStub = { listNotes: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), title: 'Note' }]), listPolls: vi.fn((tid: number) => [{ id: 2, trip_id: Number(tid), question: 'Q?' }]), listMessages: vi.fn((tid: number, before?: number) => [{ id: 3, trip_id: Number(tid), text: 'hi', _before: before ?? null }]), createNote: vi.fn((tid: number, uid: number, data: { title: string }) => ({ id: 140, trip_id: Number(tid), created_by: uid, title: data.title })), createPoll: vi.fn((tid: number, _uid: number, data: { question: string }) => ({ id: 141, trip_id: Number(tid), question: data.question })), votePoll: vi.fn((_tid: number, pollId: number, _uid: number, optionIndex: number) => (optionIndex > 5 ? { error: 'Invalid option' } : { poll: { id: Number(pollId), votes: 1 } })), createMessage: vi.fn((tid: number, uid: number, text: string) => (text === 'toolong' ? { error: 'Message too long' } : { message: { id: 142, trip_id: Number(tid), user_id: uid, text } })), } as unknown as CollabService; // Injected stub since the membership fold (was a path mock of services/tripMembership). const membershipStub = { joinTripAsMember: vi.fn((tripId: number, userId: number) => ({ joined: userId !== 5, tripId })), // owner add = no-op } as unknown as TripMembershipService; // Notifications are a constructor-injected stub since the notifications fold // (same behavior as the old services/notificationService path mock). const notifySend = vi.fn(async () => undefined); const notificationsStub = { send: notifySend } as unknown as NotificationsService; // userId 7 = no provider configured; everyone else resolves to a stub config. // Constructor-injected stub since the resolver became DI-native (settings migration). const llmConfigStub = { resolve: vi.fn((uid: number) => (uid === 7 ? null : { provider: 'openai', model: 'gpt-x', baseUrl: undefined, apiKey: 'sekret' })), } as unknown as LlmConfigResolver; const { llmExtract } = vi.hoisted(() => ({ llmExtract: vi.fn(async (input: { text?: string }) => [{ text: `answer:${input.text ?? ''}` }]) })); vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient: vi.fn(() => ({ extract: llmExtract })) })); // The per-user settings read the runtime deps delegate to (still a path mock — // readUserSettingDecrypted is a bare function export, not an injected service). vi.mock('../../../src/nest/plugins/plugins.service', () => ({ readUserSettingDecrypted: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)) })); const oauthStub = { async getAccessToken(_pid: string, uid: number) { return uid === 5 ? 'tok-5' : null; } } as unknown as PluginOAuthService; // Reservations: injected stub so the reservation deps' side-effect branches // (accommodation / budget-sync / notify) run. const reservationsStub = { create(tid: string, input: Record) { return { reservation: { id: 40, trip_id: Number(tid), ...input }, accommodationCreated: input.title === 'Stay' }; }, getReservation(id: string) { return id === '404' ? undefined : { id: Number(id), title: 'Old', type: 'flight' }; }, update(id: string, tid: string, input: Record) { return { reservation: { id: Number(id), trip_id: Number(tid), ...input }, accommodationChanged: input.title === 'New' }; }, remove(id: string) { if (id === '404') return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }; return { deleted: { id: Number(id), title: 'Gone', type: 'hotel', accommodation_id: 7 }, accommodationDeleted: true, deletedBudgetItemId: 9 }; }, list(tid: string) { return [{ id: 1, trip_id: Number(tid), title: 'Flight' }]; }, syncBudgetOnCreate() {}, syncBudgetOnUpdate() {}, notifyBookingChange() {}, } as unknown as ReservationsService; // Journey is a constructor-injected stub since the journey fold (same behaviors // as the old services/journeyService path mock, keyed by the service methods). const { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } = vi.hoisted(() => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn(), })); const journeyStub = { // The skeleton hooks the place writes fire (#1705). onPlaceCreated, onPlaceUpdated, onPlaceDeleted, listJourneys: vi.fn((uid: number) => [{ id: 1, owner: uid }]), // journeyId 88 = no access (listEntries self-gates to null); else returns entries listEntries: vi.fn((journeyId: number, uid: number) => (journeyId === 88 ? null : [{ id: 10, journey_id: journeyId, author_id: uid }])), // journeyId 99 = not editable by the acting user (canEdit inside returns null/false) createEntry: vi.fn((journeyId: number, uid: number, data: unknown) => (journeyId === 99 ? null : { id: 120, journey_id: journeyId, created_by: uid, ...(data as object) })), updateEntry: vi.fn((entryId: number, _uid: number, data: unknown) => (entryId === 99 ? null : { id: entryId, ...(data as object) })), deleteEntry: vi.fn((entryId: number) => entryId !== 99), createJourney: vi.fn((uid: number, data: unknown) => ({ id: 130, owner: uid, ...(data as object) })), deleteJourney: vi.fn((journeyId: number) => journeyId !== 99), } as unknown as JourneyDomainService; // Atlas is a constructor-injected stub since the atlas fold (same behaviors as // the old services/atlasService path mock, keyed by the service method names). const atlasStub = { listVisitedCountries: vi.fn(() => [{ country_code: 'JP' }]), listManuallyVisitedRegions: vi.fn(() => [{ region_code: 'JP-13' }]), bucketList: vi.fn((uid: number) => [{ id: 5, user_id: uid, name: 'Kyoto' }]), markCountry: vi.fn(), unmarkCountry: vi.fn(), markRegion: vi.fn(), unmarkRegion: vi.fn(), createBucketItem: vi.fn((uid: number, data: { name: string }) => ({ id: 110, user_id: uid, name: data.name })), deleteBucketItem: vi.fn((_uid: number, itemId: number) => Number(itemId) !== 404), } as unknown as AtlasService; // Collections is a constructor-injected stub since the collections fold (same // behaviors as the old services/collectionsService path mock — the service // throws status-tagged errors the factory maps onto the RPC error classes). const collectionsHttpError = (status: number, message: string) => { const e = new Error(message) as Error & { status: number }; e.status = status; throw e; }; const collectionsStub = { listCollections: vi.fn((uid: number) => ({ collections: [{ id: 1, owner: uid }] })), getCollection: vi.fn((uid: number, id: number) => ({ id, owner: uid, places: [] })), createCollection: vi.fn((uid: number, body: unknown) => ({ id: 100, owner_id: uid, ...(body as object) })), // id 99 = viewer-only (403); id 404 = invisible (404) — the service throws status-tagged errors updateCollection: vi.fn((_uid: number, id: number, body: unknown) => { if (id === 99) collectionsHttpError(403, 'read-only'); if (id === 404) collectionsHttpError(404, 'Collection not found'); return { id, ...(body as object) }; }), savePlace: vi.fn((uid: number, body: unknown) => ({ id: 101, saved_by: uid, ...(body as object) })), copyToTrip: vi.fn(() => ({ copied: 2, skipped: [] })), deletePlace: vi.fn((_uid: number, placeId: number) => { if (placeId === 404) collectionsHttpError(404, 'Collection not found'); }), } as unknown as CollectionsService; // Day notes are a constructor-injected stub (same behaviors as the old path mock). const dayNotesStub = { list: vi.fn((dayId: number, tripId: number) => [{ id: 1, day_id: dayId, trip_id: tripId }]), create: vi.fn((dayId: number, _tripId: number, text: string) => ({ id: 50, day_id: dayId, text })), getNote: vi.fn((id: number) => (id === 99 ? undefined : { id, text: 'Old' })), update: vi.fn((id: number, _current: unknown, fields: Record) => ({ id, ...fields })), remove: vi.fn(), dayExists: vi.fn((dayId: number) => dayId === 3), } as unknown as DayNotesService; // Vacay is a constructor-injected stub (same behaviors as the old path mock). const vacayStub = { getPlanData: vi.fn((uid: number) => ({ plan: { id: 1, owner: uid } })), getActivePlanId: vi.fn(() => 77), toggleEntry: vi.fn((uid: number, planId: number) => ({ action: 'added', uid, planId })), toggleCompanyHoliday: vi.fn((planId: number) => ({ action: 'added', planId })), } as unknown as VacayService; import { PluginHostDepsFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-host-deps.factory'; import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state'; import { db as mockDb } from '../../../src/db/database'; import type { BudgetService } from '../../../src/nest/budget/budget.service'; import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { TagsService } from '../../../src/nest/tags/tags.service'; import type { CategoriesService } from '../../../src/nest/categories/categories.service'; import type { TodoService } from '../../../src/nest/todo/todo.service'; import type { PackingService } from '../../../src/nest/packing/packing.service'; import type { DayNotesService } from '../../../src/nest/days/day-notes.service'; import type { DaysService } from '../../../src/nest/days/days.service'; import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver'; import type { FilesService } from '../../../src/nest/files/files.service'; import type { CollabService } from '../../../src/nest/collab/collab.service'; import type { VacayService } from '../../../src/nest/vacay/vacay.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import type { PlacesService } from '../../../src/nest/places/places.service'; import type { CollectionsService } from '../../../src/nest/collections/collections.service'; import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; import type { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.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 tripsStub = { updateTrip: (tripId: number, _u: number, input: Record) => { 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) => { 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, placesStub, collectionsStub, atlasStub, notificationsStub, membershipStub, journeyStub); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); let tmp: string; beforeAll(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-crh-')); process.env.TREK_PLUGINS_DATA_DIR = tmp; }); afterAll(() => { closePluginDataDb('wired'); delete process.env.TREK_PLUGINS_DATA_DIR; fs.rmSync(tmp, { recursive: true, force: true }); }); describe('plugin host wiring (PluginHostDepsFactory)', () => { it('caches one data db per plugin id', () => { const a = getPluginDataDb('wired'); const b = getPluginDataDb('wired'); expect(a).toBe(b); }); it('a granted db:own call runs against the plugin db, and a trip broadcast is namespaced', async () => { const host = createRealRpcHost('wired', new Set(['db:own', 'ws:broadcast:trip'])); const migrated = await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE t (v TEXT)' } }); expect(migrated.ok).toBe(true); // acting user 5 is a member of trip 1 (mocked canAccessTrip) → broadcast allowed + namespaced await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } }, 5); expect(broadcast).toHaveBeenCalledWith(1, 'plugin:wired:ping', { a: 1 }); const bcastUser = createRealRpcHost('wired', new Set(['ws:broadcast:user'])); // a per-user broadcast may only target the acting user themselves await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 5, event: 'hi', data: {} } }, 5); expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:wired', event: 'hi' }); }); it('db.tx dispatches an atomic batch through to the plugin db under db:own', async () => { const host = createRealRpcHost('wtx', new Set(['db:own'])); await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE kv (k TEXT, n INTEGER)' } }); const res = await host.dispatch({ k: 'req', id: '2', method: 'db.tx', params: { ops: [ { sql: 'INSERT INTO kv (k, n) VALUES (?, ?)', args: ['a', 1] }, { sql: 'SELECT n FROM kv WHERE k = ?', args: ['a'] }, ] } }); expect(res.ok).toBe(true); expect((res as { result: { results: unknown[] } }).result.results).toEqual([{ changes: 1 }, { rows: [{ n: 1 }] }]); // malformed ops → BAD_PARAMS, and no db:own grant → not reachable const bad = await host.dispatch({ k: 'req', id: '3', method: 'db.tx', params: { ops: [{ sql: 42 }] } }); expect(bad.ok).toBe(false); const noGrant = createRealRpcHost('wtx2', new Set()); expect((await noGrant.dispatch({ k: 'req', id: '4', method: 'db.tx', params: { ops: [] } })).ok).toBe(false); closePluginDataDb('wtx'); closePluginDataDb('wtx2'); }); it('closePluginDataDb closes and drops the cached handle', () => { getPluginDataDb('transient'); closePluginDataDb('transient'); // a fresh get after close returns a NEW instance (cache was cleared) const a = getPluginDataDb('transient'); closePluginDataDb('transient'); const b = getPluginDataDb('transient'); expect(a).not.toBe(b); closePluginDataDb('transient'); }); it('getPluginDataDb recreates a handle that was closed WITHOUT eviction (terminal-failure dispose)', () => { const a = getPluginDataDb('closedcache'); expect(a.isOpen()).toBe(true); // simulate the supervisor's dispose() path: close the handle but leave it cached a.close(); expect(a.isOpen()).toBe(false); const b = getPluginDataDb('closedcache'); // must NOT return the dead handle expect(b).not.toBe(a); expect(b.isOpen()).toBe(true); expect(b.exec('CREATE TABLE t (v)').changes).toBe(0); // usable again closePluginDataDb('closedcache'); }); }); describe('host-deps factory — planner write + metadata deps', () => { const host = (...perms: string[]) => createRealRpcHost('writer', new Set(perms)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid = 5): Promise => h.dispatch({ k: 'req', id: 'x', method, params }, uid); beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); }); afterAll(() => closePluginDataDb('writer')); // places, days and itinerary left this factory with the decorator migration; the // cases now run in tests/unit/places/ and tests/unit/days/. // #1705: a place write from a plugin has to update a linked journey the same way // the REST route does, otherwise the journey shows the old title (or an entry for // a place that no longer exists) until it is reloaded. // A journey link that blows up must not turn a successful place write into an // RPC error — every other caller of these hooks swallows them too. // #1705: a plugin itinerary write has to reach open sessions exactly like the REST // route. The delete payload needs the dayId the client reducer evicts by, and both // directions run the same journey-skeleton reconcile the controller/MCP tool run. // trips.*, the cross-trip feeds and member management left this factory with the // decorator migration; the cases now run in tests/unit/trips/trips.rpc.test.ts. it('metadata: round-trips and enforces the key/value/access limits', async () => { const h = host('db:meta'); expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true); expect((await call(h, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 }); expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert path expect((await call(h, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place → trip 1 expect((await call(h, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ deleted: true }); expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error.code).toBe('BAD_PARAMS'); expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error.code).toBe('BAD_PARAMS'); expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error.code).toBe('RESOURCE_FORBIDDEN'); }); // costs, reservations and accommodations left this factory with the decorator // migration; the cases now run in tests/unit/budget/ and tests/unit/reservations/. it('users.getById is scoped to people the acting user shares a trip with', async () => { const h = host('db:read:users'); expect((await call(h, 'users.getById', { id: 6 }, 5)).ok).toBe(true); // 5 (owner) + 6 (member) share trip 1 expect((await call(h, 'users.getById', { id: 999 }, 5)).error.code).toBe('RESOURCE_FORBIDDEN'); }); }); // The 'reservations, day notes, cross-trip + addon reads' suite is gone: every case in it moved onto the decorators. // The packing write deps left this factory with the decorator migration. The #858 // transitions are asserted in tests/unit/packing/packing.rpc.test.ts, against the // PackingService methods the REST controller uses too, so there is one copy now. // The Wave 1 wiring suite is gone: weather, categories, rates, tags, todos, the // roster and the packing bags all moved onto the decorators, so there was nothing // left in it. Their cases live in the domain suites now. // The 'Wave 2 wiring' suite is gone: every case in it moved onto the decorators. // The 'Wave 3 wiring' suite is gone: every case in it moved onto the decorators. describe('host-deps factory — Wave 4 wiring (notify / ai)', () => { const host = (...perms: string[]) => createRealRpcHost('w4', new Set(perms)) // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => h.dispatch({ k: 'req', id: 'x', method, params }, uid) beforeEach(() => { notifySend.mockClear(); llmExtract.mockClear() }) afterAll(() => closePluginDataDb('w4')) it('notify.send delegates to notificationService with the plugin_notification event + forced target', async () => { const h = host('notify:send') const r = await call(h, 'notify.send', { input: { title: 'Delay', body: 'AB123 late', scope: 'user', targetId: 5, link: '/trips/1' } }, 5) expect(r.ok).toBe(true) expect(notifySend).toHaveBeenCalledWith(expect.objectContaining({ event: 'plugin_notification', actorId: null, scope: 'user', targetId: 5, params: expect.objectContaining({ title: 'Delay', body: 'AB123 late', link: '/trips/1' }), })) // trip scope for a trip the acting user (owner 5) can access expect((await call(h, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 1 } }, 5)).ok).toBe(true) }) it('ai.complete/extract run under the resolved provider; unconfigured user → BAD_PARAMS', async () => { const h = host('ai:invoke') const c = await call(h, 'ai.complete', { prompt: 'Summarize' }, 5) expect(c.ok).toBe(true) expect(c.result).toMatchObject({ text: expect.stringContaining('answer:') }) expect(llmExtract).toHaveBeenCalled() const e = await call(h, 'ai.extract', { text: 'AB123 JFK', jsonSchema: { type: 'object' } }, 5) expect(e.ok).toBe(true) expect(Array.isArray(e.result.results)).toBe(true) // user 7 has no provider → the router's aiConfigured() check trips first expect(((await call(h, 'ai.complete', { prompt: 'hi' }, 7)) as { error: { code: string } }).error.code).toBe('BAD_PARAMS') }) it('scheduler.set upserts by name with caps; cancel removes; recurring floor enforced', async () => { const h = createRealRpcHost('wsched', new Set(['jobs:run'])); const c = async (method: string, params: Record): Promise<{ ok: boolean; result?: unknown; error?: { code: string; message: string } }> => h.dispatch({ k: 'req', id: 'x', method, params }, undefined) as never; const due = Date.now() + 120_000; expect((await c('scheduler.set', { name: 'poll', dueAt: due, payload: { a: 1 } })).ok).toBe(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any const dbAny = mockDb as any; expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(1); // re-set same name = upsert (still 1 row, new due) expect((await c('scheduler.set', { name: 'poll', dueAt: due + 1000 })).ok).toBe(true); expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(1); // recurring below the 60s floor is refused expect((await c('scheduler.set', { name: 'fast', dueAt: due, everyMs: 5000 })).error?.code).toBe('BAD_PARAMS'); // cancel removes it expect((await c('scheduler.cancel', { name: 'poll' })).result).toMatchObject({ cancelled: true }); expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(0); closePluginDataDb('wsched'); }) it('enforces the daily notify + AI budgets, seeded from today\'s audit rows', async () => { // Seed the audit with a plugin that has already spent its whole day (default caps // are 100 notify / 200 ai) so the budget is exhausted on first use. const today = new Date().toISOString().slice(0, 10) + 'T08:00:00.000Z'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const dbAny = mockDb as any; const ins = dbAny.prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)"); for (let i = 0; i < 100; i++) ins.run('wbudget', 'notify.send', today); for (let i = 0; i < 200; i++) ins.run('wbudget', 'ai.complete', today); const h = createRealRpcHost('wbudget', new Set(['notify:send', 'ai:invoke'])); const c = async (method: string, params: Record): Promise<{ ok: boolean; error?: { code: string; message: string } }> => h.dispatch({ k: 'req', id: 'x', method, params }, 5) as never; const n = await c('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }); expect(n.ok).toBe(false); expect(n.error?.code).toBe('BAD_PARAMS'); expect(n.error?.message).toMatch(/budget/i); const a = await c('ai.complete', { prompt: 'hi' }); expect(a.ok).toBe(false); expect(a.error?.message).toMatch(/budget/i); closePluginDataDb('wbudget'); }) }) describe('host-deps factory — Wave 8 wiring (settings.get / oauth.getToken)', () => { const host = (...perms: string[]) => createRealRpcHost('w8', new Set(perms)) // uid is explicit here (no default) — an undefined must stay undefined, not fall back. // eslint-disable-next-line @typescript-eslint/no-explicit-any const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined): Promise => h.dispatch({ k: 'req', id: 'x', method, params }, uid) afterAll(() => closePluginDataDb('w8')) it('settings.get returns the acting user\'s decrypted value; userless → undefined', async () => { const h = host() // no permission needed — the plugin's own settings expect((await call(h, 'settings.get', { key: 'apiKey' }, 5)).result).toEqual({ value: 'k-5' }) expect((await call(h, 'settings.get', { key: 'apiKey' }, undefined)).result).toEqual({ value: undefined }) }) it('oauth.getToken returns only the acting user\'s access token, gated by oauth:client', async () => { const h = host('oauth:client') expect((await call(h, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' }) expect((await call(h, 'oauth.getToken', {}, 7)).result).toEqual({ accessToken: null }) // not connected expect((await call(h, 'oauth.getToken', {}, undefined)).result).toEqual({ accessToken: null }) // userless → null (SDK contract) expect((await call(host(), 'oauth.getToken', {}, 5)).ok).toBe(false) // no grant }) })