test/openagents_web/controllers/issue_completion_claim_controller_test.exs

58e6347eeb72 · 7 KB

defmodule OpenAgentsWeb.IssueCompletionClaimControllerTest do
  @moduledoc """
  The one route a completion claim arrives on.

  The properties under test are about authority and about what the request may
  say. An agent may claim only for the attempt it requested. A user may claim
  for any attempt in a repository it can write, and its claim is
  `not_applicable` because the contract gates agent-authored claims. A path
  that names another repository resolves to nothing.
  """
  use OpenAgentsWeb.ConnCase, async: true

  import OpenAgents.AccountsFixtures

  alias OpenAgents.Agents
  alias OpenAgents.Conversations
  alias OpenAgents.Deployments.CheckResult
  alias OpenAgents.Forge.Assignment
  alias OpenAgents.Issues
  alias OpenAgents.Issues.{CompletionClaims, Evidence}
  alias OpenAgents.Machines
  alias OpenAgents.Repo
  alias OpenAgents.Work

  @sha String.duplicate("ab", 20)
  @digest "sha256:" <> String.duplicate("7c", 32)

  @scoped_body """
  ## Problem

  A claim has no route to arrive on.

  ## Scope

  One endpoint on the issue.

  ## Acceptance criteria

  - The route grades and stores the claim.

  ## Success metrics

  A client can read back what it claimed.
  """

  @criterion "The route grades and stores the claim."

  setup do
    owner = repository_user_fixture("claim-route-owner")
    repository = repository_with_member_fixture(owner, %{}, "owner")

    {:ok, issue} =
      Issues.create_issue(repository, %{title: "Claim over HTTP", body: @scoped_body})

    {:ok, agent, credential} =
      Agents.register(%{
        handle: "claim-agent-#{System.unique_integer([:positive])}",
        display_name: "Claim agent",
        registration_ip: "192.0.2.52"
      })

    assignment = attempt(repository, issue, owner, %{"type" => "agent", "id" => agent.id})
    result = check_result(repository, @sha, "succeeded")
    [entry] = Evidence.record_check_result(result)

    %{
      owner: owner,
      repository: repository,
      issue: issue,
      agent: agent,
      credential: credential,
      assignment: assignment,
      evidence: entry
    }
  end

  test "the agent that requested the attempt closes an opted-in issue", context do
    {:ok, _policy} =
      CompletionClaims.set_policy(
        context.repository,
        %{agents_enabled: true, verified_closing_enabled: true},
        context.owner
      )

    conn = post_claim(build_conn(), context, "Bearer #{context.credential}")

    assert %{"claim" => claim, "issue" => %{"state" => "closed"}} = json_response(conn, 201)
    assert claim["state"] == "accepted"
    assert claim["closed"] == true
    assert claim["closed_by_actor"] == "system:accepted-outcome"
    assert [%{"criterion" => @criterion}] = claim["criteria"]
  end

  test "an agent that did not request the attempt is refused", context do
    {:ok, _other, other_credential} =
      Agents.register(%{
        handle: "other-claim-agent-#{System.unique_integer([:positive])}",
        display_name: "Other agent",
        registration_ip: "192.0.2.53"
      })

    conn = post_claim(build_conn(), context, "Bearer #{other_credential}")

    assert json_response(conn, 403)["code"] == "forbidden"
    assert Repo.get!(Issues.Issue, context.issue.id).state == "open"
  end

  test "a user who can write the repository records a not_applicable claim", context do
    conn =
      build_conn()
      |> put_forge_api_token("claim-writer", context.repository)
      |> post_claim_conn(context)

    assert %{"claim" => claim, "issue" => %{"state" => "open"}} = json_response(conn, 201)
    assert claim["state"] == "not_applicable"
    assert claim["reasons"] == ["human_only_work"]
  end

  test "a user with no write authority is refused", context do
    conn =
      build_conn()
      |> put_forge_api_token("claim-outsider")
      |> post_claim_conn(context)

    assert json_response(conn, 403)["code"] == "forbidden"
  end

  test "a path naming another repository resolves to nothing", context do
    elsewhere = repository_with_member_fixture(context.owner, %{}, "owner")

    conn =
      build_conn()
      |> put_req_header("authorization", "Bearer #{context.credential}")
      |> post(
        ~p"/api/v1/repos/#{elsewhere.owner}/#{elsewhere.name}/issues/#{context.issue.number}/completion_claim",
        %{
          "assignment_id" => context.assignment.id,
          "evidence" => [%{"criterion" => @criterion, "evidence_id" => context.evidence.id}]
        }
      )

    assert json_response(conn, 404)["code"] == "not_found"
  end

  test "the issue response carries the claim it recorded", context do
    {:ok, _policy} =
      CompletionClaims.set_policy(
        context.repository,
        %{agents_enabled: true, verified_closing_enabled: true},
        context.owner
      )

    conn = post_claim(build_conn(), context, "Bearer #{context.credential}")
    assert json_response(conn, 201)

    conn =
      get(
        build_conn(),
        ~p"/api/v1/repos/#{context.repository.owner}/#{context.repository.name}/issues/#{context.issue.number}"
      )

    assert %{"openagents" => %{"completion_claims" => [claim]}} = json_response(conn, 200)
    assert claim["state"] == "accepted"
    assert claim["revision"] == @sha
  end

  # ── fixtures ────────────────────────────────────────────────────────────

  defp post_claim(conn, context, authorization) do
    conn
    |> put_req_header("authorization", authorization)
    |> post_claim_conn(context)
  end

  defp post_claim_conn(conn, context) do
    post(
      conn,
      ~p"/api/v1/repos/#{context.repository.owner}/#{context.repository.name}/issues/#{context.issue.number}/completion_claim",
      %{
        "assignment_id" => context.assignment.id,
        "evidence" => [%{"criterion" => @criterion, "evidence_id" => context.evidence.id}]
      }
    )
  end

  defp check_result(repository, sha, status) do
    %CheckResult{repository_id: repository.id}
    |> CheckResult.changeset(%{
      name: "route-coverage",
      commit_sha: sha,
      artifact_digest: @digest,
      status: status
    })
    |> Repo.insert!()
  end

  defp attempt(repository, issue, user, principal) do
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)

    %Assignment{}
    |> Assignment.changeset(%{
      target_kind: "computer",
      machine_id: paired_machine(user).id,
      repository_id: repository.id,
      issue_id: issue.id,
      requesting_principal: principal,
      branch: "agent/issue-#{issue.number}",
      state: "completed",
      terminal_commit: @sha,
      work_job_id: work_job().id,
      admitted_at: now,
      started_at: now,
      finished_at: now,
      deadline_at: DateTime.add(now, 3600, :second)
    })
    |> Repo.insert!()
  end

  defp work_job do
    key = "claim-route-job-#{System.unique_integer([:positive])}"
    {:ok, conversation} = Conversations.ensure_conversation(key)
    owner = Conversations.get_conversation_owner!(conversation)

    {:ok, job} =
      Work.create_job(%{
        conversation_id: conversation.id,
        owner_visitor_id: owner.id,
        surface: "text",
        goal: "claim completion over the route",
        budget_snapshot: %{"tokens" => 50_000}
      })

    job
  end

  defp paired_machine(user) do
    {:ok, %{code: code}} =
      Machines.start_pairing(%{
        "name" => "claim-route-machine-#{System.unique_integer([:positive])}",
        "tier" => "curated",
        "platform" => "linux-x64",
        "agent_version" => "0.1.0",
        "roots" => []
      })

    {:ok, machine} = Machines.approve_pairing(user, code)
    machine
  end
end