test/openagents_web/live/project_workspace_live_test.exs

main at 58e6347eeb72 · 9 KB

defmodule OpenAgentsWeb.ProjectWorkspaceLiveTest do
  @moduledoc """
  The global project list at `/projects`, held to the same rules as `/issues`:
  one meaning wherever you reach it from, the repository named on every row,
  no leakage across a private repository in either direction, and a bound.
  """
  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query
  import Phoenix.LiveViewTest

  alias OpenAgents.Projects
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Membership
  alias OpenAgents.Repositories.Repository

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

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

    {:ok, secret} = Projects.create_project(private, %{"title" => "Key rotation"}, owner)

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

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

    assert html =~ "Key rotation"
    assert has_element?(view, "#workspace-projects")
    assert project_linked?(view, context.private, context.secret)
    assert html =~ "#{context.private.owner}/#{context.private.name}"
  end

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

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

    refute html =~ "Key rotation"
    refute project_linked?(view, context.private, context.secret)
  end

  test "the open and closed tabs each carry their own count", context do
    {:ok, closable} =
      Projects.create_project(context.private, %{"title" => "Shipped"}, context.owner)

    {:ok, _closed} = Projects.update_project(closable, %{"state" => "closed"})

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

    assert has_element?(view, ~s{a[href="/projects?state=open"][aria-current]})
    refute render(view) =~ "Shipped"

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

    assert closed =~ "Shipped"
    refute closed =~ "Key rotation"
  end

  test "the created-by filter shows only what you created", context do
    {:ok, _mine} =
      Projects.create_project(context.private, %{"title" => "Mine to run"}, context.member)

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

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

    assert created =~ "Mine to run"
    refute created =~ "Key rotation"
  end

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

    assert html =~ "Key rotation"
  end

  test "repositories with no matching projects get an empty state that says so", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/projects?state=closed")

    assert has_element?(view, "#workspace-projects-empty")
    assert html =~ "No closed projects"
    refute has_element?(view, "#workspace-projects-no-repositories")
  end

  test "an account that can read no repository is told that instead", %{} do
    stranger = github_user("workspace-projects-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"/projects")

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

  test "the list is bounded and pages rather than loading everything", context do
    bulk = for index <- 1..(Projects.per_page() + 3), do: bulk_title(index)

    for title <- bulk do
      {:ok, _project} =
        Projects.create_project(context.private, %{"title" => title}, context.owner)
    end

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

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

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

    second = render_patch(view, ~p"/projects?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"/projects")

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

  test "a signed-out visitor cannot see a private project through any filter", context do
    for query <- [
          "",
          "involvement=created",
          "state=all",
          "state=closed",
          "state=closed&involvement=created",
          "page=2"
        ] do
      {:ok, view, _html} = live(build_conn(), ~p"/projects?#{query}")
      refute project_linked?(view, context.private, context.secret)
    end
  end

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

  defp project_linked?(view, repository, project) do
    has_element?(
      view,
      ~s{a[href="/#{repository.owner}/#{repository.name}/projects/#{project.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 list without a reload", context do
      {:ok, view, _html} = live(context.conn, ~p"/projects")

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

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

      assert project_linked?(view, context.private, fresh)
      assert render(view) =~ "Filed from the API"
    end

    test "updates, closes, reopens, and deletes converge through PubSub", context do
      {:ok, view, _html} = live(context.conn, ~p"/projects")

      {:ok, renamed} = Projects.update_project(context.secret, %{"title" => "Renamed board"})
      _ = :sys.get_state(view.pid)
      assert render(view) =~ "Renamed board"

      {:ok, closed} = Projects.update_project(renamed, %{"state" => "closed"})
      _ = :sys.get_state(view.pid)
      refute render(view) =~ "Renamed board"

      assert render_patch(view, ~p"/projects?state=closed") =~ "Renamed board"

      {:ok, reopened} = Projects.update_project(closed, %{"state" => "open"})
      _ = :sys.get_state(view.pid)
      refute render(view) =~ "Renamed board"

      assert render_patch(view, ~p"/projects?state=open") =~ "Renamed board"

      assert {:ok, _deleted} = Projects.delete_project(reopened)
      _ = :sys.get_state(view.pid)
      refute render(view) =~ "Renamed board"
    end

    test "closing out of band moves the row to the closed tab", context do
      {:ok, view, _html} = live(context.conn, ~p"/projects")

      {:ok, _closed} = Projects.update_project(context.secret, %{"state" => "closed"})

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

      refute render(view) =~ "Key rotation"

      assert render_patch(view, ~p"/projects?state=closed") =~ "Key rotation"
    end

    test "a burst of writes coalesces into one refresh", context do
      {:ok, view, _html} = live(context.conn, ~p"/projects")

      Enum.each(1..5, fn n ->
        {:ok, _} =
          Projects.create_project(context.private, %{"title" => "Burst #{n}"}, context.owner)

        send(view.pid, {:projects_changed, context.private.id})
      end)

      # With the test debounce at zero every armed timer fires immediately; what
      # matters here is that the page converges to all five rows in one piece.
      _ = :sys.get_state(view.pid)

      html = render(view)

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

    test "a private project 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"/projects")

      refute html =~ "Key rotation"

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

      refute render(view) =~ "Key rotation"
    end
  end
end