test/openagents_web/live/home_live_updates_test.exs

58e6347eeb72 · 13 KB

defmodule OpenAgentsWeb.HomeLiveUpdatesTest do
  @moduledoc """
  The signed-in homepage as a live surface (#154, following #128).

  `93c3383` scoped the dashboard correctly and said in as many words that it
  was a mount-time snapshot. This is that follow-up: every panel now names a
  publisher and re-reads when it hears, so the page stops disagreeing with the
  database the moment anything moves.

  What matters here is not only that a panel changes. It is that it changes
  through the viewer's own authorized read: the messages carry ids, so a
  refresh cannot hand a viewer a row -- or a row counted into a number -- that
  the database would have refused them. Both directions are proved: a write in
  a repository the viewer cannot read moves nothing, and a viewer who loses
  access loses the rows and the count they already had.

  The counts stay aggregates. A live update that measured a collection by
  loading it would reintroduce, once per event, exactly the read `93c3383`
  removed.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query
  import Phoenix.LiveViewTest

  alias OpenAgents.Forum
  alias OpenAgents.Issues
  alias OpenAgents.Projects
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Membership

  @legacy_actor %{
    actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20",
    actor_display_name: "Orrery",
    actor_slug: "orrery"
  }

  setup %{conn: conn} do
    owner = github_user("home-live-owner")
    repository = ready_repository!(owner, "live-repository", "private")

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

  describe "issues" do
    test "an issue opened elsewhere moves the count and the feed", context do
      {:ok, view, _html} = live(context.conn, ~p"/")

      assert integer_at(view, "#dashboard-open-issue-count") == 0

      assert has_element?(
               view,
               ".panel__empty",
               "No open issues in the repositories you can read."
             )

      {:ok, issue} =
        Issues.create_issue(
          context.repository,
          %{"title" => "Opened in another tab"},
          context.owner
        )

      assert integer_at(view, "#dashboard-open-issue-count") == 1
      assert render(view) =~ "Opened in another tab"

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

    test "closing an issue elsewhere moves both counts", context do
      {:ok, issue} =
        Issues.create_issue(context.repository, %{"title" => "Already underway"}, context.owner)

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

      assert integer_at(view, "#dashboard-open-issue-count") == 1
      assert integer_at(view, "#dashboard-closed-issue-count") == 0

      {:ok, _closed} = Issues.update_issue(issue, %{"state" => "closed"}, context.owner)

      assert integer_at(view, "#dashboard-open-issue-count") == 0
      assert integer_at(view, "#dashboard-closed-issue-count") == 1
      refute render(view) =~ "Already underway"
    end
  end

  describe "projects" do
    test "a project opened elsewhere moves the count and the list", context do
      {:ok, view, _html} = live(context.conn, ~p"/")

      assert integer_at(view, "#dashboard-open-project-count") == 0

      {:ok, project} =
        Projects.create_project(
          context.repository,
          %{title: "Second quarter board", owner: context.owner.github_login, state: "open"},
          context.owner
        )

      assert integer_at(view, "#dashboard-open-project-count") == 1
      assert render(view) =~ "Second quarter board"

      assert has_element?(
               view,
               ~s{a[href="/#{context.repository.owner}/#{context.repository.name}/projects/#{project.number}"]}
             )
    end
  end

  describe "repositories" do
    test "a repository created elsewhere joins the rail and one deleted leaves it", context do
      {:ok, view, _html} = live(context.conn, ~p"/")

      refute has_element?(view, "#dashboard-repository-list", "second-repository")

      second = ready_repository!(context.owner, "second-repository", "private")
      assert has_element?(view, "#repositories-#{second.id}")

      {:ok, _deleted} =
        Repositories.delete_owned_repository(second.owner, second.name, context.owner)

      refute has_element?(view, "#repositories-#{second.id}")
    end

    test "the rail re-reads one row rather than the whole collection", context do
      {:ok, view, _html} = live(context.conn, ~p"/")

      other = ready_repository!(context.owner, "other-repository", "private")

      sql =
        capture_queries(view.pid, fn ->
          Repositories.broadcast_repository_change(other.id)
          render(view)
        end)

      # `get_visible_repository/2` names the row it wants. A rail that answered
      # a push by reloading every repository the viewer can read would be the
      # expensive half of the defect this fixes.
      repository_reads = Enum.filter(sql, &String.contains?(&1, ~s(FROM "repositories")))

      assert repository_reads != []
      assert Enum.all?(repository_reads, &bounded_read?/1)
    end
  end

  describe "the forum panel" do
    test "a post written elsewhere joins the panel", context do
      board = board!("product-promises", "Product promises")

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

      assert has_element?(view, ".panel__empty", "No posts yet on the boards you can read.")

      topic = topic!(board, "What a promise costs", "what-a-promise-costs")

      assert has_element?(view, "#dashboard-post-list")
      assert has_element?(view, ~s{a[href="/forum/t/#{topic.id}"]}, "What a promise costs")

      {:ok, reply} =
        Forum.create_post(topic, Map.merge(@legacy_actor, %{body_text: "Rather less than that"}))

      assert has_element?(view, "#dashboard-post-#{reply.id}")

      # The identity the post was written under, not one resolved for it: a
      # migrated post keeps its legacy byline until a claim binds it, and a
      # live refresh must not attribute it differently from a mount.
      assert has_element?(view, "#dashboard-post-#{reply.id} .post-rail__meta", "Orrery")
    end

    test "a post on a board kept off listings never arrives", context do
      void = board!("void", "Void", discoverability: "unlisted")

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

      topic = topic!(void, "Smoke test", "smoke-test")

      # The message reached the page -- the panel re-read -- and the panel's
      # own scope refused the row, which is what makes this an authorization
      # result rather than an undelivered broadcast.
      refute has_element?(view, ~s{a[href="/forum/t/#{topic.id}"]})
      assert has_element?(view, ".panel__empty", "No posts yet on the boards you can read.")
    end
  end

  describe "authorization" do
    test "a write in a repository the viewer cannot read moves nothing", context do
      stranger = github_user("home-live-stranger")
      conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})

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

      before_open = integer_at(view, "#dashboard-open-issue-count")

      {:ok, _issue} =
        Issues.create_issue(context.repository, %{"title" => "Not for you"}, context.owner)

      {:ok, _project} =
        Projects.create_project(
          context.repository,
          %{title: "Not your board", owner: context.owner.github_login, state: "open"},
          context.owner
        )

      html = render(view)

      refute html =~ "Not for you"
      refute html =~ "Not your board"
      assert integer_at(view, "#dashboard-open-issue-count") == before_open
      assert integer_at(view, "#dashboard-open-issue-count") == 0
      assert integer_at(view, "#dashboard-open-project-count") == 0

      # The owner of the same repository does see both, which is what makes the
      # refusal above an authorization result rather than an empty database.
      {:ok, owner_view, _html} = live(context.conn, ~p"/")
      assert integer_at(owner_view, "#dashboard-open-issue-count") == 1
      assert integer_at(owner_view, "#dashboard-open-project-count") == 1
    end

    test "a viewer who loses access loses the rows and the count they had", context do
      member = github_user("home-live-member")

      Repo.insert!(%Membership{
        repository_id: context.repository.id,
        user_id: member.id,
        role: "viewer"
      })

      conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => member.id})

      {:ok, _issue} =
        Issues.create_issue(context.repository, %{"title" => "Readable for now"}, context.owner)

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

      assert html =~ "Readable for now"
      assert integer_at(view, "#dashboard-open-issue-count") == 1
      assert has_element?(view, "#repositories-#{context.repository.id}")

      Repo.delete_all(
        from membership in Membership,
          where:
            membership.repository_id == ^context.repository.id and
              membership.user_id == ^member.id
      )

      # The same announcement the next write would carry. The page re-reads
      # through a predicate that no longer admits the repository.
      Repositories.broadcast_issues(context.repository.id)
      Repositories.broadcast_repository_change(context.repository.id)

      refute render(view) =~ "Readable for now"
      assert integer_at(view, "#dashboard-open-issue-count") == 0
      refute has_element?(view, "#repositories-#{context.repository.id}")
    end
  end

  describe "cost" do
    test "a live update counts with aggregates rather than by loading the collection",
         context do
      for index <- 1..3 do
        {:ok, _issue} =
          Issues.create_issue(context.repository, %{"title" => "Issue #{index}"}, context.owner)
      end

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

      sql =
        capture_queries(view.pid, fn ->
          Repositories.broadcast_issues(context.repository.id)
          Repositories.broadcast_projects(context.repository.id)
          render(view)
        end)

      issue_reads = Enum.filter(sql, &String.contains?(&1, ~s(FROM "issues")))
      project_reads = Enum.filter(sql, &String.contains?(&1, ~s(FROM "projects")))

      assert issue_reads != []
      assert project_reads != []

      # The numbers beside a panel cost an aggregate, and the rows beside them
      # cost one bounded page. Nothing here reads a collection to measure it.
      assert Enum.any?(issue_reads, &aggregate?/1)
      assert Enum.any?(project_reads, &aggregate?/1)
      assert Enum.all?(issue_reads, &bounded_read?/1)
      assert Enum.all?(project_reads, &bounded_read?/1)
    end

    test "a burst of issue writes leaves the other panels alone", context do
      board = board!("product-promises", "Product promises")
      topic!(board, "Untouched by an issue burst", "untouched")

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

      sql =
        capture_queries(view.pid, fn ->
          for _ <- 1..5, do: Repositories.broadcast_issues(context.repository.id)
          render(view)
        end)

      # Which panels are stale is remembered beside the timer that fires them,
      # so an issue burst never re-reads the forum or the projects.
      refute Enum.any?(sql, &String.contains?(&1, ~s(FROM "forum_posts")))
      refute Enum.any?(sql, &String.contains?(&1, ~s(FROM "projects")))
    end
  end

  # ── helpers ───────────────────────────────────────────────────────────────

  # An aggregate, or a page with a ceiling on it. `IN` subqueries carry their
  # own bound: the id list was already read from a bounded page.
  defp bounded_read?(sql),
    do: aggregate?(sql) or String.contains?(sql, "LIMIT") or String.contains?(sql, "= ANY(")

  defp aggregate?(sql), do: String.contains?(sql, "count(")

  # Telemetry fires in the process that ran the query, so filtering on the
  # LiveView's pid isolates the refresh from the write that provoked it.
  defp capture_queries(pid, fun) do
    handler = {__MODULE__, make_ref()}
    test = self()

    :telemetry.attach(
      handler,
      Repo.config()[:telemetry_prefix] ++ [:query],
      fn _event, _measurements, metadata, _config ->
        if self() == pid, do: send(test, {handler, metadata.query})
      end,
      nil
    )

    try do
      fun.()
      drain(handler, [])
    after
      :telemetry.detach(handler)
    end
  end

  defp drain(handler, acc) do
    receive do
      {^handler, query} -> drain(handler, [query | acc])
    after
      0 -> Enum.reverse(acc)
    end
  end

  defp integer_at(view, selector) do
    view
    |> render()
    |> LazyHTML.from_fragment()
    |> LazyHTML.query(selector)
    |> LazyHTML.text()
    |> String.trim()
    |> String.to_integer()
  end

  defp board!(slug, title, opts \\ []) do
    {:ok, board} =
      %Forum.Forum{}
      |> Forum.Forum.changeset(%{
        slug: slug,
        title: title,
        visibility: Keyword.get(opts, :visibility, "public"),
        discoverability: Keyword.get(opts, :discoverability, "listed")
      })
      |> Repo.insert()

    board
  end

  defp topic!(board, title, slug) do
    {:ok, topic} =
      Forum.create_topic(
        board,
        Map.merge(@legacy_actor, %{
          title: title,
          slug: slug,
          body_text: "Body of #{title}",
          idempotency_key: Ecto.UUID.generate()
        })
      )

    topic
  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
end