mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* GET /api/addons e2e — exercises the AddonsController through the real
|
||||
* JwtAuthGuard against a temp SQLite db. getPhotoProviderConfig is
|
||||
* mocked; the addons/photo_providers/photo_provider_fields/app_settings reads
|
||||
* JwtAuthGuard against a real migrated-and-seeded temp SQLite db
|
||||
* (createSnapshotTestDb(), task-6-fix-brief.md item 5 — this used to hand-roll
|
||||
* four CREATE TABLEs, a second hand-maintained schema copy whose NOT
|
||||
* NULL/defaults/FK/UNIQUE drifted from the real migrations). getPhotoProviderConfig
|
||||
* is mocked; the addons/photo_providers/photo_provider_fields/app_settings reads
|
||||
* run against the temp db (the collab/bag-tracking flags are real AddonsService
|
||||
* reads since the admin-1 extraction). Asserts the byte-identical body the legacy inline handler produced.
|
||||
* reads since the admin-1 extraction). Asserts the byte-identical body the legacy
|
||||
* inline handler produced.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -11,32 +15,23 @@ import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
// description/config (addons) and description (photo_providers) are absent
|
||||
// from the legacy SELECTs this suite exercises, but the Addons/PhotoProviders
|
||||
// entities declare them as columns (Plan 3a Task 4) — MikroORM's generated
|
||||
// SELECT names every scalar column, so they must exist on this hand-rolled
|
||||
// schema even though no test here reads them off addons/photo_providers rows.
|
||||
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, enabled INTEGER, config TEXT DEFAULT '{}', sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, description TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
|
||||
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
|
||||
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);`);
|
||||
return { db: tmp };
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
import { db } from '../../src/db/database';
|
||||
|
||||
// The snapshot ships the real seeded catalogue (production default addons +
|
||||
// photo providers) — this suite wants a known, empty set of each so every
|
||||
// case controls its own fixture rows exactly like the legacy hand-rolled
|
||||
// tables did. Child table first for the FK on photo_provider_fields.
|
||||
db.exec('DELETE FROM photo_provider_fields');
|
||||
db.exec('DELETE FROM photo_providers');
|
||||
db.exec('DELETE FROM addons');
|
||||
db.exec('DELETE FROM app_settings');
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({
|
||||
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
|
||||
@@ -62,7 +57,13 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
// harness.ts's seedUser() omits password_hash, which the real migrated
|
||||
// schema requires NOT NULL (share.e2e.test.ts hit the same thing) — a
|
||||
// raw insert here instead, matching the SeededUser shape id/role/
|
||||
// password_version=0 that sessionCookie(1) needs.
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
||||
).run();
|
||||
// bag tracking is opt-in (=== 'true'); collab flags default ON with no rows
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run();
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
|
||||
|
||||
@@ -20,15 +20,22 @@ const tagsPerHandle = new WeakMap<Database.Database, Promise<TagsRepository>>();
|
||||
const settingsPerHandle = new WeakMap<Database.Database, Promise<SettingsRepository>>();
|
||||
const usersPerHandle = new WeakMap<Database.Database, Promise<UsersRepository>>();
|
||||
// ONE MikroORM per handle, shared by createTestUnitOfWork and
|
||||
// createTestAppSettingsRepo (task-2-review.md I2): each used to call
|
||||
// createTestOrm(db) independently, which opened a SECOND MikroORM.init over the
|
||||
// same better-sqlite3 handle — two identity maps, two Kysely clients, and
|
||||
// (the functional bug) `UnitOfWork.transactional` opening its transaction on
|
||||
// ORM A's EntityManager while a repository built from ORM B resolved a
|
||||
// DIFFERENT EntityManager, so `TransactionContext` never saw it: a repository
|
||||
// write inside `uow.transactional(...)` silently ran outside the transaction.
|
||||
// Both functions below now derive from this single `t.em`, so a repository
|
||||
// resolved from `t.repo(X)` and a `UnitOfWork` built from `t.em` share the same
|
||||
// createTestAppSettingsRepo (task-2-review.md I2; reworded per
|
||||
// task-1-2-rereview.md's I-A — the original wording here claimed a
|
||||
// functional bug that measurement disproved): each used to call
|
||||
// createTestOrm(db) independently, which opened a SECOND MikroORM.init over
|
||||
// the same better-sqlite3 handle — two identity maps and two Kysely clients,
|
||||
// neither ever closed. That is NOT the same as a repository write silently
|
||||
// running outside a transaction: `TransactionContext.getEntityManager(name)`
|
||||
// keys on the EntityManager's CONTEXT NAME ('default'), not on which ORM
|
||||
// instance created it, so a repository built from ORM B still resolved ORM
|
||||
// A's transactional fork correctly even under the old split wiring —
|
||||
// transaction resolution worked either way. The reason to share one ORM per
|
||||
// handle is simpler: it is the same EntityManager Nest's real DI graph would
|
||||
// inject (one MikroORM per app, not one per consumer), and it stops leaking
|
||||
// a second identity map and Kysely client per test file. Both functions below
|
||||
// now derive from this single `t.em`, so a repository resolved from
|
||||
// `t.repo(X)` and a `UnitOfWork` built from `t.em` share the same
|
||||
// context-resolving EntityManager, exactly like Nest's real DI graph.
|
||||
const ormPerHandle = new WeakMap<Database.Database, Promise<TestOrm>>();
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Boot-sweep request-context regression guard (task-6-review-parity.md
|
||||
* Critical C1; task-6-fix-brief.md item 7).
|
||||
*
|
||||
* `journey-thumbs.job.ts`'s `onApplicationBootstrap` used to call
|
||||
* `void this.sweep()` directly, bypassing `CronRegistrarService`'s
|
||||
* request-context wrapper entirely — the one place every SCHEDULED tick
|
||||
* gets it. Once `sweepOrphanThumbs` went repository-backed
|
||||
* (`AddonsService.isAddonEnabled`), that boot-time call threw
|
||||
* `cannotUseGlobalContext` on EVERY production boot, silently, because the
|
||||
* job's own try/catch swallowed it into a log line about the sweep itself.
|
||||
* The smoke boot reproduced it twice. Every `onApplicationBootstrap` boot
|
||||
* sweep across the seven job providers listed in the parity review now
|
||||
* routes through `CronRegistrarService.runOnBoot` instead.
|
||||
*
|
||||
* Under `NODE_ENV=test`, `CronRegistrarService.isEnabled()` is false
|
||||
* (SCHED-GATE), so `onApplicationBootstrap` never fires these sweeps
|
||||
* automatically in this suite — deliberately, so tests stay timer- and
|
||||
* network-free. This test instead triggers each job's real boot-sweep
|
||||
* entrypoint directly through the SAME, production-wired
|
||||
* `CronRegistrarService.runOnBoot` (its `orm` is `app.get(MikroORM)` —
|
||||
* pinned by orm-request-context-seams.test.ts), which is exactly what
|
||||
* `onApplicationBootstrap` itself would call in production. It then asserts
|
||||
* the captured log contains zero `cannotUseGlobalContext` / "global
|
||||
* EntityManager" lines — the regression guard the smoke boot would
|
||||
* otherwise be the only thing catching.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
|
||||
import { JourneyThumbsJob } from '../../src/nest/memories/journey-thumbs.job';
|
||||
import { PlacePhotoCacheJob } from '../../src/nest/place-photos/place-photo-cache.job';
|
||||
import { TrekPhotoCacheJob } from '../../src/nest/memories/trek-photo-cache.job';
|
||||
import { ReminderJobsService } from '../../src/nest/notifications/reminder-jobs.service';
|
||||
import { DocSyncJob } from '../../src/nest/doc-sync/doc-sync.job';
|
||||
import { AirtrailSyncJob } from '../../src/nest/integrations/airtrail-sync.job';
|
||||
import { DawarichSyncJob } from '../../src/nest/integrations/dawarich-sync.job';
|
||||
|
||||
describe('Every onApplicationBootstrap boot sweep runs inside a request context', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('BOOT-SWEEP-001: the seven boot-sweep entrypoints, invoked through the real production-wired CronRegistrarService.runOnBoot, never log cannotUseGlobalContext / "global EntityManager"', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const registrar = app.get(CronRegistrarService);
|
||||
|
||||
await registrar.runOnBoot('journey-thumbs-boot', () => app.get(JourneyThumbsJob).sweep());
|
||||
await registrar.runOnBoot('place-photo-cache-boot', () => app.get(PlacePhotoCacheJob).sweep());
|
||||
await registrar.runOnBoot('trek-photo-cache-boot', () => app.get(TrekPhotoCacheJob).tick());
|
||||
await registrar.runOnBoot('reminder-jobs-boot', async () => {
|
||||
await app.get(ReminderJobsService).tripTick();
|
||||
await app.get(ReminderJobsService).todoTick();
|
||||
});
|
||||
await registrar.runOnBoot('docsync-boot', () => app.get(DocSyncJob).tick());
|
||||
await registrar.runOnBoot('airtrail-sync-boot', () => app.get(AirtrailSyncJob).tick());
|
||||
await registrar.runOnBoot('dawarich-sync-boot', () => app.get(DawarichSyncJob).tick());
|
||||
|
||||
const suspicious = errSpy.mock.calls
|
||||
.map((args) => args.map(String).join(' '))
|
||||
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
|
||||
expect(suspicious).toEqual([]);
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* ORM request-context seam ratchet (task-6-fix-brief.md item 2;
|
||||
* task-6-review-template.md's Important 4 / M-E). task-6-fix-brief.md item 1
|
||||
* made the three D6 choke points — CronRegistrarService.register/runOnBoot,
|
||||
* PluginSupervisor.onMessage's dispatch, TrekWsAdapter.bindMessageHandlers —
|
||||
* THROW when their ORM/thunk is absent, and every unit suite (WSAD-040,
|
||||
* CRONREG-010, CTX-PLUGIN-001) asserts that throw. None of those tests can
|
||||
* tell a genuinely wired production seam apart from a hand-built double that
|
||||
* happens to pass no ORM — they'd pass either way. This is the one test that
|
||||
* pins production wiring itself: that `buildApp()` actually populates
|
||||
* `CronRegistrarService`'s and `PluginRuntimeService`'s `@Optional()` ORM
|
||||
* params and `PluginSupervisor`'s `resolveOrm` thunk with the SAME MikroORM
|
||||
* instance `app.get(MikroORM)` resolves — so a future refactor that quietly
|
||||
* drops one of those three constructor arguments fails here, not silently in
|
||||
* production.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
|
||||
import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service';
|
||||
|
||||
describe('ORM request-context seams populated in production', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('SEAM-001: CronRegistrarService, PluginRuntimeService and PluginSupervisor.resolveOrm() all resolve the SAME MikroORM app.get(MikroORM) does', () => {
|
||||
const orm = app.get(MikroORM);
|
||||
|
||||
const cronRegistrar = app.get(CronRegistrarService) as unknown as { orm?: MikroORM };
|
||||
expect(cronRegistrar.orm).toBe(orm);
|
||||
|
||||
const runtime = app.get(PluginRuntimeService) as unknown as {
|
||||
orm?: MikroORM;
|
||||
supervisor: { resolveOrm?: () => unknown };
|
||||
};
|
||||
expect(runtime.orm).toBe(orm);
|
||||
expect(runtime.supervisor.resolveOrm?.()).toBe(orm);
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,12 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
// (if it had one) first. The boot reconcile must therefore not live there.
|
||||
{
|
||||
provide: PluginRuntimeService,
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory),
|
||||
// 8th arg (orm): task-6-fix-brief.md item 1 — PluginSupervisor.onMessage
|
||||
// now THROWS on a 'req' dispatch (e.g. this fixture's ctx.db.migrate in
|
||||
// onLoad) when resolveOrm returns undefined, instead of running it
|
||||
// unwrapped, so this hand-built double needs the same real ORM the
|
||||
// repositories above (auditLogRepo/usersRepo) already share.
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory, undefined, t?.orm),
|
||||
},
|
||||
{
|
||||
provide: 'REGISTRY_SCAN',
|
||||
|
||||
@@ -11,12 +11,14 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginSupervisor, type SupervisorHooks, type SupervisorTuning } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
|
||||
import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service';
|
||||
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
||||
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
||||
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
/**
|
||||
* DbRpc also carries `settings.get`, which needs the per-user settings store. No
|
||||
@@ -53,7 +55,12 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
|
||||
onEvent: (_id, topic, data) => events.push({ topic, data }),
|
||||
onLog: (_id, level, msg, meta) => events.push({ topic: '__log', data: { level, msg, meta } }),
|
||||
};
|
||||
return new PluginSupervisor(createRpcHost, hooks, tuning);
|
||||
// task-6-fix-brief.md item 1: every real `ctx.*` round-trip below dispatches
|
||||
// through PluginSupervisor.onMessage's 'req' branch over real child-process
|
||||
// IPC, which now THROWS without a resolveOrm thunk rather than dispatching
|
||||
// unwrapped — so this double needs a real (if otherwise unused) ORM, even
|
||||
// though PluginDataDb/DbRpc here are raw better-sqlite3, not MikroORM.
|
||||
return new PluginSupervisor(createRpcHost, hooks, tuning, () => ormHandle.orm);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,17 +73,23 @@ function logMeta<T = Record<string, unknown>>(events: Array<{ topic: string; dat
|
||||
return (hit && (hit.data as { meta?: unknown }).meta) as T;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const ormDb = new Database(':memory:');
|
||||
let ormHandle: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-code-'));
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-pdata-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
ormHandle = await createTestOrm(ormDb);
|
||||
});
|
||||
afterAll(async () => {
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
await ormHandle.close();
|
||||
ormDb.close();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await sup?.shutdownAll();
|
||||
|
||||
@@ -64,13 +64,6 @@ describe('AddonsRepository', () => {
|
||||
// repeat call from the identity map unless refresh: true is set —
|
||||
// invisible to a write on the same id in the same request.
|
||||
describe('sees a write on the same id in the same request (I1, identity-map regression)', () => {
|
||||
it('ADDONSREPO-004: setEnabled then isEnabled reads the new value, not the stale one', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map
|
||||
await addons.setEnabled('budget', true);
|
||||
expect(await addons.isEnabled('budget')).toBe(true);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-005: a raw UPDATE on the same id then isEnabled reads the new value', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map
|
||||
@@ -116,30 +109,4 @@ describe('AddonsRepository', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('ADDONSREPO-010: stores the boolean as the legacy 0/1', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
await addons.setEnabled('budget', true);
|
||||
expect(rawRow('budget')).toMatchObject({ enabled: 1 });
|
||||
|
||||
await addons.setEnabled('budget', false);
|
||||
expect(rawRow('budget')).toMatchObject({ enabled: 0 });
|
||||
});
|
||||
|
||||
it('ADDONSREPO-011: does not touch any other column', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Costs', description: 'Track spend', type: 'trip', icon: 'wallet', enabled: 0, sort_order: 5 });
|
||||
await addons.setEnabled('budget', true);
|
||||
expect(rawRow('budget')).toEqual({
|
||||
id: 'budget',
|
||||
name: 'Costs',
|
||||
description: 'Track spend',
|
||||
type: 'trip',
|
||||
icon: 'wallet',
|
||||
enabled: 1,
|
||||
config: '{}',
|
||||
sort_order: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* AirtrailSyncJob (AIRTRAIL-JOB-001..010): the scheduling shell around the
|
||||
* AirTrail poller, built with `new`, collaborators as plain mocks, no Nest
|
||||
* container and no timer. Mirrors dawarich-sync.job.test.ts's shape — the
|
||||
* two jobs are structurally identical siblings with different numbers (floor
|
||||
* 1 vs 5, default 5 vs 15) and a different setting key.
|
||||
*
|
||||
* task-6-review-parity.md C1 / task-6-fix-brief.md item 7: the boot-time
|
||||
* interval read now goes through CronRegistrarService.runOnBoot instead of
|
||||
* running inline in onApplicationBootstrap — AIRTRAIL-JOB-009/010 pin that.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { AirtrailSyncJob } from '../../../src/nest/integrations/airtrail-sync.job';
|
||||
import type { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
|
||||
const SETTING_KEY = 'airtrail_poll_interval_minutes';
|
||||
|
||||
function makeJob(intervalSetting?: string, enabled = true) {
|
||||
let onTick: (() => void | Promise<void>) | undefined;
|
||||
const registrar = {
|
||||
isEnabled: vi.fn(() => enabled),
|
||||
register: vi.fn((_name: string, _expression: string, cb: () => void | Promise<void>) => {
|
||||
onTick = cb;
|
||||
return enabled;
|
||||
}),
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
const db = {
|
||||
get: vi.fn((_sql: string, ..._params: unknown[]) => (intervalSetting === undefined ? undefined : { value: intervalSetting })),
|
||||
};
|
||||
const airtrail = {
|
||||
runAirtrailSync: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
};
|
||||
const job = new AirtrailSyncJob(
|
||||
db as unknown as DatabaseService,
|
||||
airtrail as unknown as AirtrailSyncService,
|
||||
registrar as unknown as CronRegistrarService,
|
||||
);
|
||||
return { job, registrar, db, airtrail, takeTick: () => onTick };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('AirtrailSyncJob bootstrap', () => {
|
||||
it('AIRTRAIL-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', async () => {
|
||||
const { job, registrar } = makeJob('20');
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/20 * * * *', expect.any(Function));
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('AirTrail sync: scheduled every 20m');
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-002: reads its own setting key, not the Dawarich one', async () => {
|
||||
const { job, db } = makeJob('10');
|
||||
await job.onApplicationBootstrap();
|
||||
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY);
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-003: clamps the interval to 1-59 minutes and falls back to 5 on anything else', async () => {
|
||||
for (const [setting, minutes] of [
|
||||
[undefined, 5],
|
||||
['', 5],
|
||||
['not-a-number', 5],
|
||||
['0', 5],
|
||||
['60', 5],
|
||||
['1', 1], // the floor itself is allowed
|
||||
['59', 59], // and so is the ceiling
|
||||
] as const) {
|
||||
vi.clearAllMocks();
|
||||
const { job, registrar } = makeJob(setting);
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', `*/${minutes} * * * *`, expect.any(Function));
|
||||
}
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-004: the test gate stops the job before it registers, reads or logs anything', async () => {
|
||||
const { job, registrar, db } = makeJob('5', false);
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
expect(registrar.runOnBoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-005: the callback handed to the registrar is the tick, so a fired cron reaches runAirtrailSync', async () => {
|
||||
const { job, airtrail, takeTick } = makeJob('5');
|
||||
await job.onApplicationBootstrap();
|
||||
const onTick = takeTick();
|
||||
expect(onTick).toBeTypeOf('function');
|
||||
await onTick?.();
|
||||
expect(airtrail.runAirtrailSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-009: the boot-time interval read goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point)', async () => {
|
||||
const { job, registrar } = makeJob('5');
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('airtrail-sync-boot', expect.any(Function));
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-010: registers at the default 5m cadence if runOnBoot declines to run fn (no ORM available)', async () => {
|
||||
const { job, registrar, db } = makeJob('30');
|
||||
registrar.runOnBoot.mockImplementationOnce(async () => { /* simulates no ORM available — fn never runs */ });
|
||||
await job.onApplicationBootstrap();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/5 * * * *', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AirtrailSyncJob tick', () => {
|
||||
it('AIRTRAIL-JOB-006: delegates to runAirtrailSync and stays quiet on success', async () => {
|
||||
const { job, airtrail } = makeJob('5');
|
||||
await expect(job.tick()).resolves.toBeUndefined();
|
||||
expect(airtrail.runAirtrailSync).toHaveBeenCalledTimes(1);
|
||||
expect(logMock.logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-007: an unreachable instance becomes one log line and a resolved promise, never an unhandled rejection in the scheduler', async () => {
|
||||
const { job, airtrail } = makeJob('5');
|
||||
airtrail.runAirtrailSync.mockRejectedValue(new Error('fetch failed'));
|
||||
await expect(job.tick()).resolves.toBeUndefined();
|
||||
expect(logMock.logError).toHaveBeenCalledWith('AirTrail sync tick failed: fetch failed');
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-008: a non-Error rejection is logged by value instead of as an empty message', async () => {
|
||||
const { job, airtrail } = makeJob('5');
|
||||
airtrail.runAirtrailSync.mockRejectedValue('ECONNREFUSED');
|
||||
await expect(job.tick()).resolves.toBeUndefined();
|
||||
expect(logMock.logError).toHaveBeenCalledWith('AirTrail sync tick failed: ECONNREFUSED');
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||||
}));
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { ValidationError } from '@mikro-orm/core';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
@@ -38,6 +39,7 @@ testDb.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
let t: TestOrm;
|
||||
let auditLogRepo: AuditLogRepository;
|
||||
let usersRepo: UsersRepository;
|
||||
let svc: AuditService;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -45,7 +47,8 @@ beforeAll(async () => {
|
||||
runMigrations(testDb);
|
||||
t = await createTestOrm(testDb);
|
||||
auditLogRepo = t.repo(AuditLog) as AuditLogRepository;
|
||||
svc = new AuditService(auditLogRepo, t.repo(Users) as UsersRepository);
|
||||
usersRepo = t.repo(Users) as UsersRepository;
|
||||
svc = new AuditService(auditLogRepo, usersRepo);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -186,6 +189,23 @@ describe('writeAudit', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('AUDIT-SVC-020: a MikroORM ValidationError from insertEntry still never throws, but is logged with a message distinct from a normal write failure (task-6-fix-brief.md item 4)', async () => {
|
||||
const spy = vi.spyOn(auditLogRepo, 'insertEntry').mockRejectedValueOnce(ValidationError.cannotUseGlobalContext());
|
||||
await expect(svc.writeAudit({ userId: 1, action: 'user.login' })).resolves.not.toThrow();
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('Audit write ran with no request context'));
|
||||
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('Audit write failed:'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('AUDIT-SVC-021: a MikroORM ValidationError from the email lookup still resolves to uid:<id>, but is logged with a distinct message', async () => {
|
||||
seedUser(1, 'a@b.c');
|
||||
const spy = vi.spyOn(usersRepo, 'getEmail').mockRejectedValueOnce(ValidationError.cannotUseGlobalContext());
|
||||
await svc.writeAudit({ userId: 1, action: 'user.login' });
|
||||
expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('uid:1 logged in'));
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('Audit email lookup ran with no request context'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', async () => {
|
||||
seedUser(1, 'admin@b.c');
|
||||
await svc.writeAudit({
|
||||
|
||||
@@ -26,6 +26,12 @@ function registrarStub(enabled = true) {
|
||||
isEnabled: vi.fn(() => enabled),
|
||||
register: vi.fn(() => enabled),
|
||||
unregister: vi.fn(),
|
||||
// task-6-fix-brief.md item 7: every boot-time one-off sweep now goes
|
||||
// through CronRegistrarService.runOnBoot instead of running inline —
|
||||
// this double just runs fn immediately (synchronously reaching fn's own
|
||||
// first call, exactly the pre-fix `void this.sweep()` shape), so every
|
||||
// existing assertion below is unaffected.
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ vi.mock('cron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const logErrorMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: logErrorMock,
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
@@ -170,20 +178,17 @@ describe('CronRegistrarService', () => {
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('CRONREG-010 — without MikroORM injected, a repository read inside onTick throws cannotUseGlobalContext', async () => {
|
||||
it('CRONREG-010 — without MikroORM injected, a fired tick throws rather than running the onTick body unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point)', async () => {
|
||||
const registry = new SchedulerRegistry();
|
||||
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService); // no orm arg
|
||||
let caught: unknown;
|
||||
registrar.register('job', '0 2 * * *', async () => {
|
||||
try {
|
||||
await t.orm.em.find(Users, {});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
let bodyRan = false;
|
||||
registrar.register('job', '0 2 * * *', () => {
|
||||
bodyRan = true;
|
||||
});
|
||||
await h.jobs[0].onTick();
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect(String((caught as Error).message)).toMatch(/global (EntityManager|context)/i);
|
||||
await expect(h.jobs[0].onTick()).rejects.toThrow(/no MikroORM available/i);
|
||||
// The wrapper throws BEFORE calling onTick at all — the whole point is
|
||||
// that a repository read inside the body never runs unwrapped.
|
||||
expect(bodyRan).toBe(false);
|
||||
});
|
||||
|
||||
it('CRONREG-011 — with MikroORM injected, the SAME repository read inside onTick succeeds — the wrapper is load-bearing', async () => {
|
||||
@@ -203,4 +208,40 @@ describe('CronRegistrarService', () => {
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOnBoot (task-6-fix-brief.md item 7 — the boot-sweep choke point)', () => {
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await createTestOrm(testDb, { allowGlobalContext: false });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('CRONREG-012 — with MikroORM injected, fn runs inside a request context and a repository read succeeds', async () => {
|
||||
const registry = new SchedulerRegistry();
|
||||
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm);
|
||||
let result: unknown;
|
||||
await registrar.runOnBoot('boot-sweep', async () => {
|
||||
result = await t.orm.em.find(Users, {});
|
||||
});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('CRONREG-013 — without MikroORM injected, fn never runs and the failure is logged with a distinct message, not swallowed', async () => {
|
||||
const registry = new SchedulerRegistry();
|
||||
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService); // no orm
|
||||
let fnRan = false;
|
||||
await registrar.runOnBoot('boot-sweep', () => {
|
||||
fnRan = true;
|
||||
});
|
||||
expect(fnRan).toBe(false);
|
||||
expect(logErrorMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/runOnBoot: no MikroORM available.*boot-sweep/),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TransactionContext } from '@mikro-orm/core';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { Users } from '../../../../src/db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
|
||||
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
||||
|
||||
/**
|
||||
* D5 ratchet (task-6-fix-brief.md item 2; task-1-2-rereview.md's "half that
|
||||
* IS testable"): the property `test-uow.ts`'s one-ORM-per-handle wiring
|
||||
* exists to protect is not "the split (two-ORM) wiring was broken" — measured
|
||||
* and disproven in that re-review, since `TransactionContext.getEntityManager`
|
||||
* matches on the EntityManager's CONTEXT NAME, not the ORM instance, so even
|
||||
* a repository built from a second `MikroORM.init` over the same handle
|
||||
* resolves the open transaction correctly. The property that IS real, and
|
||||
* WAS previously unpinned by any test, is global-context-resolving EM vs a
|
||||
* manually forked one: a fork defaults to `useContext: false`, so its
|
||||
* `getContext()` returns itself and ignores `TransactionContext` — a
|
||||
* repository built on a fork used inside `uow.transactional(...)` would
|
||||
* write outside the open transaction, on a second connection the
|
||||
* transaction is holding.
|
||||
*/
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let uow: UnitOfWork;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
uow = new UnitOfWork(t.em);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
describe('UnitOfWork transaction resolution (D5)', () => {
|
||||
it('UOW-D5-001: a repository built on the global, context-resolving EM resolves the SAME EntityManager transactional() opens — a manual fork() does not', async () => {
|
||||
createUser(testDb);
|
||||
let globalMatches: boolean | undefined;
|
||||
let forkMatches: boolean | undefined;
|
||||
|
||||
await uow.transactional(async () => {
|
||||
const repo = t.repo(Users) as UsersRepository;
|
||||
globalMatches = repo.getEntityManager().getContext() === TransactionContext.getEntityManager();
|
||||
|
||||
const forkedRepo = t.orm.em.fork().getRepository(Users) as UsersRepository;
|
||||
forkMatches = forkedRepo.getEntityManager().getContext() === TransactionContext.getEntityManager();
|
||||
});
|
||||
|
||||
expect(globalMatches).toBe(true);
|
||||
expect(forkMatches).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,11 @@ function makeJob(intervalSetting?: string, enabled = true) {
|
||||
return enabled;
|
||||
}),
|
||||
unregister: vi.fn(),
|
||||
// task-6-fix-brief.md item 7: the boot-time interval read now runs
|
||||
// through CronRegistrarService.runOnBoot instead of directly inline —
|
||||
// this double just runs fn immediately, reproducing the pre-fix
|
||||
// behaviour exactly, so every existing assertion below is unaffected.
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
// Shaped like the real `get<T>(sql, ...params)` rather than a bare `vi.fn()`
|
||||
// so the stub cannot quietly drift from the signature the job calls; the
|
||||
@@ -153,6 +158,17 @@ describe('DawarichSyncJob bootstrap', () => {
|
||||
expect(registrar.register).toHaveBeenCalledTimes(1);
|
||||
expect(registrar.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DAWARICH-JOB-013: the boot-time interval read goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point), and registers at the default cadence if it declines to run fn', async () => {
|
||||
const { job, registrar, db } = makeJob('30');
|
||||
registrar.runOnBoot.mockImplementationOnce(async () => { /* simulates no ORM available — fn never runs */ });
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('dawarich-sync-boot', expect.any(Function));
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
// minutes keeps its 15-default (the interval read never ran) and the job
|
||||
// still registers, rather than never scheduling at all.
|
||||
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/15 * * * *', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DawarichSyncJob tick', () => {
|
||||
|
||||
@@ -82,6 +82,11 @@ function makeJob(over: Partial<Setup> = {}) {
|
||||
return setup.registrarEnabled;
|
||||
}),
|
||||
unregister: vi.fn(),
|
||||
// task-6-fix-brief.md item 7: the boot-time banner read now runs through
|
||||
// CronRegistrarService.runOnBoot instead of directly inline — this
|
||||
// double just runs fn immediately, reproducing the pre-fix behaviour
|
||||
// exactly, so every existing assertion below is unaffected.
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
|
||||
// Both stubs carry the real signatures, so a case can read back which link a
|
||||
@@ -150,6 +155,12 @@ describe('DocSyncJob bootstrap', () => {
|
||||
await job.onApplicationBootstrap();
|
||||
expect(addons.isAddonEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the boot-time interval banner goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point)', async () => {
|
||||
const { job, registrar } = makeJob({ interval: '90' });
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('docsync-boot', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocSyncJob tick', () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* JourneyThumbsJob boot sweep (task-6-review-parity.md C1 — this is the
|
||||
* exact regression the smoke boot reproduced: `onApplicationBootstrap` used
|
||||
* to call `void this.sweep()` directly, bypassing CronRegistrarService's
|
||||
* request-context wrapper entirely, so once `sweepOrphanThumbs` went
|
||||
* repository-backed (`AddonsService.isAddonEnabled`), the boot-time call
|
||||
* threw `cannotUseGlobalContext` — silently, swallowed by the job's own
|
||||
* try/catch — on every production boot).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { JourneyThumbsJob } from '../../../../src/nest/memories/journey-thumbs.job';
|
||||
import type { ThumbnailService } from '../../../../src/nest/memories/thumbnail.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { Addons } from '../../../../src/db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Global context disallowed — the production setting — so a repository
|
||||
// read with no wrapper around it genuinely throws.
|
||||
t = await createTestOrm(testDb, { allowGlobalContext: false });
|
||||
});
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* A `sweepOrphanThumbs` double that reaches a real `AddonsRepository` read —
|
||||
* the same repository-backed dependency `ThumbnailService.sweepOrphanThumbs`
|
||||
* has in production — rather than mocking the property away, so this test
|
||||
* actually exercises the request-context property the real chain depends on.
|
||||
*/
|
||||
function makeThumbnails(): Pick<ThumbnailService, 'sweepOrphanThumbs'> {
|
||||
const addonsRepo = t.repo(Addons) as AddonsRepository;
|
||||
return {
|
||||
sweepOrphanThumbs: async () => {
|
||||
await addonsRepo.isEnabled('journey');
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('JourneyThumbsJob boot sweep', () => {
|
||||
it('JTHUMB-001: onApplicationBootstrap delegates the boot-time sweep through CronRegistrarService.runOnBoot, not a bare void this.sweep()', async () => {
|
||||
const registered: Array<[string, () => void | Promise<void>]> = [];
|
||||
const registrar = {
|
||||
isEnabled: vi.fn(() => true),
|
||||
register: vi.fn(() => true),
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (name: string, fn: () => void | Promise<void>) => { registered.push([name, fn]); await fn(); }),
|
||||
};
|
||||
const job = new JourneyThumbsJob(makeThumbnails() as ThumbnailService, registrar as unknown as CronRegistrarService);
|
||||
job.onApplicationBootstrap();
|
||||
await Promise.resolve(); // flush the fire-and-forget void runOnBoot(...) call
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('journey-thumbs-boot', expect.any(Function));
|
||||
});
|
||||
|
||||
it('JTHUMB-002: with MikroORM wired into the registrar, the boot-time sweep\'s repository read succeeds — no cannotUseGlobalContext', async () => {
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
|
||||
const job = new JourneyThumbsJob(makeThumbnails() as ThumbnailService, registrar);
|
||||
await registrar.runOnBoot('journey-thumbs-boot', () => job.sweep());
|
||||
expect(logMock.logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JTHUMB-003: without MikroORM, the boot-time sweep never runs at all — the failure is logged with runOnBoot\'s own distinct message, never swallowed into "Journey thumbnail cleanup: ..."', async () => {
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
|
||||
let ran = false;
|
||||
const job = new JourneyThumbsJob({ sweepOrphanThumbs: async () => { ran = true; return 0; } } as ThumbnailService, registrar);
|
||||
await registrar.runOnBoot('journey-thumbs-boot', () => job.sweep());
|
||||
expect(ran).toBe(false);
|
||||
expect(logMock.logError).toHaveBeenCalledWith(expect.stringMatching(/runOnBoot: no MikroORM available.*journey-thumbs-boot/));
|
||||
expect(logMock.logError).not.toHaveBeenCalledWith(expect.stringContaining('Journey thumbnail cleanup'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* TrekPhotoCacheJob boot sweep (task-6-review-parity.md C1's "scanned for
|
||||
* siblings" list: sweepExpired is raw SQL today, safe, but the boot-time
|
||||
* one-off call now goes through CronRegistrarService.runOnBoot at the
|
||||
* entrypoint anyway, so it stays safe once this dependency graph goes
|
||||
* repository-backed too).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { TrekPhotoCacheJob } from '../../../../src/nest/memories/trek-photo-cache.job';
|
||||
import type { TrekPhotoCacheService } from '../../../../src/nest/memories/trek-photo-cache.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => { t = await createTestOrm(testDb, { allowGlobalContext: false }); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TrekPhotoCacheJob boot sweep', () => {
|
||||
it('TPCACHE-001: onApplicationBootstrap delegates the boot-time sweep through CronRegistrarService.runOnBoot, not a bare inline IIFE', async () => {
|
||||
const cache = { sweepExpired: vi.fn(async () => {}) } as unknown as TrekPhotoCacheService;
|
||||
const registrar = {
|
||||
isEnabled: vi.fn(() => true),
|
||||
register: vi.fn(() => true),
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
const job = new TrekPhotoCacheJob(cache, registrar as unknown as CronRegistrarService);
|
||||
job.onApplicationBootstrap();
|
||||
await Promise.resolve();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('trek-photo-cache-boot', expect.any(Function));
|
||||
expect(cache.sweepExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('TPCACHE-002: a missing cache dir at boot is still swallowed by the job\'s OWN catch, inside runOnBoot\'s wrapper', async () => {
|
||||
const cache = { sweepExpired: vi.fn(async () => { throw new Error('ENOENT'); }) } as unknown as TrekPhotoCacheService;
|
||||
const registrar = {
|
||||
isEnabled: vi.fn(() => true),
|
||||
register: vi.fn(() => true),
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
const job = new TrekPhotoCacheJob(cache, registrar as unknown as CronRegistrarService);
|
||||
expect(() => job.onApplicationBootstrap()).not.toThrow();
|
||||
await Promise.resolve();
|
||||
expect(logMock.logError).not.toHaveBeenCalled(); // the inline try/catch around sweepExpired() still swallows it
|
||||
});
|
||||
|
||||
it('TPCACHE-003: without MikroORM, the boot-time sweep never runs — logged with runOnBoot\'s own distinct message', async () => {
|
||||
const cache = { sweepExpired: vi.fn(async () => {}) } as unknown as TrekPhotoCacheService;
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
|
||||
await registrar.runOnBoot('trek-photo-cache-boot', async () => {
|
||||
try { await cache.sweepExpired(); } catch { /* cache dir may not exist yet — harmless */ }
|
||||
});
|
||||
expect(cache.sweepExpired).not.toHaveBeenCalled();
|
||||
expect(logMock.logError).toHaveBeenCalledWith(expect.stringMatching(/runOnBoot: no MikroORM available.*trek-photo-cache-boot/));
|
||||
});
|
||||
});
|
||||
@@ -208,7 +208,9 @@ describe('load failures', () => {
|
||||
// before any request/MCP/WS/cron entrypoint can reach this service, so
|
||||
// that race no longer exists — the message-specific swallow is dropped
|
||||
// and this failure now takes the same "log and serve defaults" path as
|
||||
// every other repository error.
|
||||
// every other repository error. (task-6-review-template.md Minor 4: this
|
||||
// case does not itself assert getPermissionsCache() stays null for the
|
||||
// same branch — PERM-SVC-024 below does.)
|
||||
const spy = vi
|
||||
.spyOn(appSettings, 'findByKeyPrefix')
|
||||
.mockRejectedValueOnce(new Error('no such table: app_settings'));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* PlacePhotoCacheJob boot sweep (task-6-review-parity.md C1's "scanned for
|
||||
* siblings" list: sweepOrphans is raw SQL today, safe, but the boot-time
|
||||
* one-off call now goes through CronRegistrarService.runOnBoot at the
|
||||
* entrypoint anyway, so it stays safe once this dependency graph goes
|
||||
* repository-backed too, without a second review having to rediscover it).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { PlacePhotoCacheJob } from '../../../../src/nest/place-photos/place-photo-cache.job';
|
||||
import type { PlacePhotoCacheService } from '../../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => { t = await createTestOrm(testDb, { allowGlobalContext: false }); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('PlacePhotoCacheJob boot sweep', () => {
|
||||
it('PPCACHE-001: onApplicationBootstrap delegates the boot-time sweep through CronRegistrarService.runOnBoot, not a bare void this.sweep()', async () => {
|
||||
const cache = { sweepOrphans: vi.fn(async () => 0) } as unknown as PlacePhotoCacheService;
|
||||
const registrar = {
|
||||
isEnabled: vi.fn(() => true),
|
||||
register: vi.fn(() => true),
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
const job = new PlacePhotoCacheJob(cache, registrar as unknown as CronRegistrarService);
|
||||
job.onApplicationBootstrap();
|
||||
await Promise.resolve();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('place-photo-cache-boot', expect.any(Function));
|
||||
});
|
||||
|
||||
it('PPCACHE-002: with MikroORM wired into the registrar, the boot-time sweep runs and completes', async () => {
|
||||
const cache = { sweepOrphans: vi.fn(async () => 3) } as unknown as PlacePhotoCacheService;
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
|
||||
const job = new PlacePhotoCacheJob(cache, registrar);
|
||||
await registrar.runOnBoot('place-photo-cache-boot', () => job.sweep());
|
||||
expect(cache.sweepOrphans).toHaveBeenCalledTimes(1);
|
||||
expect(logMock.logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PPCACHE-003: without MikroORM, the boot-time sweep never runs — logged with runOnBoot\'s own distinct message, not swallowed into "Place-photo cache cleanup: ..."', async () => {
|
||||
const cache = { sweepOrphans: vi.fn(async () => 0) } as unknown as PlacePhotoCacheService;
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
|
||||
const job = new PlacePhotoCacheJob(cache, registrar);
|
||||
await registrar.runOnBoot('place-photo-cache-boot', () => job.sweep());
|
||||
expect(cache.sweepOrphans).not.toHaveBeenCalled();
|
||||
expect(logMock.logError).toHaveBeenCalledWith(expect.stringMatching(/runOnBoot: no MikroORM available.*place-photo-cache-boot/));
|
||||
expect(logMock.logError).not.toHaveBeenCalledWith(expect.stringContaining('Place-photo cache cleanup'));
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,11 @@ function makeJobs(overrides: { notifications?: NotificationsService } = {}) {
|
||||
return true;
|
||||
}),
|
||||
unregister: vi.fn(),
|
||||
// task-6-fix-brief.md item 7: the boot-time banner block now runs through
|
||||
// CronRegistrarService.runOnBoot instead of directly inline — this
|
||||
// double just runs fn immediately, reproducing the pre-fix behaviour
|
||||
// exactly, so every existing assertion below is unaffected.
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = new ReminderJobsService(
|
||||
@@ -105,6 +110,12 @@ describe('ReminderJobsService bootstrap', () => {
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RJOB-002b — the boot banner block goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point)', async () => {
|
||||
const { svc, registrar } = makeJobs();
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('reminder-jobs-boot', expect.any(Function));
|
||||
});
|
||||
|
||||
it('RJOB-003 — logs the enabled banners by default and the disabled ones when toggled off', async () => {
|
||||
const { svc } = makeJobs();
|
||||
await svc.onApplicationBootstrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
/**
|
||||
* TrekWsAdapter: the wire protocol and the per-socket flood guard.
|
||||
@@ -35,11 +35,28 @@ import { TrekWsAdapter } from '../../../src/nest/realtime/trek-ws.adapter';
|
||||
import { getServer } from '../../../src/nest/realtime/ws-state';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm } from '../../helpers/test-orm';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
// task-6-fix-brief.md item 1: bindMessageHandlers now THROWS without an `orm`
|
||||
// rather than dispatching a matched handler unwrapped (no more silent
|
||||
// degrade), so every generic-behaviour test below — none of which cares
|
||||
// about the D6 request-context property itself, only that a message reaches
|
||||
// its handler — needs the shared adapter to carry a real (if otherwise
|
||||
// unexercised) ORM. WSAD-040/041 build their OWN local adapter instances
|
||||
// specifically to test the with/without-orm property and are unaffected.
|
||||
let adapterOrm: TestOrm;
|
||||
let adapter: TrekWsAdapter;
|
||||
beforeAll(async () => {
|
||||
adapterOrm = await createTestOrm(testDb);
|
||||
adapter = new TrekWsAdapter({} as HttpServer, adapterOrm.orm);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await adapterOrm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
type MessageListener = (buffer: Buffer) => void;
|
||||
|
||||
@@ -61,7 +78,6 @@ function fakeSocket() {
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = new TrekWsAdapter({} as HttpServer);
|
||||
const frame = (o: unknown) => Buffer.from(JSON.stringify(o));
|
||||
/** The adapter hands results to Nest's transform; here it is identity. */
|
||||
const transform = (v: unknown) => ({ subscribe: (o: { next: (x: unknown) => void }) => o.next(v) }) as never;
|
||||
@@ -126,31 +142,19 @@ describe('TrekWsAdapter D6 request context (task-2-review.md C3 ruling)', () =>
|
||||
// shape: dispatched from bindMessageHandlers below, not from an Express
|
||||
// request, so nothing has forked an EntityManager for it unless the ONE
|
||||
// wrapper there (not per-handler) does it.
|
||||
it('WSAD-040: without MikroORM passed to the adapter, a repository read inside a handler throws cannotUseGlobalContext', async () => {
|
||||
const t = await createTestOrm(testDb, { allowGlobalContext: false });
|
||||
try {
|
||||
let caught: unknown;
|
||||
const ad = new TrekWsAdapter({} as HttpServer); // no orm
|
||||
const socket = fakeSocket();
|
||||
let captured: Promise<unknown> | undefined;
|
||||
const capture = (v: unknown) => {
|
||||
captured = v instanceof Promise ? v : Promise.resolve(v);
|
||||
return { subscribe: () => {} } as never;
|
||||
};
|
||||
ad.bindMessageHandlers(
|
||||
socket as never,
|
||||
[{ message: 'join', callback: async () => {
|
||||
try { await t.orm.em.find(Users, {}); } catch (e) { caught = e; }
|
||||
} }] as never,
|
||||
capture,
|
||||
);
|
||||
socket.emit('message', frame({ type: 'join', tripId: 1 }));
|
||||
await captured;
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect(String((caught as Error).message)).toMatch(/global (EntityManager|context)/i);
|
||||
} finally {
|
||||
await t.close();
|
||||
}
|
||||
it('WSAD-040: without MikroORM passed to the adapter, dispatch THROWS rather than running the handler unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point, not just downstream)', () => {
|
||||
const ad = new TrekWsAdapter({} as HttpServer); // no orm
|
||||
const socket = fakeSocket();
|
||||
let handlerRan = false;
|
||||
ad.bindMessageHandlers(
|
||||
socket as never,
|
||||
[{ message: 'join', callback: async () => { handlerRan = true; } }] as never,
|
||||
() => ({ subscribe: () => {} }) as never,
|
||||
);
|
||||
expect(() => socket.emit('message', frame({ type: 'join', tripId: 1 }))).toThrow(/no MikroORM available/i);
|
||||
// The wrapper throws BEFORE calling the handler at all — the handler's
|
||||
// own repository read never even ran unwrapped.
|
||||
expect(handlerRan).toBe(false);
|
||||
});
|
||||
|
||||
it('WSAD-041: with MikroORM passed to the adapter, the SAME repository read inside a handler succeeds — the wrapper is load-bearing', async () => {
|
||||
|
||||
@@ -12,10 +12,16 @@
|
||||
* from its 'everybody' default to 'admin', a role-'user' actor's check must
|
||||
* come back `false`. Before the fix (dispatch outside any context, and
|
||||
* loadPermissions serving defaults on any error) it came back `true` — a
|
||||
* fail-open security regression. After BOTH this round's fixes — the wrapper
|
||||
* here, and `loadPermissions` rethrowing a MikroORM `ValidationError` instead
|
||||
* of degrading (permissions.service.ts) — an unwrapped dispatch fails CLOSED
|
||||
* (a HOST_ERROR envelope), never silently `true`.
|
||||
* fail-open security regression. Task 2's round fixed it two switches away
|
||||
* from the choke point — the wrapper here, and `loadPermissions` rethrowing a
|
||||
* MikroORM `ValidationError` instead of degrading (permissions.service.ts) —
|
||||
* so an unwrapped dispatch failed CLOSED (a HOST_ERROR envelope) but only
|
||||
* because BOTH switches held. task-6-fix-brief.md item 1 (task-6-review-
|
||||
* template.md's Important 1 / M-B) moves the fail-closed decision TO the
|
||||
* choke point itself: an absent `resolveOrm` now makes the dispatch THROW
|
||||
* before it ever reaches `rpcHost.dispatch`, so the child gets no response at
|
||||
* all — not even the old HOST_ERROR envelope — and the security property no
|
||||
* longer depends on `loadPermissions`' rethrow holding too.
|
||||
*
|
||||
* Global context is disallowed on purpose (`{ allowGlobalContext: false }`),
|
||||
* like `tests/unit/nest/database/request-context.test.ts` — the production
|
||||
@@ -106,16 +112,16 @@ function makeSupervisor(resolveOrm?: () => { em: EntityManager } | undefined) {
|
||||
}
|
||||
|
||||
describe('PluginSupervisor request context (D6, C3)', () => {
|
||||
it('CTX-PLUGIN-001: unwrapped, the RPC path fails CLOSED on a cold cache — never the old fail-open `true`', async () => {
|
||||
it('CTX-PLUGIN-001: without a resolveOrm thunk, the dispatch THROWS rather than running unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point, not just downstream)', async () => {
|
||||
permissions.invalidatePermissionsCache();
|
||||
const { supervisor, sup, sent } = makeSupervisor(); // no resolveOrm — the bug's exact reproduction
|
||||
|
||||
await supervisor.onMessage(sup, { k: 'req', id: 'r1', method: 'trips.create', params: { _inv: 'inv-1' } });
|
||||
|
||||
expect(sent).toHaveLength(1);
|
||||
const res = sent[0];
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok === false) expect(res.error.message).toMatch(/global (EntityManager|context)/i);
|
||||
await expect(
|
||||
supervisor.onMessage(sup, { k: 'req', id: 'r1', method: 'trips.create', params: { _inv: 'inv-1' } }),
|
||||
).rejects.toThrow(/no ORM available/i);
|
||||
// The dispatch never ran, so the child never got a response at all —
|
||||
// not even the old fail-closed HOST_ERROR envelope.
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('CTX-PLUGIN-002: wrapped in withRequestContext, the same dispatch enforces the stored override — a "user" role is refused', async () => {
|
||||
|
||||
@@ -4,14 +4,25 @@
|
||||
* an admin depends on: re-activating a plugin that died stays possible, and a
|
||||
* crash-restart cycle doesn't leak the dead child's cron tasks.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
import { RpcRateLimiter, TokenBucket } from '../../../src/nest/plugins/host/rate-limit';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
// task-6-fix-brief.md item 1: onMessage's 'req' branch now THROWS without a
|
||||
// resolveOrm thunk (no more silent unwrapped dispatch), so the one case below
|
||||
// that dispatches a real 'req' message ("refuses a throttled call...") needs a
|
||||
// real ORM even though nothing it exercises reaches a repository.
|
||||
const ormDb = new Database(':memory:');
|
||||
let ormHandle: TestOrm;
|
||||
beforeAll(async () => { ormHandle = await createTestOrm(ormDb); });
|
||||
afterAll(async () => { await ormHandle.close(); ormDb.close(); });
|
||||
|
||||
function makeSupervisor() {
|
||||
const dispose = vi.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = new PluginSupervisor((() => ({ dispose })) as any, {}, {});
|
||||
const s = new PluginSupervisor((() => ({ dispose })) as any, {}, {}, () => ormHandle.orm);
|
||||
const spawn = vi.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(s as any).spawn = spawn;
|
||||
|
||||
Reference in New Issue
Block a user