/** * The place plugin surface after it moved onto @PluginMethod. * * The delete path carries an ordering constraint that is easy to lose in a move and * silent when broken, so it gets its own cases: the journey hook has to run BEFORE * the row is deleted, and the id has to be scoped to the trip before the hook runs * at all. */ import { describe, it, expect, vi } from 'vitest'; import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; import { PlacesRpc } from '../../../src/nest/places/places.rpc'; import { PlacesModule } from '../../../src/nest/places/places.module'; import type { PlacesService } from '../../../src/nest/places/places.service'; import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope'; import { makeDeps } from '../../helpers/rpc-host-deps'; const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); /** * Trip 1 belongs to user 42; place 7 sits on it. * * The doubles keep their vi.fn types here and only wear the service type at the * constructor. Stamping the service type onto the object itself puts the real method * signature in front of the mock, and the cases below read .mock off these methods. */ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { // update/remove are async in production (they may delete a storage object), so the // doubles return promises: an un-awaited call in the RPC handler would compare a // Promise against null/false instead of the resolved value, and these mocks pin // that regression down rather than papering over it with a synchronous return. const places = { create: vi.fn((_t: string, i: Record) => ({ id: 10, ...i })), update: vi.fn((_t: string, id: string) => Promise.resolve(id === '7' ? { id: 7, name: 'updated' } : null)), get: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7 } : undefined)), remove: vi.fn((_t: string, id: string) => Promise.resolve({ deleted: id === '7', cancelled: { reservationIds: [], budgetItemIds: [] } })), linkedExpenseIds: vi.fn(() => []), }; const journey = { onPlaceCreated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), onPlaceUpdated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), onPlaceDeleted: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), }; const realtime = { broadcast: vi.fn() }; const guards = new PluginGuards( { findAccessible: vi.fn(async (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)), } as unknown as TripsRepository, { checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, { getRole: vi.fn(async () => 'user') } as unknown as UsersRepository, ); // Each double covers only the methods PlacesRpc reaches for, hence the cast. const rpc = new PlacesRpc( places as unknown as PlacesService, journey as unknown as JourneyDomainService, realtime as unknown as RealtimeService, guards, ); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc])); return { places, journey, realtime, host }; } describe('PlacesRpc', () => { it('PLACES-RPC-001 writes need the grant, the edit right and a bound user', async () => { const f = build(); const host = f.host('db:write:places'); expect((await host.dispatch(req('places.create', { tripId: 1, input: { name: 'Shrine' } }), 42)).ok).toBe(true); expect(((await host.dispatch(req('places.create', { tripId: 2, input: { name: 'x' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); const noUser = (await host.dispatch(req('places.create', { tripId: 1, input: { name: 'x' } }), undefined)) as RpcError; expect(noUser.error.message).toBe('place writes require an authenticated user context'); const denied = (await f.host().dispatch(req('places.create', { tripId: 1, input: { name: 'x' } }), 42)) as RpcError; expect(denied.error.code).toBe('PERMISSION_DENIED'); }); it('PLACES-RPC-002 an invalid place is BAD_PARAMS with the schema message', async () => { const f = build(); const res = (await f.host('db:write:places').dispatch(req('places.create', { tripId: 1, input: {} }), 42)) as RpcError; expect(res.error.code).toBe('BAD_PARAMS'); expect(res.error.message).toMatch(/^invalid place: /); }); it('PLACES-RPC-003 an oversized field is capped like the REST controller caps it', async () => { const f = build(); const res = (await f.host('db:write:places').dispatch( req('places.create', { tripId: 1, input: { name: 'x'.repeat(201) } }), 42, )) as RpcError; expect(res.error.message).toBe('name must be 200 characters or fewer'); expect(f.places.create).not.toHaveBeenCalled(); }); it('PLACES-RPC-004 a place on another trip is refused, naming it', async () => { const f = build(); const host = f.host('db:write:places'); expect(((await host.dispatch(req('places.update', { tripId: 1, placeId: 404, input: { name: 'x' } }), 42)) as RpcError).error.message) .toBe('no place 404 on trip 1'); expect(((await host.dispatch(req('places.delete', { tripId: 1, placeId: 404 }), 42)) as RpcError).error.message) .toBe('no place 404 on trip 1'); }); it('PLACES-RPC-005 delete runs the journey hook BEFORE the row goes', async () => { const f = build(); await f.host('db:write:places').dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42); // journey_entries.source_place_id is ON DELETE SET NULL. Run the hook afterwards // and it finds nothing left to detach, leaving the entries as orphans. expect(f.journey.onPlaceDeleted.mock.invocationCallOrder[0]).toBeLessThan(f.places.remove.mock.invocationCallOrder[0]); }); it('PLACES-RPC-006 a foreign place id never reaches the journey hook', async () => { const f = build(); await f.host('db:write:places').dispatch(req('places.delete', { tripId: 1, placeId: 404 }), 42); // The hook keys on the place alone, so an unscoped id would detach ANOTHER // trip's journey entries even though the delete itself is refused. expect(f.journey.onPlaceDeleted).not.toHaveBeenCalled(); }); it('PLACES-RPC-007 a broken journey link never fails a write that already succeeded', async () => { const f = build({ journeyThrows: true }); const host = f.host('db:write:places'); expect((await host.dispatch(req('places.create', { tripId: 1, input: { name: 'Shrine' } }), 42)).ok).toBe(true); expect((await host.dispatch(req('places.update', { tripId: 1, placeId: 7, input: { name: 'x' } }), 42)).ok).toBe(true); expect((await host.dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42)).ok).toBe(true); }); it('PLACES-RPC-008 every write broadcasts its event, a refused one broadcasts nothing', async () => { const f = build(); const host = f.host('db:write:places'); await host.dispatch(req('places.create', { tripId: 1, input: { name: 'Shrine' } }), 42); await host.dispatch(req('places.update', { tripId: 1, placeId: 7, input: { name: 'x' } }), 42); await host.dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42); expect(f.realtime.broadcast.mock.calls.map((c) => c[1])).toEqual(['place:created', 'place:updated', 'place:deleted']); f.realtime.broadcast.mockClear(); await host.dispatch(req('places.delete', { tripId: 1, placeId: 404 }), 42); expect(f.realtime.broadcast).not.toHaveBeenCalled(); }); it('PLACES-RPC-009 without the edit right nothing is written', async () => { const f = build({ canEdit: false }); const res = (await f.host('db:write:places').dispatch(req('places.create', { tripId: 1, input: { name: 'x' } }), 42)) as RpcError; expect(res.error.message).toBe('no permission to edit trip 1'); expect(f.places.create).not.toHaveBeenCalled(); }); it('PLACES-RPC-010 the class is listed in its module providers', () => { expectRegisteredProvider(PlacesModule, PlacesRpc); }); it('PLACES-RPC-011 an update broadcasts the resolved place, not the pending promise', async () => { const f = build(); const host = f.host('db:write:places'); const res = await host.dispatch(req('places.update', { tripId: 1, placeId: 7, input: { name: 'x' } }), 42); expect(res.ok).toBe(true); expect((res as { result: unknown }).result).toEqual({ id: 7, name: 'updated' }); // Not a Promise instance and not [object Promise] — the broadcast payload is the // awaited place, exactly what a client-side listener would expect to render. expect(f.realtime.broadcast).toHaveBeenCalledWith(1, 'place:updated', { place: { id: 7, name: 'updated' } }); }); it('PLACES-RPC-012 a delete that the service refuses is ForbiddenResource, not {deleted: true}', async () => { const f = build(); // place.get finds it (it exists), but the actual delete comes back false — the // race the null/false-vs-Promise bug used to paper over by always being truthy. f.places.remove.mockImplementationOnce(() => Promise.resolve({ deleted: false, cancelled: { reservationIds: [], budgetItemIds: [] } })); const res = (await f.host('db:write:places').dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42)) as RpcError; expect(res.ok).toBe(false); expect(res.error.code).toBe('RESOURCE_FORBIDDEN'); expect(res.error.message).toBe('no place 7 on trip 1'); expect(f.realtime.broadcast).not.toHaveBeenCalled(); }); // #2483: the same parse the REST controller and the MCP tools apply, so a // plugin that hands over a bare host stores the https form, not the text. it('PLACES-RPC-2483-01 a website without a scheme is stored with https, a script link is BAD_PARAMS', async () => { const f = build(); const host = f.host('db:write:places'); const site = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët'; expect((await host.dispatch(req('places.create', { tripId: 1, input: { name: 'Chapelle', website: site } }), 42)).ok).toBe(true); expect(f.places.create).toHaveBeenCalledWith('1', { name: 'Chapelle', website: `https://${site}` }); expect((await host.dispatch(req('places.update', { tripId: 1, placeId: 7, input: { website: '//www.example.fr' } }), 42)).ok).toBe(true); expect(f.places.update).toHaveBeenCalledWith('1', '7', { website: 'https://www.example.fr' }); const refused = (await host.dispatch(req('places.create', { tripId: 1, input: { name: 'X', website: 'javascript:alert(1)' } }), 42)) as RpcError; expect(refused.error.code).toBe('BAD_PARAMS'); expect(refused.error.message).toBe('invalid place: website must be an http or https URL'); expect(f.places.create).toHaveBeenCalledTimes(1); }); });