test/openagents/forum/tips_test.exs

main at 58e6347eeb72 · 15 KB

defmodule OpenAgents.Forum.TipsTest do
  use OpenAgents.DataCase, async: true

  alias OpenAgents.Forum
  alias OpenAgents.Forum.{TipIntent, TipPaymentServiceStub, TipReceipt, Tips}

  setup do
    TipPaymentServiceStub.settle()
    :ok
  end

  defp user(login_prefix) do
    {:ok, user} =
      %OpenAgents.Accounts.User{}
      |> Ecto.Changeset.change(%{
        github_id: System.unique_integer([:positive]),
        github_login: "#{login_prefix}-#{System.unique_integer([:positive])}",
        github_name: "Tips Test",
        github_avatar_url: "https://example.com/a.png"
      })
      |> Repo.insert()

    user
  end

  defp forum do
    {:ok, forum} =
      %OpenAgents.Forum.Forum{}
      |> OpenAgents.Forum.Forum.changeset(%{
        slug: "general-#{System.unique_integer([:positive])}",
        title: "General"
      })
      |> Repo.insert()

    forum
  end

  # An author with a linked identity and a destination it controls: the only
  # shape a tip can be paid to.
  defp author(opts \\ []) do
    user = user("author")
    actor_ref = "agent:author-#{System.unique_integer([:positive])}"

    {:ok, link} = Forum.start_actor_link(user, actor_ref)
    {:ok, _linked} = Forum.approve_actor_link(link)

    if Keyword.get(opts, :destination, true) do
      {:ok, _destination} =
        Tips.register_destination(%{
          user_id: user.id,
          kind: "bolt12",
          destination: "lno1qsgz#{System.unique_integer([:positive])}",
          accepting_tips: Keyword.get(opts, :accepting_tips, true)
        })
    end

    %{user: user, actor_ref: actor_ref}
  end

  defp topic_with_post(author, title \\ "Hello") do
    {:ok, topic} =
      Forum.create_topic(forum(), %{
        title: title,
        slug: "hello-#{System.unique_integer([:positive])}",
        body_text: "First post",
        actor_ref: author.actor_ref,
        actor_display_name: "Author",
        actor_slug: "author"
      })

    [post] = Forum.list_posts(topic)
    {topic, post}
  end

  defp tip(post, payer, amount_sats, opts \\ []) do
    Tips.tip_post(%{
      post: post,
      payer_user: payer,
      payer_actor_ref: "user:#{payer.id}",
      amount_sats: amount_sats,
      idempotency_key: Keyword.get(opts, :idempotency_key, Ecto.UUID.generate())
    })
  end

  describe "destinations" do
    test "registering a destination retires the previous one" do
      user = user("recipient")

      {:ok, first} =
        Tips.register_destination(%{user_id: user.id, kind: "bolt12", destination: "lno1first"})

      {:ok, second} =
        Tips.register_destination(%{user_id: user.id, kind: "bolt12", destination: "lno1second"})

      assert Repo.reload!(first).state == "retired"
      assert Tips.active_destination(user.id).id == second.id
    end

    test "a destination is fingerprinted and never stores a wallet secret" do
      user = user("recipient")

      {:ok, destination} =
        Tips.register_destination(%{user_id: user.id, kind: "bolt12", destination: "lno1abcdef"})

      assert destination.fingerprint != "lno1abcdef"
      assert byte_size(destination.fingerprint) == 16

      assert {:error, changeset} =
               Tips.register_destination(%{
                 user_id: user("other").id,
                 kind: "bolt12",
                 destination: "seed words here"
               })

      assert %{destination: _errors} = errors_on(changeset)
    end

    test "opting out stops tips without discarding the destination" do
      author = author()
      {_topic, post} = topic_with_post(author)
      destination = Tips.active_destination(author.user.id)

      {:ok, _off} = Tips.set_accepting_tips(destination, false)

      assert {:error, :not_accepting_tips} = tip(post, user("payer"), 100)
    end

    test "a post whose author has no destination cannot be tipped" do
      author = author(destination: false)
      {_topic, post} = topic_with_post(author)

      assert {:error, :no_destination} = tip(post, user("payer"), 100)
    end
  end

  describe "tip_post/1" do
    test "settles a tip, records one receipt, and counts it for ranking" do
      author = author()
      {topic, post} = topic_with_post(author)

      assert {:ok, intent} = tip(post, user("payer"), 1_000)
      assert intent.state == "settled"
      assert intent.counted_sats == 1_000
      assert is_nil(intent.exclusion_reason)

      assert [%TipReceipt{kind: "settled", payment_hash: hash}] = Tips.list_receipts(intent)
      assert is_binary(hash)

      post = Repo.reload!(post)
      assert post.tip_sats_total == 1_000
      assert post.tip_sats_counted == 1_000
      assert post.tip_count == 1

      topic = Repo.reload!(topic)
      assert topic.tip_sats_total == 1_000
      assert topic.tip_sats_counted == 1_000
    end

    test "a retry with the same key pays once and counts once" do
      author = author()
      {_topic, post} = topic_with_post(author)
      payer = user("payer")
      key = Ecto.UUID.generate()

      assert {:ok, first} = tip(post, payer, 500, idempotency_key: key)
      assert {:ok, second} = tip(post, payer, 500, idempotency_key: key)

      assert first.id == second.id
      assert [_one_request] = TipPaymentServiceStub.requests()
      assert Repo.reload!(post).tip_sats_total == 500
      assert Repo.reload!(post).tip_count == 1
      assert [%TipReceipt{kind: "settled"}] = Tips.list_receipts(first)
    end

    test "a failed payment records the failure and counts nothing" do
      author = author()
      {topic, post} = topic_with_post(author)
      TipPaymentServiceStub.fail("no_route")

      assert {:error, {:payment_failed, intent}} = tip(post, user("payer"), 1_000)
      assert intent.state == "failed"
      assert intent.failure_code == "no_route"
      assert intent.counted_sats == 0

      assert [%TipReceipt{kind: "failed", failure_code: "no_route"}] = Tips.list_receipts(intent)
      assert Repo.reload!(post).tip_sats_total == 0
      assert Repo.reload!(topic).tip_sats_counted == 0
    end

    test "an unavailable payment service leaves the tip retryable and unpaid" do
      author = author()
      {_topic, post} = topic_with_post(author)
      payer = user("payer")
      key = Ecto.UUID.generate()

      TipPaymentServiceStub.unavailable()

      assert {:error, :payment_service_unavailable} =
               tip(post, payer, 1_000, idempotency_key: key)

      intent = Tips.get_intent_by_key(key)
      assert intent.state == "created"
      assert Tips.list_receipts(intent) == []
      assert Repo.reload!(post).tip_sats_total == 0

      TipPaymentServiceStub.settle()
      assert {:ok, settled} = tip(post, payer, 1_000, idempotency_key: key)
      assert settled.id == intent.id
      assert settled.state == "settled"
      assert Repo.reload!(post).tip_sats_counted == 1_000
    end

    test "a tip larger than the per-tip cap counts only up to the cap" do
      author = author()
      {_topic, post} = topic_with_post(author)

      assert {:ok, intent} = tip(post, user("payer"), Tips.counted_sats_per_tip() * 2)
      assert intent.counted_sats == Tips.counted_sats_per_tip()
      assert intent.amount_sats == Tips.counted_sats_per_tip() * 2
    end

    test "an amount past the maximum is refused" do
      author = author()
      {_topic, post} = topic_with_post(author)

      assert {:error, %Ecto.Changeset{}} =
               tip(post, user("payer"), Tips.maximum_amount_sats() + 1)
    end
  end

  describe "anti-manipulation" do
    test "a self-tip pays but does not improve rank" do
      author = author()
      {topic, post} = topic_with_post(author)

      assert {:ok, intent} = tip(post, author.user, 5_000)
      assert intent.state == "settled"
      assert intent.counted_sats == 0
      assert intent.exclusion_reason == "self_tip"

      assert Repo.reload!(post).tip_sats_total == 5_000
      assert Repo.reload!(post).tip_sats_counted == 0
      assert Repo.reload!(topic).tip_sats_counted == 0
    end

    test "tips that go in a circle stop counting" do
      first = author()
      second = author()
      {_first_topic, first_post} = topic_with_post(first)
      {_second_topic, second_post} = topic_with_post(second)

      assert {:ok, outbound} = tip(second_post, first.user, 2_000)
      assert outbound.counted_sats == 2_000

      assert {:ok, returned} = tip(first_post, second.user, 2_000)
      assert returned.counted_sats == 0
      assert returned.exclusion_reason == "reciprocal"
      assert Repo.reload!(first_post).tip_sats_counted == 0
    end

    test "one payer cannot exceed the per-post cap by splitting tips" do
      author = author()
      {_topic, post} = topic_with_post(author)
      payer = user("payer")

      assert {:ok, first} = tip(post, payer, Tips.counted_sats_per_tip())
      assert first.counted_sats == Tips.counted_sats_per_tip()

      assert {:ok, second} = tip(post, payer, 1_000)
      assert second.counted_sats == 0
      assert second.exclusion_reason == "payer_cap"

      assert Repo.reload!(post).tip_sats_counted == Tips.counted_sats_per_tip()
    end

    test "an automated burst of tips stops counting" do
      author = author()
      payer = user("payer")

      counted =
        Enum.map(1..22, fn index ->
          {_topic, post} = topic_with_post(author, "Topic #{index}")
          {:ok, intent} = tip(post, payer, 10)
          intent.counted_sats
        end)

      assert Enum.take(counted, 20) == List.duplicate(10, 20)
      assert Enum.drop(counted, 20) == [0, 0]

      assert Repo.all(from(i in TipIntent, where: i.exclusion_reason == "rate_limited"))
             |> length() == 2
    end
  end

  describe "refunds" do
    test "a refund removes ranking weight and keeps both receipts" do
      author = author()
      {topic, post} = topic_with_post(author)

      {:ok, intent} = tip(post, user("payer"), 3_000)
      assert {:ok, refunded} = Tips.refund(intent)

      assert refunded.state == "refunded"
      assert refunded.counted_sats == 0
      assert refunded.exclusion_reason == "refunded"

      assert Enum.map(Tips.list_receipts(refunded), & &1.kind) == ["settled", "refunded"]
      assert Repo.reload!(post).tip_sats_total == 0
      assert Repo.reload!(post).tip_sats_counted == 0
      assert Repo.reload!(post).tip_count == 0
      assert Repo.reload!(topic).tip_sats_counted == 0
    end

    test "only a settled tip can be refunded" do
      author = author()
      {_topic, post} = topic_with_post(author)
      TipPaymentServiceStub.fail("insufficient_balance")

      {:error, {:payment_failed, failed}} = tip(post, user("payer"), 100)

      assert {:error, {:not_refundable, "failed"}} = Tips.refund(failed)
    end

    test "a receipt cannot be rewritten or deleted" do
      author = author()
      {_topic, post} = topic_with_post(author)
      {:ok, intent} = tip(post, user("payer"), 100)
      [receipt] = Tips.list_receipts(intent)

      assert_raise Postgrex.Error, fn ->
        Repo.update_all(from(r in TipReceipt, where: r.id == ^receipt.id),
          set: [amount_sats: 1_000_000]
        )
      end
    end
  end

  describe "moderation" do
    test "hiding a post removes its tips from topic ranking without moving funds" do
      author = author()
      {topic, post} = topic_with_post(author)
      moderator = user("moderator")

      {:ok, intent} = tip(post, user("payer"), 4_000)
      assert Repo.reload!(topic).tip_sats_counted == 4_000

      {:ok, _hidden} = Forum.hide_post(Repo.reload!(post), moderator)

      assert Repo.reload!(topic).tip_sats_counted == 0
      assert Repo.reload!(post).tip_sats_total == 4_000
      assert Repo.reload!(intent).state == "settled"
      assert Enum.map(Tips.list_receipts(intent), & &1.kind) == ["settled"]
    end
  end

  describe "ranking" do
    test "a tipped topic ranks above an untipped one of the same age" do
      board = forum()
      author = author()

      {:ok, quiet} =
        Forum.create_topic(board, %{
          title: "Quiet",
          slug: "quiet",
          body_text: "Nothing here",
          actor_ref: author.actor_ref,
          actor_display_name: "Author",
          actor_slug: "author"
        })

      {:ok, tipped} =
        Forum.create_topic(board, %{
          title: "Tipped",
          slug: "tipped",
          body_text: "Worth sats",
          actor_ref: author.actor_ref,
          actor_display_name: "Author",
          actor_slug: "author"
        })

      [post] = Forum.list_posts(tipped)
      {:ok, _intent} = tip(post, user("payer"), 5_000)

      assert Enum.map(Forum.list_topics(board, order: :ranked), & &1.id) == [tipped.id, quiet.id]
      assert Enum.map(Forum.list_topics(board), & &1.id) == [tipped.id, quiet.id]
    end

    test "ranking keeps working when the payment service is unavailable" do
      board = forum()
      author = author()

      {:ok, tipped} =
        Forum.create_topic(board, %{
          title: "Tipped",
          slug: "tipped",
          body_text: "Worth sats",
          actor_ref: author.actor_ref,
          actor_display_name: "Author",
          actor_slug: "author"
        })

      [post] = Forum.list_posts(tipped)
      {:ok, _intent} = tip(post, user("payer"), 5_000)

      TipPaymentServiceStub.unavailable()

      assert Enum.map(Forum.list_topics(board, order: :ranked), & &1.id) == [tipped.id]
      assert {:error, :payment_service_unavailable} = tip(post, user("payer"), 100)
      assert Repo.reload!(tipped).tip_sats_counted == 5_000
    end

    test "a decayed tip ranks below a fresh tip of the same size" do
      board = forum()
      author = author()

      {:ok, old} =
        Forum.create_topic(board, %{
          title: "Old",
          slug: "old",
          body_text: "Tipped a month ago",
          actor_ref: author.actor_ref,
          actor_display_name: "Author",
          actor_slug: "author"
        })

      {:ok, fresh} =
        Forum.create_topic(board, %{
          title: "Fresh",
          slug: "fresh",
          body_text: "Tipped now",
          actor_ref: author.actor_ref,
          actor_display_name: "Author",
          actor_slug: "author"
        })

      for topic <- [old, fresh] do
        [post] = Forum.list_posts(topic)
        {:ok, _intent} = tip(post, user("payer"), 5_000)
      end

      month_ago = DateTime.add(DateTime.utc_now(), -30 * 24 * 3600, :second)

      Repo.update_all(from(t in OpenAgents.Forum.Topic, where: t.id == ^old.id),
        set: [updated_at: month_ago]
      )

      assert Enum.map(Forum.list_topics(board, order: :ranked), & &1.id) == [fresh.id, old.id]
    end
  end

  describe "recipient views" do
    test "the export lists settlements to verify and no destination" do
      author = author()
      {_topic, post} = topic_with_post(author)
      {:ok, intent} = tip(post, user("payer"), 700)

      export = Tips.withdrawal_export(author.user.id)

      assert export.custody == "self"
      assert export.received_sats == 700
      assert export.refunded_sats == 0
      assert [settlement] = export.settlements
      assert settlement.state == "settled"
      assert [%{payment_hash: hash}] = Tips.list_receipts(intent)
      assert settlement.payment_hash == hash

      destination = Tips.active_destination(author.user.id)
      assert export.destination_fingerprint == destination.fingerprint
      refute export.destination_fingerprint == destination.destination
    end

    test "a refund shows in the export as refunded" do
      author = author()
      {_topic, post} = topic_with_post(author)
      {:ok, intent} = tip(post, user("payer"), 700)
      {:ok, _refunded} = Tips.refund(intent)

      export = Tips.withdrawal_export(author.user.id)
      assert export.received_sats == 0
      assert export.refunded_sats == 700
    end
  end
end