diff options
author | ILYA Khlopotov <iilyak@apache.org> | 2019-05-13 12:11:48 +0000 |
---|---|---|
committer | ILYA Khlopotov <iilyak@apache.org> | 2019-05-22 18:40:51 +0000 |
commit | d888ebd41774ba5eea909a7eb8e35290c57c00e4 (patch) | |
tree | 7c6d7535bb5d40ee79354e2d606c61f87cd53fd8 | |
parent | 94cd6f8260afd5bee3ee9cc0671eff4f480359f6 (diff) | |
download | couchdb-d888ebd41774ba5eea909a7eb8e35290c57c00e4.tar.gz |
Multiple testing adapters
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | config/config.exs | 30 | ||||
-rw-r--r-- | config/dev.exs | 1 | ||||
-rw-r--r-- | config/prod.exs | 1 | ||||
-rw-r--r-- | config/test.exs | 11 | ||||
-rw-r--r-- | mix.exs | 2 | ||||
-rw-r--r-- | src/couch/test/exunit/crud_test.exs | 33 | ||||
-rw-r--r-- | src/couch/test/exunit/test_helper.exs | 3 | ||||
-rw-r--r-- | test/elixir/README.md | 177 | ||||
-rw-r--r-- | test/elixir/lib/adapter.ex | 24 | ||||
-rw-r--r-- | test/elixir/lib/adapter/backdoor.ex | 165 | ||||
-rw-r--r-- | test/elixir/lib/adapter/clustered.ex | 163 | ||||
-rw-r--r-- | test/elixir/lib/adapter/fabric.ex | 122 | ||||
-rw-r--r-- | test/elixir/lib/adapter/shared.ex | 68 | ||||
-rw-r--r-- | test/elixir/lib/couch.ex | 77 | ||||
-rw-r--r-- | test/elixir/lib/setup.ex | 63 | ||||
-rw-r--r-- | test/elixir/lib/setup/adapter.ex | 59 | ||||
-rw-r--r-- | test/elixir/lib/setup/admin.ex | 28 | ||||
-rw-r--r-- | test/elixir/lib/setup/create_db.ex | 23 | ||||
-rw-r--r-- | test/elixir/lib/setup/login.ex | 23 | ||||
-rw-r--r-- | test/elixir/lib/setup/start.ex | 45 | ||||
-rw-r--r-- | test/elixir/lib/utils.ex | 19 | ||||
-rw-r--r-- | test/elixir/mix.exs | 2 |
23 files changed, 1090 insertions, 51 deletions
@@ -173,7 +173,7 @@ eunit: couch $(REBAR) -r eunit $(EUNIT_OPTS) apps=$$dir || exit 1; \ done -.PHONY: eunit +.PHONY: exunit # target: exunit - Run ExUnit tests exunit: export BUILDDIR = $(shell pwd) exunit: export MIX_ENV=test diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 000000000..758c1224a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure your application as: +# +# config :foo, key: :value +# +# and access this configuration in your application as: +# +# Application.get_env(:foo, :key) +# +# You can also configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +import_config "#{Mix.env}.exs"
\ No newline at end of file diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 000000000..d2d855e6d --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 000000000..d2d855e6d --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/test.exs b/config/test.exs index 4b28ea99b..c5a5ed24a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,12 @@ +use Mix.Config + config :logger, backends: [:console], - compile_time_purge_level: :debug + compile_time_purge_level: :debug, + level: :debug + +config :kernel, + error_logger: false + +config :sasl, + sasl_error_logger: false @@ -31,7 +31,7 @@ defmodule CouchDBTest.Mixfile do defp deps() do [ # {:dep_from_hexpm, "~> 0.3.0"}, - {:httpotion, "~> 3.0", only: [:dev, :test], runtime: false}, + {:httpotion, "~> 3.1.2", only: [:dev, :test], runtime: false}, {:jiffy, "~> 0.15.2", only: [:dev, :test], runtime: false}, {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false}, {:junit_formatter, "~> 3.0", only: [:test], runtime: false} diff --git a/src/couch/test/exunit/crud_test.exs b/src/couch/test/exunit/crud_test.exs new file mode 100644 index 000000000..cf85ddad8 --- /dev/null +++ b/src/couch/test/exunit/crud_test.exs @@ -0,0 +1,33 @@ +defmodule Couch.Test.CRUD do + use ExUnit.Case + alias Couch.Test.Adapter + alias Couch.Test.Utils, as: Utils + + alias Couch.Test.Setup + + require Record + + test_groups = [ + "using Clustered API": Adapter.Clustered, + "using Backdoor API": Adapter.Backdoor, + "using Fabric API": Adapter.Fabric + ] + + for {describe, adapter} <- test_groups do + describe "Database CRUD #{describe}" do + @describetag setup: + %Setup{} + |> Setup.Start.new([:chttpd]) + |> Setup.Adapter.new(adapter) + |> Setup.Admin.new(user: "adm", password: "pass") + |> Setup.Login.new(user: "adm", password: "pass") + test "Create", %{setup: setup} do + db_name = Utils.random_name("db") + setup_ctx = setup |> Setup.run() + assert {:ok, resp} = Adapter.create_db(Setup.get(setup_ctx, :adapter), db_name) + assert resp.body["ok"] + # TODO query all dbs to make sure db_name is added + end + end + end +end diff --git a/src/couch/test/exunit/test_helper.exs b/src/couch/test/exunit/test_helper.exs index 314050085..2e63e409e 100644 --- a/src/couch/test/exunit/test_helper.exs +++ b/src/couch/test/exunit/test_helper.exs @@ -1,2 +1,5 @@ ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) +:application.set_env(:kernel, :error_logger, false) +:application.load(:sasl) +:application.set_env(:sasl, :sasl_error_logger, false) ExUnit.start() diff --git a/test/elixir/README.md b/test/elixir/README.md index a59b4df90..0fc2c84c0 100644 --- a/test/elixir/README.md +++ b/test/elixir/README.md @@ -111,3 +111,180 @@ X means done, - means partially - [ ] Port view_pagination.js - [ ] Port view_sandboxing.js - [ ] Port view_update_seq.js + +# Using ExUnit to write unit tests + +Elixir has a number of benefits which makes writing unit tests easier. +For example it is trivial to do codegeneration of tests. +Bellow we present a few use cases where code-generation is really helpful. + +## How to write ExUnit tests + +1. Create new file in test/exunit/ directory (the file name should match *_test.exs) +2. In case it is a first file in the directory create test_helper.exs (look at src/couch/test/exunit/test_helper.exs to get an idea) +3. define test module which does `use ExUnit.Case` +4. Define test cases in the module + +You can run tests either: +- using make: `make exunit` +- using mix: BUILDDIR=`pwd` ERL_LIBS=`pwd`/src MIX_ENV=test mix test --trace + +## Test another implementation + +Imagine that we are doing a major rewrite of a module which would implement the same interface. +How do we compare both implementations return the same results for the same input? +It is easy in Elixir, here is a sketch: +``` +defmodule Couch.Test.Fabric.Rewrite do + use ExUnit.Case + alias Couch.Test.Utils, as: Utils + + # we cannot use defrecord here because we need to construct + # record at compile time + admin_ctx = {:user_ctx, Utils.erlang_record( + :user_ctx, "couch/include/couch_db.hrl", roles: ["_admin"])} + + test_cases = [ + {"create database": {create_db, [:db_name, []]}}, + {"create database as admin": {create_db, [:db_name, [admin_ctx]]}} + ] + module_a = :fabric + module_b = :fabric3 + + describe "Test compatibility of '#{module_a}' with '#{module_b}: '" do + for {description, {function, args}} <- test_cases do + test "#{description}" do + result_a = unquote(module_a).unquote(function)(unquote_splicing(args)) + result_b = unquote(module_b).unquote(function)(unquote_splicing(args)) + assert result_a == result_b + end + end + end + +end +``` +As a result we would get following tests +``` +Couch.Test.Fabric.Rewrite + * test Test compatibility of 'fabric' with 'fabric3': create database (0.01ms) + * test Test compatibility of 'fabric' with 'fabric3': create database as admin (0.01ms) +``` + +## Generating tests from spec + +Sometimes we have some data in structured format and want +to generate test cases using that data. This is easy in Elixir. +For example suppose we have following spec: +``` +{ + "{db_name}/_view_cleanup": { + "roles": ["_admin"] + } +} +``` +We can use this spec to generate test cases +``` +defmodule GenerateTestsFromSpec do + use ExUnit.Case + require Record + Record.defrecordp :user_ctx, Record.extract(:user_ctx, from_lib: "couch/include/couch_db.hrl") + Record.defrecordp :httpd, Record.extract(:httpd, from_lib: "couch/include/couch_db.hrl") + + {:ok, spec_bin} = File.read("roles.json") + spec = :jiffy.decode(spec_bin, [:return_maps]) + Enum.each spec, fn {path, path_spec} -> + roles = path_spec["roles"] + @roles roles + @path_parts String.split(path, "/") + test "Access with `#{inspect(roles)}` roles" do + req = httpd(path_parts: @path_parts, user_ctx: user_ctx(roles: @roles)) + :chttpd_auth_request.authorize_request(req) + end + end +end +``` +As a result we would get +``` +GenerateTestsFromSpec + * test Access with `["_admin"]` roles (0.00ms) +``` + +## Generating tests for multiple APIs using test adapter + +CouchDB has four different interfaces which we need to test. These are: +- chttpd +- couch_httpd +- fabric +- couch_db + +There is a bunch of operations which are very similar. The only differences between them are: +- setup/teardown needs a different set of applications +- we need to use different modules to test the operations + +This problem is solved by using testing adapter. We would define a common protocol, which we would use for testing. +Then we implement this protocol for every interface we want to use. + +Here is the example of how it might look like: +``` +defmodule Couch.Test.CRUD do + use ExUnit.Case + alias Couch.Test.Adapter + alias Couch.Test.Utils, as: Utils + + alias Couch.Test.Setup + + require Record + + test_groups = [ + "using Clustered API": Adapter.Clustered, + "using Backdoor API": Adapter.Backdoor, + "using Fabric API": Adapter.Fabric, + ] + + for {describe, adapter} <- test_groups do + describe "Database CRUD #{describe}" do + @describetag setup: %Setup{} + |> Setup.Start.new([:chttpd]) + |> Setup.Adapter.new(adapter) + |> Setup.Admin.new(user: "adm", password: "pass") + |> Setup.Login.new(user: "adm", password: "pass") + test "Create", %{setup: setup} do + db_name = Utils.random_name("db") + setup_ctx = setup |> Setup.run() + assert {:ok, resp} = Adapter.create_db(Setup.get(setup_ctx, :adapter), db_name) + assert resp.body["ok"] + end + end + end +end +``` + +## Test all possible combinations + +Sometimes we want to test all possible permutations for parameters. +This can be accomplished using something like the following: + +``` +defmodule Permutations do + use ExUnit.Case + pairs = :couch_tests_combinatorics.product([ + [:remote, :local], [:remote, :local] + ]) + for [source, dest] <- pairs do + @source source + @dest dest + test "Replication #{source} -> #{dest}" do + assert :ok == :ok + end + end +end +``` + +This would produce following tests +``` +Permutations + * test Replication remote -> remote (0.00ms) + * test Replication local -> remote (0.00ms) + * test Replication remote -> local (0.00ms) + * test Replication local -> local (0.00ms) +```
\ No newline at end of file diff --git a/test/elixir/lib/adapter.ex b/test/elixir/lib/adapter.ex new file mode 100644 index 000000000..342dfdd84 --- /dev/null +++ b/test/elixir/lib/adapter.ex @@ -0,0 +1,24 @@ +defprotocol Couch.Test.Adapter do + def login(adapter, user, pass) + def create_user(adapter, user \\ []) + def create_user_from_doc(adapter, user_doc) + def create_db(adapter, db_name, opts \\ []) + def delete_db(adapter, db_name) + def create_doc(adapter, db_name, body) + def open_doc(adapter, db_name, doc_id) + def update_doc(adapter, db_name, body) + def delete_doc(adapter, db_name, doc_id, rev) + + def bulk_save(adapter, db_name, docs) + def query( + adapter, + db_name, + map_fun, + reduce_fun \\ nil, + options \\ nil, + keys \\ nil, + language \\ "javascript" + ) + def set_config(adapter, section, key, val) + +end diff --git a/test/elixir/lib/adapter/backdoor.ex b/test/elixir/lib/adapter/backdoor.ex new file mode 100644 index 000000000..872ca6052 --- /dev/null +++ b/test/elixir/lib/adapter/backdoor.ex @@ -0,0 +1,165 @@ +defmodule Couch.Test.Adapter.Backdoor do + @moduledoc "Backdoor API testing adapter type" + defstruct [:connection_str, :session] + def new(connection_str) do + %Couch.Test.Adapter.Backdoor{ + connection_str: connection_str + } + end +end + +defimpl Couch.Test.Adapter, for: Couch.Test.Adapter.Backdoor do + import ExUnit.Assertions + import Couch.DBTest, only: [ + retry_until: 1, + ] + + def login(adapter, user, pass) do + # TODO this would work only if we run on the same VM + addr = :config.get('couch_httpd', 'bind_address', '127.0.0.1') + port = :mochiweb_socket_server.get(:couch_httpd, :port) + base_url = "http://#{addr}:#{port}" + session = Couch.login(base_url, user, pass) + %{adapter | session: session} + end + + def create_user(adapter, user) do + assert adapter.session, "Requires login" + user = if user in [nil, ""] do + Couch.Test.random_name("user") + end + user_doc = Couch.Test.Adapter.Shared.format_user_doc(user) + + resp = Couch.Session.get(adapter.session, "/_users/#{user_doc["_id"]}") + + user_doc = + case resp.status_code do + 404 -> + user_doc + + sc when sc >= 200 and sc < 300 -> + Map.put(user_doc, "_rev", resp.body["_rev"]) + end + create_user_from_doc(adapter, user_doc) + end + + def create_user_from_doc(adapter, user_doc) do + resp = Couch.Session.post(adapter.session, "/_users", body: user_doc) + assert HTTPotion.Response.success?(resp) + assert resp.body["ok"] + Map.put(user_doc, "_rev", resp.body["rev"]) + end + + def create_db(adapter, db_name, opts \\ []) do + assert adapter.session, "Requires login" + retry_until(fn -> + resp = Couch.Session.put(adapter.session, "/#{db_name}", opts) + assert resp.status_code in [201, 202] + assert resp.body == %{"ok" => true} + {:ok, resp} + end) + end + + def delete_db(adapter, db_name) do + assert adapter.session, "Requires login" + resp = Couch.Session.delete(adapter.sessiion, "/#{db_name}") + assert resp.status_code in [200, 202, 404] + {:ok, resp} + end + + def create_doc(adapter, db_name, body) do + assert adapter.session, "Requires login" + resp = Couch.Session.post(adapter.session, "/#{db_name}", body: body) + assert resp.status_code in [201, 202] + assert resp.body["ok"] + {:ok, resp} + end + + def open_doc(adapter, db_name, doc_id) do + assert adapter.session, "Requires login" + resp = Couch.Session.get(adapter.session, "/#{db_name}/#{doc_id}") + assert resp.status_code in [200] + {:ok, resp.body} + end + + def update_doc(adapter, db_name, body) do + assert adapter.session, "Requires login" + resp = Couch.Session.put(adapter.session, "/#{db_name}/#{body._id}", body: body) + assert resp.status_code in [200] + {:ok, resp.body} + end + + def delete_doc(adapter, db_name, doc_id, rev) do + assert adapter.session, "Requires login" + resp = Couch.Session.delete(adapter.session, "/#{db_name}/#{doc_id}", %{"rev": rev}) + assert resp.status_code in [200] + {:ok, resp.body} + end + + def bulk_save(adapter, db_name, docs) do + assert adapter.session, "Requires login" + resp = + Couch.Session.post( + adapter.session, + "/#{db_name}/_bulk_docs", + body: %{ + docs: docs + } + ) + + assert resp.status_code == 201 + end + + def query( + adapter, + db_name, + map_fun, + reduce_fun \\ nil, + options \\ nil, + keys \\ nil, + language \\ "javascript" + ) do + assert adapter.session, "Requires login" + + {view_options, request_options} = + if options != nil and Map.has_key?(options, :options) do + {options.options, Map.delete(options, :options)} + else + {nil, options} + end + ddoc = Couch.Test.Adapter.Shared.format_query_ddoc( + map_fun, reduce_fun, language, view_options) + + request_options = + if keys != nil and is_list(keys) do + Map.merge(request_options || %{}, %{:keys => :jiffy.encode(keys)}) + else + request_options + end + + resp = + Couch.Session.put( + adapter.session, + "/#{db_name}/#{ddoc._id}", + headers: ["Content-Type": "application/json"], + body: ddoc + ) + + assert resp.status_code == 201 + + resp = Couch.Session.get(adapter.session, "/#{db_name}/#{ddoc._id}/_view/view", query: request_options) + assert resp.status_code == 200 + + Couch.Session.delete(adapter.session, "/#{db_name}/#{ddoc._id}") + + resp.body + end + + def set_config(adapter, section, key, val) do + url = "#{adapter.connection_str}/#{section}/#{key}" + headers = ["X-Couch-Persist": "false"] + resp = Couch.Session.put(adapter.session, url, headers: headers, body: :jiffy.encode(val)) + resp.body + end + +end diff --git a/test/elixir/lib/adapter/clustered.ex b/test/elixir/lib/adapter/clustered.ex new file mode 100644 index 000000000..4a2b46808 --- /dev/null +++ b/test/elixir/lib/adapter/clustered.ex @@ -0,0 +1,163 @@ +defmodule Couch.Test.Adapter.Clustered do + @moduledoc "Clustered API testing adapter type" + defstruct [:connection_str, :session] + def new(connection_str) do + %Couch.Test.Adapter.Clustered{ + connection_str: connection_str + } + end +end + +defimpl Couch.Test.Adapter, for: Couch.Test.Adapter.Clustered do + import ExUnit.Assertions + import Couch.DBTest, only: [ + retry_until: 1, + ] + + def login(adapter, user, pass) do + addr = :config.get('chttpd', 'bind_address', '127.0.0.1') + port = :mochiweb_socket_server.get(:chttpd, :port) + base_url = "http://#{addr}:#{port}" + session = Couch.login(base_url, user, pass) + %{adapter | session: session} + end + + def create_user(adapter, user) do + assert adapter.session, "Requires login" + user = if user in [nil, ""] do + Couch.Test.random_name("user") + end + user_doc = Couch.Test.Adapter.Shared.format_user_doc(user) + create_user_from_doc(adapter, user_doc) + end + + def create_user_from_doc(adapter, user_doc) do + resp = Couch.Session.get(adapter.session, "/_users/#{user_doc["_id"]}") + + user_doc = + case resp.status_code do + 404 -> + user_doc + + sc when sc >= 200 and sc < 300 -> + Map.put(user_doc, "_rev", resp.body["_rev"]) + end + + resp = Couch.Session.post(adapter.session, "/_users", body: user_doc) + assert HTTPotion.Response.success?(resp) + assert resp.body["ok"] + Map.put(user_doc, "_rev", resp.body["rev"]) + end + + def create_db(adapter, db_name, opts \\ []) do + assert adapter.session, "Requires login" + retry_until(fn -> + resp = Couch.Session.put(adapter.session, "/#{db_name}", opts) + assert resp.status_code in [201, 202] + assert resp.body == %{"ok" => true} + {:ok, resp} + end) + end + + def delete_db(adapter, db_name) do + assert adapter.session, "Requires login" + resp = Couch.Session.delete(adapter.session, "/#{db_name}") + assert resp.status_code in [200, 202, 404] + {:ok, resp} + end + + def create_doc(adapter, db_name, body) do + assert adapter.session, "Requires login" + resp = Couch.Session.post(adapter.session, "/#{db_name}", body: body) + assert resp.status_code in [201, 202] + assert resp.body["ok"] + {:ok, resp} + end + + def open_doc(adapter, db_name, doc_id) do + assert adapter.session, "Requires login" + resp = Couch.Session.get(adapter.session, "/#{db_name}/#{doc_id}") + assert resp.status_code in [200] + {:ok, resp.body} + end + + def update_doc(adapter, db_name, body) do + assert adapter.session, "Requires login" + resp = Couch.Session.put(adapter.session, "/#{db_name}/#{body._id}", body: body) + assert resp.status_code in [200] + {:ok, resp.body} + end + + def delete_doc(adapter, db_name, doc_id, rev) do + assert adapter.session, "Requires login" + resp = Couch.Session.delete(adapter.session, "/#{db_name}/#{doc_id}", %{"rev": rev}) + assert resp.status_code in [200] + {:ok, resp.body} + end + + def bulk_save(adapter, db_name, docs) do + assert adapter.session, "Requires login" + resp = + Couch.Session.post(adapter.session, + "/#{db_name}/_bulk_docs", + body: %{ + docs: docs + } + ) + + assert resp.status_code == 201 + end + + def query( + adapter, + db_name, + map_fun, + reduce_fun \\ nil, + options \\ nil, + keys \\ nil, + language \\ "javascript" + ) do + assert adapter.session, "Requires login" + {view_options, request_options} = + if options != nil and Map.has_key?(options, :options) do + {options.options, Map.delete(options, :options)} + else + {nil, options} + end + ddoc = Couch.Test.Adapter.Shared.format_query_ddoc( + map_fun, reduce_fun, language, view_options) + + request_options = + if keys != nil and is_list(keys) do + Map.merge(request_options || %{}, %{:keys => :jiffy.encode(keys)}) + else + request_options + end + + resp = + Couch.Session.put( + adapter.session, + "/#{db_name}/#{ddoc._id}", + headers: ["Content-Type": "application/json"], + body: ddoc + ) + + assert resp.status_code == 201 + + resp = Couch.Session.get(adapter.session, "/#{db_name}/#{ddoc._id}/_view/view", query: request_options) + assert resp.status_code == 200 + + Couch.Session.delete(adapter.session, "/#{db_name}/#{ddoc._id}") + + resp.body + end + + def set_config(adapter, section, key, val) do + assert adapter.session, "Requires login" + url = "#{adapter.connection_str}/#{section}/#{key}" + headers = ["X-Couch-Persist": "false"] + resp = Couch.Session.put(adapter.session, url, headers: headers, body: :jiffy.encode(val)) + resp.body + end + +end diff --git a/test/elixir/lib/adapter/fabric.ex b/test/elixir/lib/adapter/fabric.ex new file mode 100644 index 000000000..a7a69e764 --- /dev/null +++ b/test/elixir/lib/adapter/fabric.ex @@ -0,0 +1,122 @@ +defmodule Couch.Test.Adapter.Fabric do + @moduledoc "Fabric API testing adapter type" + defstruct [:connection_str, :session] + + def new() do + %Couch.Test.Adapter.Fabric{} + end +end + +defimpl Couch.Test.Adapter, for: Couch.Test.Adapter.Fabric do + import ExUnit.Assertions + + @moduledoc "Implements Fabric API testing adapter" + def login(adapter, _user, _pass) do + adapter + end + + def create_user(adapter, user) do + user = if user in [nil, ""] do + Couch.Test.random_name("user") + end + + user_doc = Couch.Test.Adapter.Shared.format_user_doc(user) + create_user_from_doc(adapter, user_doc) + end + + def create_user_from_doc(_adapter, user_doc) do + doc = :couch_doc.from_json_obj(user_doc) + assert {:ok, resp} = :fabric.update_doc("_users", doc, []) + {:ok, resp} + end + + def create_db(_adapter, db_name, opts \\ []) do + # TODO opts will be different for every adapter type + assert :ok = :fabric.create_db(db_name, opts) + {:ok, %{body: %{"ok" => true}}} + end + + def delete_db(_adapter, db_name) do + assert :ok = :fabric.delete_db(db_name) + {:ok, :ok} + end + + def create_doc(adapter, db_name, body) do + update_doc(adapter, db_name, body) + end + + def update_doc(_adapter, db_name, body) do + doc = :couch_doc.from_json_obj(body) + assert {:ok, resp} = :fabric.update_doc(db_name, doc, []) + {:ok, resp} + end + + def delete_doc(_adapter, db_name, doc_id, rev) do + doc = :couch_doc.from_json_obj(%{ + "_id": doc_id, + "_rev": rev, + "_deleted": true + }) + assert {:ok, resp} = :fabric.update_doc(db_name, doc, []) + {:ok, resp} + end + + def open_doc(_adapter, db_name, doc_name) do + assert {:ok, resp} = :fabric.open_doc(db_name, doc_name, []) + {:ok, resp} + end + + def bulk_save(_adapter, db_name, docs) do + docs = docs + |> Enum.map(&:couch_doc.from_json_obj/1) + assert {:ok, resp} = :fabric.update_docs(db_name, docs, []) + {:ok, resp} + end + + def query( + adapter, + db_name, + map_fun, + reduce_fun \\ nil, + options \\ nil, + keys \\ nil, + language \\ "javascript" + ) do + {view_options, request_options} = + if options != nil and Map.has_key?(options, :options) do + {options.options, Map.delete(options, :options)} + else + {nil, options} + end + + ddoc = + Couch.Test.Adapter.Shared.format_query_ddoc( + map_fun, + reduce_fun, + language, + view_options + ) + + request_options = + if keys != nil and is_list(keys) do + Map.merge(request_options || %{}, %{:keys => :jiffy.encode(keys)}) + else + request_options + end + + resp = update_doc(adapter, db_name, ddoc) + + assert resp.status_code == 201 + + # TODO transform resp + resp = :fabric.query_view(db_name, ddoc._id, "view", request_options) + + adapter.delete_doc(db_name, ddoc._id) + + {:ok, resp} + end + + def set_config(_adapter, section, key, val) do + :config.set(section, key, String.to_charlist(val), false) + end +end diff --git a/test/elixir/lib/adapter/shared.ex b/test/elixir/lib/adapter/shared.ex new file mode 100644 index 000000000..60da0a940 --- /dev/null +++ b/test/elixir/lib/adapter/shared.ex @@ -0,0 +1,68 @@ +defmodule Couch.Test.Adapter.Shared do + @moduledoc "Common functionality for test adapters" + import ExUnit.Assertions + def format_user_doc(user) do + required = [:name, :password, :roles] + + Enum.each(required, fn key -> + assert Keyword.has_key?(user, key), "User missing key: #{key}" + end) + + name = Keyword.get(user, :name) + password = Keyword.get(user, :password) + roles = Keyword.get(user, :roles) + + assert is_binary(name), "User name must be a string" + assert is_binary(password), "User password must be a string" + assert is_list(roles), "Roles must be a list of strings" + + Enum.each(roles, fn role -> + assert is_binary(role), "Roles must be a list of strings" + end) + + %{ + "_id" => "org.couchdb.user:" <> name, + "type" => "user", + "name" => name, + "roles" => roles, + "password" => password + } + end + def format_query_ddoc(map_fun, reduce_fun, language, view_options) do + l_map_function = + if language == "javascript" do + "#{map_fun} /* avoid race cond #{now(:ms)} */" + else + map_fun + end + + view = %{ + :map => l_map_function + } + + view = + if reduce_fun != nil do + Map.put(view, :reduce, reduce_fun) + else + view + end + + view = + if view_options != nil do + Map.put(view, :options, view_options) + end + + ddoc_name = "_design/temp_#{now(:ms)}" + + %{ + _id: ddoc_name, + language: language, + views: %{ + view: view + } + } + end + defp now(:ms) do + div(:erlang.system_time(), 1_000_000) + end +end
\ No newline at end of file diff --git a/test/elixir/lib/couch.ex b/test/elixir/lib/couch.ex index 58581b2fd..c38bb0058 100644 --- a/test/elixir/lib/couch.ex +++ b/test/elixir/lib/couch.ex @@ -3,11 +3,15 @@ defmodule Couch.Session do CouchDB session helpers. """ - @enforce_keys [:cookie] - defstruct [:cookie] + @enforce_keys [:client, :cookie] + defstruct [:client, :cookie, :base_url] - def new(cookie) do - %Couch.Session{cookie: cookie} + def new(client, cookie) do + %Couch.Session{client: client, cookie: cookie} + end + + def new(client, cookie) do + %Couch.Session{client: client, cookie: cookie} end def logout(sess) do @@ -17,7 +21,7 @@ defmodule Couch.Session do Cookie: sess.cookie ] - Couch.delete!("/_session", headers: headers) + sess.client.delete!("/_session", headers: headers) end def get(sess, url, opts \\ []), do: go(sess, :get, url, opts) @@ -34,12 +38,14 @@ defmodule Couch.Session do def go(%Couch.Session{} = sess, method, url, opts) do opts = Keyword.merge(opts, cookie: sess.cookie) + opts = Keyword.merge(opts, base_url: sess.base_url) Couch.request(method, url, opts) end def go!(%Couch.Session{} = sess, method, url, opts) do opts = Keyword.merge(opts, cookie: sess.cookie) - Couch.request!(method, url, opts) + opts = Keyword.merge(opts, base_url: sess.base_url) + sess.client.request!(method, url, opts) end end @@ -54,8 +60,13 @@ defmodule Couch do url end - def process_url(url) do - base_url = System.get_env("EX_COUCH_URL") || "http://127.0.0.1:15984" + def process_url(url, options) do + base_url = case Keyword.get(options, :base_url) do + nil -> + System.get_env("EX_COUCH_URL") || "http://127.0.0.1:15984" + base_url -> + base_url + end base_url <> url end @@ -118,53 +129,25 @@ defmodule Couch do end def login(user, pass) do - resp = Couch.post("/_session", body: %{:username => user, :password => pass}) + login(nil, user, pass) + end + + def login(base_url, user, pass) do + resp = Couch.post("/_session", body: %{:username => user, :password => pass}, base_url: base_url) true = resp.body["ok"] cookie = resp.headers[:"set-cookie"] [token | _] = String.split(cookie, ";") - %Couch.Session{cookie: token} - end - - # HACK: this is here until this commit lands in a release - # https://github.com/myfreeweb/httpotion/commit/f3fa2f0bc3b9b400573942b3ba4628b48bc3c614 - def handle_response(response) do - case response do - {:ok, status_code, headers, body, _} -> - processed_headers = process_response_headers(headers) - - %HTTPotion.Response{ - status_code: process_status_code(status_code), - headers: processed_headers, - body: process_response_body(processed_headers, body) - } - - {:ok, status_code, headers, body} -> - processed_headers = process_response_headers(headers) - - %HTTPotion.Response{ - status_code: process_status_code(status_code), - headers: processed_headers, - body: process_response_body(processed_headers, body) - } - - {:ibrowse_req_id, id} -> - %HTTPotion.AsyncResponse{id: id} - - {:error, {:conn_failed, {:error, reason}}} -> - %HTTPotion.ErrorResponse{message: error_to_string(reason)} - - {:error, :conn_failed} -> - %HTTPotion.ErrorResponse{message: "conn_failed"} - - {:error, reason} -> - %HTTPotion.ErrorResponse{message: error_to_string(reason)} - end + %Couch.Session{ + client: __MODULE__, + cookie: token, + base_url: base_url + } end # Anther HACK: Until we can get process_request_headers/2 merged # upstream. @spec process_arguments(atom, String.t(), [{atom(), any()}]) :: %{} - defp process_arguments(method, url, options) do + def process_arguments(method, url, options) do options = process_options(options) body = Keyword.get(options, :body, "") diff --git a/test/elixir/lib/setup.ex b/test/elixir/lib/setup.ex new file mode 100644 index 000000000..6577b38f2 --- /dev/null +++ b/test/elixir/lib/setup.ex @@ -0,0 +1,63 @@ +defmodule Couch.Test.Setup do + @moduledoc """ + Allows to chain setup functions. + Example of using: + + ``` + @tag setup: %Setup{} + |> Setup.Start.new([:chttpd]) + |> Setup.Adapter.new(adapter) + |> Setup.Admin.new(user: "adm", password: "pass") + |> Setup.Login.new(user: "adm", password: "pass") + test "Create", %{setup: setup} do + db_name = Utils.random_name("db") + ctx = setup |> Setup.run() + ... + end + ``` + """ + import ExUnit.Callbacks, only: [on_exit: 1] + import ExUnit.Assertions, only: [assert: 2] + require Logger + + alias Couch.Test.Setup + defstruct user_acc: %{}, stages: [], completed: [] + + def step(%Setup{stages: stages} = setup, step, args) do + %{setup | stages: [{step, args, nil} | stages]} + end + defp setup_step({step, args, _}, setup) do + # credo:disable-for-next-line Credo.Check.Warning.LazyLogging + Logger.debug("Calling 'setup/2' for '#{step}'") + assert {%Setup{completed: completed} = nsetup, state} = step.setup(setup, args), "Failure in setup for '#{step}''" + completed = [step | completed] + on_exit(fn -> + # credo:disable-for-next-line Credo.Check.Warning.LazyLogging + Logger.debug("Calling 'teardown/3' for '#{step}'") + step.teardown(nsetup, args, state) + end) + setup = %Setup{nsetup | completed: completed} + {{step, args, state}, setup} + end + def run(%Setup{stages: stages} = setup) do + {stages, setup} = stages + |> Enum.reverse + |> Enum.map_reduce(setup, &setup_step/2) + %{setup | stages: stages} + end + def completed?(%Setup{completed: completed}, step) do + step in completed + end + def ctx(%Setup{user_acc: ctx}) do + ctx + end + def put(%Setup{user_acc: ctx} = setup, key, value) do + %Setup{setup | user_acc: Map.put(ctx, key, value)} + end + def get(%Setup{user_acc: ctx}, key, default \\ nil) do + Map.get(ctx, key, default) + end + def has_key?(%Setup{user_acc: ctx}, key) do + Map.has_key?(ctx, key) + end +end
\ No newline at end of file diff --git a/test/elixir/lib/setup/adapter.ex b/test/elixir/lib/setup/adapter.ex new file mode 100644 index 000000000..68200d903 --- /dev/null +++ b/test/elixir/lib/setup/adapter.ex @@ -0,0 +1,59 @@ +defmodule Couch.Test.Setup.Adapter do + @moduledoc """ + Setup step to configure testing adapter to select + appropriate testing interface. + + Supported adapters are: + - Couch.Test.Adapter.Clustered + - Couch.Test.Adapter.Backdoor + - Couch.Test.Adapter.Fabric + + start couch and some extra applications + + It is usually called as: + ``` + test "Create" do + ctx = + %Setup{} + |> Setup.Start.new([:chttpd]) + |> Setup.Adapter.new(Clustered) + ... + |> Setup.run() + ... + end + ``` + """ + alias Couch.Test.Adapter + alias Couch.Test.Adapter.{Clustered, Backdoor, Fabric} + alias Couch.Test.Setup + + alias Couch.Test.Setup.Start + import ExUnit.Assertions, only: [assert: 2] + def new(setup, args) do + setup |> Setup.step(__MODULE__, args) + end + def setup(setup, Clustered) do + assert Setup.has_key?(setup, :clustered_url), "Require `chttpd` application" + state = Clustered.new(Setup.get(setup, :clustered_url)) + {Setup.put(setup, :adapter, state), state} + end + def setup(setup, Backdoor) do + assert Setup.has_key?(setup, :backdoor_url), "Require `Start` setup" + state = Backdoor.new(Setup.get(setup, :backdoor_url)) + {Setup.put(setup, :adapter, state), state} + end + def setup(setup, Fabric) do + assert Setup.completed?(setup, Start), "Require `Start` setup" + state = Fabric.new() + {Setup.put(setup, :adapter, state), state} + end + def teardown(_setup, _args, _state) do + :ok + end + def create_db(setup, db_name) do + Adapter.create_db(Setup.get(setup, :adapter), db_name) + end + def delete_db(setup, db_name) do + Adapter.delete_db(Setup.get(setup, :adapter), db_name) + end +end
\ No newline at end of file diff --git a/test/elixir/lib/setup/admin.ex b/test/elixir/lib/setup/admin.ex new file mode 100644 index 000000000..5a101301f --- /dev/null +++ b/test/elixir/lib/setup/admin.ex @@ -0,0 +1,28 @@ +defmodule Couch.Test.Setup.Admin do + @moduledoc """ + This `setup` would make sure an admin user is created. + It is taking a shortcut and wouldn't work in standalone + integration test. + """ + alias Couch.Test.Setup + alias Couch.Test.Setup.Start + import ExUnit.Assertions, only: [assert: 2] + def new(setup, args) do + assert Keyword.has_key?(args, :user), "`user` argument is missing" + assert Keyword.has_key?(args, :password), "`password` argument is missing" + setup |> Setup.step(__MODULE__, args |> Enum.into(%{})) + end + def setup(setup, args) do + assert Setup.completed?(setup, Start), "Require `Start` setup" + # Latter we might want to mock config:set/4 so it would + # return configured value specifically for this user. + # This would allow us to execute tests concurently + # given we use random name for user + :config.set('admins', String.to_charlist(args[:user]), String.to_charlist(args[:password]), false) + {Setup.put(setup, :admin, args), nil} + end + def teardown(_setup, args, _state) do + :config.delete("admins", args[:user], false) + :ok + end +end
\ No newline at end of file diff --git a/test/elixir/lib/setup/create_db.ex b/test/elixir/lib/setup/create_db.ex new file mode 100644 index 000000000..f6c1eeeca --- /dev/null +++ b/test/elixir/lib/setup/create_db.ex @@ -0,0 +1,23 @@ +defmodule Couch.Test.Setup.Create.DB do + @moduledoc """ + This `setup` would create database with given name + """ + alias Couch.Test.Setup + alias Couch.Test.Setup.Login + import ExUnit.Assertions, only: [assert: 1, assert: 2] + def new(setup, db_name) do + setup |> Setup.step(__MODULE__, db_name) + end + + def setup(ctx, db_name) do + assert Setup.completed?(Login), "Require `Login` setup" + assert {ok, resp} = Adapter.create_db(ctx[:adapter], db_name) + assert resp.body["ok"] + {Map.put(ctx, :dbs, [db_name | ctx[:dbs] || []]), nil} + end + + def teardown(ctx, db_name, _state) do + Adapter.delete_db(ctx[:adapter], db_name) + :ok + end +end diff --git a/test/elixir/lib/setup/login.ex b/test/elixir/lib/setup/login.ex new file mode 100644 index 000000000..6434d1b3a --- /dev/null +++ b/test/elixir/lib/setup/login.ex @@ -0,0 +1,23 @@ +defmodule Couch.Test.Setup.Login do + @moduledoc """ + This `setup` would establish a session by calling + _session endpoint for given adapter. It is a noop + for Fabric adapter + """ + alias Couch.Test.Setup + alias Couch.Test.Adapter + import ExUnit.Assertions, only: [assert: 1, assert: 2] + def new(setup, args) do + assert Keyword.has_key?(args, :user), "`user` argument is missing" + assert Keyword.has_key?(args, :password), "`password` argument is missing" + setup |> Setup.step(__MODULE__, args |> Enum.into(%{})) + end + def setup(setup, %{user: user, password: password}) do + assert Setup.completed?(setup, Setup.Adapter), "Require `Adapter` setup" + adapter = Adapter.login(Setup.get(setup, :adapter), user, password) + {Setup.put(setup, :adapter, adapter), adapter} + end + def teardown(_setup, _args, _state) do + :ok + end +end
\ No newline at end of file diff --git a/test/elixir/lib/setup/start.ex b/test/elixir/lib/setup/start.ex new file mode 100644 index 000000000..60b16ae4a --- /dev/null +++ b/test/elixir/lib/setup/start.ex @@ -0,0 +1,45 @@ +defmodule Couch.Test.Setup.Start do + @moduledoc """ + Setup step to start couch and some extra applications + + It is usually called as: + ``` + test "Create" do + ctx = + %Setup{} + |> Setup.Start.new([:chttpd]) + ... + |> Setup.run() + ... + end + ``` + """ + alias Couch.Test.Setup + import ExUnit.Assertions, only: [assert: 1, assert: 2] + def new() do + new(%Setup{}) + end + def new(setup) do + new(setup, []) + end + def new(setup, extra_apps) do + setup |> Setup.step(__MODULE__, extra_apps) + end + def setup(setup, extra_apps) do + state = :test_util.start_couch(extra_apps) + addr = :config.get('couch_httpd', 'bind_address', '127.0.0.1') + port = :mochiweb_socket_server.get(:couch_httpd, :port) + url = "https://#{addr}:#{port}" + setup = Setup.put(setup, :backdoor_url, url) + if :chttpd in extra_apps do + addr = :config.get('chttpd', 'bind_address', '127.0.0.1') + port = :mochiweb_socket_server.get(:chttpd, :port) + url = "https://#{addr}:#{port}" + setup = Setup.put(setup, :clustered_url, url) + end + {setup, state} + end + def teardown(setup, args, state) do + :test_util.stop_couch(state) + end +end diff --git a/test/elixir/lib/utils.ex b/test/elixir/lib/utils.ex new file mode 100644 index 000000000..5831ae9bd --- /dev/null +++ b/test/elixir/lib/utils.ex @@ -0,0 +1,19 @@ +defmodule Couch.Test.Utils do + require Record + @moduledoc "Helper functions for testing" + def random_name(prefix) do + time = :erlang.monotonic_time() + umi = :erlang.unique_integer([:monotonic]) + "#{prefix}-#{time}-#{umi}" + end + def erlang_record(name, from_lib, opts \\ []) do + record_info = Record.extract(name, from_lib: from_lib) + index = [name | Keyword.keys(record_info)] |> Enum.with_index + draft = [name | Keyword.values(record_info)] |> List.to_tuple + opts + |> Enum.reduce(draft, fn + {k, v}, acc -> put_elem(acc, index[k], v) + end) + end + +end
\ No newline at end of file diff --git a/test/elixir/mix.exs b/test/elixir/mix.exs index f04038ef3..f0c285c60 100644 --- a/test/elixir/mix.exs +++ b/test/elixir/mix.exs @@ -27,7 +27,7 @@ defmodule Foo.Mixfile do defp deps do [ # {:dep_from_hexpm, "~> 0.3.0"}, - {:httpotion, "~> 3.0"}, + {:httpotion, "~> 3.1.2"}, {:jiffy, "~> 0.15.2"}, {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false}, {:junit_formatter, "~> 3.0", only: [:test]} |