You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by da...@apache.org on 2018/01/26 20:21:33 UTC

[couchdb] 01/01: Port replication.js to replication_test.ex

This is an automated email from the ASF dual-hosted git repository.

davisp pushed a commit to branch elixir-suite-davisp
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 307071135c261dcd418f70e5f68ab31f07b6199f
Author: Paul J. Davis <pa...@gmail.com>
AuthorDate: Fri Dec 15 12:31:47 2017 -0600

    Port replication.js to replication_test.ex
---
 test/elixir/lib/couch.ex              |   98 +-
 test/elixir/test/data/lorem.txt       |  103 ++
 test/elixir/test/data/lorem_b64.txt   |    1 +
 test/elixir/test/replication_test.exs | 1710 +++++++++++++++++++++++++++++++++
 test/elixir/test/test_helper.exs      |   79 +-
 5 files changed, 1977 insertions(+), 14 deletions(-)

diff --git a/test/elixir/lib/couch.ex b/test/elixir/lib/couch.ex
index 8ad4821..ace75bd 100644
--- a/test/elixir/lib/couch.ex
+++ b/test/elixir/lib/couch.ex
@@ -1,3 +1,44 @@
+defmodule Couch.Session do
+  @enforce_keys [:cookie]
+  defstruct [:cookie]
+
+  def new(cookie) do
+    %Couch.Session{cookie: cookie}
+  end
+
+  def logout(sess) do
+    headers = [
+        "Content-Type": "application/x-www-form-urlencoded",
+        "X-CouchDB-WWW-Authenticate": "Cookie",
+        "Cookie": sess.cookie
+      ]
+    Couch.delete!("/_session", headers: headers)
+  end
+
+  def get(sess, url, opts \\ []), do: go(sess, :get, url, opts)
+  def get!(sess, url, opts \\ []), do: go(sess, :get, url, opts)
+  def put(sess, url, opts \\ []), do: go(sess, :put, url, opts)
+  def put!(sess, url, opts \\ []), do: go(sess, :put, url, opts)
+  def post(sess, url, opts \\ []), do: go(sess, :post, url, opts)
+  def post!(sess, url, opts \\ []), do: go(sess, :post, url, opts)
+  def delete(sess, url, opts \\ []), do: go(sess, :delete, url, opts)
+  def delete!(sess, url, opts \\ []), do: go(sess, :delete, url, opts)
+
+  # Skipping head/patch/options for YAGNI. Feel free to add
+  # if the need arises.
+
+  def go(%Couch.Session{} = sess, method, url, opts) do
+    opts = Keyword.merge(opts, [cookie: sess.cookie])
+    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)
+  end
+end
+
+
 defmodule Couch do
   use HTTPotion.Base
 
@@ -9,17 +50,28 @@ defmodule Couch do
     "http://localhost:15984" <> url
   end
 
-  def process_request_headers(headers) do
+  def process_request_headers(headers, options) do
     headers = Keyword.put(headers, :"User-Agent", "couch-potion")
-    if headers[:"Content-Type"] do
+    headers = if headers[:"Content-Type"] do
       headers
     else
       Keyword.put(headers, :"Content-Type", "application/json")
     end
+    case Keyword.get options, :cookie do
+      nil ->
+        headers
+      cookie ->
+        Keyword.put headers, :"Cookie", cookie
+    end
   end
 
+
   def process_options(options) do
-    Dict.put options, :basic_auth, {"adm", "pass"}
+    if Keyword.get(options, :cookie) == nil do
+      Keyword.put(options, :basic_auth, {"adm", "pass"})
+    else
+      options
+    end
   end
 
   def process_request_body(body) do
@@ -38,10 +90,17 @@ defmodule Couch do
     end
   end
 
+  def login(userinfo) do
+    [user, pass] = String.split(userinfo, ":", [parts: 2])
+    login(user, pass)
+  end
+
   def login(user, pass) do
     resp = Couch.post("/_session", body: %{:username => user, :password => pass})
     true = resp.body["ok"]
-    resp.body
+    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
@@ -72,4 +131,35 @@ defmodule Couch do
         %HTTPotion.ErrorResponse{ message: error_to_string(reason)}
     end
   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
+    options    = process_options(options)
+
+    body       = Keyword.get(options, :body, "")
+    headers    = Keyword.merge Application.get_env(:httpotion, :default_headers, []), Keyword.get(options, :headers, [])
+    timeout    = Keyword.get(options, :timeout, Application.get_env(:httpotion, :default_timeout, 5000))
+    ib_options = Keyword.merge Application.get_env(:httpotion, :default_ibrowse, []), Keyword.get(options, :ibrowse, [])
+    follow_redirects = Keyword.get(options, :follow_redirects, Application.get_env(:httpotion, :default_follow_redirects, false))
+
+    ib_options = if stream_to = Keyword.get(options, :stream_to), do: Keyword.put(ib_options, :stream_to, spawn(__MODULE__, :transformer, [stream_to, method, url, options])), else: ib_options
+    ib_options = if user_password = Keyword.get(options, :basic_auth) do
+      {user, password} = user_password
+      Keyword.put(ib_options, :basic_auth, { to_charlist(user), to_charlist(password) })
+    else
+      ib_options
+    end
+
+    %{
+      method:     method,
+      url:        url |> to_string |> process_url(options) |> to_charlist,
+      body:       body |> process_request_body,
+      headers:    headers |> process_request_headers(options) |> Enum.map(fn ({k, v}) -> { to_charlist(k), to_charlist(v) } end),
+      timeout:    timeout,
+      ib_options: ib_options,
+      follow_redirects: follow_redirects
+    }
+  end
 end
diff --git a/test/elixir/test/data/lorem.txt b/test/elixir/test/data/lorem.txt
new file mode 100644
index 0000000..0ef85ba
--- /dev/null
+++ b/test/elixir/test/data/lorem.txt
@@ -0,0 +1,103 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus nunc sapien, porta id pellentesque at, elementum et felis. Curabitur condimentum ante in metus iaculis quis congue diam commodo. Donec eleifend ante sed nulla dapibus convallis. Ut cursus aliquam neque, vel porttitor tellus interdum ut. Sed pharetra lacinia adipiscing. In tristique tristique felis non tincidunt. Nulla auctor mauris a velit cursus ultricies. In at libero quis justo consectetur laoreet. Nullam id ultrices n [...]
+
+Nulla in convallis tellus. Proin tincidunt suscipit vulputate. Suspendisse potenti. Nullam tristique justo mi, a tristique ligula. Duis convallis aliquam iaculis. Nulla dictum fringilla congue. Suspendisse ac leo lectus, ac aliquam justo. Ut porttitor commodo mi sed luctus. Nulla at enim lorem. Nunc eu justo sapien, a blandit odio. Curabitur faucibus sollicitudin dolor, id lacinia sem auctor in. Donec varius nunc at lectus sagittis nec luctus arcu pharetra. Nunc sed metus justo. Cras vel [...]
+
+In et dolor vitae orci adipiscing congue. Aliquam gravida nibh at nisl gravida molestie. Curabitur a bibendum sapien. Aliquam tincidunt, nulla nec pretium lobortis, odio augue tincidunt arcu, a lobortis odio sem ut purus. Donec accumsan mattis nunc vitae lacinia. Suspendisse potenti. Integer commodo nisl quis nibh interdum non fringilla dui sodales. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In hac habitasse platea dictumst. Etiam ullamcor [...]
+
+In a magna nisi, a ultricies massa. Donec elit neque, viverra non tempor quis, fringilla in metus. Integer odio odio, euismod vitae mollis sed, sodales eget libero. Donec nec massa in felis ornare pharetra at nec tellus. Nunc lorem dolor, pretium vel auctor in, volutpat vitae felis. Maecenas rhoncus, orci vel blandit euismod, turpis erat tincidunt ante, elementum adipiscing nisl urna in nisi. Phasellus sagittis, enim sed accumsan consequat, urna augue lobortis erat, non malesuada quam me [...]
+
+Pellentesque sed risus a ante vulputate lobortis sit amet eu nisl. Suspendisse ut eros mi, a rhoncus lacus. Curabitur fermentum vehicula tellus, a ornare mi condimentum vel. Integer molestie volutpat viverra. Integer posuere euismod venenatis. Proin ac mauris sed nulla pharetra porttitor. Duis vel dui in risus sodales auctor sit amet non enim. Maecenas mollis lacus at ligula faucibus sodales. Cras vel neque arcu. Sed tincidunt tortor pretium nisi interdum quis dictum arcu laoreet. Morbi  [...]
+
+Donec nec nulla urna, ac sagittis lectus. Suspendisse non elit sed mi auctor facilisis vitae et lectus. Fusce ac vulputate mauris. Morbi condimentum ultrices metus, et accumsan purus malesuada at. Maecenas lobortis ante sed massa dictum vitae venenatis elit commodo. Proin tellus eros, adipiscing sed dignissim vitae, tempor eget ante. Aenean id tellus nec magna cursus pharetra vitae vel enim. Morbi vestibulum pharetra est in vulputate. Aliquam vitae metus arcu, id aliquet nulla. Phasellus [...]
+
+Donec mi enim, laoreet pulvinar mollis eu, malesuada viverra nunc. In vitae metus vitae neque tempor dapibus. Maecenas tincidunt purus a felis aliquam placerat. Nulla facilisi. Suspendisse placerat pharetra mattis. Integer tempor malesuada justo at tempus. Maecenas vehicula lorem a sapien bibendum vel iaculis risus feugiat. Pellentesque diam erat, dapibus et pellentesque quis, molestie ut massa. Vivamus iaculis interdum massa id bibendum. Quisque ut mauris dui, sit amet varius elit. Vest [...]
+
+Sed in metus nulla. Praesent nec adipiscing sapien. Donec laoreet, velit non rutrum vestibulum, ligula neque adipiscing turpis, at auctor sapien elit ut massa. Nullam aliquam, enim vel posuere rutrum, justo erat laoreet est, vel fringilla lacus nisi non lectus. Etiam lectus nunc, laoreet et placerat at, venenatis quis libero. Praesent in placerat elit. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Pellentesque fringilla augue eu nibh placerat [...]
+
+Nulla nec felis elit. Nullam in ipsum in ipsum consequat fringilla quis vel tortor. Phasellus non massa nisi, sit amet aliquam urna. Sed fermentum nibh vitae lacus tincidunt nec tincidunt massa bibendum. Etiam elit dui, facilisis sit amet vehicula nec, iaculis at sapien. Ut at massa id dui ultrices volutpat ut ac libero. Fusce ipsum mi, bibendum a lacinia et, pulvinar eget mauris. Proin faucibus urna ut lorem elementum vulputate. Duis quam leo, malesuada non euismod ut, blandit facilisis [...]
+
+Nulla a turpis quis sapien commodo dignissim eu quis justo. Maecenas eu lorem odio, ut hendrerit velit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin facilisis porttitor ullamcorper. Praesent mollis dignissim massa, laoreet aliquet velit pellentesque non. Nunc facilisis convallis tristique. Mauris porttitor ante at tellus convallis placerat. Morbi aliquet nisi ac nisl pulvinar id dictum nisl mollis. Sed ornare sem et risus placerat lobortis i [...]
+
+Duis pretium ultrices mattis. Nam euismod risus a erat lacinia bibendum. Morbi massa tortor, consectetur id eleifend id, pellentesque vel tortor. Praesent urna lorem, porttitor at condimentum vitae, luctus eget elit. Maecenas fringilla quam convallis est hendrerit viverra. Etiam vehicula, sapien non pulvinar adipiscing, nisi massa vestibulum est, id interdum mauris velit eu est. Vestibulum est arcu, facilisis at ultricies non, vulputate id sapien. Vestibulum ipsum metus, pharetra nec pel [...]
+
+Nam dignissim, nisl eget consequat euismod, sem lectus auctor orci, ut porttitor lacus dui ac neque. In hac habitasse platea dictumst. Fusce egestas porta facilisis. In hac habitasse platea dictumst. Mauris cursus rhoncus risus ac euismod. Quisque vitae risus a tellus venenatis convallis. Curabitur laoreet sapien eu quam luctus lobortis. Vivamus sollicitudin sodales dolor vitae sodales. Suspendisse pharetra laoreet aliquet. Maecenas ullamcorper orci vel tortor luctus iaculis ut vitae met [...]
+
+In sed feugiat eros. Donec bibendum ullamcorper diam, eu faucibus mauris dictum sed. Duis tincidunt justo in neque accumsan dictum. Maecenas in rutrum sapien. Ut id feugiat lacus. Nulla facilisi. Nunc ac lorem id quam varius cursus a et elit. Aenean posuere libero eu tortor vehicula ut ullamcorper odio consequat. Sed in dignissim dui. Curabitur iaculis tempor quam nec placerat. Aliquam venenatis nibh et justo iaculis lacinia. Pellentesque habitant morbi tristique senectus et netus et mal [...]
+
+Integer sem sem, semper in vestibulum vitae, lobortis quis erat. Duis ante lectus, fermentum sed tempor sit amet, placerat sit amet sem. Mauris congue tincidunt ipsum. Ut viverra, lacus vel varius pharetra, purus enim pulvinar ipsum, non pellentesque enim justo non erat. Fusce ipsum orci, ultrices sed pellentesque at, hendrerit laoreet enim. Nunc blandit mollis pretium. Ut mollis, nulla aliquam sodales vestibulum, libero lorem tempus tortor, a pellentesque nibh elit a ipsum. Phasellus fe [...]
+
+Nunc vel ullamcorper mi. Suspendisse potenti. Nunc et urna a augue scelerisque ultrices non quis mi. In quis porttitor elit. Aenean quis erat nulla, a venenatis tellus. Fusce vestibulum nisi sed leo adipiscing dignissim. Nunc interdum, lorem et lacinia vestibulum, quam est mattis magna, sit amet volutpat elit augue at libero. Cras gravida dui quis velit lobortis condimentum et eleifend ligula. Phasellus ac metus quam, id venenatis mi. Aliquam ut turpis ac tellus dapibus dapibus eu in mi. [...]
+
+Vestibulum semper egestas mauris. Morbi vestibulum sem sem. Aliquam venenatis, felis sed eleifend porta, mauris diam semper arcu, sit amet ultricies est sapien sit amet libero. Vestibulum dui orci, ornare condimentum mollis nec, molestie ac eros. Proin vitae mollis velit. Praesent eget felis mi. Maecenas eu vulputate nisi. Vestibulum varius, arcu in ultricies vestibulum, nibh leo sagittis odio, ut bibendum nisl mi nec diam. Integer at enim feugiat nulla semper bibendum ut a velit. Proin  [...]
+
+Sed aliquam mattis quam, in vulputate sapien ultrices in. Pellentesque quis velit sed dui hendrerit cursus. Pellentesque non nunc lacus, a semper metus. Fusce euismod velit quis diam suscipit consequat. Praesent commodo accumsan neque. Proin viverra, ipsum non tristique ultrices, velit velit facilisis lorem, vel rutrum neque eros ac nisi. Suspendisse felis massa, faucibus in volutpat ac, dapibus et odio. Pellentesque id tellus sit amet risus ultricies ullamcorper non nec sapien. Nam plac [...]
+
+Aliquam lorem eros, pharetra nec egestas vitae, mattis nec risus. Mauris arcu massa, sodales eget gravida sed, viverra vitae turpis. Ut ligula urna, euismod ac tincidunt eu, faucibus sed felis. Praesent mollis, ipsum quis rhoncus dignissim, odio sem venenatis nulla, at consequat felis augue vel erat. Nam fermentum feugiat volutpat. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Etiam vitae dui in nisi adipiscing ultricies non eu justo. Donec t [...]
+
+Etiam sit amet nibh justo, posuere volutpat nunc. Morbi pellentesque neque in orci volutpat eu scelerisque lorem dictum. Mauris mollis iaculis est, nec sagittis sapien consequat id. Nunc nec malesuada odio. Duis quis suscipit odio. Mauris purus dui, sodales id mattis sit amet, posuere in arcu. Phasellus porta elementum convallis. Maecenas at orci et mi vulputate sollicitudin in in turpis. Pellentesque cursus adipiscing neque sit amet commodo. Fusce ut mi eu lectus porttitor volutpat et n [...]
+
+Curabitur scelerisque eros quis nisl viverra vel ultrices velit vestibulum. Sed lobortis pulvinar sapien ac venenatis. Sed ante nibh, rhoncus eget dictum in, mollis ut nisi. Phasellus facilisis mi non lorem tristique non eleifend sem fringilla. Integer ut augue est. In venenatis tincidunt scelerisque. Etiam ante dui, posuere quis malesuada vitae, malesuada a arcu. Aenean faucibus venenatis sapien, ut facilisis nisi blandit vel. Aenean ac lorem eu sem fermentum placerat. Proin neque purus [...]
+
+Fusce hendrerit porttitor euismod. Donec malesuada egestas turpis, et ultricies felis elementum vitae. Nullam in sem nibh. Nullam ultricies hendrerit justo sit amet lobortis. Sed tincidunt, mauris at ornare laoreet, sapien purus elementum elit, nec porttitor nisl purus et erat. Donec felis nisi, rutrum ullamcorper gravida ac, tincidunt sit amet urna. Proin vel justo vitae eros sagittis bibendum a ut nibh. Phasellus sodales laoreet tincidunt. Maecenas odio massa, condimentum id aliquet ut [...]
+
+Praesent venenatis magna id sem dictum eu vehicula ipsum vulputate. Sed a convallis sapien. Sed justo dolor, rhoncus vel rutrum mattis, sollicitudin ut risus. Nullam sit amet convallis est. Etiam non tincidunt ligula. Fusce suscipit pretium elit at ullamcorper. Quisque sollicitudin, diam id interdum porta, metus ipsum volutpat libero, id venenatis felis orci non velit. Suspendisse potenti. Mauris rutrum, tortor sit amet pellentesque tincidunt, erat quam ultricies odio, id aliquam elit le [...]
+
+Praesent euismod, turpis quis laoreet consequat, neque ante imperdiet quam, ac semper tortor nibh in nulla. Integer scelerisque eros vehicula urna lacinia ac facilisis mauris accumsan. Phasellus at mauris nibh. Curabitur enim ante, rutrum sed adipiscing hendrerit, pellentesque non augue. In hac habitasse platea dictumst. Nam tempus euismod massa a dictum. Donec sit amet justo ac diam ultricies ultricies. Sed tincidunt erat quis quam tempus vel interdum erat rhoncus. In hac habitasse plat [...]
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum nisl metus, hendrerit ut laoreet sed, consectetur at purus. Duis interdum congue lobortis. Nullam sed massa porta felis eleifend consequat sit amet nec metus. Aliquam placerat dictum erat at eleifend. Vestibulum libero ante, ullamcorper a porttitor suscipit, accumsan vel nisi. Donec et magna neque. Nam elementum ultrices justo, eget sollicitudin sapien imperdiet eget. Nullam auctor dictum nunc, at feugiat odio vestibulum [...]
+
+In sed eros augue, non rutrum odio. Etiam vitae dui neque, in tristique massa. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Maecenas dictum elit at lectus tempor non pharetra nisl hendrerit. Sed sed quam eu lectus ultrices malesuada tincidunt a est. Nam vel eros risus. Maecenas eros elit, blandit fermentum tempor eget, lobortis id diam. Vestibulum lacinia lacus vitae magna volutpat eu dignissim eros convallis. Vivamus ac velit tellus, a congue n [...]
+
+Aliquam imperdiet tellus posuere justo vehicula sed vestibulum ante tristique. Fusce feugiat faucibus purus nec molestie. Nulla tempor neque id magna iaculis quis sollicitudin eros semper. Praesent viverra sagittis luctus. Morbi sit amet magna sed odio gravida varius. Ut nisi libero, vulputate feugiat pretium tempus, egestas sit amet justo. Pellentesque consequat tempor nisi in lobortis. Sed fermentum convallis dui ac sollicitudin. Integer auctor augue eget tellus tempus fringilla. Proin [...]
+
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aliquam ultrices erat non turpis auctor id ornare mauris sagittis. Quisque porttitor, tellus ut convallis sagittis, mi libero feugiat tellus, rhoncus placerat ipsum tortor id risus. Donec tincidunt feugiat leo. Cras id mi neque, eu malesuada eros. Ut molestie magna quis libero placerat malesuada. Aliquam erat volutpat. Aliquam non mauris lorem, in adipiscing metus. Donec eget ipsum in elit commo [...]
+
+Phasellus suscipit, tortor eu varius fringilla, sapien magna egestas risus, ut suscipit dui mauris quis velit. Cras a sapien quis sapien hendrerit tristique a sit amet elit. Pellentesque dui arcu, malesuada et sodales sit amet, dapibus vel quam. Sed non adipiscing ligula. Ut vulputate purus at nisl posuere sodales. Maecenas diam velit, tincidunt id mattis eu, aliquam ac nisi. Maecenas pretium, augue a sagittis suscipit, leo ligula eleifend dolor, mollis feugiat odio augue non eros. Pelle [...]
+
+Duis suscipit pellentesque pellentesque. Praesent porta lobortis cursus. Quisque sagittis velit non tellus bibendum at sollicitudin lacus aliquet. Sed nibh risus, blandit a aliquet eget, vehicula et est. Suspendisse facilisis bibendum aliquam. Fusce consectetur convallis erat, eget mollis diam fermentum sollicitudin. Quisque tincidunt porttitor pretium. Nullam id nisl et urna vulputate dapibus. Donec quis lorem urna. Quisque id justo nec nunc blandit convallis. Nunc volutpat, massa solli [...]
+
+Morbi ultricies diam eget massa posuere lobortis. Aliquam volutpat pellentesque enim eu porttitor. Donec lacus felis, consectetur a pretium vitae, bibendum non enim. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Etiam ut nibh a quam pellentesque auctor ut id velit. Duis lacinia justo eget mi placerat bibendum. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec velit tortor, tempus nec tristique id, a [...]
+
+Quisque interdum tellus ac ante posuere ut cursus lorem egestas. Nulla facilisi. Aenean sed massa nec nisi scelerisque vulputate. Etiam convallis consectetur iaculis. Maecenas ac purus ut ante dignissim auctor ac quis lorem. Pellentesque suscipit tincidunt orci. Fusce aliquam dapibus orci, at bibendum ipsum adipiscing eget. Morbi pellentesque hendrerit quam, nec placerat urna vulputate sed. Quisque vel diam lorem. Praesent id diam quis enim elementum rhoncus sagittis eget purus. Quisque  [...]
+
+Ut id augue id dolor luctus euismod et quis velit. Maecenas enim dolor, tempus sit amet hendrerit eu, faucibus vitae neque. Proin sit amet varius elit. Proin varius felis ullamcorper purus dignissim consequat. Cras cursus tempus eros. Nunc ultrices venenatis ullamcorper. Aliquam et feugiat tellus. Phasellus sit amet vestibulum elit. Phasellus ac purus lacus, et accumsan eros. Morbi ultrices, purus a porta sodales, odio metus posuere neque, nec elementum risus turpis sit amet magna. Sed e [...]
+
+Phasellus viverra iaculis placerat. Nulla consequat dolor sit amet erat dignissim posuere. Nulla lacinia augue vitae mi tempor gravida. Phasellus non tempor tellus. Quisque non enim semper tortor sagittis facilisis. Aliquam urna felis, egestas at posuere nec, aliquet eu nibh. Praesent sed vestibulum enim. Mauris iaculis velit dui, et fringilla enim. Nulla nec nisi orci. Sed volutpat, justo eget fringilla adipiscing, nisl nulla condimentum libero, sed sodales est est et odio. Cras ipsum d [...]
+
+Ut malesuada molestie eleifend. Curabitur id enim dui, eu tincidunt nibh. Mauris sit amet ante leo. Duis turpis ipsum, bibendum sed mattis sit amet, accumsan quis dolor. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aenean a imperdiet metus. Quisque sollicitudin felis id neque tempor scelerisque. Donec at orci felis. Vivamus tempus convallis auctor. Donec interdum euismod lobortis. Sed at lacus nec odio dignissim mollis. Sed sapien orci, porttito [...]
+
+Suspendisse egestas, sapien sit amet blandit scelerisque, nulla arcu tristique dui, a porta justo quam vitae arcu. In metus libero, bibendum non volutpat ut, laoreet vel turpis. Nunc faucibus velit eu ipsum commodo nec iaculis eros volutpat. Vivamus congue auctor elit sed suscipit. Duis commodo, libero eu vestibulum feugiat, leo mi dapibus tellus, in placerat nisl dui at est. Vestibulum viverra tristique lorem, ornare egestas erat rutrum a. Nullam at augue massa, ut consectetur ipsum. Pe [...]
+
+Vivamus in odio a nisi dignissim rhoncus in in lacus. Donec et nisl tortor. Donec sagittis consequat mi, vel placerat tellus convallis id. Aliquam facilisis rutrum nisl sed pretium. Donec et lacinia nisl. Aliquam erat volutpat. Curabitur ac pulvinar tellus. Nullam varius lobortis porta. Cras dapibus, ligula ut porta ultricies, leo lacus viverra purus, quis mollis urna risus eu leo. Nunc malesuada consectetur purus, vel auctor lectus scelerisque posuere. Maecenas dui massa, vestibulum bib [...]
+
+Praesent sed ipsum urna. Praesent sagittis varius magna, id commodo dolor malesuada ac. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque sit amet nunc eu sem ornare tempor. Mauris id dolor nec erat convallis porta in lobortis nisi. Curabitur hendrerit rhoncus tortor eu hendrerit. Pellentesque eu ante vel elit luctus eleifend quis viverra nulla. Suspendisse odio diam, euismod eu porttitor molestie, sollicitudin sit amet nulla. Sed ante  [...]
+
+Etiam quis augue in tellus consequat eleifend. Aenean dignissim congue felis id elementum. Duis fringilla varius ipsum, nec suscipit leo semper vel. Ut sollicitudin, orci a tincidunt accumsan, diam lectus laoreet lacus, vel fermentum quam est vel eros. Aliquam fringilla sapien ac sapien faucibus convallis. Aliquam id nunc eu justo consequat tincidunt. Quisque nec nisl dui. Phasellus augue lectus, varius vitae auctor vel, rutrum at risus. Vivamus lacinia leo quis neque ultrices nec elemen [...]
+
+Curabitur sapien lorem, mollis ut accumsan non, ultricies et metus. Curabitur vel lorem quis sapien fringilla laoreet. Morbi id urna ac orci elementum blandit eget volutpat neque. Pellentesque sem odio, iaculis eu pharetra vitae, cursus in quam. Nulla molestie ligula id massa luctus et pulvinar nisi pulvinar. Nunc fermentum augue a lacus fringilla rhoncus porttitor erat dictum. Nunc sit amet tellus et dui viverra auctor euismod at nisl. In sed congue magna. Proin et tortor ut augue place [...]
+
+Etiam in auctor urna. Fusce ultricies molestie convallis. In hac habitasse platea dictumst. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Mauris iaculis lorem faucibus purus gravida at convallis turpis sollicitudin. Suspendisse at velit lorem, a fermentum ipsum. Etiam condimentum, dui vel condimentum elementum, sapien sem blandit sapien, et pharetra leo neque et lectus. Nunc viverra urna iaculis augue ultrices ac porttitor lacus dignissim. Aliqua [...]
+
+Mauris aliquet urna eget lectus adipiscing at congue turpis consequat. Vivamus tincidunt fermentum risus et feugiat. Nulla molestie ullamcorper nibh sed facilisis. Phasellus et cursus purus. Nam cursus, dui dictum ultrices viverra, erat risus varius elit, eu molestie dui eros quis quam. Aliquam et ante neque, ac consectetur dui. Donec condimentum erat id elit dictum sed accumsan leo sagittis. Proin consequat congue risus, vel tincidunt leo imperdiet eu. Vestibulum malesuada turpis eu met [...]
+
+Pellentesque id molestie nisl. Maecenas et lectus at justo molestie viverra sit amet sit amet ligula. Nullam non porttitor magna. Quisque elementum arcu cursus tortor rutrum lobortis. Morbi sit amet lectus vitae enim euismod dignissim eget at neque. Vivamus consequat vehicula dui, vitae auctor augue dignissim in. In tempus sem quis justo tincidunt sit amet auctor turpis lobortis. Pellentesque non est nunc. Vestibulum mollis fringilla interdum. Maecenas ipsum dolor, pharetra id tristique  [...]
+
+Donec vitae pretium nibh. Maecenas bibendum bibendum diam in placerat. Ut accumsan, mi vitae vestibulum euismod, nunc justo vulputate nisi, non placerat mi urna et diam. Maecenas malesuada lorem ut arcu mattis mollis. Nulla facilisi. Donec est leo, bibendum eu pulvinar in, cursus vel metus. Aliquam erat volutpat. Nullam feugiat porttitor neque in vulputate. Quisque nec mi eu magna consequat cursus non at arcu. Etiam risus metus, sollicitudin et ultrices at, tincidunt sed nunc. Sed eget s [...]
+
+Curabitur ac fermentum quam. Morbi eu eros sapien, vitae tempus dolor. Mauris vestibulum blandit enim ut venenatis. Aliquam egestas, eros at consectetur tincidunt, lorem augue iaculis est, nec mollis felis arcu in nunc. Sed in odio sed libero pellentesque volutpat vitae a ante. Morbi commodo volutpat tellus, ut viverra purus placerat fermentum. Integer iaculis facilisis arcu, at gravida lorem bibendum at. Aenean id eros eget est sagittis convallis sed et dui. Donec eu pulvinar tellus. Nu [...]
+
+Nulla commodo odio justo. Pellentesque non ornare diam. In consectetur sapien ac nunc sagittis malesuada. Morbi ullamcorper tempor erat nec rutrum. Duis ut commodo justo. Cras est orci, consectetur sed interdum sed, scelerisque sit amet nulla. Vestibulum justo nulla, pellentesque a tempus et, dapibus et arcu. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tristique, eros nec congue adipiscing, ligula sem rhoncus felis, at ornare tellus mauris ac risus. Vestibulum ante ips [...]
+
+In nec tempor risus. In faucibus nisi eget diam dignissim consequat. Donec pulvinar ante nec enim mattis rutrum. Vestibulum leo augue, molestie nec dapibus in, dictum at enim. Integer aliquam, lorem eu vulputate lacinia, mi orci tempor enim, eget mattis ligula magna a magna. Praesent sed erat ut tortor interdum viverra. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla facilisi. Maecenas sit amet lectus lacus. Nunc vitae purus id ligula laoreet condimentum. Duis auctor torto [...]
+
+Curabitur velit arcu, pretium porta placerat quis, varius ut metus. Vestibulum vulputate tincidunt justo, vitae porttitor lectus imperdiet sit amet. Vivamus enim dolor, sollicitudin ut semper non, ornare ornare dui. Aliquam tempor fermentum sapien eget condimentum. Curabitur laoreet bibendum ante, in euismod lacus lacinia eu. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Suspendisse potenti. Sed at libero eu tortor tempus scelerisque. Nulla [...]
+
+Nulla varius, nisi eget condimentum semper, metus est dictum odio, vel mattis risus est sed velit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nunc non est nec tellus ultricies mattis ut eget velit. Integer condimentum ante id lorem blandit lacinia. Donec vel tortor augue, in condimentum nisi. Pellentesque pellentesque nulla ut nulla porttitor quis sodales enim rutrum. Sed augue risus, euismod a aliquet at, vulputate non libero. Nullam nibh odio, [...]
+
+Vivamus at fringilla eros. Vivamus at nisl id massa commodo feugiat quis non massa. Morbi tellus urna, auctor sit amet elementum sed, rutrum non lectus. Nulla feugiat dui in sapien ornare et imperdiet est ornare. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum semper rutrum tempor. Sed in felis nibh, sed aliquam enim. Curabitur ut quam scelerisque velit placerat dictum. Donec eleifend vehicula purus, eu vestibulum sapien rutrum eu. [...]
+
+Maecenas ipsum neque, auctor quis lacinia vitae, euismod ac orci. Donec molestie massa consequat est porta ac porta purus tincidunt. Nam bibendum leo nec lacus mollis non condimentum dolor rhoncus. Nulla ac volutpat lorem. Nullam erat purus, convallis eget commodo id, varius quis augue. Nullam aliquam egestas mi, vel suscipit nisl mattis consequat. Quisque vel egestas sapien. Nunc lorem velit, convallis nec laoreet et, aliquet eget massa. Nam et nibh ac dui vehicula aliquam quis eu augue [...]
+
+Cras consectetur ante eu turpis placerat sollicitudin. Mauris et lacus tortor, eget pharetra velit. Donec accumsan ultrices tempor. Donec at nibh a elit condimentum dapibus. Integer sit amet vulputate ante. Suspendisse potenti. In sodales laoreet massa vitae lacinia. Morbi vel lacus feugiat arcu vulputate molestie. Aliquam massa magna, ullamcorper accumsan gravida quis, rhoncus pulvinar nulla. Praesent sit amet ipsum diam, sit amet lacinia neque. In et sapien augue. Etiam enim elit, ultr [...]
+
+Proin et egestas neque. Praesent et ipsum dolor. Nunc non varius nisl. Fusce in tortor nisi. Maecenas convallis neque in ligula blandit quis vehicula leo mollis. Pellentesque sagittis blandit leo, dapibus pellentesque leo ultrices ac. Curabitur ac egestas libero. Donec pretium pharetra pretium. Fusce imperdiet, turpis eu aliquam porta, ante elit eleifend risus, luctus auctor arcu ante ut nunc. Vivamus in leo felis, vitae eleifend lacus. Donec tempus aliquam purus porttitor tristique. Sus [...]
diff --git a/test/elixir/test/data/lorem_b64.txt b/test/elixir/test/data/lorem_b64.txt
new file mode 100644
index 0000000..8a21d79
--- /dev/null
+++ b/test/elixir/test/data/lorem_b64.txt
@@ -0,0 +1 @@
+TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gUGhhc2VsbHVzIG51bmMgc2FwaWVuLCBwb3J0YSBpZCBwZWxsZW50ZXNxdWUgYXQsIGVsZW1lbnR1bSBldCBmZWxpcy4gQ3VyYWJpdHVyIGNvbmRpbWVudHVtIGFudGUgaW4gbWV0dXMgaWFjdWxpcyBxdWlzIGNvbmd1ZSBkaWFtIGNvbW1vZG8uIERvbmVjIGVsZWlmZW5kIGFudGUgc2VkIG51bGxhIGRhcGlidXMgY29udmFsbGlzLiBVdCBjdXJzdXMgYWxpcXVhbSBuZXF1ZSwgdmVsIHBvcnR0aXRvciB0ZWxsdXMgaW50ZXJkdW0gdXQuIFNlZCBwaGFyZXRyYSBsYWNpbmlhIGFkaXBpc2NpbmcuIEluIHRyaXN0aXF1ZSB0cmlzdGlxdWUgZmVsaXMgbm9u [...]
\ No newline at end of file
diff --git a/test/elixir/test/replication_test.exs b/test/elixir/test/replication_test.exs
new file mode 100644
index 0000000..a750afb
--- /dev/null
+++ b/test/elixir/test/replication_test.exs
@@ -0,0 +1,1710 @@
+defmodule ReplicationTest do
+  use CouchTestCase
+
+  @moduledoc """
+  Test CouchDB View Collation Behavior
+  This is a port of the view_collation.js suite
+  """
+
+  # TODO: Parameterize these
+  @admin_account "adm:pass"
+  @db_pairs_prefixes [
+    {"local-to-local", "", ""},
+    {"remote-to-local", "http://localhost:15984/", ""},
+    {"local-to-remote", "", "http://localhost:15984/"},
+    {"remote-to-remote", "http://localhost:15984/", "http://localhost:15984/"}
+  ]
+
+  # This should probably go into `make elixir` like what
+  # happens for JavaScript tests.
+  @moduletag config: [{"replicator", "startup_jitter", "0"}]
+
+  test "source database does not exist" do
+    name = random_db_name()
+    check_not_found(name <> "_src", name <> "_tgt")
+  end
+
+  test "source database not found with path - COUCHDB-317" do
+    name = random_db_name()
+    check_not_found(name <> "_src", name <> "_tgt")
+  end
+
+  test "source database not found with host" do
+    name = random_db_name()
+    url = "http://localhost:15984/" <> name <> "_src"
+    check_not_found(url, name <> "_tgt")
+  end
+
+  def check_not_found(src, tgt) do
+    body = %{:source => src, :target => tgt}
+    resp = Couch.post("/_replicate", body: body)
+    assert resp.body["error"] == "db_not_found"
+  end
+
+  test "replicating attachment without conflict - COUCHDB-885" do
+    name = random_db_name()
+    src_db_name = name <> "_src"
+    tgt_db_name = name <> "_tgt"
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    doc = %{"_id" => "doc1"}
+    [doc] = save_docs(src_db_name, [doc])
+
+    result = replicate(src_db_name, "http://localhost:15984/" <> tgt_db_name)
+    assert result["ok"]
+    assert is_list(result["history"])
+    history = Enum.at(result["history"], 0)
+    assert history["docs_written"] == 1
+    assert history["docs_read"] == 1
+    assert history["doc_write_failures"] == 0
+
+    doc = Map.put(doc, "_attachments", %{
+      "hello.txt" => %{
+        "content_type" => "text/plain",
+        "data" => "aGVsbG8gd29ybGQ=" # base64:encode("hello world")
+      },
+      "foo.dat" => %{
+        "content_type" => "not/compressible",
+        "data" => "aSBhbSBub3QgZ3ppcGVk" # base64:encode("i am not gziped")
+      }
+    })
+    [doc] = save_docs(src_db_name, [doc])
+
+    result = replicate(src_db_name, "http://localhost:15984/" <> tgt_db_name)
+
+    assert result["ok"]
+    assert is_list(result["history"])
+    assert length(result["history"]) == 2
+    history = Enum.at(result["history"], 0)
+    assert history["docs_written"] == 1
+    assert history["docs_read"] == 1
+    assert history["doc_write_failures"] == 0
+
+    query = %{
+      :conflicts => true,
+      :deleted_conflicts => true,
+      :attachments => true,
+      :att_encoding_info => true
+    }
+    opts = [headers: ["Accept": "application/json"], query: query]
+    resp = Couch.get("/#{tgt_db_name}/#{doc["_id"]}", opts)
+    assert HTTPotion.Response.success? resp
+    assert is_map(resp.body)
+    refute Map.has_key? resp.body, "_conflicts"
+    refute Map.has_key? resp.body, "_deleted_conflicts"
+
+    atts = resp.body["_attachments"]
+
+    assert atts["hello.txt"]["content_type"] == "text/plain"
+    assert atts["hello.txt"]["data"] == "aGVsbG8gd29ybGQ="
+    assert atts["hello.txt"]["encoding"] == "gzip"
+
+    assert atts["foo.dat"]["content_type"] == "not/compressible"
+    assert atts["foo.dat"]["data"] == "aSBhbSBub3QgZ3ppcGVk"
+    refute Map.has_key? atts["foo.dat"], "encoding"
+  end
+
+  test "replication cancellation" do
+    name = random_db_name()
+    src_db_name = name <> "_src"
+    tgt_db_name = name <> "_tgt"
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    save_docs(src_db_name, make_docs(1..6))
+
+    repl_body = %{:continuous => true, :create_target => true}
+    repl_src = "http://127.0.0.1:15984/" <> src_db_name
+    result = replicate(repl_src, tgt_db_name, body: repl_body)
+
+    assert result["ok"]
+    assert is_binary(result["_local_id"])
+    repl_id = result["_local_id"]
+
+    task = get_task(repl_id, 3_000)
+    assert is_map(task)
+
+    assert task["replication_id"] == repl_id
+    repl_body = %{
+      "replication_id" => repl_id,
+      "cancel": true
+    }
+    result = Couch.post("/_replicate", body: repl_body)
+    assert result.status_code == 200
+
+    wait_for_repl_stop(repl_id)
+
+    assert get_task(repl_id, 0) == :nil
+
+    result = Couch.post("/_replicate", body: repl_body)
+    assert result.status_code == 404
+  end
+
+  @tag user: [name: "joe", password: "erly", roles: ["erlanger"]]
+  test "unauthorized replication cancellation", ctx do
+    name = random_db_name()
+    src_db_name = name <> "_src"
+    tgt_db_name = name <> "_tgt"
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    save_docs(src_db_name, make_docs(1..6))
+
+    repl_src = "http://localhost:15984/" <> src_db_name
+    repl_body = %{"continuous" => true}
+    result = replicate(repl_src, tgt_db_name, body: repl_body)
+
+    assert result["ok"]
+    assert is_binary(result["_local_id"])
+    repl_id = result["_local_id"]
+
+    task = get_task(repl_id, 5_000)
+    assert is_map(task)
+
+    sess = Couch.login(ctx[:userinfo])
+    resp = Couch.Session.get(sess, "/_session")
+    assert resp.body["ok"]
+    assert resp.body["userCtx"]["name"] == "joe"
+
+    repl_body = %{
+      "replication_id" => repl_id,
+      "cancel": true
+    }
+    resp = Couch.Session.post(sess, "/_replicate", body: repl_body)
+    assert resp.status_code == 401
+    assert resp.body["error"] == "unauthorized"
+
+    assert Couch.Session.logout(sess).body["ok"]
+
+    resp = Couch.post("/_replicate", body: repl_body)
+    assert resp.status_code == 200
+  end
+
+  Enum.each(@db_pairs_prefixes, fn {name, src_prefix, tgt_prefix} ->
+    @src_prefix src_prefix
+    @tgt_prefix tgt_prefix
+
+    test "simple #{name} replication - #{name}" do
+      run_simple_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "replicate with since_seq - #{name}" do
+      run_since_seq_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "validate_doc_update failure replications - #{name}" do
+      run_vdu_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "create_target filter option - #{name}" do
+      run_create_target_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "filtered replications - #{name}" do
+      run_filtered_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "replication restarts after filter change - COUCHDB-892 - #{name}" do
+      run_filter_changed_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "replication by doc ids - #{name}" do
+      run_by_id_repl(@src_prefix, @tgt_prefix)
+    end
+
+    test "continuous replication - #{name}" do
+      run_continuous_repl(@src_prefix, @tgt_prefix)
+    end
+
+    @tag config: [
+      {"attachments", "compression_level", "8"},
+      {"attachments", "compressible_types", "text/*"}
+    ]
+    test "compressed attachment replication - #{name}" do
+      run_compressed_att_repl(@src_prefix, @tgt_prefix)
+    end
+
+    @tag user: [name: "joe", password: "erly", roles: ["erlanger"]]
+    test "non-admin user on target - #{name}", ctx do
+      run_non_admin_target_user_repl(@src_prefix, @tgt_prefix, ctx)
+    end
+
+    @tag user: [name: "joe", password: "erly", roles: ["erlanger"]]
+    test "non-admin or reader user on source - #{name}", ctx do
+      run_non_admin_or_reader_source_user_repl(@src_prefix, @tgt_prefix, ctx)
+    end
+  end)
+
+  def run_simple_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    att1_data = get_att1_data()
+    att2_data = get_att2_data()
+
+    ddoc = %{
+      "_id" => "_design/foo",
+      "language" => "javascript",
+      "value" => "ddoc"
+    }
+    docs = make_docs(1..20) ++ [ddoc]
+    docs = save_docs(src_db_name, docs)
+
+    docs = for doc <- docs do
+      if doc["integer"] >= 10 and doc["integer"] < 15 do
+        add_attachment(src_db_name, doc, body: att1_data)
+      else
+        doc
+      end
+    end
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert src_info["doc_count"] == tgt_info["doc_count"]
+
+    assert is_binary(result["session_id"])
+    assert is_list(result["history"])
+    assert length(result["history"]) == 1
+    history = Enum.at(result["history"], 0)
+    assert is_binary(history["start_time"])
+    assert is_binary(history["end_time"])
+    assert history["start_last_seq"] == 0
+    assert history["missing_checked"] == src_info["doc_count"]
+    assert history["missing_found"] == src_info["doc_count"]
+    assert history["docs_read"] == src_info["doc_count"]
+    assert history["docs_written"] == src_info["doc_count"]
+    assert history["doc_write_failures"] == 0
+
+    for doc <- docs do
+      copy = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}").body
+      assert cmp_json(doc, copy)
+
+      if doc["integer"] >= 10 and doc["integer"] < 15 do
+        atts = copy["_attachments"]
+        assert is_map(atts)
+        att = atts["readme.txt"]
+        assert is_map(att)
+        assert att["revpos"] == 2
+        assert String.match?(att["content_type"], ~r/text\/plain/)
+        assert att["stub"]
+
+        resp = Couch.get!("/#{tgt_db_name}/#{copy["_id"]}/readme.txt")
+        assert String.length(resp.body) == String.length(att1_data)
+        assert resp.body == att1_data
+      end
+    end
+
+    # Add one more doc to source and more attachments to existing docs
+    new_doc = %{"_id" => "foo666", "value" => "d"}
+    [new_doc] = save_docs(src_db_name, [new_doc])
+
+    docs = for doc <- docs do
+      if doc["integer"] >= 10 and doc["integer"] < 15 do
+        ctype = "application/binary"
+        opts = [name: "data.dat", body: att2_data, content_type: ctype]
+        add_attachment(src_db_name, doc, opts)
+      else
+        doc
+      end
+    end
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    assert is_binary(result["session_id"])
+    assert is_list(result["history"])
+    assert length(result["history"]) == 2
+    history = Enum.at(result["history"], 0)
+    assert history["session_id"] == result["session_id"]
+    assert is_binary(history["start_time"])
+    assert is_binary(history["end_time"])
+    assert history["missing_checked"] == 6
+    assert history["missing_found"] == 6
+    assert history["docs_read"] == 6
+    assert history["docs_written"] == 6
+    assert history["doc_write_failures"] == 0
+
+    copy = Couch.get!("/#{tgt_db_name}/#{new_doc["_id"]}").body
+    assert copy["_id"] == new_doc["_id"]
+    assert copy["value"] == new_doc["value"]
+
+    for i <- 10..14 do
+      doc = Enum.at(docs, i - 1)
+      copy = Couch.get!("/#{tgt_db_name}/#{i}").body
+      assert cmp_json(doc, copy)
+
+      atts = copy["_attachments"]
+      assert is_map(atts)
+      att = atts["readme.txt"]
+      assert is_map(atts)
+      assert att["revpos"] == 2
+      assert String.match?(att["content_type"], ~r/text\/plain/)
+      assert att["stub"]
+
+      resp = Couch.get!("/#{tgt_db_name}/#{i}/readme.txt")
+      assert String.length(resp.body) == String.length(att1_data)
+      assert resp.body == att1_data
+
+      att = atts["data.dat"]
+      assert is_map(att)
+      assert att["revpos"] == 3
+      assert String.match?(att["content_type"], ~r/application\/binary/)
+      assert att["stub"]
+
+      resp = Couch.get!("/#{tgt_db_name}/#{i}/data.dat")
+      assert String.length(resp.body) == String.length(att2_data)
+      assert resp.body == att2_data
+    end
+
+    # Test deletion is replicated
+    del_doc = %{
+      "_id" => "1",
+      "_rev" => Enum.at(docs, 0)["_rev"],
+      "_deleted" => true
+    }
+    [del_doc] = save_docs(src_db_name, [del_doc])
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+    assert tgt_info["doc_del_count"] == src_info["doc_del_count"]
+    assert tgt_info["doc_del_count"] == 1
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 3
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 1
+    assert history["missing_found"] == 1
+    assert history["docs_read"] == 1
+    assert history["docs_written"] == 1
+    assert history["doc_write_failures"] == 0
+
+    resp = Couch.get("/#{tgt_db_name}/#{del_doc["_id"]}")
+    assert resp.status_code == 404
+
+    resp = Couch.get!("/#{tgt_db_name}/_changes")
+    [change] = Enum.filter(resp.body["results"], &(&1["id"] == del_doc["_id"]))
+    assert change["id"] == del_doc["_id"]
+    assert change["deleted"]
+
+    # Test replicating a conflict
+    doc = Couch.get!("/#{src_db_name}/2").body
+    [doc] = save_docs(src_db_name, [Map.put(doc, :value, "white")])
+
+    copy = Couch.get!("/#{tgt_db_name}/2").body
+    save_docs(tgt_db_name, [Map.put(copy, :value, "black")])
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 4
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 1
+    assert history["missing_found"] == 1
+    assert history["docs_read"] == 1
+    assert history["docs_written"] == 1
+    assert history["doc_write_failures"] == 0
+
+    copy = Couch.get!("/#{tgt_db_name}/2", query: %{:conflicts => true}).body
+    assert String.match?(copy["_rev"], ~r/^2-/)
+    assert is_list(copy["_conflicts"])
+    assert length(copy["_conflicts"]) == 1
+    conflict = Enum.at(copy["_conflicts"], 0)
+    assert String.match?(conflict, ~r/^2-/)
+
+    # Re-replicate updated conflict
+    [doc] = save_docs(src_db_name, [Map.put(doc, :value, "yellow")])
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 5
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 1
+    assert history["missing_found"] == 1
+    assert history["docs_read"] == 1
+    assert history["docs_written"] == 1
+    assert history["doc_write_failures"] == 0
+
+    copy = Couch.get!("/#{tgt_db_name}/2", query: %{:conflicts => true}).body
+    assert String.match?(copy["_rev"], ~r/^3-/)
+    assert is_list(copy["_conflicts"])
+    assert length(copy["_conflicts"]) == 1
+    conflict = Enum.at(copy["_conflicts"], 0)
+    assert String.match?(conflict, ~r/^2-/)
+
+    # Resolve the conflict and re-replicate new revision
+    resolve_doc = %{"_id" => "2", "_rev" => conflict, "_deleted" => true}
+    save_docs(tgt_db_name, [resolve_doc])
+    save_docs(src_db_name, [Map.put(doc, :value, "rainbow")])
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 6
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 1
+    assert history["missing_found"] == 1
+    assert history["docs_read"] == 1
+    assert history["docs_written"] == 1
+    assert history["doc_write_failures"] == 0
+
+    copy = Couch.get!("/#{tgt_db_name}/2", query: %{:conflicts => true}).body
+
+    assert String.match?(copy["_rev"], ~r/^4-/)
+    assert not Map.has_key?(copy, "_conflicts")
+
+    # Test that existing revisions are not replicated
+    src_docs = [
+      %{"_id" => "foo1", "value" => 111},
+      %{"_id" => "foo2", "value" => 222},
+      %{"_id" => "foo3", "value" => 333}
+    ]
+    save_docs(src_db_name, src_docs)
+    save_docs(tgt_db_name, Enum.filter(src_docs, &(&1["_id"] != "foo2")))
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 7
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 3
+    assert history["missing_found"] == 1
+    assert history["docs_read"] == 1
+    assert history["docs_written"] == 1
+    assert history["doc_write_failures"] == 0
+
+    docs = [
+      %{"_id" => "foo4", "value" => 444},
+      %{"_id" => "foo5", "value" => 555}
+    ]
+    save_docs(src_db_name, docs)
+    save_docs(tgt_db_name, docs)
+
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 8
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 2
+    assert history["missing_found"] == 0
+    assert history["docs_read"] == 0
+    assert history["docs_written"] == 0
+    assert history["doc_write_failures"] == 0
+
+    # Test nothing to replicate
+    result = replicate(src_prefix <> src_db_name, tgt_prefix <> tgt_db_name)
+    assert result["ok"]
+    assert result["no_changes"]
+  end
+
+  def run_since_seq_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    docs = make_docs(1..5)
+    docs = save_docs(src_db_name, docs)
+
+    changes = get_db_changes(src_db_name)["results"]
+    since_seq = Enum.at(changes, 2)["seq"]
+
+    # TODO: In JS we re-fetch _changes with since_seq, is that
+    # really necessary?
+    expected_ids = for change <- Enum.drop(changes, 3) do
+      change["id"]
+    end
+    assert length(expected_ids) == 2
+
+    cancel_replication(repl_src, repl_tgt)
+    result = replicate(repl_src, repl_tgt, body: %{:since_seq => since_seq})
+    cancel_replication(repl_src, repl_tgt)
+
+    assert result["ok"]
+    assert is_list(result["history"])
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 2
+    assert history["missing_found"] == 2
+    assert history["docs_read"] == 2
+    assert history["docs_written"] == 2
+    assert history["doc_write_failures"] == 0
+
+    Enum.each(docs, fn doc ->
+      result = Couch.get("/#{tgt_db_name}/#{doc["_id"]}")
+      if Enum.member?(expected_ids, doc["_id"]) do
+        assert result.status_code < 300
+        assert cmp_json(doc, result.body)
+      else
+        assert result.status_code == 404
+      end
+    end)
+  end
+
+  def run_vdu_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    docs = make_docs(1..7)
+    docs = for doc <- docs do
+      if doc["integer"] == 2 do
+        Map.put(doc, "_attachments", %{
+          "hello.txt" => %{
+            :content_type => "text/plain",
+            :data => "aGVsbG8gd29ybGQ=" # base64:encode("hello world")
+          }
+        })
+      else
+        doc
+      end
+    end
+    docs = save_docs(src_db_name, docs)
+
+    ddoc = %{
+      "_id" => "_design/test",
+      "language" => "javascript",
+      "validate_doc_update" => """
+        function(newDoc, oldDoc, userCtx, secObj) {
+          if((newDoc.integer % 2) !== 0) {
+            throw {forbidden: "I only like multiples of 2."};
+          }
+        }
+      """
+    }
+    [_] = save_docs(tgt_db_name, [ddoc])
+
+    result = replicate(repl_src, repl_tgt)
+    assert result["ok"]
+
+    assert is_list(result["history"])
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 7
+    assert history["missing_found"] == 7
+    assert history["docs_read"] == 7
+    assert history["docs_written"] == 3
+    assert history["doc_write_failures"] == 4
+
+    for doc <- docs do
+      result = Couch.get("/#{tgt_db_name}/#{doc["_id"]}")
+      if rem(doc["integer"], 2) == 0 do
+        assert result.status_code < 300
+        assert result.body["integer"] == doc["integer"]
+      else
+        assert result.status_code == 404
+      end
+    end
+  end
+
+  def run_create_target_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    # This is created by the replication
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    docs = make_docs(1..2)
+    save_docs(src_db_name, docs)
+
+    replicate(repl_src, repl_tgt, body: %{:create_target => true})
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    src_shards = seq_to_shards(src_info["update_seq"])
+    tgt_shards = seq_to_shards(tgt_info["update_seq"])
+    assert tgt_shards == src_shards
+  end
+
+  def run_filtered_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    docs = make_docs(1..30)
+    ddoc = %{
+      "_id" => "_design/mydesign",
+      "language" => "javascript",
+      "filters" => %{
+        "myfilter" => """
+          function(doc, req) {
+            var modulus = Number(req.query.modulus);
+            var special = req.query.special;
+            return (doc.integer % modulus === 0) || (doc.string === special);
+          }
+        """
+      }
+    }
+
+    [_ | docs] = save_docs(src_db_name, [ddoc | docs])
+
+    repl_body = %{
+      "filter" => "mydesign/myfilter",
+      "query_params" => %{
+        "modulus" => "2",
+        "special" => "7"
+      }
+    }
+
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    Enum.each(docs, fn doc ->
+      resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+      if(rem(doc["integer"], 2) == 0 || doc["string"] == "7") do
+        assert resp.status_code < 300
+        assert cmp_json(doc, resp.body)
+      else
+        assert resp.status_code == 404
+      end
+    end)
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 1
+    history = Enum.at(result["history"], 0)
+
+    # We (incorrectly) don't record update sequences for things
+    # that don't pass the changes feed filter. Historically the
+    # last document to pass was the second to last doc which has
+    # an update sequence of 30. Work that has been applied to avoid
+    # conflicts from duplicate IDs breaking _bulk_docs updates added
+    # a sort to the logic which changes this. Now the last document
+    # to pass has a doc id of "8" and is at update_seq 29 (because only
+    # "9" and the design doc are after it).
+    #
+    # In the future the fix ought to be that we record that update
+    # sequence of the database. BigCouch has some existing work on
+    # this in the clustered case because if you have very few documents
+    # that pass the filter then (given single node's behavior) you end
+    # up having to rescan a large portion of the database.
+    # we can't rely on sequences in a cluster
+    # not only can one figure appear twice (at least for n>1), there's also
+    # hashes involved now - so comparing seq==29 is lottery
+    # (= cutting off hashes is nonsense) above, there was brute-force
+    # comparing all attrs of all docs - now we did check if excluded docs
+    # did NOT make it in any way, we can't rely on sequences in a
+    # cluster (so leave out)
+
+    # 16 => 15 docs with even integer field  + 1 doc with string field "7"
+    assert history["missing_checked"] == 16
+    assert history["missing_found"] == 16
+    assert history["docs_read"] == 16
+    assert history["docs_written"] == 16
+    assert history["doc_write_failures"] == 0
+
+    new_docs = make_docs(50..55)
+    new_docs = save_docs(src_db_name, new_docs)
+
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    Enum.each(new_docs, fn doc ->
+      resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+      if(rem(doc["integer"], 2) == 0) do
+        assert resp.status_code < 300
+        assert cmp_json(doc, resp.body)
+      else
+        assert resp.status_code == 404
+      end
+    end)
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 2
+    history = Enum.at(result["history"], 0)
+
+    assert history["missing_checked"] == 3
+    assert history["missing_found"] == 3
+    assert history["docs_read"] == 3
+    assert history["docs_written"] == 3
+    assert history["doc_write_failures"] == 0
+  end
+
+  def run_filter_changed_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    filter_fun_1 = """
+      function(doc, req) {
+        if(doc.value < Number(req.query.maxvalue)) {
+          return true;
+        } else {
+          return false;
+        }
+      }
+    """
+
+    filter_fun_2 = """
+      function(doc, req) {
+        return true;
+      }
+    """
+
+    docs = [
+      %{"_id" => "foo1", "value" => 1},
+      %{"_id" => "foo2", "value" => 2},
+      %{"_id" => "foo3", :value => 3},
+      %{"_id" => "foo4", :value => 4}
+    ]
+    ddoc = %{
+      "_id" => "_design/mydesign",
+      :language => "javascript",
+      :filters => %{
+        :myfilter => filter_fun_1
+      }
+    }
+
+    [ddoc | _] = save_docs(src_db_name, [ddoc | docs])
+
+    repl_body = %{
+      :filter => "mydesign/myfilter",
+      :query_params => %{
+        :maxvalue => "3"
+      }
+    }
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 1
+    history = Enum.at(result["history"], 0)
+    assert history["docs_read"] == 2
+    assert history["docs_written"] == 2
+    assert history["doc_write_failures"] == 0
+
+    resp = Couch.get!("/#{tgt_db_name}/foo1")
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["value"] == 1
+
+    resp = Couch.get!("/#{tgt_db_name}/foo2")
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["value"] == 2
+
+    resp = Couch.get!("/#{tgt_db_name}/foo3")
+    assert resp.status_code == 404
+
+    resp = Couch.get!("/#{tgt_db_name}/foo4")
+    assert resp.status_code == 404
+
+    # Replication should start from scratch after the filter's code changed
+    ddoc = Map.put(ddoc, :filters, %{:myfilter => filter_fun_2})
+    [_] = save_docs(src_db_name, [ddoc])
+
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    assert is_list(result["history"])
+    assert length(result["history"]) == 1
+    history = Enum.at(result["history"], 0)
+    assert history["docs_read"] == 3
+    assert history["docs_written"] == 3
+    assert history["doc_write_failures"] == 0
+
+    resp = Couch.get!("/#{tgt_db_name}/foo1")
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["value"] == 1
+
+    resp = Couch.get!("/#{tgt_db_name}/foo2")
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["value"] == 2
+
+    resp = Couch.get!("/#{tgt_db_name}/foo3")
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["value"] == 3
+
+    resp = Couch.get!("/#{tgt_db_name}/foo4")
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["value"] == 4
+
+    resp = Couch.get!("/#{tgt_db_name}/_design/mydesign")
+    assert HTTPotion.Response.success?(resp)
+  end
+
+  def run_by_id_repl(src_prefix, tgt_prefix) do
+    target_doc_ids = [
+      %{
+        :initial => ["1", "2", "10"],
+        :after => [],
+        :conflict_id => "2"
+      },
+      %{
+        :initial => ["1", "2"],
+        :after => ["7"],
+        :conflict_id => "1"
+      },
+      %{
+        :initial => ["1", "foo_666", "10"],
+        :after => ["7"],
+        :conflict_id => "10"
+      },
+      %{
+        :initial => ["_design/foo", "8"],
+        :after => ["foo_5"],
+        :conflict_id => "8"
+      },
+      %{
+        :initial => ["_design%2Ffoo", "8"],
+        :after => ["foo_5"],
+        :conflict_id => "8"
+      },
+      %{
+        :initial => [],
+        :after => ["foo_1000", "_design/foo", "1"],
+        :conflict_id => "1"
+      }
+    ]
+
+    Enum.each(target_doc_ids, fn test_data ->
+      run_by_id_repl_impl(src_prefix, tgt_prefix, test_data)
+    end)
+  end
+
+  def run_by_id_repl_impl(src_prefix, tgt_prefix, test_data) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    on_exit(fn -> delete_db(src_db_name) end)
+    on_exit(fn -> delete_db(tgt_db_name) end)
+
+    docs = make_docs(1..10)
+    ddoc = %{
+      "_id" => "_design/foo",
+      :language => "javascript",
+      "integer" => 1
+    }
+
+    doc_ids = test_data[:initial]
+    num_missing = Enum.count(doc_ids, fn doc_id ->
+      String.starts_with?(doc_id, "foo_")
+    end)
+    total_replicated = length(doc_ids) - num_missing
+
+    [_ | docs] = save_docs(src_db_name, [ddoc | docs])
+
+    repl_body = %{:doc_ids => doc_ids}
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    if(total_replicated == 0) do
+      assert result["no_changes"]
+    else
+      assert is_binary(result["start_time"])
+      assert is_binary(result["end_time"])
+      assert result["docs_read"] == total_replicated
+      assert result["docs_written"] == total_replicated
+      assert result["doc_write_failures"] == 0
+    end
+
+    Enum.each(doc_ids, fn doc_id ->
+      doc_id = URI.decode(doc_id)
+      orig = Couch.get!("/#{src_db_name}/#{doc_id}")
+      copy = Couch.get!("/#{tgt_db_name}/#{doc_id}")
+
+      if(String.starts_with?(doc_id, "foo_")) do
+        assert orig.status_code == 404
+        assert copy.status_code == 404
+      else
+        assert HTTPotion.Response.success?(orig)
+        assert HTTPotion.Response.success?(copy)
+        assert cmp_json(orig.body, copy.body)
+      end
+    end)
+
+    # Be absolutely sure that other docs were not replicated
+    Enum.each(docs, fn doc ->
+      encoded_id = URI.encode_www_form(doc["_id"])
+      copy = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+      is_doc_id = &(Enum.member?(doc_ids, &1))
+      if(is_doc_id.(doc["_id"]) or is_doc_id.(encoded_id)) do
+        assert HTTPotion.Response.success?(copy)
+      else
+        assert copy.status_code == 404
+      end
+    end)
+
+    tgt_info = get_db_info(tgt_db_name)
+    assert tgt_info["doc_count"] == total_replicated
+
+    doc_ids_after = test_data[:after]
+    num_missing_after = Enum.count(doc_ids_after, fn doc_id ->
+      String.starts_with?(doc_id, "foo_")
+    end)
+
+    repl_body = %{:doc_ids => doc_ids_after}
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    total_replicated_after = length(doc_ids_after) - num_missing_after
+    if(total_replicated_after == 0) do
+      assert result["no_changes"]
+    else
+      assert is_binary(result["start_time"])
+      assert is_binary(result["end_time"])
+      assert result["docs_read"] == total_replicated_after
+      assert result["docs_written"] == total_replicated_after
+      assert result["doc_write_failures"] == 0
+    end
+
+    Enum.each(doc_ids_after, fn doc_id ->
+      orig = Couch.get!("/#{src_db_name}/#{doc_id}")
+      copy = Couch.get!("/#{tgt_db_name}/#{doc_id}")
+
+      if(String.starts_with?(doc_id, "foo_")) do
+        assert orig.status_code == 404
+        assert copy.status_code == 404
+      else
+        assert HTTPotion.Response.success?(orig)
+        assert HTTPotion.Response.success?(copy)
+        assert cmp_json(orig.body, copy.body)
+      end
+    end)
+
+    # Be absolutely sure that other docs were not replicated
+    all_doc_ids = doc_ids ++ doc_ids_after
+    Enum.each(docs, fn doc ->
+      encoded_id = URI.encode_www_form(doc["_id"])
+      copy = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+      is_doc_id = &(Enum.member?(all_doc_ids, &1))
+      if(is_doc_id.(doc["_id"]) or is_doc_id.(encoded_id)) do
+        assert HTTPotion.Response.success?(copy)
+      else
+        assert copy.status_code == 404
+      end
+    end)
+
+    tgt_info = get_db_info(tgt_db_name)
+    assert tgt_info["doc_count"] == total_replicated + total_replicated_after, "#{inspect test_data}"
+
+    # Update a source document and re-replicate (no conflict introduced)
+    conflict_id = test_data[:conflict_id]
+    doc = Couch.get!("/#{src_db_name}/#{conflict_id}").body
+    assert is_map(doc)
+    doc = Map.put(doc, "integer", 666)
+    [doc] = save_docs(src_db_name, [doc])
+
+    att1 = [
+      name: "readme.txt",
+      body: get_att1_data(),
+      content_type: "text/plain"
+    ]
+    att2 = [
+      name: "data.dat",
+      body: get_att2_data(),
+      content_type: "application/binary"
+    ]
+    doc = add_attachment(src_db_name, doc, att1)
+    doc = add_attachment(src_db_name, doc, att2)
+
+    repl_body = %{:doc_ids => [conflict_id]}
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+
+    assert result["docs_read"] == 1
+    assert result["docs_written"] == 1
+    assert result["doc_write_failures"] == 0
+
+    query = %{"conflicts" => "true"}
+    copy = Couch.get!("/#{tgt_db_name}/#{conflict_id}", query: query)
+    assert HTTPotion.Response.success?(copy)
+    assert copy.body["integer"] == 666
+    assert String.starts_with?(copy.body["_rev"], "4-")
+    assert not Map.has_key?(doc, "_conflicts")
+
+    atts = copy.body["_attachments"]
+    assert is_map(atts)
+    assert is_map(atts["readme.txt"])
+    assert atts["readme.txt"]["revpos"] == 3
+    assert String.match?(atts["readme.txt"]["content_type"], ~r/text\/plain/)
+    assert atts["readme.txt"]["stub"]
+
+    att1_data = Couch.get!("/#{tgt_db_name}/#{conflict_id}/readme.txt").body
+    assert String.length(att1_data) == String.length(att1[:body])
+    assert att1_data == att1[:body]
+
+    assert is_map(atts["data.dat"])
+    assert atts["data.dat"]["revpos"] == 4
+    ct_re = ~r/application\/binary/
+    assert String.match?(atts["data.dat"]["content_type"], ct_re)
+    assert atts["data.dat"]["stub"]
+
+    att2_data = Couch.get!("/#{tgt_db_name}/#{conflict_id}/data.dat").body
+    assert String.length(att2_data) == String.length(att2[:body])
+    assert att2_data == att2[:body]
+
+    # Generate a conflict using replication by doc ids
+    orig = Couch.get!("/#{src_db_name}/#{conflict_id}").body
+    orig = Map.update!(orig, "integer", &(&1 + 100))
+    [_] = save_docs(src_db_name, [orig])
+
+    copy = Couch.get!("/#{tgt_db_name}/#{conflict_id}").body
+    copy = Map.update!(copy, "integer", &(&1 + 1))
+    [_] = save_docs(tgt_db_name, [copy])
+
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+    assert result["ok"]
+    assert result["docs_read"] == 1
+    assert result["docs_written"] == 1
+    assert result["doc_write_failures"] == 0
+
+    copy = Couch.get!("/#{tgt_db_name}/#{conflict_id}", query: query).body
+    assert String.match?(copy["_rev"], ~r/^5-/)
+    assert is_list(copy["_conflicts"])
+    assert length(copy["_conflicts"]) == 1
+    conflict_rev = Enum.at(copy["_conflicts"], 0)
+    assert String.match?(conflict_rev, ~r/^5-/)
+  end
+
+  def run_continuous_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    ddoc = %{
+      "_id" => "_design/mydesign",
+      "language" => "javascript",
+      "filters" => %{
+        "myfilter" => "function(doc, req) { return true; }"
+      }
+    }
+    docs = make_docs(1..25)
+    docs = save_docs(src_db_name, docs ++ [ddoc])
+
+    att1_data = get_att1_data()
+
+    docs = for doc <- docs do
+      if doc["integer"] >= 10 and doc["integer"] < 15 do
+        add_attachment(src_db_name, doc)
+      else
+        doc
+      end
+    end
+
+    repl_body = %{:continuous => true}
+    result = replicate(repl_src, repl_tgt, body: repl_body)
+
+    assert result["ok"]
+    assert is_binary(result["_local_id"])
+
+    repl_id = result["_local_id"]
+    task = get_task(repl_id, 30000)
+    assert is_map(task), "Error waiting for replication to start"
+
+    wait_for_repl(src_db_name, repl_id, 26)
+
+    Enum.each(docs, fn doc ->
+      resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+      assert resp.status_code < 300
+      assert cmp_json(doc, resp.body)
+
+      if doc["integer"] >= 10 and doc["integer"] < 15 do
+        atts = resp.body["_attachments"]
+        assert is_map(atts)
+        att = atts["readme.txt"]
+        assert is_map(att)
+        assert att["revpos"] == 2
+        assert String.match?(att["content_type"], ~r/text\/plain/)
+        assert att["stub"]
+
+        resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}/readme.txt")
+        assert String.length(resp.body) == String.length("some text")
+        assert resp.body == "some text"
+      end
+    end)
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    # Add attachments to more source docs
+    docs = for doc <- docs do
+      is_ddoc = String.starts_with?(doc["_id"], "_design/")
+      case doc["integer"] do
+        n when n >= 10 and n < 15 ->
+          ctype = "application/binary"
+          opts = [name: "data.dat", body: att1_data, content_type: ctype]
+          add_attachment(src_db_name, doc, opts)
+        _ when is_ddoc ->
+          add_attachment(src_db_name, doc)
+        _ ->
+          doc
+      end
+    end
+
+    wait_for_repl(src_db_name, repl_id, 32)
+
+    Enum.each(docs, fn doc ->
+      is_ddoc = String.starts_with?(doc["_id"], "_design/")
+      case doc["integer"] do
+        N when N >= 10 and N < 15 or is_ddoc ->
+          resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+          atts = resp.body["_attachments"]
+          assert is_map(atts)
+          att = atts["readme.txt"]
+          assert is_map(att)
+          assert att["revpos"] == 2
+          assert String.match?(att["content_type"], ~r/text\/plain/)
+          assert att["stub"]
+
+          resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}/readme.txt")
+          assert String.length(resp.body) == String.length("some text")
+          assert resp.body == "some text"
+
+          if not is_ddoc do
+            att = atts["data.dat"]
+            assert is_map(att)
+            assert att["revpos"] == 3
+            assert String.match?(att["content_type"], ~r/application\/binary/)
+            assert att["stub"]
+
+            resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}/data.dat")
+            assert String.length(resp.body) == String.length(att1_data)
+            assert resp.body == att1_data
+          end
+        _ ->
+          :ok
+      end
+    end)
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    ddoc = List.last(docs)
+    ctype = "application/binary"
+    opts = [name: "data.dat", body: att1_data, content_type: ctype]
+    add_attachment(src_db_name, ddoc, opts)
+
+    wait_for_repl(src_db_name, repl_id, 33)
+
+    resp = Couch.get("/#{tgt_db_name}/#{ddoc["_id"]}")
+    atts = resp.body["_attachments"]
+    assert is_map(atts)
+    att = atts["readme.txt"]
+    assert is_map(att)
+    assert att["revpos"] == 2
+    assert String.match?(att["content_type"], ~r/text\/plain/)
+    assert att["stub"]
+
+    resp = Couch.get!("/#{tgt_db_name}/#{ddoc["_id"]}/readme.txt")
+    assert String.length(resp.body) == String.length("some text")
+    assert resp.body == "some text"
+
+    att = atts["data.dat"]
+    assert is_map(att)
+    assert att["revpos"] == 3
+    assert String.match?(att["content_type"], ~r/application\/binary/)
+    assert att["stub"]
+
+    resp = Couch.get!("/#{tgt_db_name}/#{ddoc["_id"]}/data.dat")
+    assert String.length(resp.body) == String.length(att1_data)
+    assert resp.body == att1_data
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    # Check creating new normal documents
+    new_docs = make_docs(26..35)
+    new_docs = save_docs(src_db_name, new_docs)
+
+    wait_for_repl(src_db_name, repl_id, 43)
+
+    Enum.each(new_docs, fn doc ->
+      resp = Couch.get!("/#{tgt_db_name}/#{doc["_id"]}")
+      assert resp.status_code < 300
+      assert cmp_json(doc, resp.body)
+    end)
+
+    src_info = get_db_info(src_db_name)
+    tgt_info = get_db_info(tgt_db_name)
+
+    assert tgt_info["doc_count"] == src_info["doc_count"]
+
+    # Delete docs from the source
+
+    doc1 = Enum.at(new_docs, 0)
+    query = %{:rev => doc1["_rev"]}
+    Couch.delete!("/#{src_db_name}/#{doc1["_id"]}", query: query)
+
+    doc2 = Enum.at(new_docs, 6)
+    query = %{:rev => doc2["_rev"]}
+    Couch.delete!("/#{src_db_name}/#{doc2["_id"]}", query: query)
+
+    wait_for_repl(src_db_name, repl_id, 45)
+
+    resp = Couch.get("/#{tgt_db_name}/#{doc1["_id"]}")
+    assert resp.status_code == 404
+    resp = Couch.get("/#{tgt_db_name}/#{doc2["_id"]}")
+    assert resp.status_code == 404
+
+    changes = get_db_changes(tgt_db_name, %{:since => tgt_info["update_seq"]})
+    # quite unfortunately, there is no way on relying on ordering in a cluster
+    # but we can assume a length of 2
+    changes = for change <- changes["results"] do
+      {change["id"], change["deleted"]}
+    end
+    assert Enum.sort(changes) == [{doc1["_id"], true}, {doc2["_id"], true}]
+
+    # Cancel the replication
+    repl_body = %{:continuous => true, :cancel => true}
+    resp = replicate(repl_src, repl_tgt, body: repl_body)
+    assert resp["ok"]
+    assert resp["_local_id"] == repl_id
+
+    doc = %{"_id" => "foobar", "value": 666}
+    [doc] = save_docs(src_db_name, [doc])
+
+    wait_for_repl_stop(repl_id, 30000)
+
+    resp = Couch.get("/#{tgt_db_name}/#{doc["_id"]}")
+    assert resp.status_code == 404
+  end
+
+  def run_compressed_att_repl(src_prefix, tgt_prefix) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    doc = %{"_id" => "foobar"}
+    [doc] = save_docs(src_db_name, [doc])
+
+    att1_data = get_att1_data()
+    num_copies = 1 + round(128 * 1024 / String.length(att1_data))
+    big_att = List.foldl(Enum.to_list(1..num_copies), "", fn _, acc ->
+      acc <> att1_data
+    end)
+
+    doc = add_attachment(src_db_name, doc, [body: big_att])
+
+    # Disable attachment compression
+    set_config_raw("attachments", "compression_level", "0")
+
+    result = replicate(repl_src, repl_tgt)
+    assert result["ok"]
+    assert is_list(result["history"])
+    assert length(result["history"]) == 1
+    history = Enum.at(result["history"], 0)
+    assert history["missing_checked"] == 1
+    assert history["missing_found"] == 1
+    assert history["docs_read"] == 1
+    assert history["docs_written"] == 1
+    assert history["doc_write_failures"] == 0
+
+    token = Enum.random(1..1_000_000)
+    query = %{"att_encoding_info": "true", "bypass_cache": token}
+    resp = Couch.get("/#{tgt_db_name}/#{doc["_id"]}", query: query)
+    assert resp.status_code < 300
+    assert is_map(resp.body["_attachments"])
+    att = resp.body["_attachments"]["readme.txt"]
+    assert att["encoding"] == "gzip"
+    assert is_integer(att["length"])
+    assert is_integer(att["encoded_length"])
+    assert att["encoded_length"] < att["length"]
+  end
+
+  def run_non_admin_target_user_repl(src_prefix, tgt_prefix, ctx) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    set_security(tgt_db_name, %{
+        :admins => %{
+          :names => ["superman"],
+          :roles => ["god"]
+      }})
+
+    docs = make_docs(1..6)
+    ddoc = %{"_id" => "_design/foo", "language" => "javascript"}
+    docs = save_docs(src_db_name, [ddoc | docs])
+
+    sess = Couch.login(ctx[:userinfo])
+    resp = Couch.Session.get(sess, "/_session")
+    assert resp.body["ok"]
+    assert resp.body["userCtx"]["name"] == "joe"
+
+    opts = [
+      userinfo: ctx[:userinfo],
+      headers: [cookie: sess.cookie]
+    ]
+    result = replicate(repl_src, repl_tgt, opts)
+
+    assert Couch.Session.logout(sess).body["ok"]
+
+    assert result["ok"]
+    history = Enum.at(result["history"], 0)
+    assert history["docs_read"] == length(docs)
+    assert history["docs_written"] == length(docs) - 1 # ddoc write failed
+    assert history["doc_write_failures"] == 1 # ddoc write failed
+
+    Enum.each(docs, fn doc ->
+      resp = Couch.get("/#{tgt_db_name}/#{doc["_id"]}")
+      if String.starts_with?(doc["_id"], "_design/") do
+        assert resp.status_code == 404
+      else
+        assert HTTPotion.Response.success?(resp)
+        assert cmp_json(doc, resp.body)
+      end
+    end)
+  end
+
+  def run_non_admin_or_reader_source_user_repl(src_prefix, tgt_prefix, ctx) do
+    base_db_name = random_db_name()
+    src_db_name = base_db_name <> "_src"
+    tgt_db_name = base_db_name <> "_tgt"
+    repl_src = src_prefix <> src_db_name
+    repl_tgt = tgt_prefix <> tgt_db_name
+
+    create_db(src_db_name)
+    create_db(tgt_db_name)
+
+    set_security(tgt_db_name, %{
+        :admins => %{
+          :names => ["superman"],
+          :roles => ["god"]
+        },
+        :readers => %{
+          :names => ["john"],
+          :roles => ["secret"]
+        }
+      })
+
+    docs = make_docs(1..6)
+    ddoc = %{"_id" => "_design/foo", "language" => "javascript"}
+    docs = save_docs(src_db_name, [ddoc | docs])
+
+    sess = Couch.login(ctx[:userinfo])
+    resp = Couch.Session.get(sess, "/_session")
+    assert resp.body["ok"]
+    assert resp.body["userCtx"]["name"] == "joe"
+
+    opts = [
+      userinfo: ctx[:userinfo],
+      headers: [cookie: sess.cookie]
+    ]
+    assert_raise(ExUnit.AssertionError, fn() ->
+      replicate(repl_src, repl_tgt, opts)
+    end)
+
+    assert Couch.Session.logout(sess).body["ok"]
+
+    Enum.each(docs, fn doc ->
+      resp = Couch.get("/#{tgt_db_name}/#{doc["_id"]}")
+      assert resp.status_code == 404
+    end)
+  end
+
+  def get_db_info(db_name) do
+    resp = Couch.get("/#{db_name}")
+    assert HTTPotion.Response.success?(resp)
+    resp.body
+  end
+
+  def replicate(src, tgt, options \\ []) do
+    {userinfo, options} = Keyword.pop(options, :userinfo)
+    userinfo = if userinfo == nil do
+      @admin_account
+    else
+      userinfo
+    end
+
+    src = set_user(src, userinfo)
+    tgt = set_user(tgt, userinfo)
+
+    defaults = [headers: [], body: %{}, timeout: 30_000]
+    options = Keyword.merge(defaults, options) |> Enum.into(%{})
+
+    %{body: body} = options
+    body = [source: src, target: tgt] |> Enum.into(body)
+    options = Map.put(options, :body, body)
+
+    resp = Couch.post("/_replicate", Enum.to_list options)
+    assert HTTPotion.Response.success?(resp), "#{inspect resp}"
+    resp.body
+  end
+
+  def cancel_replication(src, tgt) do
+    body = %{:cancel => true}
+    try do
+      replicate(src, tgt, body: body)
+    rescue
+      ExUnit.AssertionError -> :ok
+    end
+  end
+
+  def get_db_changes(db_name, query \\ %{}) do
+    resp = Couch.get("/#{db_name}/_changes", query: query)
+    assert HTTPotion.Response.success?(resp), "#{inspect resp}"
+    resp.body
+  end
+
+  def save_docs(db_name, docs) do
+    query = %{w: 3}
+    body = %{docs: docs}
+    resp = Couch.post("/#{db_name}/_bulk_docs", query: query, body: body)
+    assert HTTPotion.Response.success?(resp)
+    for {doc, resp} <- Enum.zip(docs, resp.body) do
+      assert resp["ok"], "Error saving doc: #{doc["_id"]}"
+      Map.put(doc, "_rev", resp["rev"])
+    end
+  end
+
+  def set_security(db_name, sec_props) do
+    resp = Couch.put("/#{db_name}/_security", body: :jiffy.encode(sec_props))
+    assert HTTPotion.Response.success?(resp)
+    assert resp.body["ok"]
+  end
+
+  def add_attachment(db_name, doc, att \\ []) do
+    defaults = [
+      name: <<"readme.txt">>,
+      body: <<"some text">>,
+      content_type: "text/plain"
+    ]
+    att = Keyword.merge(defaults, att) |> Enum.into(%{})
+    uri = "/#{db_name}/#{URI.encode(doc["_id"])}/#{att[:name]}"
+    headers = ["Content-Type": att[:content_type]]
+    params = if doc["_rev"] do
+      %{:w => 3, :rev => doc["_rev"]}
+    else
+      %{:w => 3}
+    end
+    resp = Couch.put(uri, headers: headers, query: params, body: att[:body])
+    assert HTTPotion.Response.success?(resp)
+    Map.put(doc, "_rev", resp.body["rev"])
+  end
+
+  def wait_for_repl(src_db_name, repl_id, expect_revs_checked) do
+    wait_for_repl(src_db_name, repl_id, expect_revs_checked, 30000)
+  end
+
+  def wait_for_repl(_, _, _, wait_left) when wait_left <= 0 do
+    assert false, "Timeout waiting for replication"
+  end
+
+  def wait_for_repl(src_db_name, repl_id, expect_revs_checked, wait_left) do
+    task = get_task(repl_id, 0)
+    through_seq = task["through_seq"]
+    revs_checked = task["revisions_checked"]
+    changes = get_db_changes(src_db_name, %{:since => through_seq})
+    if length(changes["results"]) > 0 or revs_checked < expect_revs_checked do
+      :timer.sleep(500)
+      wait_for_repl(src_db_name, repl_id, expect_revs_checked, wait_left - 500)
+    end
+    task
+  end
+
+  def wait_for_repl_stop(repl_id) do
+    wait_for_repl_stop(repl_id, 30000)
+  end
+
+  def wait_for_repl_stop(repl_id, wait_left) when wait_left <= 0 do
+    assert false, "Timeout waiting for replication task to stop: #{repl_id}"
+  end
+
+  def wait_for_repl_stop(repl_id, wait_left) do
+    task = get_task(repl_id, 0)
+    if is_map(task) do
+      :timer.sleep(500)
+      wait_for_repl_stop(repl_id, wait_left - 500)
+    end
+  end
+
+  def get_last_seq(db_name) do
+    body = get_db_changes(db_name, %{:since => "now"})
+    body["last_seq"]
+  end
+
+  def get_task(repl_id, delay) when delay <= 0 do
+    try_get_task(repl_id)
+  end
+
+  def get_task(repl_id, delay) do
+    case try_get_task(repl_id) do
+      result when is_map(result) ->
+        result
+      _ ->
+        :timer.sleep(500)
+        get_task(repl_id, delay - 500)
+    end
+  end
+
+  def try_get_task(repl_id) do
+    resp = Couch.get("/_active_tasks")
+    assert HTTPotion.Response.success?(resp)
+    assert is_list(resp.body)
+    Enum.find(resp.body, :nil, fn task ->
+      task["replication_id"] == repl_id
+    end)
+  end
+
+  def make_docs(ids) do
+    for id <- ids, str_id = Integer.to_string(id) do
+      %{"_id" => str_id, "integer" => id, "string" => str_id}
+    end
+  end
+
+  def set_user(uri, userinfo) do
+    case URI.parse(uri) do
+      %{scheme: nil} ->
+        uri
+      %{userinfo: nil} = uri ->
+        URI.to_string(Map.put(uri, :userinfo, userinfo))
+      _ ->
+        uri
+    end
+  end
+
+  def get_att1_data do
+    File.read!("test/data/lorem.txt")
+  end
+
+  def get_att2_data do
+    File.read!("test/data/lorem_b64.txt")
+  end
+
+  def cmp_json(lhs, rhs) when is_map(lhs) and is_map(rhs) do
+    Enum.reduce_while(lhs, true, fn {k, v}, true ->
+      if Map.has_key?(rhs, k) do
+        if cmp_json(v, rhs[k]) do
+          {:cont, true}
+        else
+          Logger.error "#{inspect lhs} != #{inspect rhs}"
+          {:halt, false}
+        end
+      else
+        Logger.error "#{inspect lhs} != #{inspect rhs}"
+        {:halt, false}
+      end
+    end)
+  end
+
+  def cmp_json(lhs, rhs), do: lhs == rhs
+
+  def seq_to_shards(seq) do
+    for {_node, range, update_seq} <- decode_seq(seq) do
+      {range, update_seq}
+    end
+  end
+
+  def decode_seq(seq) do
+    seq = String.replace(seq, ~r/\d+-/, "", global: false)
+    :erlang.binary_to_term(Base.url_decode64!(seq, padding: false))
+  end
+end
diff --git a/test/elixir/test/test_helper.exs b/test/elixir/test/test_helper.exs
index cb01fc2..f84e1a0 100644
--- a/test/elixir/test/test_helper.exs
+++ b/test/elixir/test/test_helper.exs
@@ -12,7 +12,8 @@ defmodule CouchTestCase do
       setup context do
         setup_funs = [
           &set_db_context/1,
-          &set_config_context/1
+          &set_config_context/1,
+          &set_user_context/1
         ]
         context = Enum.reduce(setup_funs, context, fn setup_fun, acc ->
           setup_fun.(acc)
@@ -55,6 +56,23 @@ defmodule CouchTestCase do
         context
       end
 
+      def set_user_context(context) do
+        case Map.get(context, :user) do
+          nil ->
+            context
+          user when is_list(user) ->
+            user = create_user(user)
+            on_exit(fn ->
+              query = %{:rev => user["_rev"]}
+              resp = Couch.delete("/_users/#{user["_id"]}", query: query)
+              assert HTTPotion.Response.success? resp
+            end)
+            context = Map.put(context, :user, user)
+            userinfo = user["name"] <> ":" <> user["password"]
+            Map.put(context, :userinfo, userinfo)
+        end
+      end
+
       def random_db_name do
         random_db_name("random-test-db")
       end
@@ -66,15 +84,7 @@ defmodule CouchTestCase do
       end
 
       def set_config({section, key, value}) do
-        resp = Couch.get("/_membership")
-        existing = Enum.map(resp.body["all_nodes"], fn node ->
-          url = "/_node/#{node}/_config/#{section}/#{key}"
-          headers = ["X-Couch-Persist": "false"]
-          body = :jiffy.encode(value)
-          resp = Couch.put(url, headers: headers, body: body)
-          assert resp.status_code == 200
-          {node, resp.body}
-        end)
+        existing = set_config_raw(section, key, value)
         on_exit(fn ->
           Enum.each(existing, fn {node, prev_value} ->
             if prev_value != "" do
@@ -93,6 +103,55 @@ defmodule CouchTestCase do
         end)
       end
 
+      def set_config_raw(section, key, value) do
+        resp = Couch.get("/_membership")
+        Enum.map(resp.body["all_nodes"], fn node ->
+          url = "/_node/#{node}/_config/#{section}/#{key}"
+          headers = ["X-Couch-Persist": "false"]
+          body = :jiffy.encode(value)
+          resp = Couch.put(url, headers: headers, body: body)
+          assert resp.status_code == 200
+          {node, resp.body}
+        end)
+      end
+
+      def create_user(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)
+
+        user_doc = %{
+          "_id" => "org.couchdb.user:" <> name,
+          "type" => "user",
+          "name" => name,
+          "roles" => roles,
+          "password" => password
+        }
+        resp = Couch.get("/_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.post("/_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(db_name) do
         resp = Couch.put("/#{db_name}")
         assert resp.status_code == 201

-- 
To stop receiving notification emails like this one, please contact
davisp@apache.org.