defmodule BulkDocsTest do
  use CouchTestCase

  @moduletag :bulk_docs

  @moduledoc """
  Test CouchDB bulk docs
  This is a port of bulk_docs.js
  """

  @doc_range 1..5

  @tag :with_db
  test "bulk docs can create, update, & delete many docs per request", ctx do
    db = ctx[:db_name]
    docs = create_docs(@doc_range)
    resp = bulk_post(docs, db)
    assert revs_start_with(resp.body, "1-")
    docs = rev(docs, resp.body)
    # Modify each doc's `string` field and re-post
    docs =
      Enum.map(docs, fn doc = %{string: string} ->
        %{doc | string: string <> ".00"}
      end)

    resp = bulk_post(docs, db)
    assert revs_start_with(resp.body, "2-")
    docs = rev(docs, resp.body)
    # Confirm changes were applied for each doc
    assert Enum.all?(docs, fn doc ->
             String.ends_with?(Couch.get("/#{db}/#{doc._id}").body["string"], ".00")
           end)

    docs = Enum.map(docs, &Map.put(&1, :_deleted, true))
    resp = bulk_post(docs, db)
    assert revs_start_with(resp.body, "3-")
    # Confirm docs were deleted
    assert Enum.all?(docs, fn doc ->
             resp = Couch.get("/#{db}/#{doc._id}")
             assert resp.status_code == 404
             assert resp.body["error"] == "not_found"
             assert resp.body["reason"] == "deleted"
           end)
  end

  @tag :with_db
  @tag :skip_on_jenkins
  test "bulk docs can detect conflicts", ctx do
    db = ctx[:db_name]
    docs = create_docs(@doc_range)
    resp = bulk_post(docs, db)
    assert revs_start_with(resp.body, "1-")
    docs = rev(docs, resp.body)
    # Update just the first doc to create a conflict in subsequent bulk update
    doc = hd(docs)
    resp = Couch.put("/#{db}/#{doc._id}", body: doc)
    assert resp.status_code in [201, 202]
    # Attempt to delete all docs
    docs = Enum.map(docs, fn doc -> Map.put(doc, :_deleted, true) end)

    retry_until(fn ->
      resp = bulk_post(docs, db)
      # Confirm first doc not updated, and result has no rev field
      res = hd(resp.body)
      assert res["id"] == "1" and res["error"] == "conflict"
      assert Map.get(res, "rev") == nil
      # Confirm other docs updated normally
      assert revs_start_with(tl(resp.body), "2-")
    end)
  end

  @tag :with_db
  test "bulk docs supplies `id` if not provided in doc", ctx do
    docs = [%{foo: "bar"}]
    res = hd(bulk_post(docs, ctx[:db_name]).body)
    assert res["id"]
    assert res["rev"]
  end

  @tag :with_db
  test "bulk docs raises error for `all_or_nothing` option", ctx do
    opts = [body: %{docs: create_docs(@doc_range), all_or_nothing: true}]
    resp = Couch.post("/#{ctx[:db_name]}/_bulk_docs", opts)
    assert resp.status_code == 417
    assert Enum.all?(resp.body, &(Map.get(&1, "error") == "not_implemented"))
    expected_reason = "all_or_nothing is not supported"
    assert Enum.all?(resp.body, &(Map.get(&1, "reason") == expected_reason))
  end

  @tag :with_db
  test "bulk docs raises conflict error for combined update & delete", ctx do
    db = ctx[:db_name]
    doc = %{_id: "id", val: "val"}
    resp = Couch.put("/#{db}/#{doc._id}", body: doc)
    doc = rev(doc, resp.body)
    update = %{doc | val: "newval"}
    delete = Map.put(doc, :_deleted, true)
    body = bulk_post([update, delete], db).body
    assert Enum.count(body, &(Map.get(&1, "error") == "conflict")) == 1
    assert Enum.count(body, &Map.get(&1, "rev")) == 1
  end

  @tag :with_db
  test "bulk docs raises error for missing `docs` parameter", ctx do
    docs = [%{foo: "bar"}]
    resp = Couch.post("/#{ctx[:db_name]}/_bulk_docs", body: %{doc: docs})
    assert_bad_request(resp, "POST body must include `docs` parameter.")
  end

  @tag :with_db
  test "bulk docs raises error for invalid `docs` parameter", ctx do
    resp = Couch.post("/#{ctx[:db_name]}/_bulk_docs", body: %{docs: "foo"})
    assert_bad_request(resp, "`docs` parameter must be an array.")
  end

  @tag :with_db
  test "bulk docs raises error for invalid `new_edits` parameter", ctx do
    opts = [body: %{docs: [], new_edits: 0}]
    resp = Couch.post("/#{ctx[:db_name]}/_bulk_docs", opts)
    assert_bad_request(resp, "`new_edits` parameter must be a boolean.")
  end

  @tag :with_db
  test "bulk docs emits conflict error for duplicate doc `_id`s", ctx do
    docs = [%{_id: "0", a: 0}, %{_id: "1", a: 1}, %{_id: "1", a: 2}, %{_id: "3", a: 3}]
    rows = bulk_post(docs, ctx[:db_name]).body
    assert Enum.at(rows, 1)["id"] == "1"
    assert Enum.at(rows, 1)["ok"]
    assert Enum.at(rows, 2)["error"] == "conflict"
  end

  defp bulk_post(docs, db) do
    retry_until(fn ->
      resp = Couch.post("/#{db}/_bulk_docs", body: %{docs: docs})

      assert resp.status_code in [201, 202] and length(resp.body) == length(docs), """
      Expected 201 and the same number of response rows as in request, but got
      #{pretty_inspect(resp)}
      """

      resp
    end)
  end

  defp revs_start_with(rows, prefix) do
    Enum.all?(rows, fn %{"rev" => rev} -> String.starts_with?(rev, prefix) end)
  end

  defp assert_bad_request(resp, reason) do
    assert resp.status_code == 400
    assert resp.body["error"] == "bad_request"
    assert resp.body["reason"] == reason
  end
end
