diff --git a/docs/modules/mongodb.md b/docs/modules/mongodb.md index 96571b56b..6df0efeaa 100644 --- a/docs/modules/mongodb.md +++ b/docs/modules/mongodb.md @@ -6,7 +6,7 @@ npm install @testcontainers/mongodb --save-dev ``` -## Examples +## MongoDBContainer These examples use the following libraries: @@ -27,3 +27,34 @@ Choose an image from the [container registry](https://hub.docker.com/_/mongo) an [](../../packages/modules/mongodb/src/mongodb-container.test.ts) inside_block:connectWithCredentials + +## MongoDBAtlasLocalContainer + +The MongoDB Atlas Local image combines the MongoDB database engine with MongoT for Atlas Search. + +Choose an image from the [container registry](https://hub.docker.com/r/mongodb/mongodb-atlas-local) and substitute `IMAGE`. +The connection string returned by `getConnectionString()` does not include `directConnection=true`; pass `directConnection: true` to your MongoDB client options instead. + +### Get a connection string + + +[](../../packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts) inside_block:connectAtlasLocal + + +### Get a database connection string + + +[](../../packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts) inside_block:connectAtlasLocalDatabase + + +### Connect with credentials + + +[](../../packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts) inside_block:connectAtlasLocalWithCredentials + + +### Create an Atlas Search index and search it + + +[](../../packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts) inside_block:createAtlasIndexAndSearchIt + diff --git a/packages/modules/mongodb/Dockerfile b/packages/modules/mongodb/Dockerfile index 1c757679a..a11cb3936 100644 --- a/packages/modules/mongodb/Dockerfile +++ b/packages/modules/mongodb/Dockerfile @@ -1 +1,2 @@ FROM mongo:8.2.11 +FROM mongodb/mongodb-atlas-local:8.2.2 diff --git a/packages/modules/mongodb/src/index.ts b/packages/modules/mongodb/src/index.ts index c057e7fce..e9e0621a8 100644 --- a/packages/modules/mongodb/src/index.ts +++ b/packages/modules/mongodb/src/index.ts @@ -1 +1,2 @@ +export { MongoDBAtlasLocalContainer, StartedMongoDBAtlasLocalContainer } from "./mongodb-atlas-local-container"; export { MongoDBContainer, StartedMongoDBContainer } from "./mongodb-container"; diff --git a/packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts b/packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts new file mode 100644 index 000000000..fa6dd7a3e --- /dev/null +++ b/packages/modules/mongodb/src/mongodb-atlas-local-container.test.ts @@ -0,0 +1,156 @@ +import mongoose from "mongoose"; +import { IntervalRetry } from "../../../testcontainers/src/common"; +import { getImage } from "../../../testcontainers/src/utils/test-helper"; +import { MongoDBAtlasLocalContainer } from "./mongodb-atlas-local-container"; + +const IMAGE = getImage(__dirname, 1); +const ATLAS_SEARCH_INDEX = { + mappings: { + dynamic: false, + fields: { + test: { + type: "string", + }, + test2: { + type: "number", + representation: "int64", + indexDoubles: false, + }, + test3: { + type: "boolean", + }, + }, + }, +}; + +describe("MongoDBAtlasLocalContainer", { timeout: 240_000 }, () => { + it("should provide a connection string", async () => { + // connectAtlasLocal { + await using container = await new MongoDBAtlasLocalContainer(IMAGE).start(); + // } + + expect(container.getConnectionString()).toBe(`mongodb://${container.getHost()}:${container.getMappedPort(27017)}/`); + }); + + it("should provide a database connection string", async () => { + // connectAtlasLocalDatabase { + await using container = await new MongoDBAtlasLocalContainer(IMAGE).start(); + // } + + expect(container.getDatabaseConnectionString()).toBe( + `mongodb://${container.getHost()}:${container.getMappedPort(27017)}/test` + ); + }); + + it("should provide connection strings with credentials", async () => { + // connectAtlasLocalWithCredentials { + await using container = await new MongoDBAtlasLocalContainer(IMAGE) + .withUsername("customUsername") + .withPassword("customPassword") + .start(); + // } + + expect(container.getConnectionString()).toBe( + `mongodb://customUsername:customPassword@${container.getHost()}:${container.getMappedPort(27017)}/?authSource=admin` + ); + expect(container.getDatabaseConnectionString()).toBe( + `mongodb://customUsername:customPassword@${container.getHost()}:${container.getMappedPort(27017)}/test?authSource=admin` + ); + }); + + it("should connect to mongodb atlas local", async () => { + await using container = await new MongoDBAtlasLocalContainer(IMAGE).start(); + + const db = mongoose.createConnection(container.getConnectionString(), { directConnection: true }); + + const obj = { value: 1 }; + const collection = db.collection("test"); + await collection.insertOne(obj); + + const result = await collection.findOne({ value: 1 }); + expect(result).toEqual(obj); + + await db.close(); + }); + + it("should connect to mongodb atlas local with credentials", async () => { + await using container = await new MongoDBAtlasLocalContainer(IMAGE) + .withUsername("customUsername") + .withPassword("customPassword") + .start(); + + const db = mongoose.createConnection(container.getDatabaseConnectionString(), { directConnection: true }); + + const obj = { value: 1 }; + const collection = db.collection("test"); + await collection.insertOne(obj); + + const result = await collection.findOne({ value: 1 }); + expect(result).toEqual(obj); + + await db.close(); + }); + + it("should create an atlas search index and search it", async () => { + // createAtlasIndexAndSearchIt { + await using atlasLocalContainer = await new MongoDBAtlasLocalContainer(IMAGE).start(); + + const db = mongoose.createConnection(atlasLocalContainer.getConnectionString(), { + dbName: "test", + directConnection: true, + }); + + try { + const collection = db.collection("test"); + + await db.createCollection("test"); + + await collection.createSearchIndex({ + name: "AtlasSearchIndex", + definition: ATLAS_SEARCH_INDEX, + }); + + await new IntervalRetry(10).retryUntil( + async () => { + const searchIndexes = await collection.listSearchIndexes("AtlasSearchIndex").toArray(); + return (searchIndexes[0] as { status?: string } | undefined)?.status; + }, + (status) => status?.toUpperCase() === "READY", + () => new Error("Atlas Search index did not become ready in time"), + 5_000 + ); + + await collection.insertOne({ test: "tests", test2: 123, test3: true }); + + const found = await new IntervalRetry | null, Error>(10).retryUntil( + async () => + collection + .aggregate([ + { + $search: { + index: "AtlasSearchIndex", + text: { + query: "test", + path: "test", + fuzzy: {}, + }, + }, + }, + ]) + .next(), + (result) => result !== null, + () => new Error("Atlas Search did not return a result in time"), + 5_000 + ); + + if (found instanceof Error) { + throw found; + } + + expect(found).toMatchObject({ test: "tests", test2: 123, test3: true }); + } finally { + await db.close(); + } + // } + }); +}); diff --git a/packages/modules/mongodb/src/mongodb-atlas-local-container.ts b/packages/modules/mongodb/src/mongodb-atlas-local-container.ts new file mode 100644 index 000000000..1b940fa87 --- /dev/null +++ b/packages/modules/mongodb/src/mongodb-atlas-local-container.ts @@ -0,0 +1,74 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +const MONGODB_PORT = 27017; +const DEFAULT_DATABASE_NAME = "test"; + +export class MongoDBAtlasLocalContainer extends GenericContainer { + private username: string | undefined; + private password: string | undefined; + + constructor(image: string) { + super(image); + this.withExposedPorts(MONGODB_PORT) + .withWaitStrategy(Wait.forSuccessfulCommand("runner healthcheck")) + .withStartupTimeout(120_000); + } + + public withUsername(username: string): this { + if (!username) throw new Error("Username should not be empty."); + this.username = username; + return this; + } + + public withPassword(password: string): this { + if (!password) throw new Error("Password should not be empty."); + this.password = password; + return this; + } + + public override async start(): Promise { + if (this.username && this.password) { + // Note: the Atlas Local image uses MONGODB_INITDB_* (vs MONGO_INITDB_* in mongodb-container.ts). + // This difference is intentional — do not "fix" it to match the sibling file. + this.withEnvironment({ + MONGODB_INITDB_ROOT_USERNAME: this.username, + MONGODB_INITDB_ROOT_PASSWORD: this.password, + }); + } + + return new StartedMongoDBAtlasLocalContainer(await super.start(), this.username, this.password); + } +} + +export class StartedMongoDBAtlasLocalContainer extends AbstractStartedContainer { + constructor( + startedTestContainer: StartedTestContainer, + private readonly username: string | undefined, + private readonly password: string | undefined + ) { + super(startedTestContainer); + } + + public getConnectionString(): string { + return this.buildConnectionString(); + } + + public getDatabaseConnectionString(databaseName = DEFAULT_DATABASE_NAME): string { + return this.buildConnectionString(databaseName); + } + + private buildConnectionString(databaseName?: string): string { + const url = new URL("mongodb://"); + url.hostname = this.getHost(); + url.port = this.getMappedPort(MONGODB_PORT).toString(); + url.pathname = databaseName ? `/${databaseName}` : "/"; + + if (this.username && this.password) { + url.username = this.username; + url.password = this.password; + url.searchParams.set("authSource", "admin"); + } + + return url.toString(); + } +}