mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories
This commit is contained in:
@@ -76,9 +76,8 @@ function build(addonOn: boolean) {
|
||||
{ put: async () => undefined, delete: async () => undefined } as never,
|
||||
{ get: () => '*' } as never,
|
||||
{ schedule: () => undefined } as never,
|
||||
// Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository
|
||||
// .getEmail`, not `DatabaseService.prepare`.
|
||||
{ getEmail: async () => 'u@example.test' } as never),
|
||||
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
|
||||
{ isDemoUserId: async () => false } as never),
|
||||
new CollectionsRpc(spyService(calls, 'collections'), guards),
|
||||
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
|
||||
]);
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { DayScheduleController } from '../../../src/nest/plugins/contributions/day-schedule.controller';
|
||||
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
||||
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
@@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
|
||||
providersOf: vi.fn(() => providers),
|
||||
daySchedule: vi.fn(async (id: string) => invoke(id)),
|
||||
} as unknown as PluginHooks;
|
||||
return { c: new DayScheduleController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime };
|
||||
// CT1 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip.
|
||||
const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository;
|
||||
return { c: new DayScheduleController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime };
|
||||
}
|
||||
const item = (over: Record<string, unknown> = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over });
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { DayTintsController } from '../../../src/nest/plugins/contributions/day-tints.controller';
|
||||
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
||||
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
@@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
|
||||
providersOf: vi.fn(() => providers),
|
||||
dayTints: vi.fn(async (id: string) => invoke(id)),
|
||||
} as unknown as PluginHooks;
|
||||
return { c: new DayTintsController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime };
|
||||
// CT2 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip.
|
||||
const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository;
|
||||
return { c: new DayTintsController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime };
|
||||
}
|
||||
const tint = (over: Record<string, unknown> = {}) => ({ dayId: 10, tone: 'success', ...over });
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.r
|
||||
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
|
||||
import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
|
||||
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
|
||||
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
|
||||
import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository';
|
||||
import type { DayAccommodationsRepository } from '../../../src/db/repositories/DayAccommodations.repository';
|
||||
import type { PluginEntityMetadataRepository } from '../../../src/db/repositories/PluginEntityMetadata.repository';
|
||||
import type { PluginScheduledTasksRepository } from '../../../src/db/repositories/PluginScheduledTasks.repository';
|
||||
|
||||
// Typed from the real method rather than from the always-true body below, so a case that
|
||||
// swaps in an implementation reading the action key (HOSTRPC-015) still type-checks.
|
||||
@@ -130,6 +137,117 @@ const pluginAuditRepo = {
|
||||
},
|
||||
} as unknown as PluginCapabilityAuditRepository;
|
||||
|
||||
/**
|
||||
* Plan 3j Task 5: `MetaRpc`/`HostSurfaceRpc`'s own repository conversions
|
||||
* (MR#/HR#). Same reasoning as `pluginAuditRepo` above — this file's
|
||||
* hand-trimmed `:memory:` schema (no top-level await here) means a real
|
||||
* MikroORM repository isn't reachable, so every converted method is
|
||||
* reproduced as real raw SQL against `mockDb`, wrapped in the repository's
|
||||
* own method shape, rather than a canned stub — HOSTRPC-014/028/029/030
|
||||
* read the real table state back out through `mockDb.prepare(...)`
|
||||
* directly, so this HAS to be the real table.
|
||||
*/
|
||||
type RawDb = {
|
||||
prepare(sql: string): { get(...a: unknown[]): unknown; all(...a: unknown[]): unknown[]; run(...a: unknown[]): { changes: number } };
|
||||
};
|
||||
const raw = mockDb as unknown as RawDb;
|
||||
|
||||
const tripsRepo = {
|
||||
async sharesTripWith(userIdA: number, userIdB: number) {
|
||||
return !!raw
|
||||
.prepare(
|
||||
`SELECT 1 FROM trips t
|
||||
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
|
||||
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
|
||||
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
|
||||
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userIdA, userIdB, userIdA, userIdB);
|
||||
},
|
||||
async existsById(id: number) {
|
||||
return !!raw.prepare('SELECT id FROM trips WHERE id = ?').get(id);
|
||||
},
|
||||
} as unknown as TripsRepository;
|
||||
|
||||
const placesRepo = {
|
||||
async findTripId(id: number) {
|
||||
return (raw.prepare('SELECT trip_id FROM places WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
|
||||
},
|
||||
} as unknown as PlacesRepository;
|
||||
|
||||
const daysRepo = {
|
||||
async findTripId(id: number) {
|
||||
return (raw.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
|
||||
},
|
||||
} as unknown as DaysRepository;
|
||||
|
||||
const reservationsRepo = {
|
||||
async findTripId(id: number) {
|
||||
return (raw.prepare('SELECT trip_id FROM reservations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
|
||||
},
|
||||
} as unknown as ReservationsRepository;
|
||||
|
||||
const dayAccommodationsRepo = {
|
||||
async getTripId(id: number) {
|
||||
return (raw.prepare('SELECT trip_id FROM day_accommodations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
|
||||
},
|
||||
} as unknown as DayAccommodationsRepository;
|
||||
|
||||
const metaRepo = {
|
||||
async findValue(pluginId: string, entityType: string, entityId: number, key: string) {
|
||||
const row = raw
|
||||
.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
|
||||
.get(pluginId, entityType, entityId, key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
},
|
||||
async countForEntity(pluginId: string, entityType: string, entityId: number) {
|
||||
return (
|
||||
raw
|
||||
.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
|
||||
.get(pluginId, entityType, entityId) as { n: number }
|
||||
).n;
|
||||
},
|
||||
async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string) {
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, entity_type, entity_id, key)
|
||||
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(pluginId, entityType, entityId, key, value);
|
||||
},
|
||||
async listForEntity(pluginId: string, entityType: string, entityId: number) {
|
||||
return raw
|
||||
.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key')
|
||||
.all(pluginId, entityType, entityId) as Array<{ key: string; value: string }>;
|
||||
},
|
||||
async deleteValue(pluginId: string, entityType: string, entityId: number, key: string) {
|
||||
return raw.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?').run(pluginId, entityType, entityId, key).changes > 0;
|
||||
},
|
||||
} as unknown as PluginEntityMetadataRepository;
|
||||
|
||||
const scheduledTasksRepo = {
|
||||
async existsForPluginAndName(pluginId: string, name: string) {
|
||||
return !!raw.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(pluginId, name);
|
||||
},
|
||||
async countForPlugin(pluginId: string) {
|
||||
return (raw.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(pluginId) as { c: number }).c;
|
||||
},
|
||||
async upsertTask(input: { plugin_id: string; name: string; due_at: number; payload: string; every_ms: number | null }) {
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`,
|
||||
)
|
||||
.run(input.plugin_id, input.name, input.due_at, input.payload, input.every_ms);
|
||||
},
|
||||
async deleteByPluginAndName(pluginId: string, name: string) {
|
||||
return raw.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(pluginId, name).changes > 0;
|
||||
},
|
||||
} as unknown as PluginScheduledTasksRepository;
|
||||
|
||||
const dbs = new DatabaseService(mockDb);
|
||||
// Plan 3c Task 0b: `canAccessTrip` is `TripsRepository.findAccessible` now,
|
||||
// which needs a real EntityManager over the full migrated schema — this
|
||||
@@ -152,12 +270,21 @@ vi.spyOn(dbs, 'canAccessTrip').mockImplementation(async (tripId, userId) =>
|
||||
const seededRoles: Record<number, string> = { 5: 'trip_owner', 6: 'user', 9: 'user' };
|
||||
const usersRepo = {
|
||||
getRole: vi.fn(async (id: number) => seededRoles[id] ?? null),
|
||||
// HR1 (Plan 3j Task 5) — same "real SQL against mockDb" reasoning as the
|
||||
// repository stubs above; HOSTRPC-019 asserts the exact returned shape.
|
||||
async findPublicIdentity(id: number) {
|
||||
return (
|
||||
(raw.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id) as
|
||||
| { id: number; username: string; display_name: string | null; avatar: string | null }
|
||||
| undefined) ?? null
|
||||
);
|
||||
},
|
||||
} as unknown as UsersRepository;
|
||||
const guards = new PluginGuards(dbs, permissions, addons, usersRepo);
|
||||
const registry = createTestPluginRegistry([
|
||||
new DbRpc(userSettings),
|
||||
new MetaRpc(dbs, guards),
|
||||
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo),
|
||||
new MetaRpc(dbs, guards, metaRepo, tripsRepo, placesRepo, daysRepo, reservationsRepo, dayAccommodationsRepo),
|
||||
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo, usersRepo, tripsRepo, scheduledTasksRepo),
|
||||
]);
|
||||
const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
|
||||
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
|
||||
|
||||
@@ -5,19 +5,8 @@
|
||||
* to link and no provider asset. These cases pin what the handler refuses before
|
||||
* it writes anything, and that a refused write leaves no object behind.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted above the imports, so the flag it reads has to be too.
|
||||
// Only readEnv().demo is overridden: the module is shared, and replacing it
|
||||
// wholesale takes defaultLanguage and the rest of the env with it.
|
||||
const { demo } = vi.hoisted(() => ({ demo: { enabled: false } }));
|
||||
vi.mock('../../../src/app-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/app-config')>();
|
||||
return { ...actual, readEnv: () => ({ ...actual.readEnv(), demo }) };
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
|
||||
import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo';
|
||||
|
||||
const ACTOR = { actingUserId: 7 } as never;
|
||||
const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
|
||||
@@ -25,8 +14,10 @@ const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
|
||||
function build(overrides: {
|
||||
addPhoto?: unknown;
|
||||
allowed?: string;
|
||||
demoEnabled?: boolean;
|
||||
email?: string;
|
||||
/** SV8 (Plan 3i, R-survivors) — JournalRpc now delegates the whole demo-mode
|
||||
* check (env-enabled + email match) to `DemoService.isDemoUserId`, so the
|
||||
* test double only needs to say whether that call refuses. */
|
||||
demoBlocked?: boolean;
|
||||
} = {}) {
|
||||
// Typed parameters so the assertions below can index mock.calls.
|
||||
const put = vi.fn(async (_category: string, _filename: string, _body?: unknown, _opts?: unknown) => undefined);
|
||||
@@ -36,27 +27,20 @@ function build(overrides: {
|
||||
? () => ({ id: 5, photo_id: 42 })
|
||||
: () => overrides.addPhoto);
|
||||
|
||||
demo.enabled = overrides.demoEnabled ?? false;
|
||||
|
||||
const rpc = new JournalRpc(
|
||||
{ addPhoto } as never,
|
||||
{ requireAddon: vi.fn() } as never,
|
||||
{ put, delete: del } as never,
|
||||
{ get: () => overrides.allowed ?? '*' } as never,
|
||||
{ schedule } as never,
|
||||
// Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository
|
||||
// .getEmail`, not `DatabaseService.prepare`.
|
||||
{ getEmail: async () => overrides.email ?? 'user@example.test' } as never,
|
||||
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
|
||||
{ isDemoUserId: async () => overrides.demoBlocked ?? false } as never,
|
||||
);
|
||||
return { rpc, put, del, schedule, addPhoto };
|
||||
}
|
||||
|
||||
const input = (over: Record<string, unknown> = {}) => ({ name: 'photo.jpg', content_base64: PNG, ...over });
|
||||
|
||||
beforeEach(() => {
|
||||
demo.enabled = false;
|
||||
});
|
||||
|
||||
describe('journal.addEntryPhoto', () => {
|
||||
it('JPHOTO-001: stores the bytes under a name of its own, then links the photo', async () => {
|
||||
const { rpc, put, schedule, addPhoto } = build();
|
||||
@@ -129,7 +113,7 @@ describe('journal.addEntryPhoto', () => {
|
||||
});
|
||||
|
||||
it('JPHOTO-007: refuses to write bytes for a demo user, exactly as the REST upload does', async () => {
|
||||
const { rpc, put } = build({ demoEnabled: true, email: DEMO_EMAIL_PRIMARY });
|
||||
const { rpc, put } = build({ demoBlocked: true });
|
||||
|
||||
await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, ACTOR)).rejects.toThrow(/disabled in demo mode/);
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { PlaceDetailsController } from '../../../src/nest/plugins/contributions/place-details.controller';
|
||||
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
@@ -23,7 +24,9 @@ function controller(over: Partial<PluginHooks> = {}) {
|
||||
placeDetails: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])),
|
||||
...over,
|
||||
} as unknown as PluginHooks;
|
||||
return { c: new PlaceDetailsController(runtime, { canAccessTrip, connection: { prepare: () => ({ get: (placeId: number) => placeTrip(placeId) }) } } as unknown as DatabaseService), runtime };
|
||||
// CT7 (Plan 3j Task 5) — the place's owning trip id is now Places.repository.ts#findTripId.
|
||||
const places = { findTripId: vi.fn(async (placeId: number) => placeTrip(placeId)?.trip_id) } as unknown as PlacesRepository;
|
||||
return { c: new PlaceDetailsController(runtime, { canAccessTrip } as unknown as DatabaseService, places), runtime };
|
||||
}
|
||||
|
||||
describe('PlaceDetailsController', () => {
|
||||
|
||||
@@ -11,12 +11,10 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(() => ({ 1: 1 })), // plugin is active by default
|
||||
}));
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { PluginOAuthController } from '../../../src/nest/plugins/oauth/plugin-oauth.controller';
|
||||
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
|
||||
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
@@ -32,7 +30,9 @@ function ctrl(over: Partial<PluginOAuthService> = {}) {
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
...over,
|
||||
} as unknown as PluginOAuthService;
|
||||
return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc };
|
||||
// POC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive.
|
||||
const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository;
|
||||
return { c: new PluginOAuthController(svc, plugins), svc };
|
||||
}
|
||||
|
||||
describe('PluginOAuthController', () => {
|
||||
|
||||
@@ -10,13 +10,11 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(() => ({ 1: 1 })), // active by default
|
||||
}));
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
@@ -28,7 +26,9 @@ function ctrl() {
|
||||
} as unknown as PluginsService;
|
||||
// The controller now also takes the runtime (for settings-page actions).
|
||||
const runtime = { actionsOf: vi.fn(async () => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
|
||||
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
|
||||
// PUC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive.
|
||||
const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository;
|
||||
return { c: new PluginUserSettingsController(svc, runtime, plugins), svc, runtime };
|
||||
}
|
||||
|
||||
describe('PluginUserSettingsController', () => {
|
||||
|
||||
@@ -12,12 +12,10 @@ const { rows, pluginsEnabled } = vi.hoisted(() => ({
|
||||
rows: { value: [] as Array<Record<string, unknown>> },
|
||||
pluginsEnabled: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ all: () => rows.value }) } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
|
||||
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
|
||||
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'p1', name: 'P', type: 'integration', icon: null,
|
||||
@@ -26,7 +24,9 @@ const row = (over: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
describe('PluginsFeedController', () => {
|
||||
const c = new PluginsFeedController(new DatabaseService(dbConn));
|
||||
// PFC1 (Plan 3j Task 5) — the feed read is now Plugins.repository.ts#findActiveFeedRows.
|
||||
const plugins = { findActiveFeedRows: vi.fn(async () => rows.value) } as unknown as PluginsRepository;
|
||||
const c = new PluginsFeedController(plugins);
|
||||
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
|
||||
|
||||
it('returns an empty feed when the runtime is disabled', async () => {
|
||||
|
||||
@@ -67,6 +67,17 @@ async function makeService(): Promise<PluginsService> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PFC1 (Plan 3j Task 5) — PluginsFeedController's own feed read, now
|
||||
* Plugins.repository.ts#findActiveFeedRows, resolved over the SAME `testDb`
|
||||
* handle every raw-SQL fixture line above writes to (same `sharedTestOrm`
|
||||
* reasoning as `makeService` above).
|
||||
*/
|
||||
async function makeFeedController(): Promise<PluginsFeedController> {
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
return new PluginsFeedController(orm.repo(Plugins));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins');
|
||||
testDb.exec('DELETE FROM plugin_settings_fields');
|
||||
@@ -247,7 +258,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
it('returns active plugins when enabled, nothing when disabled', async () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
|
||||
const feed = new PluginsFeedController(new DatabaseService(dbConn));
|
||||
const feed = await makeFeedController();
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const active = await feed.list();
|
||||
@@ -261,7 +272,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
|
||||
const out = await (await makeFeedController()).list();
|
||||
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
|
||||
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
|
||||
});
|
||||
@@ -269,14 +280,14 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', async () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
|
||||
expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
|
||||
});
|
||||
|
||||
it('exposes settingsUi only when the capability is exactly true', async () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
|
||||
const out = await (await makeFeedController()).list();
|
||||
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
|
||||
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
|
||||
});
|
||||
@@ -284,7 +295,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', async () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
|
||||
expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
|
||||
});
|
||||
|
||||
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', async () => {
|
||||
@@ -294,7 +305,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
// the capability is meaningless off a trip-page and must not leak onto widgets
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
|
||||
const out = await (await makeFeedController()).list();
|
||||
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
|
||||
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
|
||||
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();
|
||||
|
||||
Reference in New Issue
Block a user