refactor(plugins): the plugin RPC kit moves out of the plugins domain into src/nest-rpc

Every domain's *.rpc.ts was written against rpc-kit, rpc-params, rpc-errors
and PluginGuards, and every domain module imported PluginGuardsModule, all
of it inside src/nest/plugins, while PluginsRuntimeModule imports those same
domains so their handlers exist at boot. That made the plugins domain part
of every cycle. The kit imports no domain but the protocol tables and
permissions/addons for the guards, so it moves to src/nest-rpc beside
src/nest-mcp, with its tests, its README and its extraction rule; the
runtime module keeps its domain imports, which the boot-time coverage check
of the wire surface needs in any subset app.
This commit is contained in:
Maurice
2026-10-10 12:19:38 +02:00
parent cc79f561e5
commit e40bc71e9f
92 changed files with 237 additions and 280 deletions
@@ -8,8 +8,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
import { CollabRpc } from '../../../src/nest/collab/collab.rpc';
import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc';
import { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
@@ -12,8 +12,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
import { CollabRpc } from '../../../src/nest/collab/collab.rpc';
import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc';
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
+3 -3
View File
@@ -54,9 +54,9 @@ const { llmExtract } = vi.hoisted(() => ({
}));
vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient: vi.fn(() => ({ extract: llmExtract })) }));
import { PluginRpcHostFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { PluginRpcRegistryService } from '../../../src/nest-rpc/rpc-kit/registry.service';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { MetaRpc } from '../../../src/nest/plugins/host/rpc/meta.rpc';
@@ -40,8 +40,8 @@ import {
createUser,
linkTripToJourney,
} from '../../helpers/factories';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest-rpc/rpc-kit/types';
import type { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { KNOWN_PERMISSIONS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import {
@@ -4,9 +4,9 @@
* schema and real-service side is output-contract.schema.test.ts.
*/
import { describe, expect, it } from 'vitest';
import { PluginController, PluginMethod } from '../../../src/nest/plugins/host/rpc-kit/decorators';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
import { PluginController, PluginMethod } from '../../../src/nest-rpc/rpc-kit/decorators';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest-rpc/rpc-kit/types';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { KNOWN_METHODS, UNCONDITIONAL_METHODS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import {
@@ -1,268 +0,0 @@
/**
* PluginGuards holds the resource gates the decorated *.rpc.ts handlers will use from
* the first rollout PR onward. Its messages are copied character-for-character from
* the legacy router, because rpc-host.test.ts asserts them and shipped plugins read
* them, so these tests pin the exact strings rather than just the refusal.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../../../src/nest/plugins/host/rpc-errors';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createUser } from '../../helpers/factories';
import { updateRows } from '../../helpers/factories/rows';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { Users } from '../../../src/db/entities/Users.entity';
/**
* The guards read `actingUserId` and nothing else off the context, so the two per-host
* collaborators are inert here. `plugins` gets real no-op functions rather than a cast,
* so a guard that ever reached for a peer would hit a callable, not undefined.
*/
const ctx = (actingUserId: number | undefined): PluginRpcContext => ({
pluginId: 'p',
actingUserId,
data: {} as PluginRpcContext['data'],
plugins: {
call: vi.fn(async () => undefined),
emit: vi.fn(),
},
});
/**
* Trip 1 belongs to user 42 and only user 42 may reach it, mirroring rpc-host.test.ts.
*
* `overrides.role` drives the stubbed `UsersRepository.getRole` (PG3/PG4, Plan 3j
* Task 1) the same way it used to drive the raw `db.prepare` stub: omitted means a
* normal row with role 'user'; explicitly `undefined` (or `null`) means "no row",
* which `getRole`'s own `string | null` contract folds into a single `null` — the
* `users` column is `NOT NULL DEFAULT 'user'`, so a present row can never actually
* carry a null role, and `getRole` has no way to tell "row absent" from "row present,
* role null" apart even if it could.
*/
function build(overrides: { role?: string | null; allow?: boolean; addonOn?: boolean } = {}) {
const db = {
findAccessible: vi.fn(async (tripId: number, userId: number) =>
tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined,
),
} as unknown as TripsRepository;
const users = {
getRole: vi.fn(async () => ('role' in overrides ? (overrides.role ?? null) : 'user')),
} as unknown as UsersRepository;
const permissions = {
checkPermission: vi.fn(() => overrides.allow ?? true),
} as unknown as PermissionsService;
const addons = {
isAddonEnabled: vi.fn(() => overrides.addonOn ?? true),
} as unknown as AddonsService;
return { guards: new PluginGuards(db, permissions, addons, users), db, users, permissions, addons };
}
describe('PluginGuards — tripRead', () => {
it('PGUARD-001 runs the read for a member and hands it the acting user', async () => {
const { guards } = build();
const read = vi.fn((userId: number) => ({ seenBy: userId }));
expect(await guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
expect(read).toHaveBeenCalledWith(42);
});
it('PGUARD-002 a userless context is refused before the read runs', async () => {
const { guards } = build();
const read = vi.fn();
await expect(guards.tripRead({ tripId: 1 }, ctx(undefined), read)).rejects.toThrow(
new ForbiddenResource('trip reads require an authenticated user context'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-003 a non-member is refused, naming the trip', async () => {
const { guards } = build();
const read = vi.fn();
await expect(guards.tripRead({ tripId: 2 }, ctx(42), read)).rejects.toThrow(
new ForbiddenResource('no access to trip 2'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', async () => {
const { guards } = build();
await expect(guards.tripRead({}, ctx(42), vi.fn())).rejects.toThrow(new BadParams('tripId must be a number'));
});
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', async () => {
const { guards } = build();
expect(await guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
});
});
describe('PluginGuards — requireActor', () => {
it('PGUARD-006 returns the acting user', () => {
expect(build().guards.requireActor(ctx(42), 'tag')).toBe(42);
});
it('PGUARD-007 appends the word "writes" to the noun, verbatim as the router did', () => {
expect(() => build().guards.requireActor(ctx(undefined), 'tag')).toThrow(
new ForbiddenResource('tag writes require an authenticated user context'),
);
});
});
describe('PluginGuards — requireTripEdit and canEditAs', () => {
it('PGUARD-008 passes when the user may access and edit', async () => {
const { guards, permissions } = build({ allow: true });
await expect(guards.requireTripEdit(1, 42, 'trip_edit')).resolves.not.toThrow();
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 42, 42, false);
});
it('PGUARD-009 no access wins over no permission, and names the trip', async () => {
const { guards } = build({ allow: false });
await expect(guards.requireTripEdit(2, 42, 'trip_edit')).rejects.toThrow(new ForbiddenResource('no access to trip 2'));
});
it('PGUARD-010 access without the edit permission is a different message', async () => {
const { guards } = build({ allow: false });
await expect(guards.requireTripEdit(1, 42, 'trip_edit')).rejects.toThrow(new ForbiddenResource('no permission to edit trip 1'));
});
it('PGUARD-011 canEditAs returns false rather than throwing when there is no access', async () => {
expect(await build().guards.canEditAs('trip_edit', 2, 42)).toBe(false);
});
it('PGUARD-012 canEditAs returns false when the user row is gone', async () => {
expect(await build({ role: undefined }).guards.canEditAs('trip_edit', 1, 42)).toBe(false);
});
it('PGUARD-013 the repository role read actually drives the permission check — mutation proof: flipping the returned role flips the argument checkPermission sees', async () => {
const { guards, permissions } = build({ role: 'admin' });
await guards.canEditAs('trip_edit', 1, 42);
// Seeded row is 'user' by default (see build()'s docstring); forcing the
// double to return 'admin' and seeing THAT value reach checkPermission
// proves canEditAs is reading through PluginGuards' own UsersRepository
// call, not a cached or hard-coded role.
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'admin', 42, 42, false);
});
it('PGUARD-014 a non-owner member is flagged as shared', async () => {
const db = {
findAccessible: vi.fn(async () => ({ id: 1, user_id: 7 })),
} as unknown as TripsRepository;
const users = { getRole: vi.fn(async () => 'user') } as unknown as UsersRepository;
const permissions = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
const guards = new PluginGuards(db, permissions, {} as AddonsService, users);
await guards.canEditAs('trip_edit', 1, 42);
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 7, 42, true);
});
});
describe('PluginGuards — canCreateAs', () => {
it('PGUARD-021 passes the looked-up role through with no trip-access check at all', async () => {
const { guards, permissions } = build({ role: 'admin', allow: true });
expect(await guards.canCreateAs('trip_create', 42)).toBe(true);
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_create', 'admin', null, 42, false);
});
it('PGUARD-022 a user id with no matching row never throws and falls back to role "user", unchanged from the legacy user?.role ?? "user" branch (no early refusal exists here, unlike canEditAs)', async () => {
const { guards, permissions } = build({ role: undefined });
await expect(guards.canCreateAs('trip_create', 999)).resolves.not.toThrow();
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_create', 'user', null, 999, false);
});
it('PGUARD-023 mutation proof: flipping the returned role flips the argument checkPermission sees', async () => {
const { guards, permissions } = build({ role: 'admin' });
await guards.canCreateAs('trip_create', 42);
expect(permissions.checkPermission).toHaveBeenCalledWith('trip_create', 'admin', null, 42, false);
});
});
/**
* The role×action×ownership matrix the sql-inventory calls for (§3), run through the
* REAL UsersRepository (a real MikroORM repository, default allowGlobalContext: true —
* these are direct, unwrapped calls, same TRAP-list allowance test-orm.ts documents)
* and the REAL PermissionsService (default in-memory permission levels, no stored
* overrides), not the stubbed doubles `build()` uses above. This is what actually
* proves PG3/PG4's conversion didn't change plugin-write authorization: a role that
* should fail a check still fails it, a role that should pass still passes, and
* physically changing the STORED row (not a test double) flips the result.
*/
describe('PluginGuards — canEditAs/canCreateAs against a real UsersRepository + PermissionsService', () => {
let testDb: ReturnType<typeof createSnapshotTestDb>;
let orm: TestOrm;
let guards: PluginGuards;
let memberId: number;
const ownerId = 1;
beforeEach(async () => {
testDb = createSnapshotTestDb();
orm = await createTestOrm(testDb);
const permissions = new PermissionsService(orm.repo(AppSettings), new UnitOfWork(orm.em));
const db = {
findAccessible: vi.fn(async (tripId: number) => (tripId === 1 ? { id: 1, user_id: ownerId } : undefined)),
} as unknown as TripsRepository;
// The permissions cache is module-scoped (permissions-cache.ts), not
// per-instance — invalidate before every case so a stored override from
// another describe block in this same worker can't leak in.
await permissions.invalidatePermissionsCache();
guards = new PluginGuards(db, permissions, {} as AddonsService, orm.repo(Users));
createUser(testDb, { role: 'user' }); // id 1, the trip owner — unused by these cases directly
memberId = createUser(testDb, { role: 'user' }).user.id; // id 2, a non-owner trip member
});
afterEach(async () => {
await orm.close();
testDb.close();
});
it('PGUARD-024 a member whose role lacks trip_delete (trip_owner-only by default) is refused', async () => {
expect(await guards.canEditAs('trip_delete', 1, memberId)).toBe(false);
});
it('PGUARD-025 mutation proof: promoting that SAME member to admin in the real users table flips PGUARD-024 to pass', async () => {
await updateRows(orm, Users, { id: memberId }, { role: 'admin' });
expect(await guards.canEditAs('trip_delete', 1, memberId)).toBe(true);
});
it('PGUARD-026 a non-member is refused regardless of role (trip access is checked first)', async () => {
expect(await guards.canEditAs('trip_delete', 999, memberId)).toBe(false);
});
it('PGUARD-027 canCreateAs: a real user id with no matching row never throws, and trip_create\'s "everybody" default lets it through — the legacy fallback preserved, not a new refusal', async () => {
const vanishedId = memberId + 1000;
await expect(guards.canCreateAs('trip_create', vanishedId)).resolves.toBe(true);
});
});
describe('PluginGuards — requireAddon', () => {
it('PGUARD-015 an enabled addon passes', async () => {
await expect(build({ addonOn: true }).guards.requireAddon('budget', 'costs')).resolves.not.toThrow();
});
it('PGUARD-016 a disabled addon is refused with the noun in the message', async () => {
await expect(build({ addonOn: false }).guards.requireAddon('budget', 'costs')).rejects.toThrow(new ForbiddenResource('the costs addon is disabled'));
});
});
describe('PluginGuards — capStrings', () => {
it('PGUARD-017 a field within the cap passes', () => {
expect(() => build().guards.capStrings({ name: 'ok' }, { name: 200 })).not.toThrow();
});
it('PGUARD-018 an oversized field is BAD_PARAMS, naming field and cap', () => {
expect(() => build().guards.capStrings({ name: 'x'.repeat(201) }, { name: 200 })).toThrow(
new BadParams('name must be 200 characters or fewer'),
);
});
it('PGUARD-019 a non-string value is left alone', () => {
expect(() => build().guards.capStrings({ name: 12345 }, { name: 2 })).not.toThrow();
});
it('PGUARD-020 an absent field is left alone', () => {
expect(() => build().guards.capStrings({}, { name: 1 })).not.toThrow();
});
});
@@ -12,7 +12,7 @@ import { describe, it, expect, vi } from 'vitest';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import { PluginsRuntimeModule } from '../../../src/nest/plugins/plugins-runtime.module';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { HOOK_PERMISSION } from '../../../src/nest/plugins/protocol/envelope';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
@@ -34,7 +34,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createUser } from '../../helpers/factories';
import { createTestAddonsService } from '../../helpers/test-addons';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { RpcRateLimiter, DEFAULT_RPC_LIMIT } from '../../../src/nest/plugins/host/rate-limit';
@@ -11,8 +11,8 @@
*/
import { describe, it, expect } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { PluginRpcRegistryService } from '../../../src/nest-rpc/rpc-kit/registry.service';
import {
KNOWN_METHODS,
KNOWN_PERMISSIONS,
@@ -10,8 +10,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
import { TagsRpc } from '../../../src/nest/tags/tags.rpc';
import { DayNotesRpc } from '../../../src/nest/day-notes/day-notes.rpc';
import { PlacesRpc } from '../../../src/nest/places/places.rpc';
@@ -30,7 +30,7 @@ import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { schemaMessage } from '../../../src/nest/plugins/host/rpc-params';
import { schemaMessage } from '../../../src/nest-rpc/rpc-params';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
const err = (r: unknown) => (r as RpcError).error;
@@ -10,13 +10,13 @@ import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import type { RpcRequest, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { PackingRpc } from '../../../src/nest/packing/packing.rpc';
import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
import type { EntityManager } from '@mikro-orm/core';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
+3 -3
View File
@@ -9,9 +9,9 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost, BadParams, ForbiddenResource, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
import { PluginController, PluginMethod, PluginOpenMethod } from '../../../src/nest/plugins/host/rpc-kit/decorators';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
import { PluginController, PluginMethod, PluginOpenMethod } from '../../../src/nest-rpc/rpc-kit/decorators';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest-rpc/rpc-kit/types';
import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
@@ -1,136 +0,0 @@
/**
* The four plugin RPC decorators. The compile-time half of their contract is
* enforced by tsc and cannot be asserted here; what these tests pin is what each
* decorator RECORDS, because that is what validate() and bindInto() later read.
*/
import { describe, it, expect } from 'vitest';
import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants';
import {
PluginController,
PluginHook,
PluginMethod,
PluginOpenMethod,
} from '../../../../src/nest/plugins/host/rpc-kit/decorators';
import { getEntry, isPluginController, type ClassRef } from '../../../../src/nest/plugins/host/rpc-kit/metadata';
const entryOf = (ctor: unknown, methodName: string) => getEntry(ctor as ClassRef, methodName);
describe('rpc-kit decorators', () => {
it('RPCKIT-DEC-001 @PluginController marks the class', () => {
@PluginController()
class Sample {}
expect(isPluginController(Sample)).toBe(true);
});
it('RPCKIT-DEC-002 @PluginController implies @Injectable', () => {
@PluginController()
class Sample {}
// What Injectable() writes. Nest resolves the class as a provider off this.
expect(Reflect.getMetadata(SCOPE_OPTIONS_METADATA, Sample)).toBeUndefined();
expect(Reflect.hasMetadata(SCOPE_OPTIONS_METADATA, Sample)).toBe(true);
});
it('RPCKIT-DEC-003 @PluginMethod records the wire method and its permission', () => {
@PluginController()
class Sample {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(entryOf(Sample, 'list')).toEqual({
kind: 'method',
methodName: 'list',
method: 'tags.list',
permission: 'db:read:tags',
});
});
it('RPCKIT-DEC-004 the class method name and the wire method are independent', () => {
@PluginController()
class Sample {
@PluginMethod('tags.delete', { permission: 'db:write:tags' })
removeTheTag() {
return { deleted: true };
}
}
const entry = entryOf(Sample, 'removeTheTag');
expect(entry).toMatchObject({ methodName: 'removeTheTag', method: 'tags.delete' });
});
it('RPCKIT-DEC-005 @PluginOpenMethod records no permission at all', () => {
@PluginController()
class Sample {
@PluginOpenMethod('settings.get')
get() {
return { value: undefined };
}
}
expect(entryOf(Sample, 'get')).toEqual({ kind: 'open', methodName: 'get', method: 'settings.get' });
});
it('RPCKIT-DEC-006 @PluginHook records hook, fn and permission', () => {
@PluginController()
class Sample {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
expect(entryOf(Sample, 'declare')).toMatchObject({
kind: 'hook',
hook: 'warningProvider',
fn: 'getWarnings',
permission: 'hook:trip-warning-provider',
});
});
it('RPCKIT-DEC-007 @PluginHook defaults the budget to 5000ms', () => {
@PluginController()
class Sample {
@PluginHook('mapMarkerProvider', { permission: 'hook:map-marker-provider', fn: 'getMarkers' })
declare() {}
}
expect(entryOf(Sample, 'declare')).toMatchObject({ timeoutMs: 5000 });
});
it('RPCKIT-DEC-008 an explicit timeout wins over the default', () => {
@PluginController()
class Sample {
@PluginHook('routeProvider', { permission: 'hook:route-provider', fn: 'getRoute', timeoutMs: 20_000 })
declare() {}
}
expect(entryOf(Sample, 'declare')).toMatchObject({ timeoutMs: 20_000 });
});
it('RPCKIT-DEC-009 several decorated methods on one class are all recorded', () => {
@PluginController()
class Sample {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
@PluginMethod('tags.create', { permission: 'db:write:tags' })
create() {
return {};
}
undecorated() {
return null;
}
}
expect(entryOf(Sample, 'list')).toMatchObject({ method: 'tags.list' });
expect(entryOf(Sample, 'create')).toMatchObject({ method: 'tags.create' });
expect(entryOf(Sample, 'undecorated')).toBeUndefined();
});
it('RPCKIT-DEC-010 a method decorator alone does not mark the class', () => {
class Sample {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
// The entry is recorded, but register() will still reject the class. That split
// is deliberate: listing the class in providers: [] must stay an explicit opt-in.
expect(entryOf(Sample, 'list')).toBeDefined();
expect(isPluginController(Sample)).toBe(false);
});
});
@@ -1,94 +0,0 @@
/**
* The metadata store under the plugin RPC decorators. It is a WeakMap/WeakSet keyed
* by constructor rather than reflect-metadata, so these tests run without a Nest app
* and without any polyfill import order to get right.
*/
import { describe, it, expect } from 'vitest';
import {
addEntry,
getEntry,
isPluginController,
markController,
type ClassRef,
} from '../../../../src/nest/plugins/host/rpc-kit/metadata';
import type { PluginRpcEntry } from '../../../../src/nest/plugins/host/rpc-kit/types';
const methodEntry = (methodName: string, method: 'tags.list' | 'tags.create' = 'tags.list'): PluginRpcEntry => ({
kind: 'method',
methodName,
method,
permission: method === 'tags.list' ? 'db:read:tags' : 'db:write:tags',
});
describe('rpc-kit metadata store', () => {
it('RPCKIT-META-001 an unmarked class is not a plugin controller', () => {
class Plain {}
expect(isPluginController(Plain)).toBe(false);
});
it('RPCKIT-META-002 markController makes the class recognisable', () => {
class Marked {}
markController(Marked as unknown as ClassRef);
expect(isPluginController(Marked)).toBe(true);
});
it('RPCKIT-META-003 a non-function is never a controller', () => {
expect(isPluginController({})).toBe(false);
expect(isPluginController(undefined)).toBe(false);
expect(isPluginController('Marked')).toBe(false);
});
it('RPCKIT-META-004 an entry is retrievable by its method name', () => {
class Holder {}
const entry = methodEntry('list');
addEntry(Holder as unknown as ClassRef, entry);
expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(entry);
});
it('RPCKIT-META-005 an unknown method name resolves to undefined', () => {
class Holder {}
addEntry(Holder as unknown as ClassRef, methodEntry('list'));
expect(getEntry(Holder as unknown as ClassRef, 'missing')).toBeUndefined();
});
it('RPCKIT-META-006 a second entry for the same name replaces the first', () => {
class Holder {}
addEntry(Holder as unknown as ClassRef, methodEntry('list'));
const second = methodEntry('list', 'tags.create');
addEntry(Holder as unknown as ClassRef, second);
expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(second);
});
it('RPCKIT-META-007 entries are found on a subclass through the prototype chain', () => {
class Base {}
class Child extends Base {}
const entry = methodEntry('list');
addEntry(Base as unknown as ClassRef, entry);
expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(entry);
});
it('RPCKIT-META-008 a subclass entry wins over the inherited one', () => {
class Base {}
class Child extends Base {}
addEntry(Base as unknown as ClassRef, methodEntry('list'));
const own = methodEntry('list', 'tags.create');
addEntry(Child as unknown as ClassRef, own);
expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(own);
});
it('RPCKIT-META-009 walking the chain stops cleanly at the root', () => {
class Lonely {}
expect(getEntry(Lonely as unknown as ClassRef, 'anything')).toBeUndefined();
});
it('RPCKIT-META-010 two classes keep separate entry maps', () => {
class A {}
class B {}
const a = methodEntry('list');
const b = methodEntry('list', 'tags.create');
addEntry(A as unknown as ClassRef, a);
addEntry(B as unknown as ClassRef, b);
expect(getEntry(A as unknown as ClassRef, 'list')).toBe(a);
expect(getEntry(B as unknown as ClassRef, 'list')).toBe(b);
});
});
@@ -1,109 +0,0 @@
/**
* The DI-discovered registry. It scans providers only, dedupes by instance, and
* validates at boot so a wrong decorator argument fails the app start rather than a
* single plugin activation.
*/
import { describe, it, expect, vi } from 'vitest';
import type { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { PluginRpcRegistryService } from '../../../../src/nest/plugins/host/rpc-kit/registry.service';
import { PluginController, PluginMethod } from '../../../../src/nest/plugins/host/rpc-kit/decorators';
@PluginController()
class TagsRpc {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
@PluginController()
class BadRpc {
@PluginMethod('tags.create', { permission: 'db:read:tags' as 'db:write:tags' })
create() {
return {};
}
}
class PlainProvider {
helper() {
return 1;
}
}
/** Mimics Nest's provider wrappers, including the same instance listed twice. */
function serviceFor(instances: object[]): PluginRpcRegistryService {
const discovery = {
getProviders: () => instances.map((instance) => ({ instance, metatype: instance.constructor })),
} as unknown as DiscoveryService;
const scanner = {
getAllMethodNames: (proto: object) =>
Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'),
} as unknown as MetadataScanner;
return new PluginRpcRegistryService(discovery, scanner);
}
describe('PluginRpcRegistryService', () => {
it('RPCKIT-SVC-001 registers the marked providers at boot', () => {
const service = serviceFor([new TagsRpc(), new PlainProvider()]);
service.scanProviders();
expect(service.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-SVC-002 the same instance wrapped twice is registered once', () => {
const instance = new TagsRpc();
const service = serviceFor([instance, instance]);
service.scanProviders();
// Without the dedupe this would be two entries and validate() would report
// tags.list as double-owned.
expect(service.list()).toHaveLength(1);
});
it('RPCKIT-SVC-003 unmarked providers are skipped rather than rejected', () => {
const service = serviceFor([new PlainProvider()]);
expect(() => service.scanProviders()).not.toThrow();
expect(service.list()).toEqual([]);
});
it('RPCKIT-SVC-004 a null instance is skipped', () => {
const discovery = {
getProviders: () => [{ instance: null, metatype: TagsRpc }, { instance: undefined, metatype: TagsRpc }],
} as unknown as DiscoveryService;
const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner;
const service = new PluginRpcRegistryService(discovery, scanner);
expect(() => service.scanProviders()).not.toThrow();
expect(service.list()).toEqual([]);
});
it('RPCKIT-SVC-005 a wrong decorator argument fails the boot, not the first plugin call', () => {
const service = serviceFor([new BadRpc()]);
expect(() => service.onModuleInit()).toThrow(
/method "tags.create" \(BadRpc.create\) declares "db:read:tags" but METHOD_PERMISSION says "db:write:tags"/,
);
});
it('RPCKIT-SVC-006 boot fails when the surface is incomplete', () => {
// The rollout is finished, so a decorated class that no module registers is no
// longer a silent PERMISSION_DENIED at runtime: the app refuses to start, and the
// message names the method that lost its owner.
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).toThrow(/has no handler/);
});
it('RPCKIT-SVC-008 a hook nobody consumes fails boot too, so no grant is dead', () => {
// A hook:* permission on the consent screen with nothing behind it looks exactly
// like a live one to the user granting it.
const service = serviceFor([new TagsRpc()]);
expect(() => service.onModuleInit()).toThrow(/has no host-side consumer/);
});
it('RPCKIT-SVC-007 only providers are scanned, never controllers', () => {
const getControllers = vi.fn(() => []);
const discovery = {
getProviders: () => [],
getControllers,
} as unknown as DiscoveryService;
const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner;
new PluginRpcRegistryService(discovery, scanner).scanProviders();
expect(getControllers).not.toHaveBeenCalled();
});
});
@@ -1,403 +0,0 @@
/**
* The registry is where a decorator argument is re-checked against
* protocol/envelope.ts at runtime, and where the granted subset is bound into the
* per-plugin dispatch map. Types evaporate under an `as` cast, so every compile-time
* guarantee has a runtime twin here, and each one gets a test.
*/
import { describe, it, expect, vi } from 'vitest';
import { Controller } from '@nestjs/common';
import {
PluginController,
PluginHook,
PluginMethod,
PluginOpenMethod,
} from '../../../../src/nest/plugins/host/rpc-kit/decorators';
import { PluginRpcRegistry } from '../../../../src/nest/plugins/host/rpc-kit/registry';
import { createTestPluginRegistry } from '../../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../../src/nest/plugins/host/rpc-kit/types';
const ctxFor = (actingUserId: number | undefined): PluginRpcContext => ({
pluginId: 'p',
actingUserId,
data: {} as PluginRpcContext['data'],
// No case below dispatches to a peer plugin, but the peers are a real collaborator
// on the context, so they get a stub that fails loudly rather than an empty object
// whose methods would be undefined the moment a handler reached for one.
plugins: {
call: () => Promise.reject(new Error('ctxFor: no peer plugin router in these cases')),
emit: () => {
throw new Error('ctxFor: no peer plugin router in these cases');
},
},
});
/** A well-formed controller: two tags methods with the permissions the table assigns. */
@PluginController()
class TagsRpc {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list(_params: Record<string, unknown>, ctx: PluginRpcContext) {
return { calledBy: ctx.actingUserId };
}
@PluginMethod('tags.create', { permission: 'db:write:tags' })
create(params: Record<string, unknown>, ctx: PluginRpcContext) {
return { name: params.name, by: ctx.actingUserId };
}
}
describe('PluginRpcRegistry — registration', () => {
it('RPCKIT-REG-001 an undecorated class is rejected by name', () => {
class NotAController {}
expect(() => new PluginRpcRegistry().register(new NotAController())).toThrow(
/NotAController is not decorated with @PluginController/,
);
});
it('RPCKIT-REG-002 a Nest @Controller is rejected, because @Injectable rewrites its scope metadata', () => {
@PluginController()
@Controller('things')
class HttpThing {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(() => new PluginRpcRegistry().register(new HttpThing())).toThrow(/is a Nest @Controller/);
});
it('RPCKIT-REG-003 only decorated methods are recorded', () => {
@PluginController()
class Mixed {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
helper() {
return 1;
}
}
const registry = createTestPluginRegistry([new Mixed()]);
expect(registry.list()).toHaveLength(1);
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-REG-004 an explicit method-name list narrows the scan', () => {
const registry = new PluginRpcRegistry();
registry.register(new TagsRpc(), ['list']);
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-REG-005 inherited decorated methods are picked up', () => {
@PluginController()
class Base {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
@PluginController()
class Child extends Base {}
const registry = createTestPluginRegistry([new Child()]);
expect(registry.methodNames()).toEqual(new Set(['tags.list']));
});
it('RPCKIT-REG-006 list() reports kind, permission and the owning class', () => {
const registry = createTestPluginRegistry([new TagsRpc()]);
expect(registry.list()).toEqual(
expect.arrayContaining([
{ kind: 'method', name: 'tags.list', permission: 'db:read:tags', className: 'TagsRpc', methodName: 'list' },
]),
);
});
});
describe('PluginRpcRegistry — validate', () => {
it('RPCKIT-REG-007 a well-formed controller validates', () => {
expect(() => createTestPluginRegistry([new TagsRpc()])).not.toThrow();
});
it('RPCKIT-REG-008 a permission the table does not assign is named with the expected literal', () => {
@PluginController()
class Wrong {
// The cast is the point: this is exactly what a hand-emitted consumer or an
// `as` cast would smuggle past tsc.
@PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' })
list() {
return [];
}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/method "tags.list" \(Wrong.list\) declares "db:write:tags" but METHOD_PERMISSION says "db:read:tags"/,
);
});
it('RPCKIT-REG-008b a permission that does not exist at all is reported as unknown, too', () => {
@PluginController()
class Typo {
@PluginMethod('tags.list', { permission: 'db:read:tapps' as 'db:read:tags' })
list() {
return [];
}
}
let error: Error | undefined;
try {
createTestPluginRegistry([new Typo()]);
} catch (e) {
error = e as Error;
}
// Both checks fire: it disagrees with the table AND names a permission the host
// does not know. The second is what catches a permission invented out of thin air.
expect(error?.message).toMatch(/but METHOD_PERMISSION says "db:read:tags"/);
expect(error?.message).toMatch(
/method "tags.list" \(Typo.list\) declares unknown permission "db:read:tapps"/,
);
});
it('RPCKIT-REG-009 a method outside KNOWN_METHODS is named', () => {
@PluginController()
class Bogus {
@PluginMethod('tags.lst' as 'tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(() => createTestPluginRegistry([new Bogus()])).toThrow(
/method "tags.lst" \(Bogus.list\) is not in KNOWN_METHODS/,
);
});
it('RPCKIT-REG-010 an open method outside UNCONDITIONAL_METHODS is named', () => {
@PluginController()
class Bogus {
@PluginOpenMethod('tags.list' as 'settings.get')
get() {
return {};
}
}
expect(() => createTestPluginRegistry([new Bogus()])).toThrow(
/open method "tags.list" \(Bogus.get\) is not in UNCONDITIONAL_METHODS/,
);
});
it('RPCKIT-REG-011 two owners of the same method name both classes', () => {
@PluginController()
class Second {
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return [];
}
}
expect(() => createTestPluginRegistry([new TagsRpc(), new Second()])).toThrow(
/method "tags.list" is handled by TagsRpc.list and Second.list/,
);
});
it('RPCKIT-REG-012 a hook permission that disagrees with HOOK_PERMISSION is named', () => {
@PluginController()
class Wrong {
@PluginHook('warningProvider', {
permission: 'hook:route-provider' as 'hook:trip-warning-provider',
fn: 'getWarnings',
})
declare() {}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/hook "warningProvider" \(Wrong.declare\) declares "hook:route-provider" but HOOK_PERMISSION says "hook:trip-warning-provider"/,
);
});
it('RPCKIT-REG-013 a hook name that is not a HOOK_PERMISSION key is named', () => {
@PluginController()
class Wrong {
@PluginHook('warningProvidr' as 'warningProvider', {
permission: 'hook:trip-warning-provider',
fn: 'x',
})
declare() {}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/hook "warningProvidr" \(Wrong.declare\) is not a HOOK_PERMISSION key/,
);
});
it('RPCKIT-REG-014 the same (hook, fn) pair declared twice is rejected', () => {
@PluginController()
class A {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
@PluginController()
class B {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
expect(() => createTestPluginRegistry([new A(), new B()])).toThrow(
/hook fn "warningProvider#getWarnings" is declared twice \(B.declare\)/,
);
});
it('RPCKIT-REG-015 two fns of the SAME hook are allowed — the fan-out is not uniform', () => {
@PluginController()
class Calendar {
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
name() {}
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 })
events() {}
}
expect(() => createTestPluginRegistry([new Calendar()])).not.toThrow();
});
it('RPCKIT-REG-016 every problem is reported at once, not just the first', () => {
@PluginController()
class Wrong {
@PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' })
list() {
return [];
}
@PluginOpenMethod('tags.create' as 'settings.get')
get() {
return {};
}
}
expect(() => createTestPluginRegistry([new Wrong()])).toThrow(
/METHOD_PERMISSION says "db:read:tags"; open method "tags.create"/,
);
});
it('RPCKIT-REG-017 requireTotalCoverage off tolerates an incomplete registry', () => {
expect(() => createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: false })).not.toThrow();
});
it('RPCKIT-REG-018 requireTotalCoverage on names an unhandled method and a consumerless hook', () => {
let error: Error | undefined;
try {
createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: true });
} catch (e) {
error = e as Error;
}
expect(error?.message).toMatch(/method "db.query" has no handler/);
expect(error?.message).toMatch(/method "plugins.call" has no handler/);
expect(error?.message).toMatch(/hook "photoProvider" has no host-side consumer \(dead grant on the consent screen\)/);
// tags.list IS handled, so it must not be listed.
expect(error?.message).not.toMatch(/method "tags.list" has no handler/);
});
});
describe('PluginRpcRegistry — bindInto', () => {
const bind = (registry: PluginRpcRegistry, granted: string[]) => {
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
registry.bindInto(map, new Set(granted), ctxFor);
return map;
};
it('RPCKIT-REG-019 registration is authorization: an ungranted method is ABSENT, not guarded', () => {
const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:read:tags']);
expect([...map.keys()]).toEqual(['tags.list']);
expect(map.has('tags.create')).toBe(false);
});
it('RPCKIT-REG-020 no grants means an empty map', () => {
expect(bind(createTestPluginRegistry([new TagsRpc()]), []).size).toBe(0);
});
it('RPCKIT-REG-021 the bound handler receives params and a context', () => {
const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:write:tags']);
expect(map.get('tags.create')!({ name: 'work' }, 42)).toEqual({ name: 'work', by: 42 });
});
it('RPCKIT-REG-022 the handler keeps its `this`', () => {
@PluginController()
class Stateful {
private readonly answer = 7;
@PluginMethod('tags.list', { permission: 'db:read:tags' })
list() {
return this.answer;
}
}
const map = bind(createTestPluginRegistry([new Stateful()]), ['db:read:tags']);
expect(map.get('tags.list')!({}, 42)).toBe(7);
});
it('RPCKIT-REG-022b a host-scoped http:outbound grant is honoured, not rejected as unknown', () => {
// The check uses isKnownPermission, not a set lookup on KNOWN_PERMISSIONS. A naive
// lookup would reject every host-scoped egress grant, because that family is
// open-ended by design.
const registry = createTestPluginRegistry([new TagsRpc()]);
const map = bind(registry, ['db:read:tags', 'http:outbound:api.example.com']);
expect(map.has('tags.list')).toBe(true);
});
it('RPCKIT-REG-023 an open method binds with no grant at all', () => {
@PluginController()
class Open {
@PluginOpenMethod('settings.get')
get() {
return { value: undefined };
}
}
expect([...bind(createTestPluginRegistry([new Open()]), []).keys()]).toEqual(['settings.get']);
});
it('RPCKIT-REG-024 hook declarations are never bound as methods', () => {
@PluginController()
class Hooks {
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' })
declare() {}
}
expect(bind(createTestPluginRegistry([new Hooks()]), ['hook:trip-warning-provider']).size).toBe(0);
});
it('RPCKIT-REG-025 a collision with a name the legacy router already owns throws', () => {
const registry = createTestPluginRegistry([new TagsRpc()]);
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
map.set('tags.list', () => 'legacy');
expect(() => registry.bindInto(map, new Set(['db:read:tags']), ctxFor)).toThrow(
/plugin RPC method "tags.list" is registered twice \(TagsRpc.list and the legacy router\)/,
);
});
it('RPCKIT-REG-026 the context is built per dispatch, not once per bind', () => {
const makeCtx = vi.fn(ctxFor);
const registry = createTestPluginRegistry([new TagsRpc()]);
const map = new Map<string, (p: Record<string, unknown>, uid: number | undefined) => unknown>();
registry.bindInto(map, new Set(['db:read:tags']), makeCtx);
expect(makeCtx).not.toHaveBeenCalled();
expect(map.get('tags.list')!({}, 1)).toEqual({ calledBy: 1 });
expect(map.get('tags.list')!({}, 2)).toEqual({ calledBy: 2 });
expect(makeCtx).toHaveBeenCalledTimes(2);
});
});
describe('PluginRpcRegistry — hook contracts', () => {
@PluginController()
class Calendar {
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 })
name() {}
@PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents' })
events() {}
}
it('RPCKIT-REG-027 every declared pair is listed with its consumer', () => {
const contracts = createTestPluginRegistry([new Calendar()]).hookContracts();
expect([...contracts.keys()]).toEqual(['calendarSource#getName', 'calendarSource#getEvents']);
expect(contracts.get('calendarSource#getName')).toEqual({
hook: 'calendarSource',
fn: 'getName',
permission: 'hook:calendar-source',
timeoutMs: 3000,
consumer: 'Calendar.name',
});
});
it('RPCKIT-REG-028 hookContract returns the budget a call site should use', () => {
const registry = createTestPluginRegistry([new Calendar()]);
expect(registry.hookContract('calendarSource', 'getEvents').timeoutMs).toBe(5000);
});
it('RPCKIT-REG-029 asking for an undeclared pair throws and names it', () => {
const registry = createTestPluginRegistry([new Calendar()]);
expect(() => registry.hookContract('calendarSource', 'getNothing')).toThrow(
/no @PluginHook declaration for "calendarSource#getNothing"/,
);
});
});
@@ -8,7 +8,7 @@
import { describe, it, expect, vi } from 'vitest';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
import { CategoriesRpc } from '../../../src/nest/categories/categories.rpc';
import { CategoriesModule } from '../../../src/nest/categories/categories.module';
import { WeatherRpc } from '../../../src/nest/weather/weather.rpc';