You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by ju...@apache.org on 2020/02/25 08:36:26 UTC

[couchdb] branch 3.x updated: Port changes.js test suite into elixir

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

juanjo pushed a commit to branch 3.x
in repository https://gitbox.apache.org/repos/asf/couchdb.git


The following commit(s) were added to refs/heads/3.x by this push:
     new bc0d4d7  Port changes.js test suite into elixir
     new 208252e  Merge pull request #2593 from jjrodrig/3x-merge
bc0d4d7 is described below

commit bc0d4d7bb3f8237b9678d3f6ecb938a3cc29ac28
Author: Juanjo Rodriguez <jj...@gmail.com>
AuthorDate: Thu Feb 20 23:53:51 2020 +0100

    Port changes.js test suite into elixir
---
 test/elixir/test/changes_async_test.exs | 545 ++++++++++++++++++++++++++++++++
 test/elixir/test/changes_test.exs       | 440 +++++++++++++++++++++++++-
 test/javascript/tests/changes.js        |   7 +-
 3 files changed, 973 insertions(+), 19 deletions(-)

diff --git a/test/elixir/test/changes_async_test.exs b/test/elixir/test/changes_async_test.exs
new file mode 100644
index 0000000..07afcdc
--- /dev/null
+++ b/test/elixir/test/changes_async_test.exs
@@ -0,0 +1,545 @@
+defmodule ChangesAsyncTest do
+  use CouchTestCase
+
+  @moduletag :changes
+
+  @moduledoc """
+  Test CouchDB /{db}/_changes
+  """
+
+  @tag :with_db
+  test "live changes", context do
+    db_name = context[:db_name]
+    test_changes(db_name, "live")
+  end
+
+  @tag :with_db
+  test "continuous changes", context do
+    db_name = context[:db_name]
+    test_changes(db_name, "continuous")
+  end
+
+  @tag :with_db
+  test "longpoll changes", context do
+    db_name = context[:db_name]
+
+    check_empty_db(db_name)
+
+    create_doc(db_name, sample_doc_foo())
+
+    req_id =
+      Couch.get("/#{db_name}/_changes?feed=longpoll",
+        stream_to: self()
+      )
+
+    changes = process_response(req_id.id, &parse_chunk/1)
+    {changes_length, last_seq_prefix} = parse_changes_response(changes)
+    assert changes_length == 1, "db should not be empty"
+    assert last_seq_prefix == "1-", "seq must start with 1-"
+
+    last_seq = changes["last_seq"]
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    req_id =
+      Couch.get("/#{db_name}/_changes?feed=longpoll&since=#{last_seq}",
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+
+    create_doc_bar(db_name, "bar")
+
+    {changes_length, last_seq_prefix} =
+      req_id.id
+      |> process_response(&parse_chunk/1)
+      |> parse_changes_response()
+
+    assert changes_length == 1, "should return one change"
+    assert last_seq_prefix == "2-", "seq must start with 2-"
+
+    req_id =
+      Couch.get("/#{db_name}/_changes?feed=longpoll&since=now",
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+
+    create_doc_bar(db_name, "barzzzz")
+
+    changes = process_response(req_id.id, &parse_chunk/1)
+    {changes_length, last_seq_prefix} = parse_changes_response(changes)
+    assert changes_length == 1, "should return one change"
+    assert Enum.at(changes["results"], 0)["id"] == "barzzzz"
+    assert last_seq_prefix == "3-", "seq must start with 3-"
+  end
+
+  @tag :with_db
+  test "eventsource changes", context do
+    db_name = context[:db_name]
+
+    check_empty_db(db_name)
+
+    create_doc(db_name, sample_doc_foo())
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    req_id =
+      Rawresp.get("/#{db_name}/_changes?feed=eventsource&timeout=500",
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+
+    create_doc_bar(db_name, "bar")
+
+    changes = process_response(req_id.id, &parse_event/1)
+
+    assert length(changes) == 2
+    assert Enum.at(changes, 0)["id"] == "foo"
+    assert Enum.at(changes, 1)["id"] == "bar"
+
+    HTTPotion.stop_worker_process(worker_pid)
+  end
+
+  @tag :with_db
+  test "eventsource heartbeat", context do
+    db_name = context[:db_name]
+
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    req_id =
+      Rawresp.get("/#{db_name}/_changes?feed=eventsource&heartbeat=10",
+        stream_to: {self(), :once},
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+    beats = wait_for_heartbeats(req_id.id, 0, 3)
+    assert beats == 3
+    HTTPotion.stop_worker_process(worker_pid)
+  end
+
+  @tag :with_db
+  test "longpoll filtered changes", context do
+    db_name = context[:db_name]
+    create_filters_view(db_name)
+
+    create_doc(db_name, %{bop: "foom"})
+    create_doc(db_name, %{bop: false})
+
+    req_id =
+      Couch.get("/#{db_name}/_changes?feed=longpoll&filter=changes_filter/bop",
+        stream_to: self()
+      )
+
+    changes = process_response(req_id.id, &parse_chunk/1)
+    {changes_length, last_seq_prefix} = parse_changes_response(changes)
+    assert changes_length == 1, "db should not be empty"
+    assert last_seq_prefix == "3-", "seq must start with 3-"
+
+    last_seq = changes["last_seq"]
+    # longpoll waits until a matching change before returning
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    req_id =
+      Couch.get(
+        "/#{db_name}/_changes?feed=longpoll&filter=changes_filter/bop&since=#{last_seq}",
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+    create_doc(db_name, %{_id: "falsy", bop: ""})
+    # Doc doesn't match the filter
+    changes = process_response(req_id.id, &parse_chunk/1)
+    assert changes == :timeout
+
+    # Doc matches the filter
+    create_doc(db_name, %{_id: "bingo", bop: "bingo"})
+    changes = process_response(req_id.id, &parse_chunk/1)
+    {changes_length, last_seq_prefix} = parse_changes_response(changes)
+    assert changes_length == 1, "db should not be empty"
+    assert last_seq_prefix == "5-", "seq must start with 5-"
+    assert Enum.at(changes["results"], 0)["id"] == "bingo"
+  end
+
+  @tag :with_db
+  test "continuous filtered changes", context do
+    db_name = context[:db_name]
+    create_filters_view(db_name)
+
+    create_doc(db_name, %{bop: false})
+    create_doc(db_name, %{_id: "bingo", bop: "bingo"})
+
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    req_id =
+      Rawresp.get(
+        "/#{db_name}/_changes?feed=continuous&filter=changes_filter/bop&timeout=500",
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+    create_doc(db_name, %{_id: "rusty", bop: "plankton"})
+
+    changes = process_response(req_id.id, &parse_changes_line_chunk/1)
+
+    changes_ids =
+      changes
+      |> Enum.filter(fn p -> Map.has_key?(p, "id") end)
+      |> Enum.map(fn p -> p["id"] end)
+
+    assert Enum.member?(changes_ids, "bingo")
+    assert Enum.member?(changes_ids, "rusty")
+    assert length(changes_ids) == 2
+  end
+
+  @tag :with_db
+  test "continuous filtered changes with doc ids", context do
+    db_name = context[:db_name]
+    doc_ids = %{doc_ids: ["doc1", "doc3", "doc4"]}
+
+    create_doc(db_name, %{_id: "doc1", value: 1})
+    create_doc(db_name, %{_id: "doc2", value: 2})
+
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    req_id =
+      Rawresp.post(
+        "/#{db_name}/_changes?feed=continuous&timeout=500&filter=_doc_ids",
+        body: doc_ids,
+        headers: ["Content-Type": "application/json"],
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id.id, 200)
+    create_doc(db_name, %{_id: "doc3", value: 3})
+
+    changes = process_response(req_id.id, &parse_changes_line_chunk/1)
+
+    changes_ids =
+      changes
+      |> Enum.filter(fn p -> Map.has_key?(p, "id") end)
+      |> Enum.map(fn p -> p["id"] end)
+
+    assert Enum.member?(changes_ids, "doc1")
+    assert Enum.member?(changes_ids, "doc3")
+    assert length(changes_ids) == 2
+  end
+
+  @tag :with_db
+  test "COUCHDB-1852", context do
+    db_name = context[:db_name]
+
+    create_doc(db_name, %{bop: "foom"})
+    create_doc(db_name, %{bop: "foom"})
+    create_doc(db_name, %{bop: "foom"})
+    create_doc(db_name, %{bop: "foom"})
+
+    resp = Couch.get("/#{db_name}/_changes")
+    assert length(resp.body["results"]) == 4
+    seq = Enum.at(resp.body["results"], 1)["seq"]
+
+    {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url(""))
+
+    # simulate an EventSource request with a Last-Event-ID header
+    req_id =
+      Rawresp.get(
+        "/#{db_name}/_changes?feed=eventsource&timeout=100&since=0",
+        headers: [Accept: "text/event-stream", "Last-Event-ID": seq],
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    changes = process_response(req_id.id, &parse_event/1)
+    assert length(changes) == 2
+  end
+
+  defp wait_for_heartbeats(id, beats, expexted_beats) do
+    if beats < expexted_beats do
+      :ibrowse.stream_next(id)
+      is_heartbeat = process_response(id, &parse_heartbeat/1)
+
+      case is_heartbeat do
+        :heartbeat -> wait_for_heartbeats(id, beats + 1, expexted_beats)
+        :timeout -> beats
+        _ -> wait_for_heartbeats(id, beats, expexted_beats)
+      end
+    else
+      beats
+    end
+  end
+
+  defp wait_for_headers(id, status, timeout \\ 1000) do
+    receive do
+      %HTTPotion.AsyncHeaders{id: ^id, status_code: ^status} ->
+        :ok
+
+      _ ->
+        wait_for_headers(id, status, timeout)
+    after
+      timeout -> :timeout
+    end
+  end
+
+  defp process_response(id, chunk_parser, timeout \\ 1000) do
+    receive do
+      %HTTPotion.AsyncChunk{id: ^id} = msg ->
+        chunk_parser.(msg)
+
+      _ ->
+        process_response(id, chunk_parser, timeout)
+    after
+      timeout -> :timeout
+    end
+  end
+
+  defp parse_chunk(msg) do
+    msg.chunk |> IO.iodata_to_binary() |> :jiffy.decode([:return_maps])
+  end
+
+  defp parse_event(msg) do
+    captures = Regex.scan(~r/data: (.*)/, msg.chunk)
+
+    captures
+    |> Enum.map(fn p -> Enum.at(p, 1) end)
+    |> Enum.filter(fn p -> String.trim(p) != "" end)
+    |> Enum.map(fn p ->
+      p
+      |> IO.iodata_to_binary()
+      |> :jiffy.decode([:return_maps])
+    end)
+  end
+
+  defp parse_heartbeat(msg) do
+    is_heartbeat = Regex.match?(~r/event: heartbeat/, msg.chunk)
+
+    if is_heartbeat do
+      :heartbeat
+    else
+      :other
+    end
+  end
+
+  defp parse_changes_response(changes) do
+    {length(changes["results"]), String.slice(changes["last_seq"], 0..1)}
+  end
+
+  defp check_empty_db(db_name) do
+    resp = Couch.get("/#{db_name}/_changes")
+    assert resp.body["results"] == [], "db must be empty"
+    assert String.at(resp.body["last_seq"], 0) == "0", "seq must start with 0"
+  end
+
+  defp test_changes(db_name, feed) do
+    check_empty_db(db_name)
+    {_, resp} = create_doc(db_name, sample_doc_foo())
+    rev = resp.body["rev"]
+
+    # TODO: retry_part
+    resp = Couch.get("/#{db_name}/_changes")
+    assert length(resp.body["results"]) == 1, "db must not be empty"
+    assert String.at(resp.body["last_seq"], 0) == "1", "seq must start with 1"
+
+    # increase timeout to 100 to have enough time 2 assemble
+    # (seems like too little timeouts kill
+    resp = Rawresp.get("/#{db_name}/_changes?feed=#{feed}&timeout=100")
+    changes = parse_changes_line(resp.body)
+
+    change = Enum.at(changes, 0)
+    assert Enum.at(change["changes"], 0)["rev"] == rev
+
+    # the sequence is not fully ordered and a complex structure now
+    change = Enum.at(changes, 1)
+    assert String.at(change["last_seq"], 0) == "1"
+
+    # create_doc_bar(db_name,"bar")
+    {:ok, worker_pid} = HTTPotion.spawn_worker_process(Couch.process_url(""))
+
+    %HTTPotion.AsyncResponse{id: req_id} =
+      Rawresp.get("/#{db_name}/_changes?feed=#{feed}&timeout=500",
+        stream_to: self(),
+        direct: worker_pid
+      )
+
+    :ok = wait_for_headers(req_id, 200)
+    create_doc_bar(db_name, "bar")
+
+    changes = process_response(req_id, &parse_changes_line_chunk/1)
+    assert length(changes) == 3
+
+    HTTPotion.stop_worker_process(worker_pid)
+  end
+
+  def create_doc_bar(db_name, id) do
+    create_doc(db_name, %{:_id => id, :bar => 1})
+  end
+
+  defp parse_changes_line_chunk(msg) do
+    parse_changes_line(msg.chunk)
+  end
+
+  defp parse_changes_line(body) do
+    body_lines = String.split(body, "\n")
+
+    body_lines
+    |> Enum.filter(fn line -> line != "" end)
+    |> Enum.map(fn line ->
+      line |> IO.iodata_to_binary() |> :jiffy.decode([:return_maps])
+    end)
+  end
+
+  defp create_filters_view(db_name) do
+    dynamic_fun = """
+    function(doc, req) {
+      var field = req.query.field;
+      return doc[field];
+    }
+    """
+
+    userctx_fun = """
+    function(doc, req) {
+      var field = req.query.field;
+      return doc[field];
+    }
+    """
+
+    blah_fun = """
+    function(doc) {
+              if (doc._id == "blah") {
+              emit(null, null);
+            }
+          }
+    """
+
+    ddoc = %{
+      _id: "_design/changes_filter",
+      filters: %{
+        bop: "function(doc, req) { return (doc.bop);}",
+        dynamic: dynamic_fun,
+        userCtx: userctx_fun,
+        conflicted: "function(doc, req) { return (doc._conflicts);}"
+      },
+      options: %{
+        local_seq: true
+      },
+      views: %{
+        local_seq: %{
+          map: "function(doc) {emit(doc._local_seq, null)}"
+        },
+        blah: %{
+          map: blah_fun
+        }
+      }
+    }
+
+    create_doc(db_name, ddoc)
+  end
+end
+
+defmodule Rawresp do
+  use HTTPotion.Base
+
+  @request_timeout 60_000
+  @inactivity_timeout 55_000
+
+  def process_url("http://" <> _ = url) do
+    url
+  end
+
+  def process_url(url) do
+    base_url = System.get_env("EX_COUCH_URL") || "http://127.0.0.1:15984"
+    base_url <> url
+  end
+
+  def process_request_headers(headers, _body, options) do
+    headers =
+      headers
+      |> Keyword.put(:"User-Agent", "couch-potion")
+
+    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
+    options
+    |> set_auth_options()
+    |> set_inactivity_timeout()
+    |> set_request_timeout()
+  end
+
+  def process_request_body(body) do
+    if is_map(body) do
+      :jiffy.encode(body)
+    else
+      body
+    end
+  end
+
+  def set_auth_options(options) do
+    if Keyword.get(options, :cookie) == nil do
+      headers = Keyword.get(options, :headers, [])
+
+      if headers[:basic_auth] != nil or headers[:authorization] != nil do
+        options
+      else
+        username = System.get_env("EX_USERNAME") || "adm"
+        password = System.get_env("EX_PASSWORD") || "pass"
+        Keyword.put(options, :basic_auth, {username, password})
+      end
+    else
+      options
+    end
+  end
+
+  def set_inactivity_timeout(options) do
+    Keyword.update(
+      options,
+      :ibrowse,
+      [{:inactivity_timeout, @inactivity_timeout}],
+      fn ibrowse ->
+        Keyword.put_new(ibrowse, :inactivity_timeout, @inactivity_timeout)
+      end
+    )
+  end
+
+  def set_request_timeout(options) do
+    timeout = Application.get_env(:httpotion, :default_timeout, @request_timeout)
+    Keyword.put_new(options, :timeout, timeout)
+  end
+
+  def login(userinfo) do
+    [user, pass] = String.split(userinfo, ":", parts: 2)
+    login(user, pass)
+  end
+
+  def login(user, pass, expect \\ :success) do
+    resp = Couch.post("/_session", body: %{:username => user, :password => pass})
+
+    if expect == :success do
+      true = resp.body["ok"]
+      cookie = resp.headers[:"set-cookie"]
+      [token | _] = String.split(cookie, ";")
+      %Couch.Session{cookie: token}
+    else
+      true = Map.has_key?(resp.body, "error")
+      %Couch.Session{error: resp.body["error"]}
+    end
+  end
+end
diff --git a/test/elixir/test/changes_test.exs b/test/elixir/test/changes_test.exs
index b554508..5bb376b 100644
--- a/test/elixir/test/changes_test.exs
+++ b/test/elixir/test/changes_test.exs
@@ -11,33 +11,441 @@ defmodule ChangesTest do
   test "Changes feed negative heartbeat", context do
     db_name = context[:db_name]
 
-    resp = Couch.get(
-      "/#{db_name}/_changes",
-      query: %{
-        :feed => "continuous",
-        :heartbeat => -1000
-      }
-    )
+    resp =
+      Couch.get(
+        "/#{db_name}/_changes",
+        query: %{
+          :feed => "continuous",
+          :heartbeat => -1000
+        }
+      )
 
     assert resp.status_code == 400
     assert resp.body["error"] == "bad_request"
-    assert resp.body["reason"] == "The heartbeat value should be a positive integer (in milliseconds)."
+
+    assert resp.body["reason"] ==
+             "The heartbeat value should be a positive integer (in milliseconds)."
   end
 
   @tag :with_db
   test "Changes feed non-integer heartbeat", context do
     db_name = context[:db_name]
 
-    resp = Couch.get(
-      "/#{db_name}/_changes",
-      query: %{
-        :feed => "continuous",
-        :heartbeat => "a1000"
-      }
-    )
+    resp =
+      Couch.get(
+        "/#{db_name}/_changes",
+        query: %{
+          :feed => "continuous",
+          :heartbeat => "a1000"
+        }
+      )
 
     assert resp.status_code == 400
     assert resp.body["error"] == "bad_request"
-    assert resp.body["reason"] == "Invalid heartbeat value. Expecting a positive integer value (in milliseconds)."
+
+    assert resp.body["reason"] ==
+             "Invalid heartbeat value. Expecting a positive integer value (in milliseconds)."
+  end
+
+  @tag :with_db
+  test "function filtered changes", context do
+    db_name = context[:db_name]
+    create_filters_view(db_name)
+
+    resp = Couch.get("/#{db_name}/_changes?filter=changes_filter/bop")
+    assert Enum.empty?(resp.body["results"]), "db must be empty"
+
+    {:ok, doc_resp} = create_doc(db_name, %{bop: "foom"})
+    rev = doc_resp.body["rev"]
+    id = doc_resp.body["id"]
+    create_doc(db_name, %{bop: false})
+
+    resp = Couch.get("/#{db_name}/_changes?filter=changes_filter/bop")
+    assert length(resp.body["results"]) == 1
+    change_rev = get_change_rev_at(resp.body["results"], 0)
+    assert change_rev == rev
+
+    doc = open_doc(db_name, id)
+    doc = Map.put(doc, "newattr", "a")
+
+    doc = save_doc(db_name, doc)
+
+    resp = Couch.get("/#{db_name}/_changes?filter=changes_filter/bop")
+    assert length(resp.body["results"]) == 1
+    new_change_rev = get_change_rev_at(resp.body["results"], 0)
+    assert new_change_rev == doc["_rev"]
+    assert new_change_rev != change_rev
+
+    resp = Couch.get("/#{db_name}/_changes?filter=changes_filter/dynamic&field=woox")
+    assert Enum.empty?(resp.body["results"]), "db must be empty"
+
+    resp = Couch.get("/#{db_name}/_changes?filter=changes_filter/dynamic&field=bop")
+    assert length(resp.body["results"]) == 1, "db must have one change"
+    new_change_rev = get_change_rev_at(resp.body["results"], 0)
+    assert new_change_rev == doc["_rev"]
+  end
+
+  @tag :with_db
+  test "non-existing desing doc for filtered changes", context do
+    db_name = context[:db_name]
+    resp = Couch.get("/#{db_name}/_changes?filter=nothingtosee/bop")
+    assert resp.status_code == 404
+  end
+
+  @tag :with_db
+  test "non-existing function for filtered changes", context do
+    db_name = context[:db_name]
+    create_filters_view(db_name)
+    resp = Couch.get("/#{db_name}/_changes?filter=changes_filter/movealong")
+    assert resp.status_code == 404
+  end
+
+  @tag :with_db
+  test "non-existing desing doc and funcion for filtered changes", context do
+    db_name = context[:db_name]
+    resp = Couch.get("/#{db_name}/_changes?filter=nothingtosee/movealong")
+    assert resp.status_code == 404
+  end
+
+  @tag :with_db
+  test "map function filtered changes", context do
+    db_name = context[:db_name]
+    create_filters_view(db_name)
+    create_doc(db_name, %{_id: "blah", bop: "plankton"})
+    resp = Couch.get("/#{db_name}/_changes?filter=_view&view=changes_filter/blah")
+    assert length(resp.body["results"]) == 1
+    assert Enum.at(resp.body["results"], 0)["id"] == "blah"
+  end
+
+  @tag :with_db
+  test "changes limit", context do
+    db_name = context[:db_name]
+
+    create_doc(db_name, %{_id: "blah", bop: "plankton"})
+    create_doc(db_name, %{_id: "blah2", bop: "plankton"})
+    create_doc(db_name, %{_id: "blah3", bop: "plankton"})
+
+    resp = Couch.get("/#{db_name}/_changes?limit=1")
+    assert length(resp.body["results"]) == 1
+
+    resp = Couch.get("/#{db_name}/_changes?limit=2")
+    assert length(resp.body["results"]) == 2
+  end
+
+  @tag :with_db
+  test "erlang function filtered changes", context do
+    db_name = context[:db_name]
+    create_erlang_filters_view(db_name)
+
+    resp = Couch.get("/#{db_name}/_changes?filter=erlang/foo")
+    assert Enum.empty?(resp.body["results"])
+
+    create_doc(db_name, %{_id: "doc1", value: 1})
+    create_doc(db_name, %{_id: "doc2", value: 2})
+    create_doc(db_name, %{_id: "doc3", value: 3})
+    create_doc(db_name, %{_id: "doc4", value: 4})
+
+    resp = Couch.get("/#{db_name}/_changes?filter=erlang/foo")
+
+    changes_ids =
+      resp.body["results"]
+      |> Enum.map(fn p -> p["id"] end)
+
+    assert Enum.member?(changes_ids, "doc2")
+    assert Enum.member?(changes_ids, "doc4")
+    assert length(resp.body["results"]) == 2
+  end
+
+  @tag :with_db
+  test "changes filtering on docids", context do
+    db_name = context[:db_name]
+    doc_ids = %{doc_ids: ["doc1", "doc3", "doc4"]}
+
+    resp =
+      Couch.post("/#{db_name}/_changes?filter=_doc_ids",
+        body: doc_ids,
+        headers: ["Content-Type": "application/json"]
+      )
+
+    assert Enum.empty?(resp.body["results"])
+
+    create_doc(db_name, %{_id: "doc1", value: 1})
+    create_doc(db_name, %{_id: "doc2", value: 2})
+
+    resp =
+      Couch.post("/#{db_name}/_changes?filter=_doc_ids",
+        body: doc_ids,
+        headers: ["Content-Type": "application/json"]
+      )
+
+    assert length(resp.body["results"]) == 1
+    assert Enum.at(resp.body["results"], 0)["id"] == "doc1"
+
+    create_doc(db_name, %{_id: "doc3", value: 3})
+
+    resp =
+      Couch.post("/#{db_name}/_changes?filter=_doc_ids",
+        body: doc_ids,
+        headers: ["Content-Type": "application/json"]
+      )
+
+    assert length(resp.body["results"]) == 2
+
+    changes_ids =
+      resp.body["results"]
+      |> Enum.map(fn p -> p["id"] end)
+
+    assert Enum.member?(changes_ids, "doc1")
+    assert Enum.member?(changes_ids, "doc3")
+
+    encoded_doc_ids = doc_ids.doc_ids |> :jiffy.encode()
+
+    resp =
+      Couch.get("/#{db_name}/_changes",
+        query: %{filter: "_doc_ids", doc_ids: encoded_doc_ids}
+      )
+
+    assert length(resp.body["results"]) == 2
+
+    changes_ids =
+      resp.body["results"]
+      |> Enum.map(fn p -> p["id"] end)
+
+    assert Enum.member?(changes_ids, "doc1")
+    assert Enum.member?(changes_ids, "doc3")
+  end
+
+  @tag :with_db
+  test "changes filtering on design docs", context do
+    db_name = context[:db_name]
+
+    create_erlang_filters_view(db_name)
+    create_doc(db_name, %{_id: "doc1", value: 1})
+
+    resp = Couch.get("/#{db_name}/_changes?filter=_design")
+    assert length(resp.body["results"]) == 1
+    assert Enum.at(resp.body["results"], 0)["id"] == "_design/erlang"
+  end
+
+  @tag :with_db
+  test "COUCHDB-1037-empty result for ?limit=1&filter=foo/bar in some cases",
+       context do
+    db_name = context[:db_name]
+
+    filter_fun = """
+     function(doc, req) {
+        return (typeof doc.integer === "number");
+      }
+    """
+
+    ddoc = %{
+      _id: "_design/testdocs",
+      language: "javascript",
+      filters: %{
+        testdocsonly: filter_fun
+      }
+    }
+
+    create_doc(db_name, ddoc)
+
+    ddoc = %{
+      _id: "_design/foobar",
+      foo: "bar"
+    }
+
+    create_doc(db_name, ddoc)
+    bulk_save(db_name, make_docs(0..4))
+
+    resp = Couch.get("/#{db_name}/_changes")
+    assert length(resp.body["results"]) == 7
+
+    resp = Couch.get("/#{db_name}/_changes?limit=1&filter=testdocs/testdocsonly")
+    assert length(resp.body["results"]) == 1
+    # we can't guarantee ordering
+    assert Regex.match?(~r/[0-4]/, Enum.at(resp.body["results"], 0)["id"])
+
+    resp = Couch.get("/#{db_name}/_changes?limit=2&filter=testdocs/testdocsonly")
+    assert length(resp.body["results"]) == 2
+    # we can't guarantee ordering
+    assert Regex.match?(~r/[0-4]/, Enum.at(resp.body["results"], 0)["id"])
+    assert Regex.match?(~r/[0-4]/, Enum.at(resp.body["results"], 1)["id"])
+  end
+
+  @tag :with_db
+  test "COUCHDB-1256", context do
+    db_name = context[:db_name]
+    {:ok, resp} = create_doc(db_name, %{_id: "foo", a: 123})
+    create_doc(db_name, %{_id: "bar", a: 456})
+    foo_rev = resp.body["rev"]
+
+    Couch.put("/#{db_name}/foo?new_edits=false",
+      headers: ["Content-Type": "application/json"],
+      body: %{_rev: foo_rev, a: 456}
+    )
+
+    resp = Couch.get("/#{db_name}/_changes?style=all_docs")
+    assert length(resp.body["results"]) == 2
+
+    resp =
+      Couch.get("/#{db_name}/_changes",
+        query: %{style: "all_docs", since: Enum.at(resp.body["results"], 0)["seq"]}
+      )
+
+    assert length(resp.body["results"]) == 1
+  end
+
+  @tag :with_db
+  test "COUCHDB-1923", context do
+    db_name = context[:db_name]
+    attachment_data = "VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ="
+
+    docs =
+      make_docs(20..29, %{
+        _attachments: %{
+          "foo.txt": %{
+            content_type: "text/plain",
+            data: attachment_data
+          },
+          "bar.txt": %{
+            content_type: "text/plain",
+            data: attachment_data
+          }
+        }
+      })
+
+    bulk_save(db_name, docs)
+
+    resp = Couch.get("/#{db_name}/_changes?include_docs=true")
+    assert length(resp.body["results"]) == 10
+
+    first_doc = Enum.at(resp.body["results"], 0)["doc"]
+
+    assert first_doc["_attachments"]["foo.txt"]["stub"]
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "data")
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "encoding")
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "encoded_length")
+    assert first_doc["_attachments"]["bar.txt"]["stub"]
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "data")
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "encoding")
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "encoded_length")
+
+    resp = Couch.get("/#{db_name}/_changes?include_docs=true&attachments=true")
+    assert length(resp.body["results"]) == 10
+
+    first_doc = Enum.at(resp.body["results"], 0)["doc"]
+
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "stub")
+    assert first_doc["_attachments"]["foo.txt"]["data"] == attachment_data
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "encoding")
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "encoded_length")
+
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "stub")
+    assert first_doc["_attachments"]["bar.txt"]["data"] == attachment_data
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "encoding")
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "encoded_length")
+
+    resp = Couch.get("/#{db_name}/_changes?include_docs=true&att_encoding_info=true")
+    assert length(resp.body["results"]) == 10
+
+    first_doc = Enum.at(resp.body["results"], 0)["doc"]
+
+    assert first_doc["_attachments"]["foo.txt"]["stub"]
+    assert not Enum.member?(first_doc["_attachments"]["foo.txt"], "data")
+    assert first_doc["_attachments"]["foo.txt"]["encoding"] == "gzip"
+    assert first_doc["_attachments"]["foo.txt"]["encoded_length"] == 47
+    assert first_doc["_attachments"]["bar.txt"]["stub"]
+    assert not Enum.member?(first_doc["_attachments"]["bar.txt"], "data")
+    assert first_doc["_attachments"]["bar.txt"]["encoding"] == "gzip"
+    assert first_doc["_attachments"]["bar.txt"]["encoded_length"] == 47
+  end
+
+  defp create_erlang_filters_view(db_name) do
+    erlang_fun = """
+     fun({Doc}, Req) ->
+      case couch_util:get_value(<<"value">>, Doc) of
+        undefined -> false;
+           Value -> (Value rem 2) =:= 0;
+        _ -> false
+      end
+     end.
+    """
+
+    ddoc = %{
+      _id: "_design/erlang",
+      language: "erlang",
+      filters: %{
+        foo: erlang_fun
+      }
+    }
+
+    create_doc(db_name, ddoc)
+  end
+
+  defp create_filters_view(db_name) do
+    dynamic_fun = """
+    function(doc, req) {
+      var field = req.query.field;
+      return doc[field];
+    }
+    """
+
+    userctx_fun = """
+    function(doc, req) {
+      var field = req.query.field;
+      return doc[field];
+    }
+    """
+
+    blah_fun = """
+    function(doc) {
+              if (doc._id == "blah") {
+              emit(null, null);
+            }
+          }
+    """
+
+    ddoc = %{
+      _id: "_design/changes_filter",
+      filters: %{
+        bop: "function(doc, req) { return (doc.bop);}",
+        dynamic: dynamic_fun,
+        userCtx: userctx_fun,
+        conflicted: "function(doc, req) { return (doc._conflicts);}"
+      },
+      options: %{
+        local_seq: true
+      },
+      views: %{
+        local_seq: %{
+          map: "function(doc) {emit(doc._local_seq, null)}"
+        },
+        blah: %{
+          map: blah_fun
+        }
+      }
+    }
+
+    create_doc(db_name, ddoc)
+  end
+
+  defp get_change_rev_at(results, idx) do
+    results
+    |> Enum.at(idx)
+    |> Map.fetch!("changes")
+    |> Enum.at(0)
+    |> Map.fetch!("rev")
+  end
+
+  defp open_doc(db_name, id) do
+    resp = Couch.get("/#{db_name}/#{id}")
+    assert resp.status_code == 200
+    resp.body
+  end
+
+  defp save_doc(db_name, body) do
+    resp = Couch.put("/#{db_name}/#{body["_id"]}", body: body)
+    assert resp.status_code in [201, 202]
+    assert resp.body["ok"]
+    Map.put(body, "_rev", resp.body["rev"])
   end
 end
diff --git a/test/javascript/tests/changes.js b/test/javascript/tests/changes.js
index d312edc..d98e37c 100644
--- a/test/javascript/tests/changes.js
+++ b/test/javascript/tests/changes.js
@@ -11,6 +11,7 @@
 // the License.
 
 function jsonp(obj) {
+  return console.log('done in test/elixir/test/changes_test.exs and changes_async_test.exs');
   T(jsonp_flag == 0);
   T(obj.results.length == 1 && obj.last_seq == 1, "jsonp");
   jsonp_flag = 1;
@@ -359,7 +360,7 @@ couchTests.changes = function(debug) {
   resp = JSON.parse(req.responseText);
   T(resp.results.length == 1, "changes_filter/dynamic&field=bop");
   T(resp.results[0].changes[0].rev == docres1.rev, "filtered/dynamic&field=bop rev");
- 
+
   // these will NEVER run as we're always in navigator == undefined
   if (!is_safari && xhr) { // full test requires parallel connections
     // filter with longpoll
@@ -708,7 +709,7 @@ couchTests.changes = function(debug) {
   db = new CouchDB(db_name, {"X-Couch-Full-Commit":"true"}, {"w": 3});
   T(db.createDb());
 
-  // create 4 documents... this assumes the update sequnce will start from 0 and then do sth in the cluster 
+  // create 4 documents... this assumes the update sequnce will start from 0 and then do sth in the cluster
   db.save({"bop" : "foom"});
   db.save({"bop" : "foom"});
   db.save({"bop" : "foom"});
@@ -717,7 +718,7 @@ couchTests.changes = function(debug) {
   req = CouchDB.request("GET", "/" + db_name + "/_changes");
 
   // simulate an EventSource request with a Last-Event-ID header
-  // increase timeout to 100 to have enough time 2 assemble (seems like too little timeouts kill  
+  // increase timeout to 100 to have enough time 2 assemble (seems like too little timeouts kill
   req = CouchDB.request("GET", "/" + db_name + "/_changes?feed=eventsource&timeout=100&since=0",
         {"headers": {"Accept": "text/event-stream", "Last-Event-ID": JSON.parse(req.responseText).results[1].seq}});