fix(server): close the notification-send teardown race in tests

This commit is contained in:
jubnl
2026-07-28 22:31:40 +02:00
parent 65fb00d63b
commit c7dc38a5b9
6 changed files with 42 additions and 2 deletions
+4
View File
@@ -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(() => {
+4
View File
@@ -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(() => {
+4
View File
@@ -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);
+4
View File
@@ -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(() => {
+17 -1
View File
@@ -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));
});
+9 -1
View File
@@ -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 () => {