mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
af73943be8
The author-facing SDK, a standalone dependency-free package (not wired into the app workspaces, so it can't affect the app build). - definePlugin + the full plugin type surface (PluginContext, PluginRoute, PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime injects; PLUGIN_API_VERSION. - createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the SAME permission model + membership checks, so authors can unit-test that their plugin degrades gracefully — no running TREK needed. - validateManifest: the exact rules the registry CI runs, so a local pass predicts a CI pass. - CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe) and trek-plugin validate (manifest + README sanity). Consolidating the server loader to import this shared validator is a follow-up.
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
import type { PluginContext } from './index.js';
|
|
|
|
/**
|
|
* A mock PluginContext for unit-testing a plugin without a running TREK
|
|
* (#plugins, M6). It enforces the SAME permission model: calling a capability
|
|
* your plugin wasn't granted throws PERMISSION_DENIED — so a test can prove your
|
|
* plugin degrades gracefully. Data access returns configured fixtures; the db is
|
|
* a lightweight recorder (configure results, or use an integration test for real
|
|
* SQL).
|
|
*/
|
|
|
|
export interface MockHostOptions {
|
|
grants?: string[];
|
|
config?: Record<string, unknown>;
|
|
/** Fixtures keyed by trip id; `members` gates access like the real host. */
|
|
trips?: Record<number, { members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[] }>;
|
|
users?: Record<number, unknown>;
|
|
/** Optional canned db.query results, keyed by the exact sql string. */
|
|
queryResults?: Record<string, unknown[]>;
|
|
}
|
|
|
|
export interface MockHost {
|
|
ctx: PluginContext;
|
|
/** Everything the plugin did, for assertions. */
|
|
calls: { method: string; args: unknown[] }[];
|
|
logs: { level: string; msg: string }[];
|
|
broadcasts: { kind: 'trip' | 'user'; target: number; event: string; data: unknown }[];
|
|
}
|
|
|
|
class PermissionDenied extends Error {}
|
|
|
|
export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
|
const grants = new Set(opts.grants ?? []);
|
|
const calls: MockHost['calls'] = [];
|
|
const logs: MockHost['logs'] = [];
|
|
const broadcasts: MockHost['broadcasts'] = [];
|
|
|
|
const need = (perm: string, method: string) => {
|
|
calls.push({ method, args: [] });
|
|
if (!grants.has(perm)) throw new PermissionDenied(`PERMISSION_DENIED: ${method} requires ${perm}`);
|
|
};
|
|
const assertMember = (tripId: number, asUserId: number) => {
|
|
const t = opts.trips?.[tripId];
|
|
if (!t || !t.members.includes(asUserId)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${tripId}`);
|
|
return t;
|
|
};
|
|
|
|
const ctx: PluginContext = {
|
|
id: 'mock-plugin',
|
|
config: Object.freeze({ ...(opts.config ?? {}) }),
|
|
db: {
|
|
async query(sql) {
|
|
need('db:own', 'db.query');
|
|
return (opts.queryResults?.[sql] ?? []) as never[];
|
|
},
|
|
async exec() {
|
|
need('db:own', 'db.exec');
|
|
return { changes: 0 };
|
|
},
|
|
async migrate() {
|
|
need('db:own', 'db.migrate');
|
|
return { applied: true };
|
|
},
|
|
},
|
|
trips: {
|
|
async getById(tripId, asUserId) {
|
|
need('db:read:trips', 'trips.getById');
|
|
return assertMember(tripId, asUserId).data ?? null;
|
|
},
|
|
async getPlaces(tripId, asUserId) {
|
|
need('db:read:trips', 'trips.getPlaces');
|
|
return assertMember(tripId, asUserId).places ?? [];
|
|
},
|
|
async getReservations(tripId, asUserId) {
|
|
need('db:read:trips', 'trips.getReservations');
|
|
return assertMember(tripId, asUserId).reservations ?? [];
|
|
},
|
|
},
|
|
users: {
|
|
async getById(id) {
|
|
need('db:read:users', 'users.getById');
|
|
return opts.users?.[id] ?? null;
|
|
},
|
|
},
|
|
ws: {
|
|
async broadcastToTrip(tripId, event, data) {
|
|
need('ws:broadcast:trip', 'ws.broadcastToTrip');
|
|
broadcasts.push({ kind: 'trip', target: tripId, event, data });
|
|
},
|
|
async broadcastToUser(userId, event, data) {
|
|
need('ws:broadcast:user', 'ws.broadcastToUser');
|
|
broadcasts.push({ kind: 'user', target: userId, event, data });
|
|
},
|
|
},
|
|
log: {
|
|
info: (msg) => logs.push({ level: 'info', msg }),
|
|
warn: (msg) => logs.push({ level: 'warn', msg }),
|
|
error: (msg) => logs.push({ level: 'error', msg }),
|
|
},
|
|
};
|
|
|
|
return { ctx, calls, logs, broadcasts };
|
|
}
|