mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
294cfcd8d7
collab, journal, atlas, vacay and collections: 32 more methods, so the registry owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory at 384, down from 944. These five are the risky group, because their addon gates lived inside the deps factory's closure rather than in the router. A handler that calls its service directly still works, just without the check, and neither the compiler nor the router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as one table and asserts two things for each: with the addon off it is refused with that addon's message, and the service is never reached. The table length is pinned, so a later method added without a row is a failure rather than a silent gap. One ordering detail worth knowing: on the atlas writes the router evaluated its params before the addon gate inside the closure, so a malformed country code still reports BAD_PARAMS rather than the addon. The handlers keep that order. The collections service reports refusals as status-tagged errors rather than typed ones, so mapCollectionError came across with the handlers: 403 and 404 become RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a HOST_ERROR instead of being dressed up as a permission problem.
589 lines
41 KiB
TypeScript
589 lines
41 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'));
|
|
|
|
// places, days and itinerary left this factory with the decorator migration; the
|
|
// cases now run in tests/unit/places/ and tests/unit/days/.
|
|
// #1705: a place write from a plugin has to update a linked journey the same way
|
|
// the REST route does, otherwise the journey shows the old title (or an entry for
|
|
// a place that no longer exists) until it is reloaded.
|
|
// A journey link that blows up must not turn a successful place write into an
|
|
// RPC error — every other caller of these hooks swallows them too.
|
|
// #1705: a plugin itinerary write has to reach open sessions exactly like the REST
|
|
// route. The delete payload needs the dayId the client reducer evicts by, and both
|
|
// directions run the same journey-skeleton reconcile the controller/MCP tool run.
|
|
// trips.*, the cross-trip feeds and member management left this factory with the
|
|
// decorator migration; the cases now run in tests/unit/trips/trips.rpc.test.ts.
|
|
it('metadata: round-trips and enforces the key/value/access limits', async () => {
|
|
const h = host('db:meta');
|
|
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true);
|
|
expect((await call(h, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 });
|
|
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert path
|
|
expect((await call(h, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place → trip 1
|
|
expect((await call(h, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ deleted: true });
|
|
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error.code).toBe('BAD_PARAMS');
|
|
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error.code).toBe('BAD_PARAMS');
|
|
expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
// costs, reservations and accommodations left this factory with the decorator
|
|
// migration; the cases now run in tests/unit/budget/ and tests/unit/reservations/.
|
|
it('users.getById is scoped to people the acting user shares a trip with', async () => {
|
|
const h = host('db:read:users');
|
|
expect((await call(h, 'users.getById', { id: 6 }, 5)).ok).toBe(true); // 5 (owner) + 6 (member) share trip 1
|
|
expect((await call(h, 'users.getById', { id: 999 }, 5)).error.code).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
});
|
|
|
|
// The 'reservations, day notes, cross-trip + addon reads' suite is gone: every case in it moved onto the decorators.
|
|
|
|
// The packing write deps left this factory with the decorator migration. The #858
|
|
// transitions are asserted in tests/unit/packing/packing.rpc.test.ts, against the
|
|
// PackingService methods the REST controller uses too, so there is one copy now.
|
|
|
|
// The Wave 1 wiring suite is gone: weather, categories, rates, tags, todos, the
|
|
// roster and the packing bags all moved onto the decorators, so there was nothing
|
|
// left in it. Their cases live in the domain suites now.
|
|
|
|
// The 'Wave 2 wiring' suite is gone: every case in it moved onto the decorators.
|
|
|
|
// The 'Wave 3 wiring' suite is gone: every case in it moved onto the decorators.
|
|
|
|
describe('host-deps factory — Wave 4 wiring (notify / ai)', () => {
|
|
const host = (...perms: string[]) => createRealRpcHost('w4', new Set(perms))
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const call = async (h: ReturnType<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
|
|
})
|
|
})
|