Files
TREK/server/tests/helpers/rpc-host-deps.ts
T
Maurice ca88676858 Move packing onto the decorators and de-duplicate its privacy fan-out
Nine more methods, so the registry owns 24 of 113.

The #858 privacy logic existed three times: PackingService had the primitives, the
controller layered emitToViewers and broadcastUpdate on top privately, and the
plugin deps factory carried a standalone copy of all four, with a comment asking
for them to be kept in lockstep by hand. Migrating packing meant either copying
them a fourth time or fixing that, so the two private controller methods moved
into PackingService and the factory copy is gone. Controller and PackingRpc now
call the same code, and the controller suite proves it by passing with all 45
assertions unchanged.

The four public/private transitions get one test each, because each one is a way
to leak a private item to the whole trip room, plus a test that the privacy is
read before the write rather than after: reading it afterwards would report
"already private", skip the room delete, and leave the item on every member's
screen.
2026-08-09 13:24:58 +02:00

193 lines
14 KiB
TypeScript

import { vi } from 'vitest';
import type { HostDeps } from '../../src/nest/plugins/host/rpc-host';
import { TagsRpc } from '../../src/nest/tags/tags.rpc';
import { CategoriesRpc } from '../../src/nest/categories/categories.rpc';
import { WeatherRpc } from '../../src/nest/weather/weather.rpc';
import { ExchangeRatesRpc } from '../../src/nest/budget/exchange-rates.rpc';
import { TodoRpc } from '../../src/nest/todo/todo.rpc';
import { DayNotesRpc } from '../../src/nest/days/day-notes.rpc';
import { PackingRpc } from '../../src/nest/packing/packing.rpc';
/**
* The stubbed HostDeps every plugin router test builds on. It used to live inside
* rpc-host.test.ts; it moved here so the coverage ledger test can construct the same
* host without duplicating 150 lines of stubs.
*
* The fixture encodes one access story throughout: user 42 may access and edit trip 1,
* and nothing else. Tests rely on that, so widen it only with care.
*/
export function makeDeps(): HostDeps {
return {
data: {
query: vi.fn(() => [{ n: 1 }]),
exec: vi.fn(() => ({ changes: 1 })),
migrate: vi.fn(() => ({ applied: true })),
close: vi.fn(),
} as unknown as HostDeps['data'],
db: {
prepare: vi.fn((sql: string) => ({
all: () => [{ id: 7, name: 'Place' }],
get: () =>
sql.includes('FROM trips')
? { id: 1, title: 'Japan', start_date: '2027-01-01' }
: { id: 3, username: 'ada', display_name: 'Ada', avatar: null },
})),
},
// trip 1 is accessible to user 42; everything else is not
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1 } : undefined)),
// user 42 may see user 3 (they share a trip); nobody else
canSeeUser: vi.fn((actingUserId: number, targetUserId: number) => actingUserId === 42 && targetUserId === 3),
broadcastToTrip: vi.fn(),
broadcastToUser: vi.fn(),
// Costs (budget) — addon on; user 42 may edit trip 1's costs.
budgetAddonEnabled: vi.fn(() => true),
canEditCosts: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
listPackingItems: vi.fn((tripId: number, _userId: number) => [{ id: 1, trip_id: tripId, name: 'Socks' }]),
listTripFiles: vi.fn((tripId: number) => [{ id: 2, trip_id: tripId, filename: 'visa.pdf' }]),
getTripFileContent: vi.fn((tripId: number, fileId: number) => ({ name: 'visa.pdf', mimetype: 'application/pdf', size: 3, content_base64: 'aGk=', _t: tripId, _f: fileId })),
listCollabNotes: vi.fn((tripId: number) => [{ id: 1, trip_id: tripId, title: 'Note' }]),
listCollabPolls: vi.fn((tripId: number) => [{ id: 2, trip_id: tripId, question: 'Q?' }]),
listCollabMessages: vi.fn((tripId: number, before: number | undefined) => [{ id: 3, trip_id: tripId, text: 'hi', _before: before ?? null }]),
journalEntriesForUser: vi.fn((uid: number, journeyId: number) => [{ id: 10, journey_id: journeyId, author_id: uid }]),
atlasBucketForUser: vi.fn((uid: number) => [{ id: 5, user_id: uid, name: 'Kyoto' }]),
canCreateTrip: vi.fn((userId: number) => userId === 42),
createTripForUser: vi.fn((userId: number, input: unknown) => ({ id: 99, user_id: userId, ...(input as object) })),
getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08 })),
listCostsForTrip: vi.fn((tripId: number) => [{ id: 5, trip_id: tripId, name: 'Hotel', total_price: 100 }]),
listCostsForUser: vi.fn(() => [
{ id: 5, trip_id: 1, name: 'Hotel' },
{ id: 6, trip_id: 2, name: 'Food' },
]),
createCost: vi.fn((tripId: number, input: unknown) => ({ id: 9, trip_id: tripId, ...(input as object) })),
updateCost: vi.fn((tripId: number, itemId: number, input: unknown) => ({ id: itemId, trip_id: tripId, ...(input as object) })),
deleteCost: vi.fn(() => ({ deleted: true })),
// Planner writes — user 42 may edit trip 1 only (mirrors canAccessTrip).
canEditPlaces: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
createPlace: vi.fn((tripId: number, input: unknown) => ({ id: 10, trip_id: tripId, ...(input as object) })),
updatePlace: vi.fn((tripId: number, placeId: number, input: unknown) => ({ id: placeId, trip_id: tripId, ...(input as object) })),
deletePlace: vi.fn(() => ({ deleted: true })),
canEditDays: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
createDay: vi.fn((tripId: number, input: unknown) => ({ id: 20, trip_id: tripId, ...(input as object) })),
updateDay: vi.fn((tripId: number, dayId: number, input: unknown) => ({ id: dayId, trip_id: tripId, ...(input as object) })),
deleteDay: vi.fn(() => ({ deleted: true })),
assignPlaceToDay: vi.fn((tripId: number, dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })),
unassignPlace: vi.fn(() => ({ deleted: true })),
canEditTrip: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
updateTrip: vi.fn((tripId: number, _userId: number, input: unknown) => ({ id: tripId, ...(input as object) })),
// Cross-trip reads + reservations (bookings) — user 42 may edit trip 1 only.
listTripsForUser: vi.fn(() => [{ id: 1, title: 'Japan' }, { id: 2, title: 'Peru' }]),
listReservationsForUser: vi.fn(() => [{ id: 5, trip_id: 1, title: 'Hotel' }, { id: 6, trip_id: 2, title: 'Flight' }]),
canEditReservations: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
createReservation: vi.fn((tripId: number, input: unknown) => ({ id: 40, trip_id: tripId, ...(input as object) })),
updateReservation: vi.fn((tripId: number, reservationId: number, input: unknown) => ({ id: reservationId, trip_id: tripId, ...(input as object) })),
deleteReservation: vi.fn(() => ({ deleted: true })),
// Trip-scoped hydrated reads + accommodations (lodging blocks, day_edit-gated).
listTripDays: vi.fn((tripId: number) => [{ id: 3, trip_id: tripId, day_number: 1, assignments: [], notes_items: [] }]),
listTripReservations: vi.fn((tripId: number) => [{ id: 5, trip_id: tripId, title: 'Hotel', endpoints: [], day_positions: null }]),
listTripAccommodations: vi.fn((tripId: number) => [{ id: 11, trip_id: tripId, place_name: 'Ryokan' }]),
createAccommodation: vi.fn((tripId: number, input: unknown) => ({ id: 60, trip_id: tripId, ...(input as object) })),
updateAccommodation: vi.fn((tripId: number, accommodationId: number, input: unknown) => ({ id: accommodationId, trip_id: tripId, ...(input as object) })),
deleteAccommodation: vi.fn(() => ({ deleted: true })),
// User-scoped addon reads + day notes.
listJournalsForUser: vi.fn(() => [{ id: 1, title: 'Japan 2027' }]),
atlasVisitedForUser: vi.fn(() => ({ countries: [{ country_code: 'JP' }], regions: [] })),
vacayForUser: vi.fn(() => ({ plan: { id: 1 }, entries: [] })),
listCollectionsForUser: vi.fn(() => ({ collections: [{ id: 1, name: 'Tokyo eats' }] })),
getCollectionForUser: vi.fn((_userId: number, id: number) => ({ id, name: 'Tokyo eats', places: [] })),
listDayNotes: vi.fn((tripId: number, dayId: number) => [{ id: 1, day_id: dayId, trip_id: tripId, text: 'note' }]),
createDayNote: vi.fn((_tripId: number, dayId: number, input: unknown) => ({ id: 50, day_id: dayId, ...(input as object) })),
updateDayNote: vi.fn((_tripId: number, dayId: number, noteId: number, input: unknown) => ({ id: noteId, day_id: dayId, ...(input as object) })),
deleteDayNote: vi.fn(() => ({ deleted: true })),
canEditPacking: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
createPackingItem: vi.fn((tripId: number, input: unknown) => ({ id: 70, trip_id: tripId, ...(input as object) })),
updatePackingItem: vi.fn((tripId: number, itemId: number, input: unknown) => ({ id: itemId, trip_id: tripId, ...(input as object) })),
deletePackingItem: vi.fn(() => ({ deleted: true })),
listPackingBags: vi.fn(() => [{ id: 80, name: 'Backpack' }]),
createPackingBag: vi.fn((tripId: number, input: unknown) => ({ id: 80, trip_id: tripId, ...(input as object) })),
updatePackingBag: vi.fn((_tripId: number, bagId: number, input: unknown) => ({ id: bagId, ...(input as object) })),
deletePackingBag: vi.fn(() => ({ deleted: true })),
setPackingBagMembers: vi.fn((_tripId: number, bagId: number, userIds: number[]) => ({ bagId, members: userIds })),
getWeather: vi.fn(() => ({ temp: 20 })),
listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]),
tripMembers: vi.fn(() => [{ id: 5, username: 'ada' }, { id: 6, username: 'bob' }]),
canUploadFiles: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
canEditFiles: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
canDeleteFiles: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
createTripFile: vi.fn((tripId: number, input: unknown, uid: number) => ({ id: 130, trip_id: tripId, uploaded_by: uid, ...(input as object) })),
createTripFileLink: vi.fn(() => [{ file_id: 130 }]),
updateTripFile: vi.fn((_tripId: number, fileId: number, input: unknown) => ({ id: fileId, ...(input as object) })),
softDeleteTripFile: vi.fn(() => ({ deleted: true })),
canEditCollab: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
createCollabNote: vi.fn((tripId: number, input: unknown, uid: number) => ({ id: 140, trip_id: tripId, created_by: uid, ...(input as object) })),
createCollabPoll: vi.fn((tripId: number, input: unknown) => ({ id: 141, trip_id: tripId, ...(input as object) })),
voteCollabPoll: vi.fn((_tripId: number, pollId: number) => ({ id: pollId, votes: 1 })),
createCollabMessage: vi.fn((tripId: number, text: string) => ({ id: 142, trip_id: tripId, text })),
canManageMembers: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
addTripMember: vi.fn((tripId: number, targetUserId: number, invitedBy: number) => ({ joined: true, tripId, targetUserId, invitedBy })),
removeTripMember: vi.fn((tripId: number, targetUserId: number) => ({ removed: true, tripId, targetUserId })),
canAccessTripForNotify: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
sendPluginNotification: vi.fn(async (pluginId: string, input: unknown) => ({ sent: true, pluginId, ...(input as object) })),
aiConfigured: vi.fn((userId: number) => userId === 42),
aiComplete: vi.fn(async (_uid: number, prompt: string) => ({ text: `echo:${prompt}` })),
aiExtract: vi.fn(async () => ({ results: [{ ok: true }] })),
getUserSetting: vi.fn((pluginId: string, userId: number, key: string) => (userId === 42 && key === 'apiKey' ? `secret-of-${pluginId}` : undefined)),
getOAuthToken: vi.fn(async (_pluginId: string, userId: number) => (userId === 42 ? 'access-token-xyz' : null)),
schedulerSet: vi.fn((name: string, dueAt: number, everyMs: number | undefined) => ({ scheduled: true, name, dueAt, everyMs })),
schedulerCancel: vi.fn((name: string) => ({ cancelled: name !== 'ghost' })),
createCollectionForUser: vi.fn((uid: number, input: unknown) => ({ id: 100, owner_id: uid, ...(input as object) })),
updateCollectionForUser: vi.fn((_uid: number, id: number, input: unknown) => ({ id, ...(input as object) })),
saveCollectionPlace: vi.fn((uid: number, input: unknown) => ({ id: 101, saved_by: uid, ...(input as object) })),
copyCollectionToTrip: vi.fn(() => ({ copied: 2, skipped: [] })),
deleteCollectionPlace: vi.fn(() => ({ deleted: true })),
markCountryVisited: vi.fn(() => ({ visited: true })),
unmarkCountryVisited: vi.fn(() => ({ visited: false })),
markRegionVisited: vi.fn(() => ({ visited: true })),
unmarkRegionVisited: vi.fn(() => ({ visited: false })),
createBucketItem: vi.fn((uid: number, input: unknown) => ({ id: 110, user_id: uid, ...(input as object) })),
deleteBucketItem: vi.fn(() => ({ deleted: true })),
vacayToggleEntry: vi.fn(() => ({ action: 'added' })),
vacayToggleCompanyHoliday: vi.fn(() => ({ action: 'added' })),
createJournalEntry: vi.fn((uid: number, journeyId: number, input: unknown) => ({ id: 120, journey_id: journeyId, created_by: uid, ...(input as object) })),
updateJournalEntry: vi.fn((_uid: number, entryId: number, input: unknown) => ({ id: entryId, ...(input as object) })),
deleteJournalEntry: vi.fn(() => ({ deleted: true })),
createJournal: vi.fn((uid: number, input: unknown) => ({ id: 130, user_id: uid, ...(input as object) })),
deleteJournal: vi.fn(() => ({ deleted: true })),
canEditTodos: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42),
listTodos: vi.fn(() => [{ id: 1, name: 'Pack' }]),
createTodo: vi.fn((tripId: number, input: unknown) => ({ id: 90, trip_id: tripId, ...(input as object) })),
updateTodo: vi.fn((_tripId: number, todoId: number, input: unknown) => ({ id: todoId, ...(input as object) })),
deleteTodo: vi.fn(() => ({ deleted: true })),
// Metadata — trip 1 and place 7 resolve to trip 1 (accessible to 42); else undefined.
metaEntityTrip: vi.fn((entityType: string, entityId: number) =>
(entityType === 'trip' && entityId === 1) || (entityType === 'place' && entityId === 7) || (entityType === 'day' && entityId === 3)
|| (entityType === 'reservation' && entityId === 40) || (entityType === 'accommodation' && entityId === 11) ? 1 : undefined),
metaGet: vi.fn(() => ({ hello: 'world' })),
metaSet: vi.fn((_et: string, _eid: number, key: string, value: unknown) => ({ key, value })),
metaList: vi.fn(() => ({ a: 1 })),
metaDelete: vi.fn(() => ({ deleted: true })),
};
}
/**
* Every @PluginController() class that owns wire methods, in the order the container
* would discover them. It grows by one entry per rollout PR, and the coverage ledger
* test (tests/unit/plugins/rpc-coverage.test.ts) is what turns a forgotten entry into
* a red build instead of a silent PERMISSION_DENIED in production.
*
* The services handed in are stubs: the ledger only binds the handlers, it never runs
* them. Behaviour is asserted in each domain's own <domain>.rpc.test.ts.
*/
export function allRpcControllers(): object[] {
const anyService = () =>
new Proxy({}, { get: () => vi.fn(() => undefined) }) as unknown as never;
return [
new TagsRpc(anyService()),
new CategoriesRpc(anyService()),
new WeatherRpc(anyService()),
new ExchangeRatesRpc(anyService()),
new TodoRpc(anyService(), anyService(), anyService()),
new DayNotesRpc(anyService(), anyService(), anyService()),
new PackingRpc(anyService(), anyService(), anyService()),
];
}