test/openagents_web/live/issue_live_work_test.exs

main at 58e6347eeb72 · 8 KB

defmodule OpenAgentsWeb.IssueLiveWorkTest do
  @moduledoc """
  Stage 3 of `#10`: show a running attempt live, and stop narrating it.

  The narration went because it was a surface asserting things nothing could
  contradict. Three Markdown comments restated `forge_assignments` in prose,
  one of them — "claim released" — describing a credential revocation in a
  different table, for two of the three terminal states, while `finish/1`
  revokes for all three. Nothing compared the sentence to the row.

  What replaces it is the row. These tests hold the three properties that makes
  possible: the page moves when the attempt moves, the announcement that moves
  it carries an id and nothing else, and the re-read it triggers goes through
  the viewer's own authorization rather than through whatever the socket last
  believed.
  """
  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query
  import Phoenix.LiveViewTest

  alias OpenAgents.Forge.{Assignment, Assignments}
  alias OpenAgents.Issues
  alias OpenAgents.Repo
  alias OpenAgents.Repositories

  @sha String.duplicate("ab", 20)

  setup %{conn: conn} do
    repository = Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
    member = github_user("issue-live-work-member")
    {:ok, _} = Repositories.add_member(repository, member, "maintainer")

    {:ok, issue} = Issues.create_issue(repository, %{title: "Watch the work"})

    %{
      conn: Plug.Test.init_test_session(conn, %{"user_id" => member.id}),
      member: member,
      repository: repository,
      issue: issue
    }
  end

  defp path(issue), do: ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}"

  describe "the announcement" do
    test "carries the issue id and nothing else", context do
      :ok = Assignments.subscribe_attempts(context.issue.id)
      attempt = attempt(context)

      Assignments.announce(attempt)

      issue_id = context.issue.id
      assert_receive {:attempts_changed, ^issue_id}

      # Nothing else arrives, so nothing carried the branch, the state, the
      # revision, or the row. A subscriber that wants any of those must re-read
      # for them, which is the only place a gate can run.
      refute_receive {:attempts_changed, _issue_id, _anything}
      refute_receive %Assignment{}
    end

    test "reaches only the issue it names", context do
      {:ok, other} = Issues.create_issue(context.repository, %{title: "Elsewhere"})
      :ok = Assignments.subscribe_attempts(other.id)

      Assignments.announce(attempt(context))

      refute_receive {:attempts_changed, _issue_id}
    end
  end

  describe "a running attempt on the page" do
    test "renders its state and how long it has been running", context do
      attempt(context, started_at: DateTime.add(DateTime.utc_now(), -90, :second))

      {:ok, view, _html} = live(context.conn, path(context.issue))

      assert has_element?(view, "#issue-work-live")
      assert render(view) =~ "running"
      assert has_element?(view, "#issue-work-elapsed")
      assert render(view) =~ "1m 3"
    end

    test "lands its terminal event without a reload", context do
      attempt = attempt(context)

      {:ok, view, _html} = live(context.conn, path(context.issue))

      assert has_element?(view, "#issue-work-live")
      refute render(view) =~ "finished this work"

      # The production terminal path, which announces on the topic the page
      # subscribed to at mount. Nothing here touches the view.
      {:ok, _finished} = Assignments.finish(attempt, "completed", @sha)

      html = render(view)
      assert html =~ "finished this work at #{String.slice(@sha, 0, 7)}"
      refute has_element?(view, "#issue-work-live")
    end

    test "an attempt that started and never finished renders as started only", context do
      attempt(context)

      {:ok, view, _html} = live(context.conn, path(context.issue))

      html = render(view)
      assert html =~ "started work on a box"
      refute html =~ "finished this work"
      refute html =~ "stopped this work"
      refute html =~ "cancelled this work"
    end
  end

  describe "cancelling" do
    test "a viewer with write authority ends the attempt through the terminal path",
         context do
      attempt = attempt(context)

      {:ok, view, _html} = live(context.conn, path(context.issue))

      assert has_element?(view, "#issue-work-cancel")
      render_click(view, "cancel_work", %{})

      cancelled = Repo.get!(Assignment, attempt.id)
      assert cancelled.state == "cancelled"
      assert cancelled.failure_reason == "cancelled_by_viewer"
      assert cancelled.finished_at

      refute has_element?(view, "#issue-work-live")
      assert render(view) =~ "cancelled this work"
    end

    test "a reader without write authority is offered no control and refused the event",
         context do
      attempt = attempt(context)

      {:ok, view, _html} = live(build_conn(), path(context.issue))

      refute has_element?(view, "#issue-work-cancel")
      render_click(view, "cancel_work", %{})

      assert Repo.get!(Assignment, attempt.id).state == "running"
    end

    test "is refused for an attempt that already finished", context do
      attempt = attempt(context)
      {:ok, _} = Assignments.finish(attempt, "completed", @sha)

      assert {:error, :assignment_not_live} = Assignments.cancel(attempt.id, context.member)
    end

    test "reads its authority from the attempt's own repository", context do
      attempt = attempt(context)
      stranger = github_user("issue-live-work-stranger")

      assert {:error, :repository_not_writable} = Assignments.cancel(attempt.id, stranger)
      assert {:error, :repository_not_writable} = Assignments.cancel(attempt.id, nil)
      assert Repo.get!(Assignment, attempt.id).state == "running"
    end
  end

  describe "the re-read the announcement triggers" do
    test "runs through this viewer's own authority, not the one it mounted with",
         context do
      attempt(context)

      {:ok, view, _html} = live(context.conn, path(context.issue))

      # A member reads the attempt at `ledger`, so the branch is on the page.
      assert render(view) =~ "agent/watched"

      # Authority changes underneath a mounted socket. Nothing tells the view.
      Repo.delete_all(
        from membership in OpenAgents.Repositories.Membership,
          where:
            membership.repository_id == ^context.repository.id and
              membership.user_id == ^context.member.id
      )

      Assignments.announce(Repo.get!(Assignment, attempt_id(context)))

      # The re-read went through `Repositories.get_visible_repository/2` and
      # `WorkDisclosure.viewer/2` again, so the reader dropped to `pulse` and
      # the branch left the page. A message that carried the attempt would have
      # put it back.
      html = render(view)
      refute html =~ "agent/watched"
      refute has_element?(view, "#issue-work-cancel")
    end
  end

  defp attempt_id(context) do
    Repo.one!(from a in Assignment, where: a.issue_id == ^context.issue.id, select: a.id)
  end

  defp attempt(context, opts \\ []) do
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
    started_at = Keyword.get(opts, :started_at, now) |> DateTime.truncate(:microsecond)

    %Assignment{}
    |> Assignment.changeset(%{
      target_kind: "box",
      conversation_box_id: box(context).id,
      repository_id: context.repository.id,
      issue_id: context.issue.id,
      requesting_principal: %{
        "type" => "user",
        "id" => context.member.id,
        "actor_type" => "user",
        "actor_id" => context.member.id
      },
      branch: "agent/watched",
      state: "running",
      admitted_at: started_at,
      started_at: started_at,
      deadline_at: DateTime.add(now, 3_600, :second)
    })
    |> Repo.insert!()
  end

  defp box(context) do
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.member)

    {:ok, box} =
      %OpenAgents.Box.ConversationBox{}
      |> OpenAgents.Box.ConversationBox.changeset(%{
        conversation_id: conversation.id,
        box_id: "bx_live_work_#{System.unique_integer([:positive])}",
        state: "ready",
        setup_status: "done"
      })
      |> Repo.insert()

    box
  end
end