diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts index bf3a9de67..578bdf023 100644 --- a/server/tests/integration/collab.test.ts +++ b/server/tests/integration/collab.test.ts @@ -74,6 +74,10 @@ beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); + // Warm the notificationService module: notifyCollab does a fire-and-forget + // dynamic import of it, and a cold load can otherwise race the worker + // teardown ("Cannot load ... after the environment was torn down"). + await import('../../src/services/notificationService'); }); beforeEach(() => { diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts index c637aaa8e..a3cf3a9df 100644 --- a/server/tests/integration/packing.test.ts +++ b/server/tests/integration/packing.test.ts @@ -58,6 +58,10 @@ beforeAll(async () => { runMigrations(testDb); nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + // Warm the notificationService module: notifyTagged does a fire-and-forget + // dynamic import of it, and a cold load can otherwise race the worker + // teardown ("Cannot load ... after the environment was torn down"). + await import('../../src/services/notificationService'); }); beforeEach(() => { diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index 0535e2eb1..17d24c3c0 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -67,6 +67,10 @@ beforeAll(async () => { runMigrations(testDb); nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + // Warm the notificationService module: notifyInvite does a fire-and-forget + // dynamic import of it, and a cold load can otherwise race the worker + // teardown ("Cannot load ... after the environment was torn down"). + await import('../../src/services/notificationService'); }); beforeEach(() => { resetTestDb(testDb); diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index 8e8167472..42fde5593 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -75,6 +75,10 @@ beforeAll(async () => { runMigrations(testDb); nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + // Warm the notificationService module: sendInvite/shareCalendar do a + // fire-and-forget dynamic import of it, and a cold load can otherwise race + // the worker teardown ("Cannot load ... after the environment was torn down"). + await import('../../src/services/notificationService'); }); beforeEach(() => { diff --git a/server/tests/setup.ts b/server/tests/setup.ts index a6ecb75d4..e07dc1212 100644 --- a/server/tests/setup.ts +++ b/server/tests/setup.ts @@ -1,9 +1,25 @@ // Global test setup — runs before every test file. // Environment variables must be set before any module import so that -// config.ts, database.ts, etc. pick them up at import time. +// config.ts, database.ts, etc. pick them up at import time. (Importing from +// 'vitest' itself is safe: it is externalized and pulls in no app modules.) +import { afterEach } from 'vitest'; // Fixed encryption key (64 hex chars = 32 bytes) for at-rest crypto in tests process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2'; process.env.NODE_ENV = 'test'; process.env.COOKIE_SECURE = 'false'; process.env.LOG_LEVEL = 'error'; // suppress info/debug logs in test output + +// Several services fire notification sends as unawaited dynamic-import chains +// (`import('…/notificationService').then(({ send }) => send(…).catch(…))`). +// Give those chains one macrotask turn to settle after every test, while the +// suite's DB and the worker environment are still alive — otherwise the last +// test in a file can leave the chain pending into worker teardown ("Cannot +// load ... after the environment was torn down"). Under the default "stack" +// hook order this afterEach runs after each test's own afterEach and before +// the suite's afterAll, i.e. before any afterAll closes its test DB. +// setImmediate is captured up front because some suites install fake timers. +const realSetImmediate = globalThis.setImmediate; +afterEach(async () => { + await new Promise((resolve) => realSetImmediate(resolve)); +}); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index d10a8d34f..f8b4ac23d 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; const { dbMock } = vi.hoisted(() => { const stmt = { get: vi.fn(() => ({ id: 42 })), all: vi.fn(() => []), run: vi.fn() }; @@ -12,6 +12,8 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast })); +// notifyInvite fires a notification via a dynamic import — keep it out of unit scope +vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); const checkPermission = vi.fn(() => true); const permissionsStub = { checkPermission } as unknown as PermissionsService; @@ -48,6 +50,12 @@ const reservationsStub = { list: () => [] } as unknown as ReservationsService; const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService; function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub); } beforeEach(() => vi.clearAllMocks()); +beforeAll(async () => { + // Warm the (mocked) notificationService module: notifyInvite does a + // fire-and-forget dynamic import of it, and a cold load can otherwise race + // the worker teardown ("Cannot load ... after the environment was torn down"). + await import('../../../src/services/notificationService'); +}); describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => { it('delegates the simple wrappers to tripService', async () => {