Skip to content

Commit f00bb83

Browse files
committed
feat: integration test coverage of InMemoryMongoCollection
1 parent 6d25e09 commit f00bb83

21 files changed

Lines changed: 1515 additions & 200 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { MongoMemoryReplSet } = require('mongodb-memory-server')
2+
3+
/**
4+
* Jest globalSetup for the `integration` project: boot ONE in-memory MongoDB replica set, shared by every
5+
* `*.integration.test.ts` file in the run. A replica set (not a standalone) is required because the
6+
* change-stream observe engine relies on change streams.
7+
*
8+
* The instance handle is stashed on `globalThis` so {@link file://./integration-global-teardown.js} can stop
9+
* it, and the connection string is published via `process.env.MONGO_URL`. Env vars set here are inherited by
10+
* the forked jest workers, so both the test files' own `MongoClient`s and the production `getMongoDb()`
11+
* (see `server/collections/mongoConnection.ts`) resolve to this same instance.
12+
*/
13+
module.exports = async () => {
14+
process.env.TZ = 'UTC'
15+
16+
// eslint-disable-next-line n/file-extension-in-import -- Node's dynamic import() of a local ESM file needs the explicit extension
17+
const { DEV_MONGO_VERSION } = await import('../scripts/dev-mongo.mjs')
18+
const replSet = await MongoMemoryReplSet.create({ replSet: { count: 1 }, binary: { version: DEV_MONGO_VERSION } })
19+
globalThis.__MONGO_REPLSET__ = replSet
20+
21+
// `parseMongoConnectionString` wants the database name in the URL path.
22+
process.env.MONGO_URL = replSet.getUri('meteor-integration-test')
23+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Jest globalTeardown for the `integration` project: stop the shared MongoDB replica set started in
3+
* {@link file://./integration-global-setup.js}.
4+
*/
5+
module.exports = async () => {
6+
const replSet = globalThis.__MONGO_REPLSET__
7+
if (replSet) {
8+
await replSet.stop()
9+
globalThis.__MONGO_REPLSET__ = undefined
10+
}
11+
}

meteor/jest.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,32 @@ const commonConfig = {
3434

3535
module.exports = {
3636
projects: [
37+
// Fast unit tests (the default inner loop). Integration tests (`*.integration.test.ts`, which spin up a
38+
// real MongoDB) are excluded here and live in the `integration` project below.
3739
Object.assign({}, commonConfig, {
40+
displayName: 'unit',
3841
testMatch: [
3942
'<rootDir>/server/__tests__/**/*.(spec|test).(ts|js)',
4043
'<rootDir>/server/**/__tests__/**/*.(spec|test).(ts|js)',
4144
'!.meteor/*.*',
4245
],
46+
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.test\\.(ts|js)$'],
4347
testEnvironment: 'node',
4448
}),
49+
// Real-MongoDB integration tests. A single in-memory replica set is booted once for the whole project
50+
// (see __mocks__/integration-global-setup.js) and reused across every file. Run on its own with
51+
// `jest --selectProjects integration`; `yarn unit` runs it alongside the `unit` project.
52+
Object.assign({}, commonConfig, {
53+
displayName: 'integration',
54+
testMatch: ['<rootDir>/server/**/*.integration.test.(ts|js)'],
55+
testEnvironment: 'node',
56+
globalSetup: './__mocks__/integration-global-setup.js',
57+
globalTeardown: './__mocks__/integration-global-teardown.js',
58+
// These tests wait on real change-stream / replica-set I/O, which is slower under CI load than the
59+
// 5s jest default. Give them some headroom (the reconnect test, which waits on real backoff delays,
60+
// overrides this inline).
61+
testTimeout: 20000,
62+
}),
4563
],
4664
coverageProvider: 'v8',
4765
coverageThreshold: {

meteor/scripts/dev-mongo.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { MongoClient } from 'mongodb'
66
// Use the same replica-set name Meteor uses for its dev mongod, so the same data dir works on both this
77
// branch (our dev-mongo) and an older branch (Meteor's bundled mongod)
88
const REPLSET_NAME = 'meteor'
9+
10+
/**
11+
* The mongod version we run everywhere: the dev instance (scripts/run.mjs) and the jest integration
12+
*/
13+
export const DEV_MONGO_VERSION = '7.0.16'
14+
915
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
1016

1117
/**
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Shared helpers for the `integration` jest project: connect to the single MongoDB replica set booted once
3+
* by `__mocks__/integration-global-setup.js` (published via `process.env.MONGO_URL`), and hand out a fresh,
4+
* uniquely-named collection per test so files don't need to reboot mongo or clean up after each other.
5+
*/
6+
import { MongoClient, Collection, Document } from 'mongodb'
7+
import { randomBytes } from 'crypto'
8+
9+
/** Open a client against the shared replica set. Each test file should do this once in `beforeAll`. */
10+
export async function connectSharedMongoClient(): Promise<MongoClient> {
11+
const url = process.env.MONGO_URL
12+
if (!url) {
13+
throw new Error('MONGO_URL is not set - the integration globalSetup (shared replset) did not run')
14+
}
15+
// Match the production driver options (see server/collections/mongoConnection.ts).
16+
const client = new MongoClient(url, { ignoreUndefined: true })
17+
await client.connect()
18+
return client
19+
}
20+
21+
let collectionCounter = 0
22+
23+
/**
24+
* Create a fresh, uniquely-named collection (optionally seeded), for test isolation without rebooting mongo.
25+
* The name carries a random token so it is unique across all test files/workers sharing the one replset
26+
* (jest reuses worker processes, so a per-module counter alone would collide between files).
27+
*/
28+
export async function freshCollection<TDoc extends Document>(
29+
client: MongoClient,
30+
seed: TDoc[] = []
31+
): Promise<Collection<TDoc>> {
32+
const name = `itest_${randomBytes(6).toString('hex')}_${collectionCounter++}`
33+
const collection = client.db().collection<TDoc>(name)
34+
if (seed.length) await collection.insertMany(seed as any[])
35+
return collection
36+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Helpers shared by the change-stream integration tests (the moved engine test and the observe-parity test).
3+
* Extracted from the original `engine.integration.test.ts` so both can drive the real change-stream
4+
* multiplexer the same way.
5+
*/
6+
import { MongoClient, Collection, ChangeStreamDocument } from 'mongodb'
7+
import { protectString, ProtectedString } from '@sofie-automation/corelib/dist/protectedString'
8+
import { MongoQuery, MongoFieldSpecifier } from '@sofie-automation/corelib/dist/mongo'
9+
import { CollectionChangeFeed, createCollectionChangeStream } from '../../collections/changeStream/collectionChangeFeed'
10+
import { ObserveMultiplexer, ObserveMultiplexerDeps } from '../../collections/changeStream/observeMultiplexer'
11+
import type { ObserveViewShape } from '@sofie-automation/corelib/dist/memoryCollection/observeView'
12+
13+
export interface TestDoc {
14+
_id: ProtectedString<any>
15+
name?: string
16+
val?: number
17+
studioId?: string
18+
}
19+
20+
export const id = (s: string): ProtectedString<any> => protectString<any>(s)
21+
22+
/**
23+
* Poll `predicate` until it is true or the timeout elapses (for awaiting async change-stream delivery).
24+
* Delivery is normally sub-second; the default allows for CI load while staying below the integration
25+
* project's jest `testTimeout`, so the poll (not jest) reports a genuine hang.
26+
*/
27+
export async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise<void> {
28+
const start = Date.now()
29+
while (!predicate()) {
30+
if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
31+
await new Promise((r) => setTimeout(r, 20))
32+
}
33+
}
34+
35+
export interface Sink {
36+
added: Array<[any, any]>
37+
changed: Array<[any, any]>
38+
removed: any[]
39+
}
40+
41+
export function newSink(): Sink {
42+
return { added: [], changed: [], removed: [] }
43+
}
44+
45+
/** Build an {@link ObserveMultiplexer} backed by a real change stream + snapshot against `collection`. */
46+
export function makeMultiplexer(
47+
client: MongoClient,
48+
collection: Collection<any>,
49+
selector: MongoQuery<TestDoc>,
50+
projection: MongoFieldSpecifier<TestDoc> | undefined,
51+
shape?: ObserveViewShape<TestDoc>
52+
): ObserveMultiplexer<TestDoc> {
53+
const feed = new CollectionChangeFeed(
54+
collection.collectionName,
55+
() => createCollectionChangeStream(client, client.db(), collection.collectionName),
56+
() => undefined
57+
)
58+
const deps: ObserveMultiplexerDeps<TestDoc> = {
59+
snapshot: async () => collection.find(selector as any).toArray() as unknown as Promise<TestDoc[]>,
60+
subscribeFeed: (onChange, onReconnect) =>
61+
feed.subscribe(onChange as (c: ChangeStreamDocument<any>) => void, onReconnect),
62+
}
63+
return new ObserveMultiplexer<TestDoc>(selector, projection, shape, deps, () => undefined)
64+
}
65+
66+
/** Subscribe an observeChanges sink to a multiplexer; teardown is driven by aborting `signal`. */
67+
export async function subscribeChanges(m: ObserveMultiplexer<TestDoc>, sink: Sink, signal: AbortSignal): Promise<void> {
68+
await m.addObserveChangesSubscriber(
69+
{
70+
added: (i: any, f: any) => sink.added.push([i, f]),
71+
changed: (i: any, f: any) => sink.changed.push([i, f]),
72+
removed: (i: any) => sink.removed.push(i),
73+
} as any,
74+
signal,
75+
false
76+
)
77+
}

0 commit comments

Comments
 (0)