Skip to content

Commit eae9410

Browse files
fix(test): skip per-suite migration re-runs via globalSetup pre-warm (#3549)
* fix(test): skip per-suite migration re-runs via globalSetup pre-warm Run migrations once in jest.globalSetup (before any vm context) and set process.env.DEVKIT_MIGRATIONS_RAN='1'. bootstrap() checks this flag and skips migrations.run() on 2nd+ suite bootstraps. Why process.env set in globalSetup works: jest --experimental-vm-modules resets any process.env key that was set INSIDE a test-file vm context on teardown. Keys present BEFORE the first vm.createContext() call are included in jest's protectProperties() sweep and survive all per-suite GlobalProxy teardowns unchanged. globalSetup runs before any vm context is created — exactly that window. Each test suite still gets a fresh Express app and mongoose connection so per-suite module state (e.g. config.sign.up mutations) stays isolated. Impact: - Migrations runs: 21 → 1 per full test run (18 skips × ~100ms ≈ 1.8s saved) - Heap peak devkit: 1.43 GB (unchanged — jest module-registry accumulation dominates; migrations overhead is timing, not heap) - All 1234 devkit tests green Fixes the bootstrap accumulation root cause for downstream projects with larger test suites (trawl: 89 suites × full migrations scan each = 8.9s and additional file-read heap accumulation under --experimental-vm-modules). * fix(test): address CR/Copilot/Codacy review findings on globalSetup - Scope DEVKIT_MIGRATIONS_RAN skip to NODE_ENV=test in bootstrap() to prevent accidental migration bypass in prod/dev (CodeRabbit major) - Update JSDoc @returns to Promise<{db, app}> (Copilot) - Add explicit Array.isArray guard for config.files.mongooseModels in globalSetup Phase 2 instead of relying on TypeError for control flow - Pass config.db.options to mongoose.connect() in Phase 2 for SSL/auth parity with mongooseService.connect (Copilot) - Use pathToFileURL for ESM dynamic imports (Codacy path-traversal fix) - Tighten Phase 2 reconnect assertion from >=1 to >=2 + assert disconnect called once (CodeRabbit minor + Copilot) - Add files.mongooseModels to mocks so Phase 2 is exercised in tests * test(migrations): cover claim/unclaim/error branches via repo mocks Bootstrap no longer re-runs migrations.run() per suite (DEVKIT_MIGRATIONS_RAN short-circuits it after globalSetup), so the claim path, duplicate-key handling, and unclaim-on-error branches lost coverage. Add unit tests that spy on migrationRepository to exercise: - claimMigration E11000 → returns false (concurrent runner already claimed) - claimMigration non-duplicate error → rethrows - runMigration: import failure → unclaim + rethrow - runMigration: missing up() export → unclaim + rethrow - run(): all migrations already executed branch Restores migrations.js coverage from ~55% back to ~82%.
1 parent 337ed8d commit eae9410

4 files changed

Lines changed: 185 additions & 19 deletions

File tree

lib/app.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,26 @@ const startExpress = async () => {
3939

4040
/**
4141
* Bootstrap the required services
42-
* @returns {Object} db and app instances
42+
*
43+
* In test mode (--runInBand, single process), subsequent calls skip the
44+
* migrations step by checking the `DEVKIT_MIGRATIONS_RAN` environment variable.
45+
* That variable is set by `scripts/jest.globalSetup.js` which runs ONCE before
46+
* any test vm context is created. Keys set on process.env in globalSetup are
47+
* included in jest's protectProperties() sweep and survive per-suite vm context
48+
* teardown unchanged — unlike module-scope vars or globalThis which are reset
49+
* per test file under --experimental-vm-modules.
50+
*
51+
* Migrations are DB-backed and idempotent; skipping the glob scan + DB
52+
* round-trip on 2nd+ suite bootstraps saves ≈ 100 ms × 18 suites ≈ 1.8 s
53+
* and avoids redundant file-read accumulation under --experimental-vm-modules.
54+
*
55+
* Each test suite still gets a fresh Express app and mongoose connection so
56+
* per-suite module state (e.g. config mutations in tests) is correctly isolated.
57+
*
58+
* In production / non-test environments the env flag is never set, so behaviour
59+
* is unchanged.
60+
*
61+
* @returns {Promise<{db: object, app: object}>} Resolves with db and app instances
4362
*/
4463
const bootstrap = async () => {
4564
let db;
@@ -48,7 +67,16 @@ const bootstrap = async () => {
4867
try {
4968
await SentryService.init();
5069
db = await startMongoose();
51-
await migrations.run();
70+
// DEVKIT_MIGRATIONS_RAN is set by jest.globalSetup.js before any vm context
71+
// is created, so it persists across all test suite vm context teardown cycles.
72+
// Falls through to migrations.run() when globalSetup failed or flag is absent.
73+
// Scoped to NODE_ENV=test so the flag cannot accidentally skip migrations in
74+
// production or dev (e.g. when --env-file-if-exists=.env injects it).
75+
const migrationsAlreadyRan =
76+
process.env.NODE_ENV === 'test' && process.env.DEVKIT_MIGRATIONS_RAN === '1';
77+
if (!migrationsAlreadyRan) {
78+
await migrations.run();
79+
}
5280
app = await startExpress();
5381
} catch (e) {
5482
throw new Error(`unable to initialize Mongoose or ExpressJS : ${e}`);

modules/core/tests/migrations.unit.tests.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/**
22
* Module dependencies.
33
*/
4+
import { jest } from '@jest/globals';
45
import path from 'path';
56
import mongoose from 'mongoose';
67

78
// Ensure the Migration model is registered before tests
89
import '../models/migration.model.mongoose.js';
910

1011
import migrations from '../../../lib/services/migrations.js';
12+
import migrationRepository from '../repositories/migration.repository.js';
1113

1214
/**
1315
* Unit tests for the migration system (no DB connection required)
@@ -80,4 +82,78 @@ describe('Migrations unit tests:', () => {
8082
expect(namePath.options.unique).toBe(true);
8183
});
8284
});
85+
86+
// The following tests stub migrationRepository directly so they exercise the
87+
// claim / unclaim / error branches without needing a live MongoDB. Bootstrap
88+
// no longer re-runs migrations.run() per suite (it sets DEVKIT_MIGRATIONS_RAN
89+
// in globalSetup), so these branches must be covered explicitly.
90+
describe('repository-mocked branches', () => {
91+
let createSpy;
92+
let deleteByNameSpy;
93+
let listExecutedSpy;
94+
let syncIndexesSpy;
95+
96+
beforeEach(() => {
97+
createSpy = jest.spyOn(migrationRepository, 'create');
98+
deleteByNameSpy = jest.spyOn(migrationRepository, 'deleteByName').mockResolvedValue({ acknowledged: true, deletedCount: 1 });
99+
listExecutedSpy = jest.spyOn(migrationRepository, 'listExecuted');
100+
syncIndexesSpy = jest.spyOn(migrationRepository, 'syncIndexes').mockResolvedValue([]);
101+
});
102+
103+
afterEach(() => {
104+
createSpy.mockRestore();
105+
deleteByNameSpy.mockRestore();
106+
listExecutedSpy.mockRestore();
107+
syncIndexesSpy.mockRestore();
108+
});
109+
110+
describe('runMigration claim path', () => {
111+
it('returns false when another runner already claimed the migration (E11000)', async () => {
112+
// Simulate the unique-index duplicate-key error from a concurrent runner
113+
const dup = Object.assign(new Error('E11000 duplicate key'), { code: 11000 });
114+
createSpy.mockRejectedValueOnce(dup);
115+
const result = await migrations.runMigration('modules/core/migrations/__never-run-claim-fail.js', new Set());
116+
expect(result).toBe(false);
117+
expect(createSpy).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it('rethrows non-duplicate errors from the repository', async () => {
121+
createSpy.mockRejectedValueOnce(new Error('boom'));
122+
await expect(
123+
migrations.runMigration('modules/core/migrations/__never-run-other-error.js', new Set()),
124+
).rejects.toThrow('boom');
125+
});
126+
127+
it('unclaims when the migration file fails to import', async () => {
128+
createSpy.mockResolvedValueOnce({ name: 'doesNotExist' });
129+
await expect(
130+
migrations.runMigration('modules/core/migrations/__file-does-not-exist.js', new Set()),
131+
).rejects.toThrow();
132+
expect(deleteByNameSpy).toHaveBeenCalledTimes(1);
133+
});
134+
});
135+
136+
describe('run() summary branches', () => {
137+
it('returns total/executed counts when all migrations are already executed', async () => {
138+
// listExecuted returns every discovered file → executedCount stays 0
139+
const files = await migrations.discoverMigrationFiles();
140+
const executedNames = files.map((f) => path.relative(process.cwd(), f).replace(/\\/g, '/'));
141+
listExecutedSpy.mockResolvedValueOnce(executedNames.map((name) => ({ name })));
142+
const result = await migrations.run();
143+
expect(result.total).toBe(files.length);
144+
expect(result.executed).toBe(0);
145+
});
146+
147+
it('unclaims and rethrows when the imported migration has no up() export', async () => {
148+
// claim succeeds; the file we point at exists but exports no up()
149+
createSpy.mockResolvedValueOnce({ name: 'no-up' });
150+
// This very test file is a real ESM module that does not export up()
151+
const realFileWithoutUp = path.resolve('modules/core/tests/migrations.unit.tests.js');
152+
await expect(
153+
migrations.runMigration(realFileWithoutUp, new Set()),
154+
).rejects.toThrow(/does not export an up\(\) function/);
155+
expect(deleteByNameSpy).toHaveBeenCalled();
156+
});
157+
});
158+
});
83159
});

scripts/jest.globalSetup.js

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
/**
2-
* Jest global setup - drops the test database before the test suite runs.
3-
* Replaces the gulp dropDB task that previously ran before jest.
4-
* Silently skips if MongoDB is unreachable (e.g. fresh CI environment).
2+
* Jest global setup — runs once in the jest main process before any test file.
3+
*
4+
* Responsibilities:
5+
* 1. Drop the test database (clean-slate guard, original behaviour).
6+
* 2. Run migrations once and set `process.env.DEVKIT_MIGRATIONS_RAN = '1'`
7+
* so that per-suite bootstrap() calls can skip the migrations step.
8+
*
9+
* Why process.env and not process properties or globalThis?
10+
* Jest --experimental-vm-modules creates a fresh vm.createContext() for each
11+
* test file. Both globalThis and arbitrary process properties set INSIDE a vm
12+
* context are cleaned up by jest's GlobalProxy on teardown.
13+
* process.env keys set IN GLOBALSETUP (before any vm context is created) are
14+
* present when jest calls protectProperties(process) during vm context
15+
* initialisation — they land in the "protected" set and survive all
16+
* per-suite teardown cycles unchanged.
517
*
618
* Safety guards (#3476):
719
* 1. Refuse to drop anything when `NODE_ENV !== 'test'`. Protects downstream
@@ -15,14 +27,12 @@
1527
* Both checks log a clear refusal reason and exit without connecting.
1628
*/
1729
import mongoose from 'mongoose';
30+
import path from 'path';
31+
import { pathToFileURL } from 'url';
1832

1933
/**
20-
* Jest global setup entry point — drops the test database before the suite runs.
21-
* Enforces the NODE_ENV + DB-name guards described at the top of this file.
22-
* @returns {Promise<void>} Resolves once the guard check completes and, when
23-
* guards pass and Mongo is reachable, once the database has been dropped and
24-
* the connection closed. Never rejects — a Mongo failure is swallowed so tests
25-
* can still run against existing state (e.g. fresh CI without mongo).
34+
* Jest global setup entry point.
35+
* @returns {Promise<void>}
2636
*/
2737
export default async () => {
2838
if (process.env.NODE_ENV !== 'test') {
@@ -31,6 +41,8 @@ export default async () => {
3141
);
3242
return;
3343
}
44+
45+
// 1. Drop the test database (clean-slate guard)
3446
try {
3547
const config = (await import('../config/index.js')).default;
3648
const dbName = new URL(config.db.uri).pathname.replace(/^\//, '');
@@ -40,7 +52,7 @@ export default async () => {
4052
);
4153
return;
4254
}
43-
await mongoose.connect(config.db.uri);
55+
await mongoose.connect(config.db.uri, config.db.options);
4456
try {
4557
await mongoose.connection.dropDatabase();
4658
} finally {
@@ -49,4 +61,38 @@ export default async () => {
4961
} catch {
5062
// MongoDB unreachable or drop failed — tests will run against existing state
5163
}
64+
65+
// 2. Run migrations once before any test vm context is created.
66+
// Set process.env.DEVKIT_MIGRATIONS_RAN = '1' so per-suite bootstrap() calls
67+
// can skip migrations.run() (saves ~100ms × 18 suites ≈ 1.8s).
68+
// Keys set on process.env HERE (before vm contexts) are included in jest's
69+
// protectProperties() pass and survive per-suite GlobalProxy teardown.
70+
try {
71+
const config = (await import('../config/index.js')).default;
72+
if (!Array.isArray(config.files?.mongooseModels)) {
73+
console.warn('[jest.globalSetup] migrations pre-run skipped: config.files.mongooseModels is absent or not an array.');
74+
return;
75+
}
76+
await mongoose.connect(config.db.uri, config.db.options);
77+
try {
78+
// Load mongoose models (needed by migration model registry)
79+
await Promise.all(
80+
config.files.mongooseModels.map(async (modelPath) => {
81+
await import(pathToFileURL(path.resolve(modelPath)).href);
82+
}),
83+
);
84+
85+
// Run pending migrations
86+
const migrations = (await import('../lib/services/migrations.js')).default;
87+
await migrations.run();
88+
89+
// Mark migrations as run so test vm contexts skip migrations.run()
90+
process.env.DEVKIT_MIGRATIONS_RAN = '1';
91+
} finally {
92+
await mongoose.disconnect();
93+
}
94+
} catch (err) {
95+
// Non-fatal: test suites will run migrations themselves (pre-existing behaviour)
96+
console.warn('[jest.globalSetup] migrations pre-run failed — suites run individually:', err.message);
97+
}
5298
};

scripts/tests/jest.globalSetup.unit.tests.js

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
* observed from inside a test. These tests exercise the module as a plain
66
* async function — the same entry point jest uses — while mocking mongoose
77
* and config to avoid touching a real database.
8+
*
9+
* globalSetup has two sequential phases:
10+
* Phase 1: connect → dropDatabase → disconnect (DB drop)
11+
* Phase 2: connect → loadModels → migrations.run → disconnect (migrations pre-run)
12+
* Phase 2 uses an explicit guard — tests that mock config without
13+
* `files.mongooseModels` skip the migrations phase (with a console.warn).
814
*/
915
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
1016

@@ -80,40 +86,50 @@ describe('scripts/jest.globalSetup safety guards', () => {
8086
test('drops database when NODE_ENV=test and DB name contains "test" (case-insensitive)', async () => {
8187
process.env.NODE_ENV = 'test';
8288
jest.unstable_mockModule('../../config/index.js', () => ({
83-
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' } },
89+
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' }, files: { mongooseModels: [] } },
8490
}));
8591
const { default: globalSetup } = await import('../jest.globalSetup.js');
8692

8793
await globalSetup();
8894

89-
expect(connect).toHaveBeenCalledWith('mongodb://localhost:27017/NodeTest');
95+
// Phase 1 (drop): connect → dropDatabase → disconnect
96+
expect(connect).toHaveBeenCalledWith('mongodb://localhost:27017/NodeTest', undefined);
9097
expect(dropDatabase).toHaveBeenCalledTimes(1);
91-
expect(disconnect).toHaveBeenCalledTimes(1);
98+
// Phase 2 (migrations): connects again with mongooseModels: [] → no models to load,
99+
// migrations.run skipped, disconnect called via finally block
100+
// Minimum: both phases connected + disconnected at least once each
101+
expect(connect.mock.calls.length).toBeGreaterThanOrEqual(2);
102+
expect(disconnect.mock.calls.length).toBeGreaterThanOrEqual(2);
92103
});
93104

94105
test('swallows errors when MongoDB is unreachable', async () => {
95106
process.env.NODE_ENV = 'test';
96107
connect.mockRejectedValueOnce(new Error('ECONNREFUSED'));
97108
jest.unstable_mockModule('../../config/index.js', () => ({
98-
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' } },
109+
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' }, files: { mongooseModels: [] } },
99110
}));
100111
const { default: globalSetup } = await import('../jest.globalSetup.js');
101112

102113
await expect(globalSetup()).resolves.toBeUndefined();
114+
// Phase 1 connect threw → dropDatabase never reached
103115
expect(dropDatabase).not.toHaveBeenCalled();
116+
// Phase 2 still tries to connect after the Phase 1 failure (second call)
117+
expect(connect.mock.calls.length).toBeGreaterThanOrEqual(2);
118+
expect(disconnect).toHaveBeenCalledTimes(1);
104119
});
105120

106121
test('disconnects even when dropDatabase() fails (no dangling connection)', async () => {
107122
process.env.NODE_ENV = 'test';
108123
dropDatabase.mockRejectedValueOnce(new Error('drop failed'));
109124
jest.unstable_mockModule('../../config/index.js', () => ({
110-
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' } },
125+
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' }, files: { mongooseModels: [] } },
111126
}));
112127
const { default: globalSetup } = await import('../jest.globalSetup.js');
113128

114129
await expect(globalSetup()).resolves.toBeUndefined();
115-
expect(connect).toHaveBeenCalledTimes(1);
130+
// Phase 1: dropDatabase failed but disconnect is always called (finally block)
116131
expect(dropDatabase).toHaveBeenCalledTimes(1);
117-
expect(disconnect).toHaveBeenCalledTimes(1);
132+
// Both phases call disconnect via finally blocks — no dangling connections
133+
expect(disconnect.mock.calls.length).toBeGreaterThanOrEqual(2);
118134
});
119135
});

0 commit comments

Comments
 (0)