test/openagents_web/live/project_show_live_test.exs

main at 58e6347eeb72 · 27 KB

defmodule OpenAgentsWeb.ProjectShowLiveTest do
  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest
  import Ecto.Query
  import OpenAgents.CompensationFixtures
  import OpenAgents.LabelsFixtures
  import OpenAgents.ProjectItemsFixtures
  import OpenAgents.ProjectsFixtures

  alias OpenAgents.Issues
  alias OpenAgents.ProjectItems.ProjectItem
  alias OpenAgents.Projects

  setup %{conn: conn} do
    {:ok, conn: log_in_repository_user(conn, "project-show", repository())}
  end

  defp project!, do: project_fixture(repository(), %{title: "Roadmap", owner: "OpenAgentsInc"})

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

  test "mounts and renders every board column plus the add form", %{conn: conn} do
    project = project!()

    {:ok, view, html} = live(conn, path(project))

    assert html =~ "Roadmap"
    assert html =~ "To Do"
    assert html =~ "In Progress"
    assert html =~ "Done"
    assert has_element?(view, "#new-project-item-form")

    assert has_element?(
             view,
             ~s{a[href="/OpenAgentsInc/openagents.com/projects"]},
             "Back to projects"
           )
  end

  test "a signed-out visitor can read a project board without the add form" do
    project = project!()

    {:ok, view, _html} = live(build_conn(), path(project))

    assert has_element?(view, "#project-board-title")
    refute has_element?(view, "#new-project-item-form")
  end

  test "a signed-out visitor cannot submit an add-item event" do
    project = project!()
    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Hidden forged item"})

    {:ok, view, _html} = live(build_conn(), path(project))

    html =
      render_submit(view, "add_item", %{
        "item" => %{"issue_number" => to_string(issue.number), "status" => "Done"}
      })

    assert html =~ "Only repository members can add project items."
    refute has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]})
  end

  test "a private repository is not visible to a signed-out visitor" do
    private =
      repository_fixture(%{owner: "HiddenBoard", name: "projects", visibility: "private"})

    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
      live(build_conn(), ~p"/#{private.owner}/#{private.name}/projects/1")
    end
  end

  test "an empty board renders the columns with no cards", %{conn: conn} do
    project = project!()

    {:ok, view, _html} = live(conn, path(project))

    refute has_element?(view, ~s{a[href^="/OpenAgentsInc/openagents.com/issues/"]})
    # With no issues in the repo the issue select carries only its prompt.
    refute has_element?(view, ~s{#item_issue_number option:not([value=""])})
  end

  test "the issue select offers every issue, open or closed", %{conn: conn} do
    project = project!()
    {:ok, open} = Issues.create_issue(repository(), %{"title" => "Still open"})
    {:ok, closed} = Issues.create_issue(repository(), %{"title" => "All done"})
    {:ok, _} = Issues.update_issue(closed, %{"state" => "closed"})

    {:ok, view, _html} = live(conn, path(project))

    assert has_element?(
             view,
             ~s{#item_issue_number option[value="#{open.number}"]},
             "##{open.number} Still open"
           )

    assert has_element?(
             view,
             ~s{#item_issue_number option[value="#{closed.number}"]},
             "##{closed.number} All done"
           )

    for status <- ["To Do", "In Progress", "Done"] do
      assert has_element?(view, ~s{#item_status option[value="#{status}"]}, status)
    end
  end

  test "an existing item renders as a card in its status column", %{conn: conn} do
    project = project!()
    label_fixture(repository(), %{name: "bug", color: "d73a4a"})

    {:ok, issue} =
      Issues.create_issue(repository(), %{"title" => "Fix the parser", "labels" => ["bug"]})

    {:ok, _item} =
      Projects.create_project_item(
        %{"issue_number" => issue.number, "values" => %{"Status" => "In Progress"}},
        project
      )

    {:ok, view, html} = live(conn, path(project))

    assert html =~ "Fix the parser"
    assert html =~ "bug"

    assert has_element?(
             view,
             ~s{a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]},
             "Fix the parser"
           )
  end

  test "an item with no recorded status falls back to To Do", %{conn: conn} do
    project = project!()
    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Unsorted"})

    {:ok, _item} =
      Projects.create_project_item(
        %{"issue_number" => issue.number, "values" => %{}},
        project
      )

    {:ok, _view, html} = live(conn, path(project))

    assert html =~ "Unsorted"
  end

  test "submitting the form adds the issue to the board", %{conn: conn} do
    project = project!()
    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Ship the runbook"})

    {:ok, view, html} = live(conn, path(project))
    refute html =~ "Ship the runbook</a>"

    html =
      view
      |> form("#new-project-item-form",
        item: %{issue_number: to_string(issue.number), status: "Done"}
      )
      |> render_submit()

    assert html =~ "Issue added to project"

    assert has_element?(
             view,
             ~s{a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]},
             "Ship the runbook"
           )

    assert [item] = Projects.list_project_items(project)
    assert item.issue_id == issue.id
    assert item.values == %{"Status" => "Done"}
  end

  test "a fixture-built item lands in the column named by its Status value", %{conn: conn} do
    project = project!()
    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Fixture-placed"})

    item =
      project_item_fixture(repository(), %{
        project_id: project.id,
        issue_id: issue.id,
        values: %{"Status" => "Done"}
      })

    assert item.project_id == project.id

    {:ok, view, _html} = live(conn, path(project))

    # Cards are grouped by column; the "Done" column is the third section.
    assert has_element?(
             view,
             ~s{section:nth-of-type(3) a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]},
             "Fixture-placed"
           )

    refute has_element?(
             view,
             ~s{section:nth-of-type(1) a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
           )
  end

  test "a cross-repository card names and links to its source repository", %{conn: conn} do
    project = project!()

    source =
      repository_fixture(%{owner: "SourceOrg", name: "source-board", visibility: "public"})

    {:ok, issue} = Issues.create_issue(source, %{"title" => "Cross-repository card"})

    {:ok, _item} =
      Projects.create_project_item(
        %{
          "issue_number" => issue.number,
          "issue_repository_id" => source.id,
          "values" => %{"Status" => "To Do"}
        },
        project
      )

    {:ok, view, html} = live(conn, path(project))

    assert html =~ "SourceOrg/source-board##{issue.number}"

    assert has_element?(
             view,
             ~s{a[href="/SourceOrg/source-board/issues/#{issue.number}"]},
             "Cross-repository card"
           )
  end

  test "a card whose source repository is unreadable never renders", %{conn: conn} do
    project = project!()

    source =
      repository_fixture(%{owner: "HiddenOrg", name: "hidden-board", visibility: "private"})

    {:ok, issue} = Issues.create_issue(source, %{"title" => "Confidential card"})

    {:ok, _item} =
      Projects.create_project_item(
        %{
          "issue_number" => issue.number,
          "issue_repository_id" => source.id,
          "values" => %{"Status" => "To Do"}
        },
        project
      )

    {:ok, view, html} = live(conn, path(project))

    refute html =~ "Confidential card"
    refute html =~ "hidden-board"

    refute has_element?(
             view,
             ~s{a[href="/HiddenOrg/hidden-board/issues/#{issue.number}"]}
           )
  end

  test "a promise registry renders state columns and promise metadata", %{conn: conn} do
    project = project!()

    {:ok, _field} =
      Projects.create_project_field(%{
        project_id: project.id,
        name: "Promise state",
        data_type: "promise_state",
        options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
      })

    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Close the gap"})
    decision = outcome_decision_fixture()
    {:ok, live_issue} = Issues.create_issue(repository(), %{"title" => "Shipped promise"})

    {:ok, _item} =
      Projects.create_project_item(
        %{
          "issue_number" => issue.number,
          "values" => %{
            "Promise state" => "GATED",
            "promise" => %{
              "id" => "close_the_gap",
              "problem" => "A problem",
              "claim" => "A claim",
              "scope" => "A scope",
              "acceptance_criteria" => ["A criterion"],
              "success_metrics" => ["A metric"],
              "owner" => "OpenAgents",
              "target" => "2026-12-31",
              "evidence" => [],
              "gate" => %{
                "missing" => "A receipt",
                "owner" => "OpenAgents",
                "next_review" => "2026-09-01"
              }
            }
          }
        },
        project
      )

    {:ok, live_item} =
      Projects.create_project_item(
        %{
          "issue_number" => live_issue.number,
          "values" => %{
            "Promise state" => "LIVE",
            "promise" => %{
              "id" => "shipped_promise",
              "problem" => "A problem",
              "claim" => "A claim",
              "scope" => "A scope",
              "acceptance_criteria" => ["A criterion"],
              "success_metrics" => ["A metric"],
              "owner" => "OpenAgents",
              "target" => "2026-12-31",
              "evidence" => [
                %{
                  "kind" => "accepted_outcome",
                  "decision_receipt_ref" => decision.decision_receipt_ref
                }
              ]
            }
          }
        },
        project
      )

    live_values = put_in(live_item.values, ["promise", "verified_at"], "2026-08-23T05:00:00Z")

    OpenAgents.Repo.update_all(
      from(item in ProjectItem, where: item.id == ^live_item.id),
      set: [values: live_values]
    )

    {:ok, view, html} = live(conn, path(project))

    assert html =~ "LIVE"
    assert html =~ "GATED"
    assert html =~ "WITHDRAWN"
    assert html =~ "close_the_gap"
    assert html =~ "A receipt"
    assert html =~ "2026-09-01"
    assert html =~ "shipped_promise"
    assert html =~ "Verified: 2026-08-23 05:00 UTC"
    refute html =~ "To Do"
    refute has_element?(view, "#new-project-item-form")
  end

  test "a missing project number raises rather than rendering an empty board", %{conn: conn} do
    assert_raise Ecto.NoResultsError, fn ->
      live(conn, ~p"/OpenAgentsInc/openagents.com/projects/9999")
    end
  end

  describe "description" do
    test "renders the description as Markdown, with an empty state when there is none", %{
      conn: conn
    } do
      project = project!()

      {:ok, view, html} = live(conn, path(project))
      assert html =~ "No description yet"
      assert has_element?(view, "#project-description-empty")

      {:ok, _updated} =
        Projects.update_project(project, %{"description" => "## Why\n\nProvider order."})

      html = render(view)
      assert html =~ "<h2>Why</h2>"
      refute html =~ "No description yet"
    end

    test "a member edits the description and the change lands in the activity feed", %{
      conn: conn
    } do
      project = project!()

      {:ok, view, _html} = live(conn, path(project))

      view |> element("#edit-description") |> render_click()

      html =
        view
        |> form("#project-description-form", project: %{description: "Operating notes."})
        |> render_submit()

      assert html =~ "Operating notes."
      assert html =~ "Updated the description."

      assert Projects.get_project_by_number!(repository(), project.number).description ==
               "Operating notes."
    end
  end

  describe "discussion" do
    test "an empty project shows the notes empty state", %{conn: conn} do
      project = project!()

      {:ok, view, html} = live(conn, path(project))

      assert html =~ "Discussion and activity"
      assert has_element?(view, "#project-notes-empty")
    end

    test "a member adds a note and can delete their own", %{conn: conn} do
      project = project!()

      {:ok, view, _html} = live(conn, path(project))

      html =
        view
        |> form("#project-note-form", note: %{body: "Lane 3 is paused."})
        |> render_submit()

      assert html =~ "Lane 3 is paused."
      assert [note] = elem(Projects.list_project_notes_page(project), 0)
      assert has_element?(view, "#project-note-#{note.id}")

      html = view |> element("#project-note-#{note.id} button", "Delete") |> render_click()

      refute html =~ "Lane 3 is paused."
      assert Projects.count_project_notes(project) == 0
    end

    test "pagination appears once the notes outrun one page", %{conn: conn} do
      project = project!()
      per_page = Projects.notes_per_page()

      for index <- 1..(per_page + 1) do
        {:ok, _note} = Projects.create_project_note(project, %{"body" => "note #{index}"})
      end

      {:ok, view, _html} = live(conn, path(project))

      assert has_element?(view, "#project-notes-pagination")
      assert render(view) =~ "Page 1 of 2"

      html = view |> element("#project-notes-pagination button", "Older") |> render_click()

      assert html =~ "Page 2 of 2"
      assert html =~ "note 1"
    end

    test "a note written remotely reaches an open board without a reload", %{conn: conn} do
      project = project!()

      {:ok, view, _html} = live(conn, path(project))

      {:ok, _note} =
        Projects.create_project_note(project, %{"body" => "Decided by the CLI."})

      {:ok, _updated} = Projects.update_project(project, %{"title" => "Renamed remotely"})

      html = render(view)
      assert html =~ "Decided by the CLI."
      assert html =~ "Renamed remotely"
    end
  end

  describe "live board updates" do
    test "a status change made through the context moves the card on every open board", %{
      conn: conn
    } do
      project = project!()
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Moves without a reload"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, member, _html} = live(conn, path(project))
      {:ok, visitor, _html} = live(build_conn(), path(project))

      card = ~s{a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}

      assert has_element?(member, ~s{[data-column="To Do"] } <> card)
      assert has_element?(visitor, ~s{[data-column="To Do"] } <> card)

      {:ok, _updated} = Projects.update_project_item(item, %{"values" => %{"Status" => "Done"}})

      assert has_element?(member, ~s{[data-column="Done"] } <> card)
      refute has_element?(member, ~s{[data-column="To Do"] } <> card)

      assert has_element?(visitor, ~s{[data-column="Done"] } <> card)
      refute has_element?(visitor, ~s{[data-column="To Do"] } <> card)
    end

    test "a status change made over /api/v1 moves the card on an open board", %{conn: conn} do
      project = project!()
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Patched over the API"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, view, _html} = live(conn, path(project))

      card = ~s{a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
      assert has_element?(view, ~s{[data-column="To Do"] } <> card)

      api = put_forge_api_token(build_conn(), "project-show-live", repository())

      assert api
             |> patch(
               ~p"/api/v1/repos/OpenAgentsInc/openagents.com/projectsV2/#{project.number}/items/#{item.id}",
               %{"values" => %{"Status" => "In Progress"}}
             )
             |> json_response(200)

      assert has_element?(view, ~s{[data-column="In Progress"] } <> card)
      refute has_element?(view, ~s{[data-column="To Do"] } <> card)
    end

    test "a card whose source repository is unreadable never arrives on an update" do
      project = project!()

      source =
        repository_fixture(%{owner: "SealedOrg", name: "sealed-board", visibility: "private"})

      {:ok, issue} = Issues.create_issue(source, %{"title" => "Sealed card"})

      {:ok, view, _html} = live(build_conn(), path(project))

      {:ok, item} =
        Projects.create_project_item(
          %{
            "issue_number" => issue.number,
            "issue_repository_id" => source.id,
            "values" => %{"Status" => "To Do"}
          },
          project
        )

      html = render(view)
      refute html =~ "Sealed card"
      refute html =~ "sealed-board"

      {:ok, _updated} = Projects.update_project_item(item, %{"values" => %{"Status" => "Done"}})

      html = render(view)
      refute html =~ "Sealed card"
      refute html =~ "sealed-board"

      refute has_element?(
               view,
               ~s{a[href="/SealedOrg/sealed-board/issues/#{issue.number}"]}
             )
    end

    test "a write in another repository leaves this board alone", %{conn: conn} do
      project = project!()
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Stays put"})

      {:ok, _item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => "To Do"}},
          project
        )

      other = repository_fixture(%{owner: "OtherOrg", name: "other-board", visibility: "public"})
      other_project = project_fixture(other, %{title: "Elsewhere", owner: "OtherOrg"})
      {:ok, other_issue} = Issues.create_issue(other, %{"title" => "Belongs elsewhere"})

      {:ok, view, _html} = live(conn, path(project))

      {:ok, _other_item} =
        Projects.create_project_item(
          %{"issue_number" => other_issue.number, "values" => %{"Status" => "Done"}},
          other_project
        )

      html = render(view)
      refute html =~ "Belongs elsewhere"

      assert has_element?(
               view,
               ~s{[data-column="To Do"] a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
             )
    end
  end

  describe "a reader without write access" do
    setup %{conn: conn} do
      {:ok, conn: log_in_github_user(conn, "project-show-reader")}
    end

    test "reads the board and the notes but writes nothing", %{conn: conn} do
      project = project!()
      {:ok, _note} = Projects.create_project_note(project, %{"body" => "Context for readers."})

      {:ok, view, html} = live(conn, path(project))

      assert html =~ "Context for readers."
      assert has_element?(view, "#project-notes-unauthorized")
      refute has_element?(view, "#project-note-form")
      refute has_element?(view, "#new-project-item-form")
      refute has_element?(view, "#edit-description")
    end
  end

  describe "stored-field columns" do
    defp status_project!(options) do
      project = project!()

      {:ok, field} =
        Projects.create_project_field(%{
          "project_id" => project.id,
          "name" => "Status",
          "data_type" => "single_select",
          "options" => %{"values" => options}
        })

      %{project: project, field: field}
    end

    defp card(project, title, value) do
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => title})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => value}},
          project
        )

      %{issue: issue, item: item}
    end

    test "the board heads its columns with the project's stored options", %{conn: conn} do
      %{project: project} =
        status_project!([
          %{"id" => "triage", "name" => "Triage"},
          %{"id" => "shipping", "name" => "Shipping"}
        ])

      {:ok, view, html} = live(conn, path(project))

      assert html =~ "Triage"
      assert html =~ "Shipping"
      refute html =~ "In Progress"
      assert has_element?(view, ~s{[data-column="triage"]})
      assert has_element?(view, ~s{[data-column="shipping"]})
    end

    test "a card sits in the column its stored option identifies", %{conn: conn} do
      %{project: project} =
        status_project!([
          %{"id" => "triage", "name" => "Triage"},
          %{"id" => "shipping", "name" => "Shipping"}
        ])

      %{issue: issue} = card(project, "Sorted card", "shipping")

      {:ok, view, _html} = live(conn, path(project))

      assert has_element?(
               view,
               ~s{[data-column="shipping"] a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
             )
    end

    test "relabelling an option renames the column and leaves the card in it", %{conn: conn} do
      %{project: project, field: field} =
        status_project!([
          %{"id" => "triage", "name" => "Triage"},
          %{"id" => "shipping", "name" => "Shipping"}
        ])

      %{issue: issue} = card(project, "Kept card", "shipping")

      {:ok, view, _html} = live(conn, path(project))

      {:ok, _field} =
        Projects.update_project_field(project, field, %{
          "options" => %{
            "values" => [
              %{"id" => "triage", "name" => "Triage"},
              %{"id" => "shipping", "name" => "On the way"}
            ]
          }
        })

      html = render(view)
      assert html =~ "On the way"

      assert has_element?(
               view,
               ~s{[data-column="shipping"] a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
             )
    end

    test "a card whose option was dropped moves to its own column", %{conn: conn} do
      %{project: project} = status_project!([%{"id" => "triage", "name" => "Triage"}])
      %{issue: issue} = card(project, "Stale card", "triage")

      # Dropping an option items carry is refused, so the stale value is one an
      # item was given before the field declared its options.
      {:ok, _item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{}},
          project
        )

      OpenAgents.Repo.update_all(
        from(item in ProjectItem, where: item.project_id == ^project.id),
        set: [values: %{"Status" => "retired"}]
      )

      {:ok, view, html} = live(conn, path(project))

      assert html =~ "No status"

      assert has_element?(
               view,
               ~s{[data-unsorted] a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
             )
    end

    test "the add form offers the stored options", %{conn: conn} do
      %{project: project} = status_project!([%{"id" => "triage", "name" => "Triage"}])
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Newly added"})

      {:ok, view, _html} = live(conn, path(project))

      view
      |> form("#new-project-item-form",
        item: %{issue_number: to_string(issue.number), status: "triage"}
      )
      |> render_submit()

      assert [item] = Projects.list_project_items(project)
      assert item.values == %{"Status" => "triage"}
    end
  end

  describe "item operations on the board" do
    test "a member moves a card to the next column with a button", %{conn: conn} do
      project = project!()
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Movable"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, view, _html} = live(conn, path(project))

      view |> element("#move-right-#{item.id}") |> render_click()

      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "In Progress"}

      assert has_element?(
               view,
               ~s{[data-column="In Progress"] a[href="/OpenAgentsInc/openagents.com/issues/#{issue.number}"]}
             )
    end

    test "a member reorders a card within its column", %{conn: conn} do
      project = project!()
      {:ok, first} = Issues.create_issue(repository(), %{"title" => "First"})
      {:ok, second} = Issues.create_issue(repository(), %{"title" => "Second"})

      {:ok, _first_item} =
        Projects.create_project_item(
          %{"issue_number" => first.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, second_item} =
        Projects.create_project_item(
          %{"issue_number" => second.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, view, _html} = live(conn, path(project))

      view |> element("#move-up-#{second_item.id}") |> render_click()

      assert Enum.map(Projects.list_project_items(project), & &1.issue.title) == [
               "Second",
               "First"
             ]
    end

    test "a member removes a card and the issue survives", %{conn: conn} do
      project = project!()
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Removable"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, view, _html} = live(conn, path(project))

      html = view |> element("#remove-item-#{item.id}") |> render_click()

      assert html =~ "Item removed from project"
      assert Projects.list_project_items(project) == []
      assert Issues.get_issue!(repository(), issue.id)
    end

    test "a signed-out visitor sees no move or remove control and cannot forge one" do
      project = project!()
      {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Guarded"})

      {:ok, item} =
        Projects.create_project_item(
          %{"issue_number" => issue.number, "values" => %{"Status" => "To Do"}},
          project
        )

      {:ok, view, _html} = live(build_conn(), path(project))

      refute has_element?(view, "#move-right-#{item.id}")
      refute has_element?(view, "#remove-item-#{item.id}")

      html = render_click(view, "remove_item", %{"id" => to_string(item.id)})
      assert html =~ "Only repository members can remove project items."

      html =
        render_click(view, "move_item", %{"id" => to_string(item.id), "direction" => "right"})

      assert html =~ "Only repository members can move project items."

      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "To Do"}
    end

    test "a member cannot move a card whose source repository they cannot read", %{conn: conn} do
      project = project!()

      source =
        repository_fixture(%{owner: "SealedOrg", name: "sealed-move", visibility: "private"})

      {:ok, issue} = Issues.create_issue(source, %{"title" => "Sealed"})

      {:ok, item} =
        Projects.create_project_item(
          %{
            "issue_number" => issue.number,
            "issue_repository_id" => source.id,
            "values" => %{"Status" => "To Do"}
          },
          project
        )

      {:ok, view, _html} = live(conn, path(project))
      Process.flag(:trap_exit, true)

      assert catch_exit(
               render_click(view, "move_item", %{
                 "id" => to_string(item.id),
                 "direction" => "right"
               })
             )

      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "To Do"}
    end
  end

  defp repository do
    OpenAgents.Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
  end
end