test/openagents_web/live/issue_workspace_live_test.exs

main at 58e6347eeb72 · 9 KB

defmodule OpenAgentsWeb.IssueWorkspaceLiveTest do
  @moduledoc """
  The global issue list at `/issues`.

  What matters here is that the page means the same thing wherever you reach
  it from, that it names the repository each row belongs to, that a private
  repository stays private in both directions, and that both kinds of
  emptiness explain themselves.
  """
  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query
  import Phoenix.LiveViewTest

  alias OpenAgents.Issues
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Membership
  alias OpenAgents.Repositories.Repository

  setup %{conn: conn} do
    member = github_user("workspace-issues-member")
    owner = github_user("workspace-issues-owner")

    private = ready_repository!(owner, "member-only", "private")
    {:ok, _membership} = Repositories.add_member(private, member, "contributor")

    {:ok, secret} = Issues.create_issue(private, %{"title" => "Rotate the signing key"}, owner)

    %{
      conn: Plug.Test.init_test_session(conn, %{"user_id" => member.id}),
      member: member,
      owner: owner,
      private: private,
      secret: secret
    }
  end

  test "lists issues from every repository you can read, naming each one", context do
    {:ok, view, html} = live(context.conn, ~p"/issues")

    assert html =~ "Rotate the signing key"
    assert has_element?(view, "#workspace-issues")

    assert has_element?(
             view,
             ~s{a[href="/#{context.private.owner}/#{context.private.name}/issues/#{context.secret.number}"]},
             "Rotate the signing key"
           )

    # A cross-repository list is unreadable without the repository, so the row
    # carries it in the scan column rather than at the trailing edge.
    assert has_element?(
             view,
             ".issue-row__repository",
             "#{context.private.owner}/#{context.private.name}"
           )
  end

  test "a stranger to a private repository never sees its issues", context do
    stranger = github_user("workspace-issues-stranger")
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})

    {:ok, view, html} = live(conn, ~p"/issues")

    refute html =~ "Rotate the signing key"
    refute issue_linked?(view, context.private, context.secret)
  end

  test "a stranger cannot reach it through the search filter either", context do
    stranger = github_user("workspace-issues-searcher")
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})

    {:ok, view, html} = live(conn, ~p"/issues?q=Rotate+the+signing+key")

    # The search box echoes the needle, so the title alone is not the test:
    # what matters is that no row links to the issue.
    refute issue_linked?(view, context.private, context.secret)
    assert html =~ "Nothing matches that search"
  end

  test "the open and closed tabs each carry their own count", context do
    {:ok, closable} = Issues.create_issue(context.private, %{"title" => "Already done"})
    {:ok, _closed} = Issues.update_issue(closable, %{"state" => "closed"}, context.owner)

    {:ok, view, _html} = live(context.conn, ~p"/issues")

    assert has_element?(view, ~s{a[href="/issues?state=open"][aria-current]})
    assert render(view) =~ "Rotate the signing key"
    refute render(view) =~ "Already done"

    closed = render_patch(view, ~p"/issues?state=closed")

    assert closed =~ "Already done"
    refute closed =~ "Rotate the signing key"
  end

  test "the assigned filter shows only what is assigned to you", context do
    {:ok, _mine} =
      Issues.create_issue(
        context.private,
        %{"title" => "Mine to do", "assignees" => [context.member.github_login]}
      )

    {:ok, view, _html} = live(context.conn, ~p"/issues")

    assigned =
      view
      |> form("#workspace-issue-filter-form", %{"involvement" => "assigned", "q" => ""})
      |> render_change()

    assert assigned =~ "Mine to do"
    refute assigned =~ "Rotate the signing key"
  end

  test "the opened-by filter shows only what you opened", context do
    {:ok, _mine} =
      Issues.create_issue(context.private, %{"title" => "I filed this"}, context.member)

    {:ok, view, _html} = live(context.conn, ~p"/issues")

    created =
      view
      |> form("#workspace-issue-filter-form", %{"involvement" => "created", "q" => ""})
      |> render_change()

    assert created =~ "I filed this"
    refute created =~ "Rotate the signing key"
  end

  test "a filter value the URL invents falls back to everyone", context do
    {:ok, _view, html} = live(context.conn, ~p"/issues?involvement=everything")

    assert html =~ "Rotate the signing key"
  end

  test "repositories with no matching issues get an empty state that says so", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/issues?q=nothing-matches-this")

    assert has_element?(view, "#workspace-issues-empty")
    assert html =~ "No open issues"
    assert html =~ "Nothing matches that search"
    refute has_element?(view, "#workspace-issues-no-repositories")
  end

  test "an account that can read no repository is told that instead", %{} do
    stranger = github_user("workspace-issues-repoless")

    Repo.delete_all(from membership in Membership, where: membership.user_id == ^stranger.id)
    Repo.update_all(Repository, set: [visibility: "private"])

    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
    {:ok, view, html} = live(conn, ~p"/issues")

    assert has_element?(view, "#workspace-issues-no-repositories")
    assert html =~ "No repositories yet"
    assert has_element?(view, ~s{a[href="/repositories/new"]})
    refute has_element?(view, "#workspace-issues-empty")
  end

  test "the list is bounded and pages rather than loading everything", context do
    # Zero-padded, so "Bulk 001" is never a substring of "Bulk 010".
    bulk = for index <- 1..(Issues.per_page() + 3), do: bulk_title(index)

    for title <- bulk do
      {:ok, _issue} = Issues.create_issue(context.private, %{"title" => title})
    end

    newest = List.last(bulk)
    oldest = List.first(bulk)

    {:ok, view, html} = live(context.conn, ~p"/issues")

    assert html =~ "Showing #{Issues.per_page()} of #{length(bulk) + 1}"
    assert html =~ newest
    refute html =~ oldest
    assert has_element?(view, ~s{a[href="/issues?page=2&state=open"]}, "Next")

    second = render_patch(view, ~p"/issues?state=open&page=2")

    assert second =~ oldest
    refute second =~ newest
  end

  test "a signed-out visitor sees the public workspace and only the everyone filter" do
    Repo.update_all(Repository, set: [visibility: "private"])

    {:ok, view, _html} = live(build_conn(), ~p"/issues")

    assert has_element?(view, "#workspace-issues-no-repositories")
    assert has_element?(view, "#workspace-issue-filter-form option[value=\"all\"]")
    refute has_element?(view, "#workspace-issue-filter-form option[value=\"assigned\"]")
    refute has_element?(view, "#workspace-issue-filter-form option[value=\"created\"]")
    assert has_element?(view, "#workspace-issues-signin")
  end

  test "a signed-out visitor cannot see a private issue through any filter", context do
    for query <- [
          "",
          "q=Rotate+the+signing+key",
          "involvement=assigned",
          "involvement=created",
          "state=all",
          "state=closed",
          "state=closed&involvement=assigned",
          "state=closed&involvement=created",
          "page=2"
        ] do
      {:ok, view, _html} = live(build_conn(), ~p"/issues?#{query}")
      refute issue_linked?(view, context.private, context.secret)
    end
  end

  defp bulk_title(index), do: "Bulk #{String.pad_leading(to_string(index), 3, "0")}"

  defp issue_linked?(view, repository, issue) do
    has_element?(
      view,
      ~s{a[href="/#{repository.owner}/#{repository.name}/issues/#{issue.number}"]}
    )
  end

  defp ready_repository!(owner, name, visibility) do
    {:ok, repository, :created} =
      Repositories.create_user_repository(
        owner,
        %{name: name, visibility: visibility},
        "#{name}-key"
      )

    repository
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
    |> Repo.update!()
  end

  describe "live updates" do
    test "an out-of-band write re-renders the stream without a reload", context do
      {:ok, view, _html} = live(context.conn, ~p"/issues")

      {:ok, fresh} = Issues.create_issue(context.private, %{"title" => "Filed from the API"})

      send(view.pid, {:issues_changed, context.private.id})
      _ = :sys.get_state(view.pid)

      assert has_element?(
               view,
               ~s{a[href="/#{context.private.owner}/#{context.private.name}/issues/#{fresh.number}"]},
               "Filed from the API"
             )
    end

    test "a burst of writes converges to every new issue", context do
      {:ok, view, _html} = live(context.conn, ~p"/issues")

      Enum.each(1..5, fn n ->
        {:ok, _} = Issues.create_issue(context.private, %{"title" => "Burst #{n}"})
        send(view.pid, {:issues_changed, context.private.id})
      end)

      # Tests use a zero debounce so the page converges without waiting on a
      # wall-clock timer. Production uses the re-armed debounce path.
      _ = :sys.get_state(view.pid)

      html = render(view)

      for n <- 1..5 do
        assert html =~ "Burst #{n}"
      end
    end

    test "a private issue never reaches a viewer who cannot read it", context do
      outsider_conn =
        Plug.Test.init_test_session(build_conn(), %{"user_id" => github_user("outsider").id})

      {:ok, view, html} = live(outsider_conn, ~p"/issues")

      refute html =~ "Rotate the signing key"

      send(view.pid, {:issues_changed, context.private.id})
      _ = :sys.get_state(view.pid)
      _ = render(view)

      refute render(view) =~ "Rotate the signing key"
    end
  end
end