test/openagents/promise_registry_test.exs

main at 58e6347eeb72 · 10 KB

defmodule OpenAgents.PromiseRegistryTest do
  use OpenAgents.DataCase

  alias OpenAgents.Issues
  alias OpenAgents.Projects
  alias OpenAgents.Projects.ProjectItemEvent
  alias OpenAgents.Projects.PromiseRegistry

  import OpenAgents.CompensationFixtures
  import OpenAgents.IssuesFixtures
  import OpenAgents.ProjectsFixtures

  setup do
    repository = repository_fixture()
    project = project_fixture(repository)

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

    %{project: project, repository: repository}
  end

  test "LIVE requires an accepted outcome evidence entry", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)

    assert {:error, changeset} =
             Projects.create_project_item(
               %{"issue_number" => issue.number, "values" => promise_values("LIVE", %{})},
               project
             )

    assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
             errors_on(changeset)

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

    decision = outcome_decision_fixture()

    values =
      promise_values("LIVE", %{
        "evidence" => [
          %{
            "kind" => "accepted_outcome",
            "decision_receipt_ref" => decision.decision_receipt_ref
          }
        ]
      })

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

    assert %DateTime{} = get_in(item.values, ["promise", "verified_at"])
  end

  test "links do not satisfy LIVE and clients cannot set verification time", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)

    values =
      promise_values("LIVE", %{
        "verified_at" => "2026-01-01T00:00:00Z",
        "evidence" => [%{"kind" => "link", "url" => "https://example.com/evidence"}]
      })

    assert {:error, changeset} =
             Projects.create_project_item(
               %{"issue_number" => issue.number, "values" => values},
               project
             )

    assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
             errors_on(changeset)

    gated_values =
      promise_values("GATED", %{
        "verified_at" => "2026-01-01T00:00:00Z",
        "gate" => gate()
      })

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

    refute Map.has_key?(item.values["promise"], "verified_at")
  end

  test "closed issue, changelog, and forge receipt evidence do not satisfy LIVE", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)
    {:ok, _closed_issue} = Issues.update_issue(issue, %{"state" => "closed"})

    for evidence <- [
          %{
            "kind" => "issue",
            "owner" => repository.owner,
            "repo" => repository.name,
            "number" => issue.number
          },
          %{"kind" => "changelog", "slug" => "missing-entry"},
          %{"kind" => "receipt", "id" => Ecto.UUID.generate()}
        ] do
      values = promise_values("LIVE", %{"evidence" => [evidence]})

      assert {:error, changeset} =
               Projects.create_project_item(
                 %{"issue_number" => issue.number, "values" => values},
                 project
               )

      assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
               errors_on(changeset)
    end
  end

  test "accepted outcome evidence resolves only an accepted decision", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)
    rejected = outcome_decision_fixture("rejected", "utility_failed")

    rejected_values =
      promise_values("LIVE", %{
        "evidence" => [
          %{
            "kind" => "accepted_outcome",
            "decision_receipt_ref" => rejected.decision_receipt_ref
          }
        ]
      })

    assert {:error, changeset} =
             Projects.create_project_item(
               %{"issue_number" => issue.number, "values" => rejected_values},
               project
             )

    assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
             errors_on(changeset)
  end

  test "readable open issue evidence remains visible but cannot satisfy LIVE", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)

    evidence = %{
      "kind" => "issue",
      "owner" => repository.owner,
      "repo" => repository.name,
      "number" => issue.number
    }

    assert PromiseRegistry.readable_evidence?(evidence, nil)
    refute PromiseRegistry.resolvable_evidence?(evidence, nil)

    values = promise_values("GATED", %{"evidence" => [evidence], "gate" => gate()})

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

    assert get_in(item.values, ["promise", "evidence"]) == [evidence]
  end

  test "GATED requires a complete gate", %{project: project, repository: repository} do
    issue = issue_fixture(repository)
    values = promise_values("GATED", %{})

    assert {:error, changeset} =
             Projects.create_project_item(
               %{"issue_number" => issue.number, "values" => values},
               project
             )

    assert %{values: ["GATED requires gate.missing, gate.owner, and gate.next_review"]} =
             errors_on(changeset)
  end

  test "WITHDRAWN requires a replacement path", %{project: project, repository: repository} do
    issue = issue_fixture(repository)
    values = promise_values("WITHDRAWN", %{})

    assert {:error, changeset} =
             Projects.create_project_item(
               %{"issue_number" => issue.number, "values" => values},
               project
             )

    assert %{
             values: [
               "WITHDRAWN requires withdrawal.reason, withdrawal.replacement, and withdrawal.date"
             ]
           } =
             errors_on(changeset)
  end

  test "promise IDs are unique within a project", %{project: project, repository: repository} do
    first_issue = issue_fixture(repository)
    second_issue = issue_fixture(repository)
    values = promise_values("GATED", %{"gate" => gate()})

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

    assert {:error, changeset} =
             Projects.create_project_item(
               %{"issue_number" => second_issue.number, "values" => values},
               project
             )

    assert %{values: ["promise.id must be unique within this project"]} = errors_on(changeset)
  end

  test "promise item events identify the actor and state transition", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)
    actor = repository_user_fixture("promise-event-actor")
    values = promise_values("GATED", %{"gate" => gate()})

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

    {[created], 1, 1, 25} = Projects.list_project_item_events(item)
    assert created.actor_user_id == actor.id
    assert created.actor_login == actor.github_login
    assert created.kind == "create"
    assert created.from_state == nil
    assert created.to_state == "GATED"

    updated_values = Map.put(values, "Promise state", "WITHDRAWN")

    updated_values =
      Map.put(updated_values, "promise", Map.put(values["promise"], "withdrawal", withdrawal()))

    assert {:ok, updated} =
             Projects.update_project_item(item, %{"values" => updated_values}, actor)

    {[state_change, _created], 2, 1, 25} = Projects.list_project_item_events(updated)
    assert state_change.kind == "state_change"
    assert state_change.from_state == "GATED"
    assert state_change.to_state == "WITHDRAWN"
  end

  test "promise item events are append-only", %{project: project, repository: repository} do
    issue = issue_fixture(repository)
    values = promise_values("GATED", %{"gate" => gate()})

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

    {[event], 1, 1, 25} = Projects.list_project_item_events(item)

    assert_raise Postgrex.Error, ~r/project item events are append-only/, fn ->
      Repo.update_all(
        from(stored in ProjectItemEvent, where: stored.id == ^event.id),
        set: [kind: "tampered"]
      )
    end

    assert_raise Postgrex.Error, ~r/project item events are append-only/, fn ->
      Repo.delete!(event)
    end
  end

  test "deleting a promise project leaves its event history", %{
    project: project,
    repository: repository
  } do
    issue = issue_fixture(repository)
    values = promise_values("GATED", %{"gate" => gate()})

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

    {[event], 1, 1, 25} = Projects.list_project_item_events(item)

    assert {:ok, _deleted} = Projects.delete_project(project)
    assert Repo.get(ProjectItemEvent, event.id)
  end

  defp promise_values(state, extra) do
    promise =
      %{
        "id" => "promise_registry_v1",
        "problem" => "A problem",
        "claim" => "A claim",
        "scope" => "A scope",
        "acceptance_criteria" => ["A criterion"],
        "success_metrics" => ["A metric"],
        "owner" => "OpenAgents",
        "target" => "2026-12-31",
        "evidence" => []
      }
      |> Map.merge(extra)

    %{"Promise state" => state, "promise" => promise}
  end

  defp gate do
    %{"missing" => "A receipt", "owner" => "OpenAgents", "next_review" => "2026-09-01"}
  end

  defp withdrawal do
    %{"reason" => "Replaced", "replacement" => "A new promise", "date" => "2026-08-23"}
  end
end