test(server): cover the lease failures, sink errors and store passthroughs

The lease heartbeat and settle failures, the log sink and rotation
fallbacks, the runtime env getters, the public API summary edges, the
admin-only permission level and the WebSocket handler rejection had no
spec, which took six domains under their coverage floor.
This commit is contained in:
Maurice
2026-10-08 21:15:17 +02:00
parent 2b218a5528
commit 8bd831238d
9 changed files with 475 additions and 9 deletions
+61
View File
@@ -8,6 +8,7 @@ import { AppConfigModule } from '../../../src/nest/app-config/app-config.module'
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { httpConfig, storageConfig, BOOT_STABLE_TOKENS } from '../../../src/nest/app-config/tokens';
import { DataPathsService } from '../../../src/nest/app-config/data-paths.service';
import { imageVersion } from '../../../src/app-config/image-version';
describe('RuntimeEnvService', () => {
const service = new RuntimeEnvService();
@@ -37,6 +38,66 @@ describe('RuntimeEnvService', () => {
});
});
describe('RuntimeEnvService getters (live per call)', () => {
const service = new RuntimeEnvService();
const KEYS = ['NODE_ENV', 'TREK_MANAGED', 'APP_VERSION', 'APP_URL'] as const;
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = Object.fromEntries(KEYS.map((key) => [key, process.env[key]]));
});
afterEach(() => {
for (const key of KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
it('APPCFG-RT-001: isManaged is on only for an explicit true, and follows a change without a rebuild', () => {
process.env.TREK_MANAGED = 'true';
expect(service.isManaged()).toBe(true);
process.env.TREK_MANAGED = 'maybe';
expect(service.isManaged()).toBe(false);
delete process.env.TREK_MANAGED;
expect(service.isManaged()).toBe(false);
});
it('APPCFG-RT-002: isTest matches NODE_ENV=test case-sensitively, while production and development ignore case', () => {
process.env.NODE_ENV = 'TEST';
expect(service.isTest()).toBe(false);
expect(service.nodeEnv()).toBe('TEST');
process.env.NODE_ENV = 'Production';
expect(service.isProduction()).toBe(true);
expect(service.isDevelopment()).toBe(false);
process.env.NODE_ENV = 'DEVELOPMENT';
expect(service.isDevelopment()).toBe(true);
expect(service.isProduction()).toBe(false);
process.env.NODE_ENV = 'test';
expect(service.isTest()).toBe(true);
});
it('APPCFG-RT-003: appUrl is handed out raw, trailing slash and all', () => {
process.env.APP_URL = 'https://trip.example.org/';
expect(service.appUrl()).toBe('https://trip.example.org/');
delete process.env.APP_URL;
expect(service.appUrl()).toBeUndefined();
});
// A checkout has no image VERSION file; inside a built image the file would
// win over APP_VERSION (derive.ts), so this case only means something here.
it.skipIf(imageVersion() !== null)('APPCFG-RT-004: appVersion reads APP_VERSION live when the image ships no VERSION file', () => {
process.env.APP_VERSION = '4.4.0';
expect(service.appVersion()).toBe('4.4.0');
process.env.APP_VERSION = '4.4.1';
expect(service.appVersion()).toBe('4.4.1');
delete process.env.APP_VERSION;
expect(service.appVersion()).toBeUndefined();
});
});
describe('boot-stable tokens', () => {
it('factories re-derive from the current env on each invocation', () => {
const prev = process.env.HSTS_INCLUDE_SUBDOMAINS;
@@ -1,6 +1,6 @@
/**
* Unit tests for nest/audit/audit-log.logger: AUDIT-LOG-001 through
* AUDIT-LOG-007. The logger ranks error < warn < info < debug against the
* AUDIT-LOG-009. The logger ranks error < warn < info < debug against the
* import-frozen LOG_LEVEL, prints every line to the console at once and hands
* the same line, without colour codes, to the buffered file sink. The sink is
* replaced here by a recorder; its own disk behaviour (batching, rotation,
@@ -10,14 +10,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const sink = vi.hoisted(() => ({
lines: [] as string[],
options: [] as Array<{ dir: string; file?: string; maxBytes?: number; maxFiles?: number }>,
options: [] as Array<{ dir: string; file?: string; maxBytes?: number; maxFiles?: number; onError: (message: string) => void }>,
flush: vi.fn(async () => {}),
flushSync: vi.fn(),
}));
vi.mock('../../../src/nest/audit/log-file', () => ({
BufferedLogFile: class {
constructor(options: { dir: string; file?: string; maxBytes?: number; maxFiles?: number }) {
constructor(options: { dir: string; file?: string; maxBytes?: number; maxFiles?: number; onError: (message: string) => void }) {
sink.options.push(options);
}
write(line: string): void {
@@ -118,6 +118,21 @@ describe('severity threshold (frozen at import)', () => {
}
});
it('AUDIT-LOG-008: an unknown LOG_LEVEL falls back to info rather than silencing or flooding the log', async () => {
const fresh = await freshLogger('verbose');
try {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
fresh.logInfo('kept');
fresh.logDebug('dropped');
expect(log).toHaveBeenCalledTimes(1);
expect(sink.lines).toHaveLength(1);
expect(sink.lines[0]).toMatch(/^\[INFO\] .*kept$/);
} finally {
vi.unstubAllEnvs();
vi.resetModules();
}
});
it('AUDIT-LOG-004: the freeze happens at first import, so LOG_LEVEL reflects the env then', async () => {
const fresh = await freshLogger('debug');
try {
@@ -153,4 +168,12 @@ describe('the file sink', () => {
expect(sink.flush).toHaveBeenCalledTimes(1);
expect(sink.flushSync).toHaveBeenCalledTimes(1);
});
it('AUDIT-LOG-009: a failing sink reports to the console with a [logger] prefix, never back into the file', () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
sink.options[0]!.onError('log file write failed: disk full');
expect(err).toHaveBeenCalledWith('[logger] log file write failed: disk full');
// Writing the failure into the failing file would only queue more of the same.
expect(sink.lines).toEqual([]);
});
});
@@ -387,6 +387,92 @@ describe('CronRegistrarService', () => {
finish();
await tick;
});
it('CRONREG-022: a failed heartbeat is logged and the tick keeps running to completion', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
logErrorMock.mockClear();
const name = 'lease-heartbeat-fails';
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
let finish!: () => void;
let started!: () => void;
const running = new Promise<void>((resolve) => {
started = resolve;
});
let completed = false;
registrar.register(name, '* * * * *', async () => {
started();
await new Promise<void>((resolve) => {
finish = resolve;
});
completed = true;
});
const extend = vi.spyOn(SchedulerLeasesRepository.prototype, 'extend').mockRejectedValueOnce(new Error('database is locked'));
try {
const tick = h.jobs[0].onTick();
await running;
vi.advanceTimersByTime(LEASE_HEARTBEAT_MS);
await vi.waitFor(() =>
expect(logErrorMock).toHaveBeenCalledWith(`Cron job "${name}": lease update failed: database is locked`),
);
finish();
await tick;
expect(completed).toBe(true);
// The settle extend after the tick still went through.
expect((await holder(name))!.owner).toBe(LEASE_OWNER);
} finally {
extend.mockRestore();
}
});
it('CRONREG-023: a failed settle extend is logged but never replaces the tick outcome', async () => {
logErrorMock.mockClear();
const name = 'lease-settle-fails';
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
let ran = false;
registrar.register(name, '* * * * *', () => {
ran = true;
});
const extend = vi.spyOn(SchedulerLeasesRepository.prototype, 'extend').mockRejectedValueOnce('busy');
try {
await expect(h.jobs[0].onTick()).resolves.toBeUndefined();
expect(ran).toBe(true);
// A non-Error rejection is stringified rather than dropped.
expect(logErrorMock).toHaveBeenCalledWith(`Cron job "${name}": lease update failed: busy`);
expect(logErrorMock).not.toHaveBeenCalledWith(expect.stringContaining(`Cron job "${name}" failed`));
} finally {
extend.mockRestore();
}
});
it('CRONREG-024: a tick that throws keeps its own error when the settle extend also fails', async () => {
logErrorMock.mockClear();
const name = 'lease-settle-fails-after-throw';
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
const boom = new Error('backup target unreachable');
registrar.register(name, '* * * * *', () => {
throw boom;
});
const extend = vi.spyOn(SchedulerLeasesRepository.prototype, 'extend').mockRejectedValueOnce(new Error('disk I/O error'));
try {
await expect(h.jobs[0].onTick()).rejects.toBe(boom);
await vi.waitFor(() =>
expect(logErrorMock).toHaveBeenCalledWith(`Cron job "${name}" failed: backup target unreachable`),
);
expect(logErrorMock).toHaveBeenCalledWith(`Cron job "${name}": lease update failed: disk I/O error`);
} finally {
extend.mockRestore();
}
});
it('CRONREG-025: a non-Error failure that reaches the cron error handler is logged with its string form', () => {
logErrorMock.mockClear();
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
registrar.register('lease-string-failure', '* * * * *', () => undefined);
// A primitive can never be marked as traced, so the handler always logs it.
h.jobs[0].errorHandler!('quota exceeded');
expect(logErrorMock).toHaveBeenCalledTimes(1);
expect(logErrorMock).toHaveBeenCalledWith('Cron job "lease-string-failure" failed: quota exceeded');
});
});
describe('runOnBoot (task-6-fix-brief.md item 7 — the boot-sweep choke point)', () => {
@@ -1,7 +1,7 @@
/**
* traceEntry (nest/audit/entry-trace.logger.ts): one correlation id and one
* log line per call of a non-HTTP entry point, without changing what the call
* returns or when it runs. TRACE-001 through TRACE-009.
* returns or when it runs. TRACE-001 through TRACE-010.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -112,4 +112,14 @@ describe('traceEntry', () => {
expect(log.logDebug.mock.calls[1][0]).toMatch(/^cron auto-backup ok \d+ms$/);
expect(log.logWarn).not.toHaveBeenCalled();
});
it('TRACE-010: a rejection with a primitive is logged by its string form and cannot be marked as reported', async () => {
const call = Promise.reject(404);
const traced = traceEntry('rpc', 'plugin demo trips.get', () => call);
await expect(traced).rejects.toBe(404);
await vi.waitFor(() => expect(log.logWarn).toHaveBeenCalledTimes(1));
expect(log.logWarn.mock.calls[0][0]).toMatch(/^rpc plugin demo trips\.get failed \d+ms: 404$/);
// An outer handler cannot tell it was logged, so it may log it once more;
// that is the price of a primitive, and the line it writes is still there.
expect(wasTraced(404)).toBe(false);
});
});
+86 -1
View File
@@ -1,6 +1,6 @@
/**
* BufferedLogFile (nest/audit/log-file.ts), the file half of the server log,
* against a real temporary directory: LOGFILE-001 through LOGFILE-009.
* against a real temporary directory: LOGFILE-001 through LOGFILE-015.
*
* What it promises: a write never touches the disk on the caller's turn,
* queued lines reach the file in order through one append per batch, the file
@@ -152,4 +152,89 @@ describe('BufferedLogFile', () => {
expect(lines.some((line) => line.includes('1 log line(s) dropped'))).toBe(true);
expect(lines.at(-1)).toBe('d');
});
it('LOGFILE-011: a rotation that cannot rename is reported, and the batch still lands in the live file', async () => {
const dir = tmpDir();
const file = path.join(dir, 'trek.log');
fs.writeFileSync(file, 'x'.repeat(20));
const onError = vi.fn();
const sink = new BufferedLogFile({ dir, maxBytes: 10, onError });
vi.spyOn(fs.promises, 'rename').mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
sink.write('kept');
await sink.flush();
expect(onError).toHaveBeenCalledWith('log rotation failed: permission denied');
expect(onError).toHaveBeenCalledTimes(1);
expect(read(file)).toBe(`${'x'.repeat(20)}kept\n`);
expect(fs.existsSync(`${file}.1`)).toBe(false);
});
it('LOGFILE-012: when the size cannot be read again after a failed rotation, appending still goes on', async () => {
const dir = tmpDir();
const file = path.join(dir, 'trek.log');
fs.writeFileSync(file, 'x'.repeat(20));
const onError = vi.fn();
const sink = new BufferedLogFile({ dir, maxBytes: 10, onError });
const realStat = fs.promises.stat.bind(fs.promises);
let stats = 0;
// The first two reads (before the append and inside the rotation) see the
// full file; the read after the failed rename fails as well.
vi.spyOn(fs.promises, 'stat').mockImplementation(((target: fs.PathLike) => {
stats++;
if (stats > 2) return Promise.reject(Object.assign(new Error('stale handle'), { code: 'ESTALE' }));
return realStat(target);
}) as typeof fs.promises.stat);
vi.spyOn(fs.promises, 'rename').mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }));
sink.write('first');
await sink.flush();
expect(onError).toHaveBeenCalledWith('log rotation failed: busy');
expect(read(file)).toBe(`${'x'.repeat(20)}first\n`);
// The size fell back to 0, so the next small batch is appended without another rotation attempt.
onError.mockClear();
sink.write('second');
await sink.flush();
expect(onError).not.toHaveBeenCalled();
expect(read(file)).toBe(`${'x'.repeat(20)}first\nsecond\n`);
});
it('LOGFILE-013: a size read that fails for a reason other than a missing file is reported as a failed write', async () => {
const dir = tmpDir();
const onError = vi.fn();
const sink = new BufferedLogFile({ dir, onError });
vi.spyOn(fs.promises, 'stat').mockRejectedValueOnce('io error');
sink.write('lost');
await sink.flush();
// A non-Error rejection is still named, not swallowed.
expect(onError).toHaveBeenCalledWith('log file write failed: io error');
sink.write('kept');
await sink.flush();
expect(read(sink.path)).toBe('kept\n');
});
it('LOGFILE-014: flushSync reports a failing disk instead of throwing out of the exit handler, and a flush with nothing queued writes nothing', async () => {
const dir = tmpDir();
const onError = vi.fn();
const sink = new BufferedLogFile({ dir, onError });
const append = vi.spyOn(fs.promises, 'appendFile');
await sink.flush();
expect(append).not.toHaveBeenCalled();
vi.spyOn(fs, 'appendFileSync').mockImplementation(() => {
throw new Error('read-only file system');
});
sink.write('last words');
expect(() => sink.flushSync()).not.toThrow();
expect(onError).toHaveBeenCalledWith('log file write failed: read-only file system');
});
it('LOGFILE-015: a long line that does not fit is dropped, and the drop is noted where it happened', async () => {
const dir = tmpDir();
const sink = new BufferedLogFile({ dir, maxBufferedBytes: 12, onError: vi.fn() });
sink.write('aaaaa'); // 6 bytes queued
sink.write('a line too long to fit'); // dropped
sink.write('b'); // fits, so the note goes in before it
await sink.flush();
const lines = read(sink.path).trim().split('\n');
expect(lines).toHaveLength(3);
expect(lines[0]).toBe('aaaaa');
expect(lines[1]).toContain('1 log line(s) dropped');
expect(lines[2]).toBe('b');
});
});
@@ -96,6 +96,15 @@ describe('checkPermission — everybody level', () => {
});
});
describe('checkPermission: admin level', () => {
it('PERM-SVC-026: trip_create narrowed to admin refuses every non-admin, the trip owner included', async () => {
await svc.savePermissions({ trip_create: 'admin' });
expect(await svc.checkPermission('trip_create', 'user', 42, 42, false)).toBe(false);
expect(await svc.checkPermission('trip_create', 'user', null, 42, true)).toBe(false);
expect(await svc.checkPermission('trip_create', 'admin', null, 42, false)).toBe(true);
});
});
describe('checkPermission — trip_owner level', () => {
const ownerId = 10;
const memberId = 20;
@@ -0,0 +1,157 @@
/**
* PublicApiService: the shaping of sparse rows and the two early answers.
*
* The e2e suite drives the service against real SQL with well-filled rows. This
* file pins what that cannot reach cheaply: a caller with no trips is answered
* without a second query, a trip that is not readable (or vanished between the
* access check and the read) comes back as null, and every optional column the
* database leaves empty reaches the wire as an explicit `null` rather than a
* missing key, so a consumer can rely on the field being there.
*/
import { describe, it, expect, vi } from 'vitest';
import { PublicApiService } from '../../../src/nest/public-api/public-api.service';
import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { DayNotesRepository } from '../../../src/db/repositories/DayNotes.repository';
import type { BucketListRepository } from '../../../src/db/repositories/BucketList.repository';
/** A trip row as the summary query returns it when nothing optional was filled in. */
const BARE_TRIP = { id: 7, title: 'Somewhere', is_archived: 0 };
function makeService(overrides: {
accessibleIds?: number[];
accessible?: boolean;
summary?: unknown;
days?: unknown[];
assignedPlaces?: unknown[];
dayNotes?: unknown[];
scheduledReservations?: unknown[];
unscheduledReservations?: unknown[];
unplannedPlaces?: unknown[];
accommodations?: unknown[];
bucketList?: unknown[];
} = {}) {
const membership = { listAccessibleTripIds: vi.fn().mockResolvedValue(overrides.accessibleIds ?? []) };
const trips = {
listSummariesByIds: vi.fn().mockResolvedValue([BARE_TRIP]),
findAccessible: vi.fn().mockResolvedValue(overrides.accessible ?? true),
findSummaryById: vi.fn().mockResolvedValue('summary' in overrides ? overrides.summary : BARE_TRIP),
listTravellerUsernames: vi.fn().mockResolvedValue([]),
};
const reservations = {
listScheduledForPublicApi: vi.fn().mockResolvedValue(overrides.scheduledReservations ?? []),
listUnscheduledForPublicApi: vi.fn().mockResolvedValue(overrides.unscheduledReservations ?? []),
listUnplannedPlacesForPublicApi: vi.fn().mockResolvedValue(overrides.unplannedPlaces ?? []),
listAccommodationsForPublicApi: vi.fn().mockResolvedValue(overrides.accommodations ?? []),
};
const days = { listForPublicApi: vi.fn().mockResolvedValue(overrides.days ?? []) };
const places = { listAssignedForPublicApi: vi.fn().mockResolvedValue(overrides.assignedPlaces ?? []) };
const dayNotes = { listForPublicApi: vi.fn().mockResolvedValue(overrides.dayNotes ?? []) };
const bucketList = { listForPublicApi: vi.fn().mockResolvedValue(overrides.bucketList ?? []) };
const service = new PublicApiService(
membership as unknown as TripMembershipService,
trips as unknown as TripsRepository,
reservations as unknown as ReservationsRepository,
days as unknown as DaysRepository,
places as unknown as PlacesRepository,
dayNotes as unknown as DayNotesRepository,
bucketList as unknown as BucketListRepository,
);
return { service, membership, trips, reservations };
}
describe('PublicApiService', () => {
it('PUBAPI-SVC-001: a caller with no trips gets an empty list without a summary query', async () => {
const { service, trips } = makeService({ accessibleIds: [] });
await expect(service.listTrips(1)).resolves.toEqual([]);
expect(trips.listSummariesByIds).not.toHaveBeenCalled();
});
it('PUBAPI-SVC-002: a trip the caller may not read is null, and nothing else is queried', async () => {
const { service, trips } = makeService({ accessible: false });
await expect(service.getTrip(7, 1, ['days'])).resolves.toBeNull();
expect(trips.findSummaryById).not.toHaveBeenCalled();
});
it('PUBAPI-SVC-003: a trip deleted between the access check and the read is null, not a crash', async () => {
const { service } = makeService({ summary: null });
await expect(service.getTrip(7, 1, ['days'])).resolves.toBeNull();
});
it('PUBAPI-SVC-004: a bare trip summary carries every optional field as an explicit null', async () => {
const { service } = makeService({ accessibleIds: [7] });
const [trip] = await service.listTrips(1);
expect(trip).toEqual({
id: 7,
title: 'Somewhere',
description: null,
start_date: null,
end_date: null,
currency: null,
archived: false,
updated_at: null,
});
});
it('PUBAPI-SVC-005: sparse places, notes and bookings on a day report null instead of dropping the key', async () => {
const { service } = makeService({
days: [{ id: 70, day_number: 1, date: null }],
assignedPlaces: [{ day_id: 70, name: 'Market' }],
dayNotes: [{ day_id: 70, text: 'Bring cash' }],
scheduledReservations: [{ day_id: 70 }],
});
const trip = await service.getTrip(7, 1, ['days', 'places', 'notes', 'reservations']);
const day = trip!.days![0]!;
expect(day.places).toEqual([
{
name: 'Market',
address: null,
lat: null,
lng: null,
time: null,
end_time: null,
duration_minutes: null,
category: null,
notes: null,
transport_mode: null,
},
]);
expect(day.day_notes).toEqual([{ text: 'Bring cash', time: null }]);
expect(day.reservations).toEqual([
{ type: null, title: null, location: null, time: null, end_time: null, status: null, notes: null },
]);
// Every key is present on the wire, not just equal to null after a lookup.
expect(Object.keys(day.places[0]!)).toHaveLength(10);
expect(Object.keys(day.reservations[0]!)).toHaveLength(7);
});
it('PUBAPI-SVC-006: an accommodation without dates or details is still listed, every field null', async () => {
const { service } = makeService({ accommodations: [{}] });
const trip = await service.getTrip(7, 1, ['accommodations']);
expect(trip!.accommodations).toEqual([
{
name: null,
address: null,
lat: null,
lng: null,
start_date: null,
end_date: null,
check_in: null,
check_out: null,
notes: null,
},
]);
// Accommodations alone do not imply the day spine.
expect(trip!.days).toBeUndefined();
});
it('PUBAPI-SVC-007: a bucket list entry with only a name reports the rest as null', async () => {
const { service } = makeService({ bucketList: [{ name: 'Lofoten' }] });
await expect(service.listBucketList(1)).resolves.toEqual([
{ name: 'Lofoten', lat: null, lng: null, country_code: null, notes: null, target_date: null },
]);
});
});
@@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TrekWsPayload, TrekWsUserEventName } from '@trek/shared';
const { broadcast, broadcastToUser } = vi.hoisted(() => ({
const { broadcast, broadcastToUser, getOnlineUserIds } = vi.hoisted(() => ({
broadcast: vi.fn(),
broadcastToUser: vi.fn(),
getOnlineUserIds: vi.fn(),
}));
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser, getOnlineUserIds }));
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
@@ -63,4 +64,20 @@ describe('RealtimeService', () => {
expect(broadcast).toHaveBeenCalledWith('1', 'todo:created', { item: {} }, undefined);
expect(broadcastToUser).toHaveBeenCalledWith(2, { type: 'notification:new' });
});
it('RTSVC-005: getOnlineUserIds hands back the live set from the websocket module, read at call time', () => {
const online = new Set([3, 8]);
getOnlineUserIds.mockReturnValueOnce(online);
expect(svc.getOnlineUserIds()).toBe(online);
getOnlineUserIds.mockReturnValueOnce(new Set());
expect(svc.getOnlineUserIds().size).toBe(0);
expect(getOnlineUserIds).toHaveBeenCalledTimes(2);
});
it('RTSVC-006: getOnlineUserIds is not guarded, so a failure reaches the caller instead of reading as nobody online', () => {
getOnlineUserIds.mockImplementationOnce(() => {
throw new Error('socket server not ready');
});
expect(() => svc.getOnlineUserIds()).toThrow('socket server not ready');
});
});
+20 -2
View File
@@ -12,11 +12,12 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// The origin allowlist is env-driven and empty in the test environment, so the
// branch that builds verifyClient would never run. Driving readEnv lets both
// sides of it be asserted rather than assumed.
const { wsOrigins, logError } = vi.hoisted(() => ({
const { wsOrigins, logError, logWarn } = vi.hoisted(() => ({
wsOrigins: { value: null as string[] | null },
logError: vi.fn(),
logWarn: vi.fn(),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logError, logInfo: vi.fn(), logDebug: vi.fn(), logWarn: vi.fn() }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logError, logInfo: vi.fn(), logDebug: vi.fn(), logWarn }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
@@ -31,6 +32,7 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
};
});
import { from, of } from 'rxjs';
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';
@@ -188,6 +190,22 @@ describe('TrekWsAdapter D6 request context (task-2-review.md C3 ruling)', () =>
});
describe('TrekWsAdapter correlation', () => {
it('WSAD-055: a handler that rejects is logged once by the trace, answers nothing, and does not escape as an unhandled error', async () => {
logWarn.mockClear();
const socket = fakeSocket();
// Nest's own transform: a promise becomes an observable that errors when it rejects.
const nestTransform = ((v: unknown) => (v instanceof Promise ? from(v) : of(v))) as never;
adapter.bindMessageHandlers(
socket as never,
[{ message: 'join', callback: () => Promise.reject(new Error('trip 9 is gone')) }] as never,
nestTransform,
);
socket.emit('message', frame({ type: 'join', tripId: 9 }));
await vi.waitFor(() => expect(logWarn).toHaveBeenCalledTimes(1));
expect(String(logWarn.mock.calls[0][0])).toMatch(/^ws join failed \d+ms: trip 9 is gone$/);
expect(socket.sent).toEqual([]);
});
it('WSAD-050: every message runs under its own ws correlation, and a plain answer still goes out at once', () => {
const socket = fakeSocket();
const seen: Array<Correlation | undefined> = [];