defmodule OpenAgentsWeb.RepositoryLiveUpdatesTest do
@moduledoc """
The repository-scoped surfaces as live surfaces (#159, following #154).
`#154` fixed the signed-in homepage and audited the rest. Three pages here
subscribed but re-read only part of what they had loaded; five more had a
publisher nobody had subscribed to. Both shapes render the same defect: a
number that stopped being true while you were looking at it.
Every surface below re-reads through the authorization it mounted under, and
proves it in both directions -- a write moves the page, and a viewer whose
write access is withdrawn loses what it was giving them. The counts
stay aggregates: the assignee and milestone indexes now group in Postgres
rather than loading every issue in the repository to count it in memory.
"""
use OpenAgentsWeb.ConnCase, async: false
import Ecto.Query
import OpenAgents.AccountsFixtures
import Phoenix.LiveViewTest
alias OpenAgents.Issues
alias OpenAgents.Labels
alias OpenAgents.Milestones
alias OpenAgents.Projects
alias OpenAgents.PullRequests.PullRequest
alias OpenAgents.Repo
alias OpenAgents.Repositories
alias OpenAgents.Repositories.Membership
setup %{conn: conn} do
member = github_user("repo-live-member")
repository = repository_with_member_fixture(member)
%{
conn: Plug.Test.init_test_session(conn, %{"user_id" => member.id}),
member: member,
repository: repository
}
end
describe "the issue page" do
test "a label created elsewhere joins the picker", context do
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Needs a label"}, context.member)
{:ok, view, _html} = live(context.conn, issue_path(context.repository, issue))
refute render(view) =~ "needs-triage"
{:ok, _label} =
Labels.create_label(context.repository, %{"name" => "needs-triage", "color" => "ededed"})
# The label write announces itself now, so no issue has to move for the
# picker to catch up. Before `Labels.broadcast_labels/1` existed this
# only worked when an issue happened to be written as well.
assert render(view) =~ "needs-triage"
end
test "a milestone created elsewhere joins the picker", context do
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Needs a milestone"}, context.member)
{:ok, view, _html} = live(context.conn, issue_path(context.repository, issue))
refute render(view) =~ "Second quarter"
{:ok, _milestone} =
Milestones.create_milestone(
context.repository,
%{"title" => "Second quarter"},
context.member
)
assert render(view) =~ "Second quarter"
end
test "write access withdrawn elsewhere takes the pickers with it", context do
{:ok, _label} =
Labels.create_label(context.repository, %{"name" => "needs-triage", "color" => "ededed"})
{:ok, issue} =
Issues.create_issue(
context.repository,
%{"title" => "Written while a member"},
context.member
)
{:ok, view, _html} = live(context.conn, issue_path(context.repository, issue))
assert render(view) =~ "needs-triage"
revoke_write_access!(context.repository, context.member)
Repositories.broadcast_issues(context.repository.id)
# The pickers are read beside the authority that decides whether to offer
# them, so they cannot disagree: no write access, nothing offered.
refute render(view) =~ "needs-triage"
end
test "a label deleted elsewhere leaves the picker", context do
{:ok, label} =
Labels.create_label(context.repository, %{"name" => "wont-fix", "color" => "ededed"})
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Keeps a picker"}, context.member)
{:ok, view, _html} = live(context.conn, issue_path(context.repository, issue))
assert render(view) =~ "wont-fix"
{:ok, _deleted} = Labels.delete_label(label)
refute render(view) =~ "wont-fix"
end
test "a milestone written elsewhere does not reload the timeline", context do
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Has a comment"}, context.member)
{:ok, view, _html} = live(context.conn, issue_path(context.repository, issue))
queries =
capture_queries(view.pid, fn ->
{:ok, _milestone} =
Milestones.create_milestone(
context.repository,
%{"title" => "Third quarter"},
context.member
)
render(view)
end)
# The pickers moved; nothing about the issue did. A page that reloaded
# its timeline on every neighbouring write would pay for the comments,
# the attempts, and the closing references once per label a script
# creates.
assert Enum.any?(queries, &(&1 =~ ~s("milestones")))
refute Enum.any?(queries, &(&1 =~ ~s("issue_comments")))
end
end
describe "the label index" do
test "a label created elsewhere joins the list", context do
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/labels")
assert has_element?(view, "#labels-empty")
{:ok, _label} =
Labels.create_label(context.repository, %{"name" => "from-the-api", "color" => "ededed"})
assert has_element?(view, "#labels", "from-the-api")
refute has_element?(view, "#labels-empty")
end
test "write access withdrawn elsewhere retires the form", context do
{:ok, _label} =
Labels.create_label(context.repository, %{"name" => "still-here", "color" => "ededed"})
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/labels")
assert has_element?(view, "#new-label-form")
revoke_write_access!(context.repository, context.member)
{:ok, _other} =
Labels.create_label(context.repository, %{"name" => "written-after", "color" => "ededed"})
# The list and the authority to change it are read in the same call, so
# the page cannot go on offering a delete button to a viewer who can no
# longer use it.
refute has_element?(view, "#new-label-form")
assert has_element?(view, "#labels", "written-after")
end
end
describe "the milestone index" do
test "a milestone created elsewhere joins the list", context do
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/milestones")
refute render(view) =~ "Opened by a script"
{:ok, _milestone} =
Milestones.create_milestone(
context.repository,
%{"title" => "Opened by a script"},
context.member
)
assert render(view) =~ "Opened by a script"
end
test "a milestone deleted elsewhere leaves the list", context do
{:ok, milestone} =
Milestones.create_milestone(
context.repository,
%{"title" => "Retired elsewhere"},
context.member
)
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/milestones")
assert render(view) =~ "Retired elsewhere"
{:ok, _deleted} = Milestones.delete_milestone(milestone)
refute render(view) =~ "Retired elsewhere"
end
test "a milestone written elsewhere still counts its issues by aggregate", context do
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/milestones")
queries =
capture_queries(view.pid, fn ->
{:ok, _milestone} =
Milestones.create_milestone(
context.repository,
%{"title" => "Counted by aggregate"},
context.member
)
render(view)
end)
issue_reads = Enum.filter(queries, &(&1 =~ ~s(FROM "issues")))
assert issue_reads != []
assert Enum.all?(issue_reads, &(&1 =~ "count("))
end
end
describe "the project board" do
test "an issue opened elsewhere joins the item picker", context do
{:ok, project} =
Projects.create_project(
context.repository,
%{title: "Delivery", owner: context.member.github_login, state: "open"},
context.member
)
{:ok, view, _html} = live(context.conn, project_path(context.repository, project))
refute render(view) =~ "Opened in another tab"
{:ok, _issue} =
Issues.create_issue(
context.repository,
%{"title" => "Opened in another tab"},
context.member
)
assert render(view) =~ "Opened in another tab"
end
test "write access withdrawn elsewhere retires the composer", context do
{:ok, project} =
Projects.create_project(
context.repository,
%{title: "Delivery", owner: context.member.github_login, state: "open"},
context.member
)
{:ok, view, _html} = live(context.conn, project_path(context.repository, project))
assert has_element?(view, "#new-project-item-form")
revoke_write_access!(context.repository, context.member)
# A change message, not an action taken on the page: authority is
# re-read on the same beat as the board rather than only when the viewer
# tries something.
Repositories.broadcast_projects(context.repository.id)
refute has_element?(view, "#new-project-item-form")
end
end
describe "the project list" do
test "a project opened elsewhere joins the list", context do
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/projects")
refute render(view) =~ "Second quarter board"
{:ok, _project} =
Projects.create_project(
context.repository,
%{title: "Second quarter board", owner: context.member.github_login, state: "open"},
context.member
)
assert render(view) =~ "Second quarter board"
end
end
describe "the milestone list" do
test "closing an issue elsewhere moves the milestone's counts", context do
{:ok, milestone} =
Milestones.create_milestone(context.repository, %{"title" => "v1"}, context.member)
{:ok, issue} =
Issues.create_issue(
context.repository,
%{"title" => "In the milestone", "milestone" => milestone.number},
context.member
)
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/milestones")
assert has_element?(view, "#milestone-#{milestone.number}", "1 open")
assert has_element?(view, "#milestone-#{milestone.number}", "0 closed")
{:ok, _closed} = Issues.update_issue(issue, %{"state" => "closed"}, context.member)
assert has_element?(view, "#milestone-#{milestone.number}", "0 open")
assert has_element?(view, "#milestone-#{milestone.number}", "1 closed")
end
test "the counts are grouped aggregates, not a collection loaded to measure",
context do
{:ok, milestone} =
Milestones.create_milestone(context.repository, %{"title" => "v1"}, context.member)
for index <- 1..3 do
{:ok, _issue} =
Issues.create_issue(
context.repository,
%{"title" => "Issue #{index}", "milestone" => milestone.number},
context.member
)
end
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/milestones")
sql =
capture_queries(view.pid, fn ->
Repositories.broadcast_issues(context.repository.id)
render(view)
end)
issue_reads = Enum.filter(sql, &String.contains?(&1, ~s(FROM "issues")))
assert issue_reads != []
assert Enum.all?(issue_reads, &String.contains?(&1, "count("))
end
end
describe "the assignee list" do
test "an assignment made elsewhere moves the count", context do
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Unassigned"}, context.member)
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/assignees")
refute has_element?(view, "#assignees")
{:ok, _assigned} =
Issues.add_assignees(issue, [context.member.github_login], context.member)
assert has_element?(view, "#assignees", context.member.github_login)
end
test "write access withdrawn elsewhere empties the list", context do
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Assigned"}, context.member)
{:ok, _assigned} =
Issues.add_assignees(issue, [context.member.github_login], context.member)
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/assignees")
assert has_element?(view, "#assignees", context.member.github_login)
revoke_write_access!(context.repository, context.member)
Repositories.broadcast_issues(context.repository.id)
refute has_element?(view, "#assignees")
end
test "the counts are grouped aggregates, not a collection loaded to measure",
context do
for index <- 1..3 do
{:ok, issue} =
Issues.create_issue(context.repository, %{"title" => "Issue #{index}"}, context.member)
{:ok, _assigned} =
Issues.add_assignees(issue, [context.member.github_login], context.member)
end
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/assignees")
sql =
capture_queries(view.pid, fn ->
Repositories.broadcast_issues(context.repository.id)
render(view)
end)
issue_reads = Enum.filter(sql, &String.contains?(&1, ~s(FROM "issues")))
assert issue_reads != []
assert Enum.all?(issue_reads, &String.contains?(&1, "count("))
end
end
describe "the pull request pages" do
test "an issue opened elsewhere moves the issue tab count", context do
issue = pull_request_issue!(context.repository, context.member)
{:ok, index, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/pulls")
{:ok, show, _html} =
live(
context.conn,
~p"/#{context.repository.owner}/#{context.repository.name}/pulls/#{issue.number}"
)
refute render(index) =~ "Nothing yet"
{:ok, _issue} =
Issues.create_issue(context.repository, %{"title" => "Nothing yet"}, context.member)
# The tab badge only renders once the count is above zero, which is what
# makes its arrival the assertion.
assert has_element?(
index,
~s{a[href="/#{context.repository.owner}/#{context.repository.name}/issues"]},
"1"
)
assert has_element?(
show,
~s{a[href="/#{context.repository.owner}/#{context.repository.name}/issues"]},
"1"
)
end
end
describe "the repository home" do
test "an issue opened elsewhere moves the issue tab count", context do
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}")
{:ok, _issue} =
Issues.create_issue(context.repository, %{"title" => "Nothing yet"}, context.member)
assert has_element?(
view,
~s{a[href="/#{context.repository.owner}/#{context.repository.name}/issues"]},
"1"
)
end
end
describe "authorization" do
test "a write in another repository moves nothing", context do
other = repository_with_member_fixture(context.member)
{:ok, view, _html} =
live(context.conn, ~p"/#{context.repository.owner}/#{context.repository.name}/milestones")
{:ok, _milestone} =
Milestones.create_milestone(other, %{"title" => "Somewhere else"}, context.member)
{:ok, _label} =
Labels.create_label(other, %{"name" => "elsewhere", "color" => "ededed"})
{:ok, _issue} = Issues.create_issue(other, %{"title" => "Not here"}, context.member)
# Each page subscribes to its own repository's topic, so another
# repository's write is not a message this page has to filter -- it never
# arrives.
refute render(view) =~ "Somewhere else"
refute render(view) =~ "Not here"
end
end
# ── helpers ───────────────────────────────────────────────────────────────
# Write access withdrawn somewhere else, which is the direction a page cannot
# notice by watching what the viewer does. The membership is demoted rather
# than deleted because a project references the membership that owns it.
defp revoke_write_access!(repository, user) do
{1, _} =
Repo.update_all(
from(membership in Membership,
where: membership.repository_id == ^repository.id and membership.user_id == ^user.id
),
set: [role: "viewer"]
)
:ok
end
defp issue_path(repository, issue),
do: ~p"/#{repository.owner}/#{repository.name}/issues/#{issue.number}"
defp project_path(repository, project),
do: ~p"/#{repository.owner}/#{repository.name}/projects/#{project.number}"
defp pull_request_issue!(repository, author) do
{:ok, issue} =
Issues.create_issue(repository, %{"title" => "A proposed change"}, author)
source = repository_fixture()
%PullRequest{}
|> PullRequest.changeset(%{
repository_id: repository.id,
issue_id: issue.id,
head_repository_id: source.id,
head_ref: "feature",
head_sha: String.duplicate("a", 40),
base_ref: "main",
base_sha: String.duplicate("b", 40)
})
|> Repo.insert!()
issue
end
# 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
end