diff --git a/lib/container/mongo_container.ex b/lib/container/mongo_container.ex new file mode 100644 index 0000000..dadc791 --- /dev/null +++ b/lib/container/mongo_container.ex @@ -0,0 +1,308 @@ +# SPDX-License-Identifier: MIT +defmodule Testcontainers.MongoContainer do + @behaviour Testcontainers.DatabaseBehaviour + @moduledoc """ + Provides functionality for creating and managing Mongo container configurations. + """ + + alias Testcontainers.CommandWaitStrategy + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.MongoContainer + + import Testcontainers.Container, only: [is_valid_image: 1] + + @default_image "mongo" + @default_tag "latest" + @default_image_with_tag "#{@default_image}:#{@default_tag}" + @default_user "test" + @default_password "test" + @default_database "test" + @default_port 27017 + @default_wait_timeout 180_000 + + @enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume] + defstruct [ + :image, + :user, + :password, + :database, + :port, + :wait_timeout, + :persistent_volume, + check_image: @default_image, + reuse: false + ] + + @doc """ + Creates a new `MongoContainer` struct with default configurations. + """ + def new, + do: %__MODULE__{ + image: @default_image_with_tag, + user: @default_user, + password: @default_password, + database: @default_database, + port: @default_port, + wait_timeout: @default_wait_timeout, + persistent_volume: nil + } + + @doc """ + Overrides the default image used for the Mongo container. + + ## Examples + + iex> config = MongoContainer.new() + iex> new_config = MongoContainer.with_image(config, "mongo:5") + iex> new_config.image + "mongo:5" + """ + def with_image(%__MODULE__{} = config, image) when is_binary(image) do + %{config | image: image} + end + + @doc """ + Alias for `with_user/2`, matching Mongo naming in other implementations. + """ + def with_username(%__MODULE__{} = config, username) when is_binary(username) do + with_user(config, username) + end + + @doc """ + Overrides the default user used for the Mongo container. + + ## Examples + + iex> config = MongoContainer.new() + iex> new_config = MongoContainer.with_user(config, "another-user") + iex> new_config.user + "another-user" + """ + def with_user(%__MODULE__{} = config, user) when is_binary(user) do + %{config | user: user} + end + + @doc """ + Overrides the default password used for the Mongo container. + + ## Examples + + iex> config = MongoContainer.new() + iex> new_config = MongoContainer.with_password(config, "another-password") + iex> new_config.password + "another-password" + """ + def with_password(%__MODULE__{} = config, password) when is_binary(password) do + %{config | password: password} + end + + @doc """ + Overrides the default database used for the Mongo container. + + ## Examples + + iex> config = MongoContainer.new() + iex> new_config = MongoContainer.with_database(config, "another-database") + iex> new_config.database + "another-database" + """ + def with_database(%__MODULE__{} = config, database) when is_binary(database) do + %{config | database: database} + end + + @doc """ + Overrides the default port used for the Mongo container. + + Note: this will not change what port the docker container is listening to internally. + + ## Examples + + iex> config = MongoContainer.new() + iex> new_config = MongoContainer.with_port(config, 27018) + iex> new_config.port + 27018 + """ + def with_port(%__MODULE__{} = config, port) when is_integer(port) or is_tuple(port) do + %{config | port: port} + end + + @doc """ + mounts persistent volume in Mongo data path used for the Mongo container. + + ## Examples + + iex> config = MongoContainer.new() + iex> config = MongoContainer.with_persistent_volume(config, "data_volume") + iex> config.persistent_volume + "data_volume" + """ + def with_persistent_volume(%__MODULE__{} = config, persistent_volume) + when is_binary(persistent_volume) do + %{config | persistent_volume: persistent_volume} + end + + @doc """ + Mounts the default wait timeout used for the Mongo container. + + Note: this timeout will be used for each individual wait strategy. + + ## Examples + + iex> config = MongoContainer.new() + iex> new_config = MongoContainer.with_wait_timeout(config, 8000) + iex> new_config.wait_timeout + 8000 + """ + def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do + %{config | wait_timeout: wait_timeout} + end + + @doc """ + Set the regular expression to check the image validity. + """ + def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do + %__MODULE__{config | check_image: check_image} + end + + @doc """ + Set the reuse flag to reuse the container if it is already running. + """ + def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do + %__MODULE__{config | reuse: reuse} + end + + @doc """ + Retrieves the default exposed port for the Mongo container. + """ + def default_port, do: @default_port + + @doc """ + Retrieves the default Docker image for the Mongo container. + """ + def default_image, do: @default_image + + @doc """ + Retrieves the default Docker image including tag for the Mongo container. + """ + def default_image_with_tag, do: @default_image <> ":" <> @default_tag + + @doc """ + Returns the port on the _host machine_ where the Mongo container is listening. + """ + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) + + @doc """ + Returns the connection parameters to connect to the database from the _host machine_. + """ + def connection_parameters(%Container{} = container) do + [ + hostname: Testcontainers.get_host(container), + port: port(container), + username: container.environment[:MONGO_INITDB_ROOT_USERNAME], + password: container.environment[:MONGO_INITDB_ROOT_PASSWORD], + database: container.environment[:MONGO_INITDB_DATABASE] + ] + end + + @doc """ + Generates the MongoDB connection URL. + + ## Options + * `:protocol` - URL scheme, defaults to `"mongodb"`. + * `:username` - Overrides username from container env. + * `:password` - Overrides password from container env. + * `:database` - Overrides database from container env. + * `:options` - Query options as map/keyword list. + """ + def mongo_url(%Container{} = container, opts \\ []) when is_list(opts) do + protocol = Keyword.get(opts, :protocol, "mongodb") + username = Keyword.get(opts, :username, container.environment[:MONGO_INITDB_ROOT_USERNAME]) + password = Keyword.get(opts, :password, container.environment[:MONGO_INITDB_ROOT_PASSWORD]) + database = Keyword.get(opts, :database, container.environment[:MONGO_INITDB_DATABASE]) + query_string = opts |> Keyword.get(:options, []) |> encode_query_string() + + "#{protocol}://#{username}:#{password}@#{Testcontainers.get_host(container)}:#{port(container)}/#{database}#{query_string}" + end + + @doc """ + Alias for `mongo_url/2`. + """ + def database_url(%Container{} = container, opts \\ []), do: mongo_url(container, opts) + + defimpl ContainerBuilder do + import Container + + @doc """ + Implementation of the `ContainerBuilder` protocol specific to `MongoContainer`. + + This function builds a new container configuration, ensuring the Mongo image is compatible, setting environment variables, and applying a waiting strategy for the container to be ready. + + The build process raises an `ArgumentError` if the specified container image is not compatible with the expected Mongo image. + + ## Examples + + # Assuming `ContainerBuilder.build/2` is called from somewhere in the application with a `MongoContainer` configuration: + iex> config = MongoContainer.new() + iex> built_container = ContainerBuilder.build(config, []) + # `built_container` is now a ready-to-use `%Container{}` configured specifically for Mongo. + + ## Errors + + - Raises `ArgumentError` if the provided image is not compatible with the default Mongo image. + """ + @spec build(%MongoContainer{}) :: %Container{} + @impl true + def build(%MongoContainer{} = config) do + new(config.image) + |> then(MongoContainer.container_port_fun(config.port)) + |> with_environment(:MONGO_INITDB_ROOT_USERNAME, config.user) + |> with_environment(:MONGO_INITDB_ROOT_PASSWORD, config.password) + |> with_environment(:MONGO_INITDB_DATABASE, config.database) + |> then(MongoContainer.container_volume_fun(config.persistent_volume)) + |> with_waiting_strategy( + CommandWaitStrategy.new( + [ + "sh", + "-c", + "mongosh --eval \"db.adminCommand('ping')\" || mongo --eval \"db.adminCommand('ping')\"" + ], + config.wait_timeout + ) + ) + |> with_check_image(config.check_image) + |> with_reuse(config.reuse) + |> valid_image!() + end + + @impl true + def after_start(_config, _container, _conn), do: :ok + end + + @doc false + def container_port_fun(nil), do: &Function.identity/1 + + def container_port_fun({exposed_port, host_port}) do + fn container -> Container.with_fixed_port(container, exposed_port, host_port) end + end + + def container_port_fun(port) do + fn container -> Container.with_exposed_port(container, port) end + end + + @doc false + def container_volume_fun(nil), do: &Function.identity/1 + + def container_volume_fun(volume) when is_binary(volume) do + fn container -> Container.with_bind_volume(container, volume, "/data/db") end + end + + defp encode_query_string(options) when options in [nil, [], %{}], do: "" + + defp encode_query_string(options) when is_map(options) or is_list(options) do + case URI.encode_query(options) do + "" -> "" + query -> "?" <> query + end + end +end diff --git a/mix.exs b/mix.exs index 6cdca50..fd75216 100644 --- a/mix.exs +++ b/mix.exs @@ -59,6 +59,8 @@ defmodule TestcontainersElixir.MixProject do {:myxql, "~> 0.4", only: [:dev, :test]}, # postgres {:postgrex, "~> 0.14", only: [:dev, :test]}, + # mongo + {:mongodb_driver, "~> 1.6.2", only: [:dev, :test]}, # redis {:redix, "~> 1.0", only: [:dev, :test]}, # ceph and minio diff --git a/mix.lock b/mix.lock index 757c7a2..4bebf6e 100644 --- a/mix.lock +++ b/mix.lock @@ -27,6 +27,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mongodb_driver": {:hex, :mongodb_driver, "1.6.2", "3ae95f3a3bf194578400e9c334f3656fcbd70e98d7d112619bb5214c9a979702", [:mix], [{:db_connection, "~> 2.6", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, ">= 2.1.1 and < 3.0.0-0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ezstd, "~> 1.1", [hex: :ezstd, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cf1ad8bf74609ab1f4f3f3ed105e82e2c00860e93baa31d885d96e78a0c7b7d4"}, "myxql": {:hex, :myxql, "0.8.1", "5d7b96f288a98927a40309b274917d16a288a8c0b273205135913ff9799563b3", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "85a4795712bbab1a0f0803d5f0c7332bb383e5f07d3443a42e17a9aa996bbddb"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, diff --git a/test/container/mongo_container_test.exs b/test/container/mongo_container_test.exs new file mode 100644 index 0000000..53720d2 --- /dev/null +++ b/test/container/mongo_container_test.exs @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: MIT +defmodule Testcontainers.Container.MongoContainerTest do + use ExUnit.Case, async: true + + import Testcontainers.ExUnit + + alias Testcontainers.CommandWaitStrategy + alias Testcontainers.ContainerBuilder + alias Testcontainers.MongoContainer + + describe "new/0 and builder options" do + test "returns default mongo configuration" do + config = MongoContainer.new() + + assert config.image == "mongo:latest" + assert config.user == "test" + assert config.password == "test" + assert config.database == "test" + assert config.port == 27017 + assert config.wait_timeout == 180_000 + end + + test "supports custom image" do + config = MongoContainer.new() |> MongoContainer.with_image("bitnami/mongodb:latest") + assert config.image == "bitnami/mongodb:latest" + end + + test "with_username/2 delegates to with_user/2" do + config = MongoContainer.new() |> MongoContainer.with_username("foo") + assert config.user == "foo" + end + + test "sets Mongo init env vars and a Mongo readiness command" do + container = + MongoContainer.new() + |> MongoContainer.with_user("mongo-user") + |> MongoContainer.with_password("mongo-pass") + |> MongoContainer.with_database("mongo-db") + |> ContainerBuilder.build() + + assert container.environment[:MONGO_INITDB_ROOT_USERNAME] == "mongo-user" + assert container.environment[:MONGO_INITDB_ROOT_PASSWORD] == "mongo-pass" + assert container.environment[:MONGO_INITDB_DATABASE] == "mongo-db" + + assert [%CommandWaitStrategy{command: ["sh", "-c", command]}] = container.wait_strategies + assert command =~ "db.adminCommand('ping')" + end + + test "mounts persistent volume in Mongo data path" do + container = + MongoContainer.new() + |> MongoContainer.with_persistent_volume("mongo_data") + |> ContainerBuilder.build() + + assert [%{volume: "mongo_data", container_dest: "/data/db", read_only: false}] = + container.bind_volumes + end + end + + describe "runtime behavior" do + container(:mongo, MongoContainer.new()) + + test "has the default port mapped", %{mongo: mongo} do + assert MongoContainer.port(mongo) + end + + test "returns connection parameters", %{mongo: mongo} do + params = MongoContainer.connection_parameters(mongo) + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + + assert params[:hostname] == host + assert params[:port] == port + assert params[:username] == "test" + assert params[:password] == "test" + assert params[:database] == "test" + end + + test "returns default database urls", %{mongo: mongo} do + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + assert MongoContainer.mongo_url(mongo) == "mongodb://test:test@#{host}:#{port}/test" + assert MongoContainer.database_url(mongo) == "mongodb://test:test@#{host}:#{port}/test" + end + + test "returns mongo url with custom database", %{mongo: mongo} do + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + + assert MongoContainer.mongo_url(mongo, database: "foo") == + "mongodb://test:test@#{host}:#{port}/foo" + end + + test "returns mongo url with custom protocol", %{mongo: mongo} do + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + + assert MongoContainer.mongo_url(mongo, protocol: "mongodb2") == + "mongodb2://test:test@#{host}:#{port}/test" + end + + test "returns mongo url with custom username", %{mongo: mongo} do + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + + assert MongoContainer.mongo_url(mongo, username: "foo") == + "mongodb://foo:test@#{host}:#{port}/test" + end + + test "returns mongo url with custom password", %{mongo: mongo} do + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + + assert MongoContainer.mongo_url(mongo, password: "bar") == + "mongodb://test:bar@#{host}:#{port}/test" + end + + test "returns mongo url with custom query options", %{mongo: mongo} do + host = Testcontainers.get_host(mongo) + port = MongoContainer.port(mongo) + + assert MongoContainer.mongo_url(mongo, options: [replicaSet: "rs0"]) == + "mongodb://test:test@#{host}:#{port}/test?replicaSet=rs0" + end + + test "is reachable and can insert/query a document", %{mongo: mongo} do + connection_params = MongoContainer.connection_parameters(mongo) ++ [auth_source: "admin"] + {:ok, pid} = Mongo.start_link(connection_params) + + on_exit(fn -> + if Process.alive?(pid) do + GenServer.stop(pid) + end + end) + + assert {:ok, %{inserted_id: _id}} = + Mongo.insert_one(pid, "artists", %{name: "FKA Twigs"}) + + artists = + pid + |> Mongo.find("artists", %{name: "FKA Twigs"}) + |> Enum.to_list() + + assert length(artists) == 1 + end + end +end