mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
f0f69df67e
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become JourneyDomainService and JourneyShareService. Every SQL statement, error string and broadcast is the one that shipped. The two could never be split: the share service gates on isOwner, which lives in the domain service, so it injects it. JourneyDomainModule is a leaf carrying just the two services. Places, assignments and the plugin host each need a handful of journey functions; importing JourneyModule for that would drag MemoriesModule and both photo providers into their graphs. Same shape as TrekPhotosModule. The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is down to _shared.ts and registerTools() no longer fans out to anything. journey.bridge.ts keeps four exports, for the journey resources in src/mcp/resources.ts that register at import time. McpAccessMode is host-augmentable now. journey:share is neither read nor write, so the three share tools would have had to be opaque predicates - and predicates bypass the access policy entirely, invisible to the boot-time scope gate. A scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'. Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed PlacesService with five of eight arguments. tsconfig only includes src, so nothing typechecked it, and the missing dependencies were harmless until the journey skeleton hooks landed on the place write paths. journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25 cases for the skeleton lifecycle, the photo gallery and the preference row take the domain service from 73.9% to 82.8%.
1059 lines
73 KiB
TypeScript
1059 lines
73 KiB
TypeScript
/**
|
|
* 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<string, unknown>) { return { id: 1, trip_id: Number(tid), ...input }; },
|
|
async update(id: string, tid: string, input: Record<string, unknown>) {
|
|
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<string, unknown>) => ({ 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<string, unknown>) => ({ 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<string, unknown>) => ({ 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<string, unknown>) {
|
|
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<string, unknown>) {
|
|
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<string, unknown>) => ({ 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<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, placesStub, collectionsStub, atlasStub, notificationsStub, membershipStub, journeyStub);
|
|
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
|
|
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, 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<typeof host>, method: string, params: Record<string, unknown>, uid = 5): Promise<any> =>
|
|
h.dispatch({ k: 'req', id: 'x', method, params }, uid);
|
|
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); });
|
|
afterAll(() => closePluginDataDb('writer'));
|
|
|
|
it('places.create/update/delete delegate + broadcast; a missing place is RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:write:places');
|
|
expect((await call(h, 'places.create', { tripId: 1, input: { name: 'P' } })).ok).toBe(true);
|
|
// Write deps re-emit the SAME core event the controllers do (not the plugin: namespace).
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'place:created', expect.anything());
|
|
expect((await call(h, 'places.update', { tripId: 1, placeId: 5, input: { name: 'Q' } })).ok).toBe(true);
|
|
expect((await call(h, 'places.update', { tripId: 1, placeId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'places.delete', { tripId: 1, placeId: 5 })).ok).toBe(true);
|
|
expect((await call(h, 'places.delete', { tripId: 1, placeId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
// #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.
|
|
it('places writes fire the journey hooks, delete ahead of the row, and skip a foreign id', async () => {
|
|
const h = host('db:write:places');
|
|
const created = vi.mocked(onPlaceCreated);
|
|
const updated = vi.mocked(onPlaceUpdated);
|
|
const deleted = vi.mocked(onPlaceDeleted);
|
|
const removePlace = removePlaceStub;
|
|
[created, updated, deleted, removePlace].forEach((m) => m.mockClear());
|
|
|
|
expect((await call(h, 'places.create', { tripId: 1, input: { name: 'P' } })).ok).toBe(true);
|
|
expect(created).toHaveBeenCalledWith(1, 10);
|
|
|
|
expect((await call(h, 'places.update', { tripId: 1, placeId: 5, input: { name: 'Q' } })).ok).toBe(true);
|
|
expect(updated).toHaveBeenCalledWith(5);
|
|
|
|
expect((await call(h, 'places.delete', { tripId: 1, placeId: 5 })).ok).toBe(true);
|
|
expect(deleted).toHaveBeenCalledWith(5);
|
|
// source_place_id is ON DELETE SET NULL — after the row is gone the hook has
|
|
// nothing left to detach, so the order is part of the fix.
|
|
expect(deleted.mock.invocationCallOrder[0]).toBeLessThan(removePlace.mock.invocationCallOrder[0]);
|
|
|
|
// A place on another trip: refused before the hook can touch that trip's journeys.
|
|
deleted.mockClear();
|
|
updated.mockClear();
|
|
expect((await call(h, 'places.delete', { tripId: 1, placeId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'places.update', { tripId: 1, placeId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(deleted).not.toHaveBeenCalled();
|
|
expect(updated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// 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.
|
|
it('places writes survive a throwing journey hook', async () => {
|
|
const h = host('db:write:places');
|
|
const updated = vi.mocked(onPlaceUpdated);
|
|
updated.mockImplementationOnce(() => { throw new Error('journey db locked'); });
|
|
expect((await call(h, 'places.update', { tripId: 1, placeId: 5, input: { name: 'Q' } })).ok).toBe(true);
|
|
});
|
|
|
|
it('days + itinerary delegate; a day/place/assignment outside the trip is refused', async () => {
|
|
const h = host('db:write:days', 'db:write:itinerary');
|
|
expect((await call(h, 'days.create', { tripId: 1, input: { notes: 'n' } })).ok).toBe(true);
|
|
expect((await call(h, 'days.update', { tripId: 1, dayId: 3, input: { notes: 'x' } })).ok).toBe(true);
|
|
expect((await call(h, 'days.delete', { tripId: 1, dayId: 3 })).ok).toBe(true);
|
|
expect((await call(h, 'days.update', { tripId: 1, dayId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 3, placeId: 7 })).ok).toBe(true);
|
|
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 99, placeId: 7 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 3, placeId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'itinerary.unassign', { tripId: 1, assignmentId: 30 })).ok).toBe(true);
|
|
expect((await call(h, 'itinerary.unassign', { tripId: 1, assignmentId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
// #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.
|
|
it('itinerary writes carry the dayId the client evicts by and re-mirror linked journeys', async () => {
|
|
const h = host('db:write:itinerary');
|
|
const reconcile = vi.mocked(assignmentsStub.reconcile);
|
|
|
|
reconcile.mockClear();
|
|
expect((await call(h, 'itinerary.assign', { tripId: 1, dayId: 3, placeId: 7 })).ok).toBe(true);
|
|
expect(reconcile).toHaveBeenCalledWith(1);
|
|
|
|
reconcile.mockClear();
|
|
expect((await call(h, 'itinerary.unassign', { tripId: 1, assignmentId: 30 })).ok).toBe(true);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'assignment:deleted', { assignmentId: 30, dayId: 3 });
|
|
expect(reconcile).toHaveBeenCalledWith(1);
|
|
|
|
// A refused unassign deletes nothing, so it must not touch the journeys either.
|
|
reconcile.mockClear();
|
|
expect((await call(h, 'itinerary.unassign', { tripId: 1, assignmentId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(reconcile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('trips.update: archive/cover need their own permission; service errors map to RPC codes', async () => {
|
|
const h = host('db:write:trips');
|
|
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'T' } })).ok).toBe(true);
|
|
checkPermission.mockImplementation((action: string) => action !== 'trip_archive');
|
|
expect((await call(h, 'trips.update', { tripId: 1, input: { is_archived: 1 } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
checkPermission.mockImplementation((action: string) => action !== 'trip_cover_upload');
|
|
expect((await call(h, 'trips.update', { tripId: 1, input: { cover_image: '/x.jpg' } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
checkPermission.mockReturnValue(true);
|
|
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS');
|
|
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'gone' } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'crash' } })).error.code).toBe('HOST_ERROR'); // rethrow of an unknown error
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('costs deps: create + reads wired through the budget service and addon gate', async () => {
|
|
const h = host('db:read:costs', 'db:write:costs');
|
|
expect((await call(h, 'costs.create', { tripId: 1, input: { name: 'Hotel' } })).ok).toBe(true);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'budget:created', expect.anything());
|
|
expect((await call(h, 'costs.getByTrip', { tripId: 1 })).ok).toBe(true);
|
|
expect((await call(h, 'costs.listMine', {})).ok).toBe(true);
|
|
});
|
|
|
|
it('costs deps: update wired through BudgetService.update + broadcasts budget:updated', async () => {
|
|
const h = host('db:write:costs');
|
|
expect((await call(h, 'costs.update', { tripId: 1, itemId: 9, input: { name: 'Hostel' } })).ok).toBe(true);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'budget:updated', expect.anything());
|
|
});
|
|
|
|
it('costs deps: update of a missing item is RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:write:costs');
|
|
expect((await call(h, 'costs.update', { tripId: 1, itemId: 404, input: { name: 'X' } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('costs deps: delete wired through BudgetService.remove + broadcasts budget:deleted', async () => {
|
|
const h = host('db:write:costs');
|
|
const res = await call(h, 'costs.delete', { tripId: 1, itemId: 9 });
|
|
expect(res.ok).toBe(true);
|
|
expect(res.result).toMatchObject({ deleted: true });
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'budget:deleted', { itemId: 9 });
|
|
});
|
|
|
|
it('costs deps: delete of a missing item is RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:write:costs');
|
|
expect((await call(h, 'costs.delete', { tripId: 1, itemId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('packing/files read deps delegate to their services (trash excluded for files)', async () => {
|
|
const h = host('db:read:packing', 'db:read:files');
|
|
// acting user 5 is threaded to the packing service (#858 private-item filter); _uid proves it
|
|
expect((await call(h, 'packing.list', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, name: 'Socks', _uid: 5 }]);
|
|
expect((await call(h, 'files.list', { tripId: 1 })).result).toEqual([{ id: 2, trip_id: 1, trash: false }]);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('host-deps factory — reservations, day notes, cross-trip + addon reads (Waves 1-5)', () => {
|
|
const host = (...perms: string[]) => createRealRpcHost('w15', new Set(perms));
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
|
h.dispatch({ k: 'req', id: 'x', method, params }, uid);
|
|
beforeEach(() => {
|
|
checkPermission.mockReset(); checkPermission.mockReturnValue(true);
|
|
isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true);
|
|
});
|
|
afterAll(() => closePluginDataDb('w15'));
|
|
|
|
it('reservations create/update/delete run the real side-effect wiring; a missing one is refused', async () => {
|
|
const h = host('db:write:reservations');
|
|
expect((await call(h, 'reservations.create', { tripId: 1, input: { title: 'Flight' } })).ok).toBe(true);
|
|
// title 'Stay' drives the accommodationCreated branch of the wiring
|
|
expect((await call(h, 'reservations.create', { tripId: 1, input: { title: 'Stay' } })).ok).toBe(true);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'reservation:created', expect.anything(), undefined);
|
|
expect((await call(h, 'reservations.update', { tripId: 1, reservationId: 40, input: { title: 'New' } })).ok).toBe(true);
|
|
expect((await call(h, 'reservations.update', { tripId: 1, reservationId: 404, input: { title: 'X' } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'reservations.delete', { tripId: 1, reservationId: 40 })).ok).toBe(true);
|
|
expect((await call(h, 'reservations.delete', { tripId: 1, reservationId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('day notes create/update/delete run the wiring; a day/note outside the trip is refused', async () => {
|
|
const h = host('db:write:daynotes');
|
|
expect((await call(h, 'daynotes.create', { tripId: 1, dayId: 3, input: { text: 'Pack' } })).ok).toBe(true);
|
|
expect((await call(h, 'daynotes.create', { tripId: 1, dayId: 88, input: { text: 'x' } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'daynotes.update', { tripId: 1, dayId: 3, noteId: 5, input: { text: 'y' } })).ok).toBe(true);
|
|
expect((await call(h, 'daynotes.update', { tripId: 1, dayId: 3, noteId: 99, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'daynotes.delete', { tripId: 1, dayId: 3, noteId: 5 })).ok).toBe(true);
|
|
expect((await call(h, 'daynotes.delete', { tripId: 1, dayId: 3, noteId: 99 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('cross-trip reads enumerate accessible trips and reservations', async () => {
|
|
const h = host('db:read:trips');
|
|
expect((await call(h, 'trips.listMine', {})).ok).toBe(true);
|
|
const r = await call(h, 'reservations.listMine', {});
|
|
expect(r.ok).toBe(true);
|
|
expect(r.result).toHaveLength(1);
|
|
});
|
|
|
|
it('wave-13 wiring: collab reads, journal entries, atlas bucket, file content, trip create, rates delegate correctly', async () => {
|
|
// collab reads delegate to collabService and require the collab addon
|
|
const collab = host('db:read:collab');
|
|
expect((await call(collab, 'collab.listNotes', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, title: 'Note' }]);
|
|
expect((await call(collab, 'collab.listMessages', { tripId: 1, before: 9 })).result).toEqual([{ id: 3, trip_id: 1, text: 'hi', _before: 9 }]);
|
|
isAddonEnabled.mockReturnValueOnce(false);
|
|
expect((await call(collab, 'collab.listPolls', { tripId: 1 })).error.code).toBeDefined(); // addon off -> refused
|
|
// journal.getEntries: delegates + self-gate (journey 88 = no access)
|
|
const journal = host('db:read:journal');
|
|
expect((await call(journal, 'journal.getEntries', { journeyId: 7 })).result).toEqual([{ id: 10, journey_id: 7, author_id: 5 }]);
|
|
expect((await call(journal, 'journal.getEntries', { journeyId: 88 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
// atlas.bucketList
|
|
const atlas = host('db:read:atlas');
|
|
expect((await call(atlas, 'atlas.bucketList', {})).result).toEqual([{ id: 5, user_id: 5, name: 'Kyoto' }]);
|
|
// files.getContent: size-capped (file 500 = 400MB -> BAD_PARAMS), 404 refused
|
|
fs.mkdirSync(testFilesDir, { recursive: true });
|
|
fs.writeFileSync(`${testFilesDir}/visa.pdf`, 'hi');
|
|
const files = host('db:read:files:content');
|
|
const content = await call(files, 'files.getContent', { tripId: 1, fileId: 2 });
|
|
expect(content.result).toMatchObject({ name: 'visa.pdf', mimetype: 'application/pdf', content_base64: Buffer.from('hi').toString('base64') });
|
|
expect((await call(files, 'files.getContent', { tripId: 1, fileId: 500 })).error.code).toBe('BAD_PARAMS');
|
|
expect((await call(files, 'files.getContent', { tripId: 1, fileId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
// trips.create: owner = acting user, delegates to createTrip; validation error mapped
|
|
const create = host('db:create:trips');
|
|
expect((await call(create, 'trips.create', { input: { title: 'Japan' } })).result).toMatchObject({ id: 99, user_id: 5, title: 'Japan' });
|
|
expect((await call(create, 'trips.create', { input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS');
|
|
// rates.get: tenant-free, delegates to the cached service
|
|
const rates = host('rates:read');
|
|
expect((await call(rates, 'rates.get', { base: 'EUR' })).result).toMatchObject({ EUR: 1, USD: 1.08 });
|
|
});
|
|
|
|
it('trip-scoped reads are wired to the hydrated services (days incl. assignments, reservations incl. endpoints)', async () => {
|
|
const h = host('db:read:trips');
|
|
const days = await call(h, 'trips.getDays', { tripId: 1 });
|
|
expect(days.ok).toBe(true);
|
|
// listDays returns { days: [...] }; the wiring unwraps to the bare array like getPlaces
|
|
expect(days.result).toEqual([{ id: 3, trip_id: 1, day_number: 1, assignments: [], notes_items: [] }]);
|
|
const res = await call(h, 'trips.getReservations', { tripId: 1 });
|
|
expect(res.ok).toBe(true);
|
|
expect(res.result).toEqual([{ id: 1, trip_id: 1, title: 'Flight' }]);
|
|
const acc = await call(h, 'trips.getAccommodations', { tripId: 1 });
|
|
expect(acc.ok).toBe(true);
|
|
expect(acc.result).toEqual([{ id: 11, trip_id: 1, place_name: 'Ryokan' }]);
|
|
});
|
|
|
|
it('accommodations create validates refs, creates via DaysService and emits the cascade broadcasts', async () => {
|
|
const h = host('db:write:accommodations');
|
|
const good = await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4, check_in: '15:00' } });
|
|
expect(good.ok).toBe(true);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'accommodation:created', expect.anything());
|
|
// the auto-created partner hotel reservation announces itself, like the REST path
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'reservation:created', {});
|
|
// a place/day of another trip is refused before anything is written
|
|
expect((await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 999, start_day_id: 3, end_day_id: 4 } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 88, end_day_id: 4 } })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('accommodations update/delete scope the row to the trip; delete cascades reservation + budget broadcasts', async () => {
|
|
const h = host('db:write:accommodations');
|
|
expect((await call(h, 'accommodations.update', { tripId: 1, accommodationId: 60, input: { notes: 'late checkout' } })).ok).toBe(true);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'accommodation:updated', expect.anything());
|
|
expect((await call(h, 'accommodations.update', { tripId: 1, accommodationId: 404, input: {} })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
const del = await call(h, 'accommodations.delete', { tripId: 1, accommodationId: 61 });
|
|
expect(del.ok).toBe(true);
|
|
expect(del.result).toMatchObject({ deleted: true });
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'reservation:deleted', { reservationId: 40 });
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'budget:deleted', { itemId: 9 });
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'accommodation:deleted', { accommodationId: 61 });
|
|
expect((await call(h, 'accommodations.delete', { tripId: 1, accommodationId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('addon reads delegate, and a disabled addon is refused', async () => {
|
|
const h = host('db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:collections');
|
|
expect((await call(h, 'journal.listMine', {})).ok).toBe(true);
|
|
expect((await call(h, 'atlas.visited', {})).ok).toBe(true);
|
|
expect((await call(h, 'vacay.mine', {})).ok).toBe(true);
|
|
expect((await call(h, 'collections.listMine', {})).ok).toBe(true);
|
|
expect((await call(h, 'collections.get', { id: 1 })).ok).toBe(true);
|
|
isAddonEnabled.mockReturnValue(false);
|
|
expect((await call(h, 'journal.listMine', {})).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect((await call(h, 'collections.listMine', {})).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
});
|
|
|
|
describe('host-deps factory — packing write with #858 privacy-scoped broadcasts', () => {
|
|
const host = () => createRealRpcHost('pk', new Set(['db:write:packing']))
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const call = async (method: string, params: Record<string, unknown>, uid = 5): Promise<any> =>
|
|
host().dispatch({ k: 'req', id: 'x', method, params }, uid)
|
|
// the onlyUserId (5th arg) of every broadcast for `event` since the last clear:
|
|
// undefined = whole trip room, a number = that user's sockets only.
|
|
const fanout = (event: string) => broadcast.mock.calls.filter((c) => c[1] === event).map((c) => c[4])
|
|
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); broadcast.mockClear() })
|
|
afterAll(() => closePluginDataDb('pk'))
|
|
|
|
it('create: Common -> whole room; Personal -> owner-only; Shared -> owner + recipients', async () => {
|
|
expect((await call('packing.create', { tripId: 1, input: { name: 'Common', visibility: 'common' } })).ok).toBe(true)
|
|
expect(fanout('packing:created')).toEqual([undefined]) // whole room
|
|
|
|
broadcast.mockClear()
|
|
expect((await call('packing.create', { tripId: 1, input: { name: 'Mine', visibility: 'personal' } })).ok).toBe(true)
|
|
expect(fanout('packing:created')).toEqual([5]) // owner-only
|
|
|
|
broadcast.mockClear()
|
|
expect((await call('packing.create', { tripId: 1, input: { name: 'Ours', visibility: 'shared', recipient_ids: [6] } })).ok).toBe(true)
|
|
expect([...fanout('packing:created')].sort()).toEqual([5, 6]) // owner + recipient, never the room
|
|
})
|
|
|
|
it('update: the four public<->private transitions route correctly (never leaks a privatized item)', async () => {
|
|
await call('packing.update', { tripId: 1, itemId: 71, input: { is_private: true } }) // stays private (71 seeded private)
|
|
expect(fanout('packing:updated')).toEqual([5])
|
|
expect(fanout('packing:deleted')).toEqual([])
|
|
expect(fanout('packing:created')).toEqual([])
|
|
|
|
broadcast.mockClear()
|
|
await call('packing.update', { tripId: 1, itemId: 70, input: { is_private: true } }) // public -> private (70 seeded public)
|
|
expect(fanout('packing:deleted')).toEqual([undefined]) // drop from the room FIRST (the anti-leak)
|
|
expect(fanout('packing:created')).toEqual([5]) // then re-add owner-only
|
|
|
|
broadcast.mockClear()
|
|
await call('packing.update', { tripId: 1, itemId: 71, input: { is_private: false } }) // private -> public
|
|
expect(fanout('packing:created')).toEqual([undefined])
|
|
expect(fanout('packing:updated')).toEqual([undefined])
|
|
|
|
broadcast.mockClear()
|
|
await call('packing.update', { tripId: 1, itemId: 70, input: { is_private: false } }) // stays public
|
|
expect(fanout('packing:updated')).toEqual([undefined])
|
|
expect(fanout('packing:deleted')).toEqual([])
|
|
})
|
|
|
|
it('update: a stale-write conflict is BAD_PARAMS and never broadcasts', async () => {
|
|
const res = await call('packing.update', { tripId: 1, itemId: 99, input: { name: 'x' } })
|
|
expect((res as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
|
|
expect(broadcast).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('delete: a private item is owner-scoped; a missing one is RESOURCE_FORBIDDEN', async () => {
|
|
await call('packing.delete', { tripId: 1, itemId: 71 })
|
|
expect(fanout('packing:deleted')).toEqual([5]) // owner-only (recipients get no packing:deleted)
|
|
broadcast.mockClear()
|
|
await call('packing.delete', { tripId: 1, itemId: 70 })
|
|
expect(fanout('packing:deleted')).toEqual([undefined]) // common -> room
|
|
const missing = await call('packing.delete', { tripId: 1, itemId: 404 })
|
|
expect((missing as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
})
|
|
|
|
describe('host-deps factory — Wave 1 wiring (weather/categories/tags/todos/roster/bags)', () => {
|
|
const host = (...perms: string[]) => createRealRpcHost('w1', new Set(perms))
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
|
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
|
|
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true) })
|
|
afterAll(() => closePluginDataDb('w1'))
|
|
|
|
it('weather + categories are tenant-free reads (no user needed)', async () => {
|
|
expect((await call(host('weather:read'), 'weather.get', { lat: 48, lng: 11 }, undefined)).ok).toBe(true)
|
|
expect((await call(host('db:read:categories'), 'categories.list', {}, undefined)).ok).toBe(true)
|
|
})
|
|
|
|
it('tags: list/create/update/delete are the acting user\'s; a missing tag is RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:read:tags', 'db:write:tags')
|
|
expect((await call(h, 'tags.list', {}, 5)).ok).toBe(true)
|
|
expect((await call(h, 'tags.create', { input: { name: 'x' } }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'tags.update', { tagId: 1, input: { name: 'y' } }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'tags.delete', { tagId: 1 }, 5)).ok).toBe(true)
|
|
expect(((await call(h, 'tags.update', { tagId: 404, input: {} }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
expect(((await call(h, 'tags.delete', { tagId: 404 }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('trips.members returns the roster', async () => {
|
|
const r = await call(host('db:read:trips'), 'trips.members', { tripId: 1 }, 5)
|
|
expect(r.ok).toBe(true)
|
|
expect(Array.isArray(r.result)).toBe(true)
|
|
})
|
|
|
|
it('todos list/create/update/delete run the wiring; a missing one is RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:write:todos', 'db:read:todos')
|
|
expect((await call(h, 'todos.list', { tripId: 1 }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'todos.create', { tripId: 1, input: { name: 'Pack' } }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'todos.update', { tripId: 1, todoId: 90, input: { checked: 1 } }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'todos.delete', { tripId: 1, todoId: 90 }, 5)).ok).toBe(true)
|
|
expect(((await call(h, 'todos.update', { tripId: 1, todoId: 404, input: {} }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
expect(((await call(h, 'todos.delete', { tripId: 1, todoId: 404 }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('packing bags list/create/update/delete/setMembers run the wiring', async () => {
|
|
const h = host('db:write:packing')
|
|
expect((await call(h, 'packing.listBags', { tripId: 1 }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'packing.createBag', { tripId: 1, input: { name: 'Bag' } }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'packing.updateBag', { tripId: 1, bagId: 80, input: { name: 'X' } }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [5] }, 5)).ok).toBe(true)
|
|
expect((await call(h, 'packing.deleteBag', { tripId: 1, bagId: 80 }, 5)).ok).toBe(true)
|
|
expect(((await call(h, 'packing.deleteBag', { tripId: 1, bagId: 404 }, 5)) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
})
|
|
|
|
describe('host-deps factory — Wave 2 wiring (atlas/vacay/journal/collections writes)', () => {
|
|
const host = (...perms: string[]) => createRealRpcHost('w2', new Set(perms))
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
|
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
|
|
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true) })
|
|
afterAll(() => closePluginDataDb('w2'))
|
|
|
|
it('atlas writes delegate uid-scoped; disabled addon refused; missing bucket item forbidden', async () => {
|
|
const h = host('db:write:atlas')
|
|
expect((await call(h, 'atlas.markCountry', { code: 'JP' })).ok).toBe(true)
|
|
expect((await call(h, 'atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' })).ok).toBe(true)
|
|
expect((await call(h, 'atlas.unmarkRegion', { regionCode: 'JP-13' })).ok).toBe(true)
|
|
expect((await call(h, 'atlas.createBucketItem', { input: { name: 'Kyoto' } })).ok).toBe(true)
|
|
expect((await call(h, 'atlas.deleteBucketItem', { itemId: 110 })).ok).toBe(true)
|
|
expect(((await call(h, 'atlas.deleteBucketItem', { itemId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
isAddonEnabled.mockReturnValue(false)
|
|
expect(((await call(h, 'atlas.markCountry', { code: 'JP' })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('vacay writes resolve the plan host-side from the acting user', async () => {
|
|
const h = host('db:write:vacay')
|
|
const r = await call(h, 'vacay.toggleEntry', { date: '2026-08-01' })
|
|
expect(r.ok).toBe(true)
|
|
expect(r.result).toMatchObject({ uid: 5, planId: 77 }) // uid + the user's own active plan, never a plugin-named one
|
|
expect((await call(h, 'vacay.toggleCompanyHoliday', { date: '2026-12-24' })).ok).toBe(true)
|
|
})
|
|
|
|
it('journal writes map an uneditable journey/entry to RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:write:journal')
|
|
expect((await call(h, 'journal.createEntry', { journeyId: 1, input: { entry_date: '2026-08-01' } })).ok).toBe(true)
|
|
expect(((await call(h, 'journal.createEntry', { journeyId: 99, input: { entry_date: '2026-08-01' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
expect((await call(h, 'journal.updateEntry', { entryId: 120, input: { story: 'x' } })).ok).toBe(true)
|
|
expect(((await call(h, 'journal.updateEntry', { entryId: 99, input: {} })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
expect((await call(h, 'journal.deleteEntry', { entryId: 120 })).ok).toBe(true)
|
|
expect(((await call(h, 'journal.deleteEntry', { entryId: 99 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('collections writes map the service 403/404 to RESOURCE_FORBIDDEN', async () => {
|
|
const h = host('db:write:collections')
|
|
expect((await call(h, 'collections.create', { input: { name: 'Tokyo eats' } })).ok).toBe(true)
|
|
expect((await call(h, 'collections.update', { id: 1, input: { name: 'Renamed' } })).ok).toBe(true)
|
|
expect(((await call(h, 'collections.update', { id: 99, input: { name: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') // viewer-only 403
|
|
expect(((await call(h, 'collections.update', { id: 404, input: { name: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') // invisible 404
|
|
expect((await call(h, 'collections.savePlace', { input: { collection_id: 1, name: 'Ramen' } })).ok).toBe(true)
|
|
expect((await call(h, 'collections.copyToTrip', { input: { trip_id: 1, place_ids: [101] } })).ok).toBe(true)
|
|
expect((await call(h, 'collections.deletePlace', { placeId: 101 })).ok).toBe(true)
|
|
expect(((await call(h, 'collections.deletePlace', { placeId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
})
|
|
|
|
describe('host-deps factory — Wave 3 wiring (files write / collab / member-add)', () => {
|
|
const host = (...perms: string[]) => createRealRpcHost('w3', new Set(perms))
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const call = async (h: ReturnType<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
|
h.dispatch({ k: 'req', id: 'x', method, params }, uid)
|
|
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); broadcast.mockClear() })
|
|
afterAll(() => closePluginDataDb('w3'))
|
|
|
|
it('files.create writes bytes, blocks bad extensions + foreign targets, broadcasts file:created', async () => {
|
|
const h = host('db:write:files')
|
|
const good = await call(h, 'files.create', { tripId: 1, input: { name: 'plan.pdf', content_base64: Buffer.from('hello').toString('base64') } })
|
|
expect(good.ok).toBe(true)
|
|
expect(broadcast.mock.calls.some((c) => c[1] === 'file:created')).toBe(true)
|
|
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'evil.exe', content_base64: 'aGk=' } })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
|
|
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'noext', content_base64: 'aGk=' } })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
|
|
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: 'aGk=', reservation_id: 999 } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('files.create blocks a demo user while DEMO_MODE is on, but not other members', async () => {
|
|
const prev = process.env.DEMO_MODE
|
|
process.env.DEMO_MODE = 'true'
|
|
try {
|
|
const h = host('db:write:files')
|
|
// user 77 is the demo account (demo@trek.app) → the plugin upload is refused
|
|
const denied = await call(h, 'files.create', { tripId: 1, input: { name: 'demo.pdf', content_base64: Buffer.from('x').toString('base64') } }, 77)
|
|
expect((denied as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
// a normal member (user 5, no demo email) is unaffected
|
|
const ok = await call(h, 'files.create', { tripId: 1, input: { name: 'ok.pdf', content_base64: Buffer.from('x').toString('base64') } }, 5)
|
|
expect(ok.ok).toBe(true)
|
|
} finally {
|
|
if (prev === undefined) delete process.env.DEMO_MODE; else process.env.DEMO_MODE = prev
|
|
}
|
|
})
|
|
|
|
it('files link/update/softDelete verify the file is on the trip + same-trip targets', async () => {
|
|
const h = host('db:write:files')
|
|
expect((await call(h, 'files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 7 } })).ok).toBe(true)
|
|
expect(((await call(h, 'files.createLink', { tripId: 1, fileId: 404, opts: {} })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
expect(((await call(h, 'files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 999 } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
expect((await call(h, 'files.update', { tripId: 1, fileId: 130, input: { description: 'new' } })).ok).toBe(true)
|
|
expect(broadcast.mock.calls.some((c) => c[1] === 'file:updated')).toBe(true)
|
|
expect((await call(h, 'files.softDelete', { tripId: 1, fileId: 130 })).ok).toBe(true)
|
|
expect(broadcast.mock.calls.some((c) => c[1] === 'file:deleted')).toBe(true)
|
|
expect(((await call(h, 'files.softDelete', { tripId: 1, fileId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('collab writes delegate + broadcast; service errors map to BAD_PARAMS; addon gated', async () => {
|
|
const h = host('db:write:collab')
|
|
expect((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'Ideas' } })).ok).toBe(true)
|
|
expect(broadcast.mock.calls.some((c) => c[1] === 'collab:note:created')).toBe(true)
|
|
expect((await call(h, 'collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A', 'B'] } })).ok).toBe(true)
|
|
expect((await call(h, 'collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 0 })).ok).toBe(true)
|
|
expect(((await call(h, 'collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 9 })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
|
|
expect((await call(h, 'collab.createMessage', { tripId: 1, text: 'hi' })).ok).toBe(true)
|
|
expect(((await call(h, 'collab.createMessage', { tripId: 1, text: 'toolong' })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
|
|
isAddonEnabled.mockReturnValue(false)
|
|
expect(((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
|
|
it('trips.addMember verifies the target user exists and reports owner-add as joined:false', async () => {
|
|
const h = host('db:write:members')
|
|
const r = await call(h, 'trips.addMember', { tripId: 1, userId: 6 })
|
|
expect(r.ok).toBe(true)
|
|
expect(r.result).toMatchObject({ joined: true })
|
|
const ownerAdd = await call(h, 'trips.addMember', { tripId: 1, userId: 5 }) // owner -> no-op
|
|
expect(ownerAdd.result).toMatchObject({ joined: false })
|
|
expect(((await call(h, 'trips.addMember', { tripId: 1, userId: 12345 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
|
|
})
|
|
})
|
|
|
|
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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
|
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<string, unknown>): 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<string, unknown>): 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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined): Promise<any> =>
|
|
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
|
|
})
|
|
})
|