summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorILYA Khlopotov <iilyak@apache.org>2019-05-13 12:11:48 +0000
committerILYA Khlopotov <iilyak@apache.org>2019-05-22 18:40:51 +0000
commitd888ebd41774ba5eea909a7eb8e35290c57c00e4 (patch)
tree7c6d7535bb5d40ee79354e2d606c61f87cd53fd8
parent94cd6f8260afd5bee3ee9cc0671eff4f480359f6 (diff)
downloadcouchdb-d888ebd41774ba5eea909a7eb8e35290c57c00e4.tar.gz
Multiple testing adapters
-rw-r--r--Makefile2
-rw-r--r--config/config.exs30
-rw-r--r--config/dev.exs1
-rw-r--r--config/prod.exs1
-rw-r--r--config/test.exs11
-rw-r--r--mix.exs2
-rw-r--r--src/couch/test/exunit/crud_test.exs33
-rw-r--r--src/couch/test/exunit/test_helper.exs3
-rw-r--r--test/elixir/README.md177
-rw-r--r--test/elixir/lib/adapter.ex24
-rw-r--r--test/elixir/lib/adapter/backdoor.ex165
-rw-r--r--test/elixir/lib/adapter/clustered.ex163
-rw-r--r--test/elixir/lib/adapter/fabric.ex122
-rw-r--r--test/elixir/lib/adapter/shared.ex68
-rw-r--r--test/elixir/lib/couch.ex77
-rw-r--r--test/elixir/lib/setup.ex63
-rw-r--r--test/elixir/lib/setup/adapter.ex59
-rw-r--r--test/elixir/lib/setup/admin.ex28
-rw-r--r--test/elixir/lib/setup/create_db.ex23
-rw-r--r--test/elixir/lib/setup/login.ex23
-rw-r--r--test/elixir/lib/setup/start.ex45
-rw-r--r--test/elixir/lib/utils.ex19
-rw-r--r--test/elixir/mix.exs2
23 files changed, 1090 insertions, 51 deletions
diff --git a/Makefile b/Makefile
index 50f0392be..1d87fae1c 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/mix.exs b/mix.exs
index 7e22a2fb3..8253949b9 100644
--- a/mix.exs
+++ b/mix.exs
@@ -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]}