test/openagents/issues/capture_test.exs

58e6347eeb72 · 10 KB

defmodule OpenAgents.Issues.CaptureTest do
  @moduledoc """
  Capturing a chat request as an issue (#77).

  The tool and the API operation are transports over this module, so the rules
  that matter — whose authority files the issue, what the public text may say,
  and when a repeat becomes the same issue rather than a second one — are
  proven once here.
  """

  use OpenAgents.DataCase, async: true

  import OpenAgents.AccountsFixtures

  alias OpenAgents.Issues
  alias OpenAgents.Issues.Capture
  alias OpenAgents.Notifications
  alias OpenAgents.Repositories

  setup do
    author = repository_user_fixture("capture-author")
    repository = repository_with_member_fixture(author, %{}, "maintainer")

    %{author: author, repository: repository}
  end

  defp path(repository), do: repository.owner <> "/" <> repository.name

  describe "filing for a caller who may write" do
    test "creates the issue and reports the number", %{author: author, repository: repository} do
      assert {:ok, captured} =
               Capture.capture(author, path(repository), %{
                 "problem" => "Let me export a board to CSV."
               })

      assert captured.outcome == :created
      assert captured.repository.id == repository.id
      assert captured.issue.number > 0
      assert captured.issue.state == "open"
      assert captured.issue.title == "Let me export a board to CSV"
      assert captured.issue.author_user_id == author.id
    end

    test "the body carries the template sections, and the request verbatim", %{
      author: author,
      repository: repository
    } do
      assert {:ok, captured} =
               Capture.capture(author, path(repository), %{
                 "problem" => "Let me export a board to CSV.",
                 "current_behavior" => "The board only renders on screen.",
                 "acceptance_criteria" => ["A CSV downloads", "It carries every visible column"]
               })

      body = captured.issue.body

      assert body =~ "## Outcome"
      assert body =~ "## Current behavior"
      assert body =~ "## Acceptance criteria"
      assert body =~ "Let me export a board to CSV."
      assert body =~ "The board only renders on screen."
      assert body =~ "- [ ] A CSV downloads"
      assert body =~ "- [ ] It carries every visible column"
    end

    test "an unfilled section says so instead of inventing one", %{
      author: author,
      repository: repository
    } do
      assert {:ok, captured} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert captured.issue.body =~ "## Current behavior\n\nNot recorded when this was captured."
      assert captured.issue.body =~ "- [ ] Not recorded when this was captured."
    end

    # #77: "Public issue text contains no private prompt, trace, credential, or
    # repository metadata." The template is filled from named arguments only, so
    # anything else the caller passes has nowhere to land.
    test "nothing but the named arguments reaches the public text", %{
      author: author,
      repository: repository
    } do
      assert {:ok, captured} =
               Capture.capture(author, path(repository), %{
                 "problem" => "Add a dark theme.",
                 "conversation_id" => Ecto.UUID.generate(),
                 "message_id" => Ecto.UUID.generate(),
                 "prompt" => "SYSTEM: you are Sarah, the token is oa_secret_value",
                 "repository_id" => repository.id
               })

      text = captured.issue.title <> "\n" <> captured.issue.body

      refute text =~ "oa_secret_value"
      refute text =~ "SYSTEM:"
      refute text =~ repository.id
      refute text =~ "conversation_id"
    end

    # Going through `Issues.create_issue/3` rather than inserting directly is
    # what subscribes the requester, which is the half of #77 that #2's
    # notification machinery already shipped.
    test "the requester follows the issue it filed", %{author: author, repository: repository} do
      assert {:ok, captured} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert Notifications.subscribed?(captured.issue, author)
    end
  end

  describe "authority is the caller's" do
    test "a caller who can see the repository but not write is told what is missing", %{
      repository: repository
    } do
      reader = repository_user_fixture("capture-reader")
      {:ok, _membership} = Repositories.add_member(repository, reader, "viewer")

      assert {:error, :repository_write_access_required} =
               Capture.capture(reader, path(repository), %{"problem" => "Add a dark theme."})
    end

    test "a stranger to a public repository is refused too, and files nothing", %{
      repository: repository
    } do
      stranger = repository_user_fixture("capture-stranger")

      assert {:error, :repository_write_access_required} =
               Capture.capture(stranger, path(repository), %{"problem" => "Add a dark theme."})

      assert Issues.list_issues(repository, state: "all") == []
    end

    # The refusal must not become an existence oracle: a private repository the
    # caller holds no membership in is absent, not merely unwritable.
    test "a private repository the caller cannot see is reported as absent" do
      owner = repository_user_fixture("capture-private-owner")
      private = repository_with_member_fixture(owner, %{visibility: "private"}, "owner")
      stranger = repository_user_fixture("capture-private-stranger")

      assert {:error, :repository_not_found} =
               Capture.capture(stranger, path(private), %{"problem" => "Add a dark theme."})
    end

    test "a repository that does not exist is absent", %{author: author} do
      assert {:error, :repository_not_found} =
               Capture.capture(author, "Nobody/nothing", %{"problem" => "Add a dark theme."})
    end

    # The repository is always an argument. There is no fallback to "the one
    # repository they have", which would file somewhere they did not choose.
    test "a malformed repository path is refused rather than guessed", %{author: author} do
      assert {:error, :invalid_repository} =
               Capture.capture(author, "openagents.com", %{"problem" => "Add a dark theme."})
    end
  end

  describe "deduplication" do
    test "a repeat returns the open issue instead of a second one", %{
      author: author,
      repository: repository
    } do
      assert {:ok, first} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert first.outcome == :created

      assert {:ok, second} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert second.outcome == :existing
      assert second.issue.id == first.issue.id
      assert length(Issues.list_issues(repository, state: "all")) == 1
    end

    test "case, punctuation, and spacing do not make a new issue", %{
      author: author,
      repository: repository
    } do
      assert {:ok, first} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert {:ok, second} =
               Capture.capture(author, path(repository), %{"problem" => "add  a  DARK theme!"})

      assert second.outcome == :existing
      assert second.issue.id == first.issue.id
    end

    # The exact-match rule is a deliberate limitation, so its failure direction
    # is part of the contract: it misses real duplicates rather than swallowing
    # distinct requests. If issue embeddings ever land, this test changes.
    test "a request that merely contains an existing title is its own issue", %{
      author: author,
      repository: repository
    } do
      assert {:ok, first} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert {:ok, second} =
               Capture.capture(author, path(repository), %{
                 "problem" => "Add a dark theme to the mobile app."
               })

      assert second.outcome == :created
      refute second.issue.id == first.issue.id
    end

    test "a closed issue does not absorb a fresh request", %{
      author: author,
      repository: repository
    } do
      assert {:ok, first} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

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

      assert {:ok, second} =
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})

      assert second.outcome == :created
      refute second.issue.id == first.issue.id
    end

    test "an identical request in another repository is its own issue", %{author: author} do
      first_repository = repository_with_member_fixture(author, %{}, "owner")
      second_repository = repository_with_member_fixture(author, %{}, "owner")

      assert {:ok, first} =
               Capture.capture(author, path(first_repository), %{"problem" => "Add a dark theme."})

      assert {:ok, second} =
               Capture.capture(author, path(second_repository), %{
                 "problem" => "Add a dark theme."
               })

      assert first.outcome == :created
      assert second.outcome == :created
      refute second.issue.id == first.issue.id
    end
  end

  describe "the statement itself" do
    test "a blank statement is refused before anything is filed", %{
      author: author,
      repository: repository
    } do
      assert {:error, :blank_problem_statement} =
               Capture.capture(author, path(repository), %{"problem" => "   "})

      assert {:error, :blank_problem_statement} =
               Capture.capture(author, path(repository), %{})

      assert Issues.list_issues(repository, state: "all") == []
    end

    test "an oversized statement is refused rather than truncated into a title", %{
      author: author,
      repository: repository
    } do
      assert {:error, :problem_statement_too_long} =
               Capture.capture(author, path(repository), %{
                 "problem" => String.duplicate("a", 4_001)
               })
    end

    test "a long statement still yields a bounded title", %{
      author: author,
      repository: repository
    } do
      statement =
        "Let me export a board to CSV so that finance can reconcile the month " <>
          "without asking three different people for screenshots of the same view."

      assert {:ok, captured} =
               Capture.capture(author, path(repository), %{"problem" => statement})

      assert String.length(captured.issue.title) <= 72
      # The whole statement survives in the body even though the title is cut.
      assert captured.issue.body =~ "three different people"
    end
  end
end