diff --git a/server/tests/unit/nest/app-config.test.ts b/server/tests/unit/nest/app-config.test.ts index d97bacacc..dddac7048 100644 --- a/server/tests/unit/nest/app-config.test.ts +++ b/server/tests/unit/nest/app-config.test.ts @@ -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; + + 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; diff --git a/server/tests/unit/nest/audit-log.logger.test.ts b/server/tests/unit/nest/audit-log.logger.test.ts index 42bbd12bb..ba59fdaa8 100644 --- a/server/tests/unit/nest/audit-log.logger.test.ts +++ b/server/tests/unit/nest/audit-log.logger.test.ts @@ -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([]); + }); }); diff --git a/server/tests/unit/nest/cron-registrar.service.test.ts b/server/tests/unit/nest/cron-registrar.service.test.ts index b1d2c37f4..5be8a4c40 100644 --- a/server/tests/unit/nest/cron-registrar.service.test.ts +++ b/server/tests/unit/nest/cron-registrar.service.test.ts @@ -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((resolve) => { + started = resolve; + }); + let completed = false; + registrar.register(name, '* * * * *', async () => { + started(); + await new Promise((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)', () => { diff --git a/server/tests/unit/nest/entry-trace.logger.test.ts b/server/tests/unit/nest/entry-trace.logger.test.ts index fba12a0d9..947cc32c4 100644 --- a/server/tests/unit/nest/entry-trace.logger.test.ts +++ b/server/tests/unit/nest/entry-trace.logger.test.ts @@ -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); + }); }); diff --git a/server/tests/unit/nest/log-file.test.ts b/server/tests/unit/nest/log-file.test.ts index 1361e12f2..99220a5e2 100644 --- a/server/tests/unit/nest/log-file.test.ts +++ b/server/tests/unit/nest/log-file.test.ts @@ -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'); + }); }); diff --git a/server/tests/unit/nest/permissions.service.test.ts b/server/tests/unit/nest/permissions.service.test.ts index 41ceffc33..ff8665f61 100644 --- a/server/tests/unit/nest/permissions.service.test.ts +++ b/server/tests/unit/nest/permissions.service.test.ts @@ -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; diff --git a/server/tests/unit/nest/public-api.service.test.ts b/server/tests/unit/nest/public-api.service.test.ts new file mode 100644 index 000000000..5d0818324 --- /dev/null +++ b/server/tests/unit/nest/public-api.service.test.ts @@ -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 }, + ]); + }); +}); diff --git a/server/tests/unit/nest/realtime.service.test.ts b/server/tests/unit/nest/realtime.service.test.ts index e08b615c1..a81702e76 100644 --- a/server/tests/unit/nest/realtime.service.test.ts +++ b/server/tests/unit/nest/realtime.service.test.ts @@ -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'); + }); }); diff --git a/server/tests/unit/nest/trek-ws-adapter.test.ts b/server/tests/unit/nest/trek-ws-adapter.test.ts index 1e8e18cf1..6806be220 100644 --- a/server/tests/unit/nest/trek-ws-adapter.test.ts +++ b/server/tests/unit/nest/trek-ws-adapter.test.ts @@ -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; 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 = [];