diff --git a/docs/modules/mongodb.md b/docs/modules/mongodb.md index 5820e1bc0..586173048 100644 --- a/docs/modules/mongodb.md +++ b/docs/modules/mongodb.md @@ -11,9 +11,9 @@ npm install @testcontainers/mongodb --save-dev ## Examples -[Mongo 4.0.x:](../../packages/modules/mongodb/src/mongodb-container.test.ts) inside_block:connect4 +[Connect:](../../packages/modules/mongodb/src/mongodb-container.test.ts) inside_block:connectMongo -[MongoDB 6.0.x:](../../packages/modules/mongodb/src/mongodb-container.test.ts) inside_block:connect6 +[Connect with credentials:](../../packages/modules/mongodb/src/mongodb-container.test.ts) inside_block:connectWithCredentials diff --git a/mkdocs.yml b/mkdocs.yml index 1f71adc38..f83c3a2b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,7 +29,7 @@ markdown_extensions: - pymdownx.details - pymdownx.superfences - pymdownx.tabbed: - alternate_style: true + alternate_style: false - toc: permalink: true diff --git a/package-lock.json b/package-lock.json index 2dc04a335..b55f4f57d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16019,6 +16019,7 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", "dev": true, + "license": "Apache-2.0", "optional": true, "peer": true, "dependencies": { @@ -16036,6 +16037,7 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", "dev": true, + "license": "Apache-2.0", "optional": true, "peer": true, "dependencies": { @@ -16051,6 +16053,7 @@ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", @@ -22668,6 +22671,7 @@ "version": "11.3.2", "license": "MIT", "dependencies": { + "compare-versions": "^6.1.1", "testcontainers": "^11.3.2" }, "devDependencies": { diff --git a/packages/modules/mongodb/package.json b/packages/modules/mongodb/package.json index 70a726b45..1112b682b 100644 --- a/packages/modules/mongodb/package.json +++ b/packages/modules/mongodb/package.json @@ -32,6 +32,7 @@ "mongoose": "^8.16.4" }, "dependencies": { - "testcontainers": "^11.3.2" + "testcontainers": "^11.3.2", + "compare-versions": "^6.1.1" } } diff --git a/packages/modules/mongodb/src/mongodb-container.test.ts b/packages/modules/mongodb/src/mongodb-container.test.ts index aed4cec34..a74750460 100644 --- a/packages/modules/mongodb/src/mongodb-container.test.ts +++ b/packages/modules/mongodb/src/mongodb-container.test.ts @@ -4,62 +4,42 @@ import { MongoDBContainer } from "./mongodb-container"; const IMAGE = getImage(__dirname); -describe("MongodbContainer", { timeout: 240_000 }, () => { - // connect4 { - it("should work using default version 4.0.1", async () => { - await using mongodbContainer = await new MongoDBContainer(IMAGE).start(); +describe("MongoDBContainer", { timeout: 240_000 }, () => { + it.each([IMAGE, "mongo:6.0.25", "mongo:4.4.29"])("should work with %s", async (image) => { + // connectMongo { + await using mongodbContainer = await new MongoDBContainer(image).start(); - // directConnection: true is required as the testcontainer is created as a MongoDB Replica Set. const db = mongoose.createConnection(mongodbContainer.getConnectionString(), { directConnection: true }); - - // You can also add the default connection flag as a query parameter - // const connectionString = `${mongodbContainer.getConnectionString()}?directConnection=true`; - // const db = mongoose.createConnection(connectionString); - const fooCollection = db.collection("foo"); const obj = { value: 1 }; const session = await db.startSession(); - await session.withTransaction(async () => { - await fooCollection.insertOne(obj); - }); + await session.withTransaction(async () => await fooCollection.insertOne(obj)); - expect( - await fooCollection.findOne({ - value: 1, - }) - ).toEqual(obj); + const result = await fooCollection.findOne({ value: 1 }); + expect(result).toEqual(obj); - await mongoose.disconnect(); + await db.close(); + // } }); - // } - // connect6 { - it("should work using version 6.0.1", async () => { - await using mongodbContainer = await new MongoDBContainer("mongo:6.0.1").start(); + it("should connect with credentials", async () => { + // connectWithCredentials { + await using mongodbContainer = await new MongoDBContainer(IMAGE) + .withUsername("mongo_user") + .withPassword("mongo_password") + .start(); - // directConnection: true is required as the testcontainer is created as a MongoDB Replica Set. const db = mongoose.createConnection(mongodbContainer.getConnectionString(), { directConnection: true }); - // You can also add the default connection flag as a query parameter - // const connectionString = `${mongodbContainer.getConnectionString()}?directConnection=true`; - // const db = mongoose.createConnection(connectionString); - - const fooCollection = db.collection("foo"); - const obj = { value: 1 }; - - const session = await db.startSession(); - await session.withTransaction(async () => { - await fooCollection.insertOne(obj); - }); + const result = await db.collection("testcontainers").insertOne({ title: "testcontainers" }); + const resultId = result.insertedId.toString(); + expect(resultId).toBeTruthy(); - expect( - await fooCollection.findOne({ - value: 1, - }) - ).toEqual(obj); + const rsStatus = await db.db?.admin().replSetGetStatus(); + expect(rsStatus?.set).toBe("rs0"); - await mongoose.disconnect(); + await db.close(); + // } }); - // } }); diff --git a/packages/modules/mongodb/src/mongodb-container.ts b/packages/modules/mongodb/src/mongodb-container.ts index 3634d844d..47db093ca 100644 --- a/packages/modules/mongodb/src/mongodb-container.ts +++ b/packages/modules/mongodb/src/mongodb-container.ts @@ -1,68 +1,90 @@ -import { AbstractStartedContainer, ExecResult, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import { satisfies } from "compare-versions"; +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; const MONGODB_PORT = 27017; export class MongoDBContainer extends GenericContainer { + private username: string | undefined; + private password: string | undefined; + constructor(image: string) { super(image); - this.withExposedPorts(MONGODB_PORT) - .withCommand(["--replSet", "rs0"]) - .withWaitStrategy(Wait.forLogMessage(/.*waiting for connections.*/i)) - .withStartupTimeout(120_000); + this.withExposedPorts(MONGODB_PORT).withWaitStrategy(Wait.forHealthCheck()).withStartupTimeout(120_000); } - public override async start(): Promise { - return new StartedMongoDBContainer(await super.start()); + public withUsername(username: string): this { + if (!username) throw new Error("Username should not be empty."); + this.username = username; + return this; } - protected override async containerStarted(startedTestContainer: StartedTestContainer): Promise { - await this.initReplicaSet(startedTestContainer); + public withPassword(password: string): this { + if (!password) throw new Error("Password should not be empty."); + this.password = password; + return this; } - private async initReplicaSet(startedTestContainer: StartedTestContainer) { - await this.executeMongoEvalCommand(startedTestContainer, "rs.initiate();"); - await this.executeMongoEvalCommand(startedTestContainer, this.buildMongoWaitCommand()); + public override async start(): Promise { + const cmdArgs = ["--replSet", "rs0"]; + if (!this.healthCheck) this.withWaitForRsHealthCheck(); + if (this.username && this.password) { + cmdArgs.push("--keyFile", "/data/db/key.txt"); + this.withEnvironment({ + MONGO_INITDB_ROOT_USERNAME: this.username, + MONGO_INITDB_ROOT_PASSWORD: this.password, + }) + .withCopyContentToContainer([ + { + content: "1111111111", + mode: 0o400, + target: "/data/db/key.txt", + }, + ]) + .withCommand(cmdArgs); + } else { + this.withCommand(cmdArgs); + } + return new StartedMongoDBContainer(await super.start(), this.username, this.password); } - private async executeMongoEvalCommand(startedTestContainer: StartedTestContainer, command: string) { - const execResult = await startedTestContainer.exec(this.buildMongoEvalCommand(command)); - this.checkMongoNodeExitCode(execResult); + private withWaitForRsHealthCheck(): this { + return this.withHealthCheck({ + test: [ + "CMD-SHELL", + this.buildMongoEvalCommand( + `'try { rs.initiate(); } catch (e){} while (db.runCommand({isMaster: 1}).ismaster==false) { sleep(100); }'` + ), + ], + interval: 250, + timeout: 60000, + retries: 1000, + }); } private buildMongoEvalCommand(command: string) { - return [this.getMongoCmdBasedOnImageTag(), "--eval", command]; - } - - private getMongoCmdBasedOnImageTag() { - return parseInt(this.imageName.tag[0]) >= 5 ? "mongosh" : "mongo"; - } - - private checkMongoNodeExitCode(execResult: ExecResult) { - const { exitCode, output } = execResult; - if (execResult.exitCode !== 0) { - throw new Error(`Error running mongo command. Exit code ${exitCode}: ${output}`); - } - } - - private buildMongoWaitCommand() { - return ` - var attempt = 0; - while(db.runCommand({isMaster: 1}).ismaster==false) { - if (attempt > 60) { - quit(1); - } - print(attempt); sleep(100); attempt++; - } - `; + const useMongosh = satisfies(this.imageName.tag, ">=5.0.0"); + const args = []; + if (useMongosh) args.push("mongosh"); + else args.push("mongo", "admin"); + if (this.username && this.password) args.push("-u", this.username, "-p", this.password); + args.push("--quiet", "--eval", command); + return args.join(" "); } } export class StartedMongoDBContainer extends AbstractStartedContainer { - constructor(startedTestContainer: StartedTestContainer) { + private readonly username: string | undefined; + private readonly password: string | undefined; + + constructor(startedTestContainer: StartedTestContainer, username: string | undefined, password: string | undefined) { super(startedTestContainer); + this.username = username; + this.password = password; } public getConnectionString(): string { + if (this.username && this.password) + return `mongodb://${this.username}:${this.password}@${this.getHost()}:${this.getMappedPort(MONGODB_PORT)}?authSource=admin`; return `mongodb://${this.getHost()}:${this.getMappedPort(MONGODB_PORT)}`; } }