Cover the /api/v3 steps 6-9 controllers with tests

f8c8ad3e6630 · AtlantisPleb · · parent 78d4cb06cf48

Cover the /api/v3 steps 6-9 controllers with tests

Steps 1-5 of the AGENTS.md build order (issues + comments) shipped
test-first. Steps 6-9 -- assignees, labels, milestones, projects --
shipped without any. Every one of those controllers and JSON views sat
at 0% line coverage.

Adds 81 tests across six files, following the issue_controller_test.exs
house style: one assertion focus per test, status code plus a few key
fields plus the link back to the database. Error paths are covered as
first-class contract, not afterthoughts -- 404 for a missing
repo/issue/label/milestone/project/item, 422 for invalid params.

Fixes two crashes found while writing them, both in ProjectController:

  - POST .../projectsV2/:project_number/items with no issue_number
    reached Repo.get_by!(Issue, number: nil), which Ecto refuses with
    an ArgumentError -- a 500 where 422 is due.
  - PATCH .../items/:item_id with a non-map "values" reached
    Map.merge/2 and raised BadMapError -- again 500 instead of 422.

Both are now validated in the controller before the context call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/controllers/project_controller.ex
  • added test/openagents_web/controllers/assignee_controller_test.exs
  • added test/openagents_web/controllers/issue_assignee_controller_test.exs
  • added test/openagents_web/controllers/issue_label_controller_test.exs
  • added test/openagents_web/controllers/label_controller_test.exs
  • added test/openagents_web/controllers/milestone_controller_test.exs
  • added test/openagents_web/controllers/project_controller_test.exs

Diff

7 files changed, +1026 -16

lib/openagents_web/controllers/project_controller.ex modified +45 -16

@@ -63,16 +63,24 @@ defmodule OpenAgentsWeb.ProjectController do

63 63
      ) do
64 64
    project = Projects.get_project_by_number!(String.to_integer(project_number))
65 65
66
    case Projects.create_project_item(params, project.id) do
67
      {:ok, item} ->
68
        conn
69
        |> put_status(:created)
70
        |> render(:items, items: [item])
66
    case cast_issue_number(params["issue_number"]) do
67
      :error ->
68
        unprocessable(conn, %{issue_number: ["is invalid"]})
71 69
72
      {:error, %Ecto.Changeset{} = changeset} ->
73
        conn
74
        |> put_status(:unprocessable_entity)
75
        |> render(:error, changeset: changeset)
70
      {:ok, issue_number} ->
71
        params = Map.put(params, "issue_number", issue_number)
72
73
        case Projects.create_project_item(params, project.id) do
74
          {:ok, item} ->
75
            conn
76
            |> put_status(:created)
77
            |> render(:items, items: [item])
78
79
          {:error, %Ecto.Changeset{} = changeset} ->
80
            conn
81
            |> put_status(:unprocessable_entity)
82
            |> render(:error, changeset: changeset)
83
        end
76 84
    end
77 85
  rescue
78 86
    Ecto.NoResultsError ->

@@ -91,14 +99,18 @@ defmodule OpenAgentsWeb.ProjectController do

91 99
      ) do
92 100
    item = Projects.get_project_item!(String.to_integer(item_id))
93 101
94
    case Projects.update_project_item(item, params) do
95
      {:ok, item} ->
96
        render(conn, :items, items: [item])
102
    if is_map(Map.get(params, "values", %{})) do
103
      case Projects.update_project_item(item, params) do
104
        {:ok, item} ->
105
          render(conn, :items, items: [item])
97 106
98
      {:error, %Ecto.Changeset{} = changeset} ->
99
        conn
100
        |> put_status(:unprocessable_entity)
101
        |> render(:error, changeset: changeset)
107
        {:error, %Ecto.Changeset{} = changeset} ->
108
          conn
109
          |> put_status(:unprocessable_entity)
110
          |> render(:error, changeset: changeset)
111
      end
112
    else
113
      unprocessable(conn, %{values: ["is invalid"]})
102 114
    end
103 115
  rescue
104 116
    Ecto.NoResultsError ->

@@ -120,4 +132,21 @@ defmodule OpenAgentsWeb.ProjectController do

120 132
      |> put_status(:not_found)
121 133
      |> json(%{message: "Not Found"})
122 134
  end
135
136
  defp cast_issue_number(number) when is_integer(number), do: {:ok, number}
137
138
  defp cast_issue_number(number) when is_binary(number) do
139
    case Integer.parse(number) do
140
      {parsed, ""} -> {:ok, parsed}
141
      _ -> :error
142
    end
143
  end
144
145
  defp cast_issue_number(_other), do: :error
146
147
  defp unprocessable(conn, errors) do
148
    conn
149
    |> put_status(:unprocessable_entity)
150
    |> json(%{errors: errors})
151
  end
123 152
end
test/openagents_web/controllers/assignee_controller_test.exs added +20

@@ -0,0 +1,20 @@

1
defmodule OpenAgentsWeb.AssigneeControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  describe "index" do
5
    test "GET /api/v3/repos/:owner/:repo/assignees returns the assignable list", %{conn: conn} do
6
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/assignees")
7
8
      assert json_response(conn, 200) == %{"assignees" => []}
9
    end
10
  end
11
12
  describe "show" do
13
    test "GET /api/v3/repos/:owner/:repo/assignees/:assignee returns 404 for a user who cannot be assigned",
14
         %{conn: conn} do
15
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/assignees/octocat")
16
17
      assert response(conn, 404) == ""
18
    end
19
  end
20
end
test/openagents_web/controllers/issue_assignee_controller_test.exs added +139

@@ -0,0 +1,139 @@

1
defmodule OpenAgentsWeb.IssueAssigneeControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Issues
5
6
  setup do
7
    {:ok, issue} = Issues.create_issue(%{title: "Assignable issue"})
8
    %{issue: issue}
9
  end
10
11
  describe "index" do
12
    test "GET .../issues/:issue_number/assignees returns an empty list for a fresh issue", %{
13
      conn: conn,
14
      issue: issue
15
    } do
16
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees")
17
18
      assert json_response(conn, 200) == %{"assignees" => []}
19
    end
20
21
    test "GET .../issues/:issue_number/assignees lists current assignees", %{
22
      conn: conn,
23
      issue: issue
24
    } do
25
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat"])
26
27
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees")
28
29
      assert %{"assignees" => [assignee]} = json_response(conn, 200)
30
      assert assignee["login"] == "octocat"
31
    end
32
33
    test "GET .../issues/:issue_number/assignees returns 404 for a missing issue", %{conn: conn} do
34
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/assignees")
35
36
      assert json_response(conn, 404) == %{"message" => "Not Found"}
37
    end
38
  end
39
40
  describe "create" do
41
    test "POST .../issues/:issue_number/assignees adds an assignee", %{conn: conn, issue: issue} do
42
      conn =
43
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
44
          assignees: ["octocat"]
45
        })
46
47
      assert %{"assignees" => [%{"login" => "octocat"}]} = json_response(conn, 200)
48
49
      assert [%{"login" => "octocat"}] = Issues.get_issue_by_number!(issue.number).assignees
50
    end
51
52
    test "POST .../issues/:issue_number/assignees adds several at once", %{
53
      conn: conn,
54
      issue: issue
55
    } do
56
      conn =
57
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
58
          assignees: ["octocat", "hubot"]
59
        })
60
61
      assert %{"assignees" => assignees} = json_response(conn, 200)
62
      assert Enum.map(assignees, & &1["login"]) == ["octocat", "hubot"]
63
    end
64
65
    test "POST .../issues/:issue_number/assignees does not duplicate an existing assignee", %{
66
      conn: conn,
67
      issue: issue
68
    } do
69
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat"])
70
71
      conn =
72
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
73
          assignees: ["octocat"]
74
        })
75
76
      assert %{"assignees" => [_only_one]} = json_response(conn, 200)
77
    end
78
79
    test "POST .../issues/:issue_number/assignees with no assignees leaves the issue unchanged",
80
         %{conn: conn, issue: issue} do
81
      conn =
82
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{})
83
84
      assert json_response(conn, 200) == %{"assignees" => []}
85
    end
86
87
    test "POST .../issues/:issue_number/assignees returns 404 for a missing issue", %{conn: conn} do
88
      conn =
89
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/assignees", %{
90
          assignees: ["octocat"]
91
        })
92
93
      assert json_response(conn, 404) == %{"message" => "Not Found"}
94
    end
95
  end
96
97
  describe "delete" do
98
    test "DELETE .../issues/:issue_number/assignees removes the named assignees", %{
99
      conn: conn,
100
      issue: issue
101
    } do
102
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat", "hubot"])
103
104
      conn =
105
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
106
          assignees: ["octocat"]
107
        })
108
109
      assert %{"assignees" => [%{"login" => "hubot"}]} = json_response(conn, 200)
110
111
      assert [%{"login" => "hubot"}] = Issues.get_issue_by_number!(issue.number).assignees
112
    end
113
114
    test "DELETE .../issues/:issue_number/assignees ignores an assignee that is not set", %{
115
      conn: conn,
116
      issue: issue
117
    } do
118
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat"])
119
120
      conn =
121
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
122
          assignees: ["nobody"]
123
        })
124
125
      assert %{"assignees" => [%{"login" => "octocat"}]} = json_response(conn, 200)
126
    end
127
128
    test "DELETE .../issues/:issue_number/assignees returns 404 for a missing issue", %{
129
      conn: conn
130
    } do
131
      conn =
132
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/assignees", %{
133
          assignees: ["octocat"]
134
        })
135
136
      assert json_response(conn, 404) == %{"message" => "Not Found"}
137
    end
138
  end
139
end
test/openagents_web/controllers/issue_label_controller_test.exs added +180

@@ -0,0 +1,180 @@

1
defmodule OpenAgentsWeb.IssueLabelControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  import OpenAgents.LabelsFixtures
5
6
  alias OpenAgents.Issues
7
8
  setup do
9
    {:ok, issue} = Issues.create_issue(%{title: "Labelled issue"})
10
    %{issue: issue}
11
  end
12
13
  describe "index" do
14
    test "GET .../issues/:issue_number/labels returns an empty list for a fresh issue", %{
15
      conn: conn,
16
      issue: issue
17
    } do
18
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels")
19
20
      assert json_response(conn, 200) == %{"labels" => []}
21
    end
22
23
    test "GET .../issues/:issue_number/labels lists labels already on the issue", %{
24
      conn: conn,
25
      issue: issue
26
    } do
27
      label_fixture(%{name: "bug", color: "d73a4a"})
28
      {:ok, _issue} = Issues.add_labels(issue, ["bug"])
29
30
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels")
31
32
      assert %{"labels" => [label]} = json_response(conn, 200)
33
      assert label["name"] == "bug"
34
    end
35
36
    test "GET .../issues/:issue_number/labels returns 404 for a missing issue", %{conn: conn} do
37
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/labels")
38
39
      assert json_response(conn, 404) == %{"message" => "Not Found"}
40
    end
41
  end
42
43
  describe "create" do
44
    test "POST .../issues/:issue_number/labels adds a label to the issue", %{
45
      conn: conn,
46
      issue: issue
47
    } do
48
      label_fixture(%{name: "bug", color: "d73a4a"})
49
50
      conn =
51
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
52
          labels: ["bug"]
53
        })
54
55
      assert %{"labels" => [label]} = json_response(conn, 200)
56
      assert label["name"] == "bug"
57
58
      assert [%{"name" => "bug"}] = Issues.get_issue_by_number!(issue.number).labels
59
    end
60
61
    test "POST .../issues/:issue_number/labels adds several labels at once", %{
62
      conn: conn,
63
      issue: issue
64
    } do
65
      label_fixture(%{name: "bug", color: "d73a4a"})
66
      label_fixture(%{name: "docs", color: "0075ca"})
67
68
      conn =
69
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
70
          labels: ["bug", "docs"]
71
        })
72
73
      assert %{"labels" => labels} = json_response(conn, 200)
74
      assert Enum.map(labels, & &1["name"]) == ["bug", "docs"]
75
    end
76
77
    test "POST .../issues/:issue_number/labels does not duplicate an existing label", %{
78
      conn: conn,
79
      issue: issue
80
    } do
81
      label_fixture(%{name: "bug", color: "d73a4a"})
82
      {:ok, _issue} = Issues.add_labels(issue, ["bug"])
83
84
      conn =
85
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
86
          labels: ["bug"]
87
        })
88
89
      assert %{"labels" => [_only_one]} = json_response(conn, 200)
90
    end
91
92
    test "POST .../issues/:issue_number/labels with no labels leaves the issue unchanged", %{
93
      conn: conn,
94
      issue: issue
95
    } do
96
      conn =
97
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{})
98
99
      assert json_response(conn, 200) == %{"labels" => []}
100
    end
101
102
    test "POST .../issues/:issue_number/labels returns 404 for a label that does not exist", %{
103
      conn: conn,
104
      issue: issue
105
    } do
106
      conn =
107
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
108
          labels: ["never-created"]
109
        })
110
111
      assert json_response(conn, 404) == %{"message" => "Not Found"}
112
      assert Issues.get_issue_by_number!(issue.number).labels == []
113
    end
114
115
    test "POST .../issues/:issue_number/labels returns 404 for a missing issue", %{conn: conn} do
116
      conn =
117
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/labels", %{
118
          labels: ["bug"]
119
        })
120
121
      assert json_response(conn, 404) == %{"message" => "Not Found"}
122
    end
123
  end
124
125
  describe "delete" do
126
    test "DELETE .../issues/:issue_number/labels/:name removes the label", %{
127
      conn: conn,
128
      issue: issue
129
    } do
130
      label_fixture(%{name: "bug", color: "d73a4a"})
131
      label_fixture(%{name: "docs", color: "0075ca"})
132
      {:ok, _issue} = Issues.add_labels(issue, ["bug", "docs"])
133
134
      conn =
135
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels/bug")
136
137
      assert %{"labels" => [label]} = json_response(conn, 200)
138
      assert label["name"] == "docs"
139
140
      assert [%{"name" => "docs"}] = Issues.get_issue_by_number!(issue.number).labels
141
    end
142
143
    test "DELETE .../issues/:issue_number/labels/:name decodes an escaped name", %{
144
      conn: conn,
145
      issue: issue
146
    } do
147
      label_fixture(%{name: "good first issue", color: "7057ff"})
148
      {:ok, _issue} = Issues.add_labels(issue, ["good first issue"])
149
150
      conn =
151
        delete(
152
          conn,
153
          ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels/good first issue"
154
        )
155
156
      assert json_response(conn, 200) == %{"labels" => []}
157
    end
158
159
    test "DELETE .../issues/:issue_number/labels/:name is a no-op for an unattached label", %{
160
      conn: conn,
161
      issue: issue
162
    } do
163
      label_fixture(%{name: "bug", color: "d73a4a"})
164
      {:ok, _issue} = Issues.add_labels(issue, ["bug"])
165
166
      conn =
167
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels/docs")
168
169
      assert %{"labels" => [%{"name" => "bug"}]} = json_response(conn, 200)
170
    end
171
172
    test "DELETE .../issues/:issue_number/labels/:name returns 404 for a missing issue", %{
173
      conn: conn
174
    } do
175
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/labels/bug")
176
177
      assert json_response(conn, 404) == %{"message" => "Not Found"}
178
    end
179
  end
180
end
test/openagents_web/controllers/label_controller_test.exs added +162

@@ -0,0 +1,162 @@

1
defmodule OpenAgentsWeb.LabelControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  import OpenAgents.LabelsFixtures
5
6
  alias OpenAgents.Labels
7
8
  describe "index" do
9
    test "GET /api/v3/repos/:owner/:repo/labels lists labels", %{conn: conn} do
10
      label_fixture(%{name: "bug", color: "d73a4a"})
11
12
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels")
13
14
      assert %{"labels" => [label]} = json_response(conn, 200)
15
      assert label["name"] == "bug"
16
      assert label["color"] == "d73a4a"
17
    end
18
19
    test "GET /api/v3/repos/:owner/:repo/labels renders a repo-scoped url", %{conn: conn} do
20
      label_fixture(%{name: "bug", color: "d73a4a"})
21
22
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels")
23
24
      assert %{"labels" => [label]} = json_response(conn, 200)
25
26
      assert label["url"] ==
27
               "https://openagents.com/api/v3/repos/OpenAgents/openagents/labels/bug"
28
    end
29
30
    test "GET /api/v3/repos/:owner/:repo/labels returns an empty list", %{conn: conn} do
31
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels")
32
33
      assert json_response(conn, 200) == %{"labels" => []}
34
    end
35
  end
36
37
  describe "create" do
38
    test "POST /api/v3/repos/:owner/:repo/labels creates a label", %{conn: conn} do
39
      conn =
40
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{
41
          name: "enhancement",
42
          color: "a2eeef",
43
          description: "New feature"
44
        })
45
46
      assert %{
47
               "name" => "enhancement",
48
               "color" => "a2eeef",
49
               "description" => "New feature",
50
               "default" => false
51
             } = json_response(conn, 201)
52
53
      assert Labels.get_label_by_name!("enhancement").color == "a2eeef"
54
    end
55
56
    test "POST /api/v3/repos/:owner/:repo/labels returns 422 without a color", %{conn: conn} do
57
      conn = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{name: "no-color"})
58
59
      assert %{"errors" => %{"color" => _}} = json_response(conn, 422)
60
    end
61
62
    test "POST /api/v3/repos/:owner/:repo/labels returns 422 without a name", %{conn: conn} do
63
      conn = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{color: "ffffff"})
64
65
      assert %{"errors" => %{"name" => _}} = json_response(conn, 422)
66
    end
67
68
    test "POST /api/v3/repos/:owner/:repo/labels does not persist owner or repo", %{conn: conn} do
69
      post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{
70
        name: "scoped",
71
        color: "ffffff"
72
      })
73
74
      label = Labels.get_label_by_name!("scoped")
75
      refute Map.has_key?(label, :owner)
76
      refute Map.has_key?(label, :repo)
77
    end
78
  end
79
80
  describe "show" do
81
    test "GET /api/v3/repos/:owner/:repo/labels/:name returns the label", %{conn: conn} do
82
      label = label_fixture(%{name: "bug", color: "d73a4a"})
83
84
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug")
85
86
      assert %{"name" => "bug", "id" => id} = json_response(conn, 200)
87
      assert id == label.id
88
    end
89
90
    test "GET /api/v3/repos/:owner/:repo/labels/:name decodes an escaped name", %{conn: conn} do
91
      label_fixture(%{name: "good first issue", color: "7057ff"})
92
93
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/good first issue")
94
95
      assert json_response(conn, 200)["name"] == "good first issue"
96
    end
97
98
    test "GET /api/v3/repos/:owner/:repo/labels/:name returns 404 when missing", %{conn: conn} do
99
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/nope")
100
101
      assert json_response(conn, 404) == %{"message" => "Not Found"}
102
    end
103
  end
104
105
  describe "update" do
106
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name updates the color", %{conn: conn} do
107
      label_fixture(%{name: "bug", color: "d73a4a"})
108
109
      conn =
110
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug", %{color: "000000"})
111
112
      assert %{"name" => "bug", "color" => "000000"} = json_response(conn, 200)
113
      assert Labels.get_label_by_name!("bug").color == "000000"
114
    end
115
116
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name updates the description", %{conn: conn} do
117
      label_fixture(%{name: "bug", color: "d73a4a", description: "old"})
118
119
      conn =
120
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug", %{description: "new"})
121
122
      assert json_response(conn, 200)["description"] == "new"
123
    end
124
125
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name returns 422 for a blank color", %{
126
      conn: conn
127
    } do
128
      label_fixture(%{name: "bug", color: "d73a4a"})
129
130
      conn =
131
        conn
132
        |> put_req_header("content-type", "application/json")
133
        |> patch(~p"/api/v3/repos/OpenAgents/openagents/labels/bug", ~s({"color": null}))
134
135
      assert %{"errors" => %{"color" => _}} = json_response(conn, 422)
136
      assert Labels.get_label_by_name!("bug").color == "d73a4a"
137
    end
138
139
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name returns 404 when missing", %{conn: conn} do
140
      conn = patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/nope", %{color: "000000"})
141
142
      assert json_response(conn, 404) == %{"message" => "Not Found"}
143
    end
144
  end
145
146
  describe "delete" do
147
    test "DELETE /api/v3/repos/:owner/:repo/labels/:name returns 204", %{conn: conn} do
148
      label_fixture(%{name: "bug", color: "d73a4a"})
149
150
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug")
151
152
      assert response(conn, 204) == ""
153
      assert Labels.list_labels() == []
154
    end
155
156
    test "DELETE /api/v3/repos/:owner/:repo/labels/:name returns 404 when missing", %{conn: conn} do
157
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/nope")
158
159
      assert json_response(conn, 404) == %{"message" => "Not Found"}
160
    end
161
  end
162
end
test/openagents_web/controllers/milestone_controller_test.exs added +163

@@ -0,0 +1,163 @@

1
defmodule OpenAgentsWeb.MilestoneControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  import OpenAgents.MilestonesFixtures
5
6
  alias OpenAgents.Milestones
7
8
  describe "index" do
9
    test "GET /api/v3/repos/:owner/:repo/milestones lists milestones", %{conn: conn} do
10
      milestone_fixture(%{title: "v1.0", state: "open"})
11
12
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones")
13
14
      assert %{"milestones" => [milestone]} = json_response(conn, 200)
15
      assert milestone["title"] == "v1.0"
16
      assert milestone["state"] == "open"
17
    end
18
19
    test "GET /api/v3/repos/:owner/:repo/milestones renders issue counts and url", %{conn: conn} do
20
      milestone = milestone_fixture(%{title: "v1.0"})
21
22
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones")
23
24
      assert %{"milestones" => [rendered]} = json_response(conn, 200)
25
      assert rendered["open_issues"] == 0
26
      assert rendered["closed_issues"] == 0
27
28
      assert rendered["url"] ==
29
               "https://openagents.com/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}"
30
    end
31
32
    test "GET /api/v3/repos/:owner/:repo/milestones returns an empty list", %{conn: conn} do
33
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones")
34
35
      assert json_response(conn, 200) == %{"milestones" => []}
36
    end
37
  end
38
39
  describe "create" do
40
    test "POST /api/v3/repos/:owner/:repo/milestones creates a milestone", %{conn: conn} do
41
      conn =
42
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{
43
          title: "v2.0",
44
          description: "Second release"
45
        })
46
47
      assert %{
48
               "title" => "v2.0",
49
               "description" => "Second release",
50
               "state" => "open",
51
               "number" => number
52
             } = json_response(conn, 201)
53
54
      assert Milestones.get_milestone_by_number!(number).title == "v2.0"
55
    end
56
57
    test "POST /api/v3/repos/:owner/:repo/milestones assigns sequential numbers", %{conn: conn} do
58
      first = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{title: "one"})
59
      second = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{title: "two"})
60
61
      assert json_response(second, 201)["number"] == json_response(first, 201)["number"] + 1
62
    end
63
64
    test "POST /api/v3/repos/:owner/:repo/milestones returns 422 without a title", %{conn: conn} do
65
      conn =
66
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{description: "No title"})
67
68
      assert %{"errors" => %{"title" => _}} = json_response(conn, 422)
69
      assert Milestones.list_milestones() == []
70
    end
71
  end
72
73
  describe "show" do
74
    test "GET /api/v3/repos/:owner/:repo/milestones/:milestone_number returns it", %{conn: conn} do
75
      milestone = milestone_fixture(%{title: "Show me"})
76
77
      conn =
78
        get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}")
79
80
      assert %{"title" => "Show me", "id" => id} = json_response(conn, 200)
81
      assert id == milestone.id
82
    end
83
84
    test "GET /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 404 when missing",
85
         %{conn: conn} do
86
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/999999")
87
88
      assert json_response(conn, 404) == %{"message" => "Not Found"}
89
    end
90
  end
91
92
  describe "update" do
93
    test "PATCH /api/v3/repos/:owner/:repo/milestones/:milestone_number closes it", %{conn: conn} do
94
      milestone = milestone_fixture(%{title: "Close me", state: "open"})
95
96
      conn =
97
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}", %{
98
          state: "closed"
99
        })
100
101
      assert json_response(conn, 200)["state"] == "closed"
102
      assert Milestones.get_milestone_by_number!(milestone.number).state == "closed"
103
    end
104
105
    test "PATCH /api/v3/repos/:owner/:repo/milestones/:milestone_number retitles it", %{
106
      conn: conn
107
    } do
108
      milestone = milestone_fixture(%{title: "Old title"})
109
110
      conn =
111
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}", %{
112
          title: "New title"
113
        })
114
115
      assert json_response(conn, 200)["title"] == "New title"
116
    end
117
118
    test "PATCH /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 422 for a blank title",
119
         %{conn: conn} do
120
      milestone = milestone_fixture(%{title: "Keep me"})
121
122
      conn =
123
        conn
124
        |> put_req_header("content-type", "application/json")
125
        |> patch(
126
          ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}",
127
          ~s({"title": null})
128
        )
129
130
      assert %{"errors" => %{"title" => _}} = json_response(conn, 422)
131
      assert Milestones.get_milestone_by_number!(milestone.number).title == "Keep me"
132
    end
133
134
    test "PATCH /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 404 when missing",
135
         %{conn: conn} do
136
      conn =
137
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/999999", %{state: "closed"})
138
139
      assert json_response(conn, 404) == %{"message" => "Not Found"}
140
    end
141
  end
142
143
  describe "delete" do
144
    test "DELETE /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 204", %{
145
      conn: conn
146
    } do
147
      milestone = milestone_fixture(%{title: "Delete me"})
148
149
      conn =
150
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}")
151
152
      assert response(conn, 204) == ""
153
      assert Milestones.list_milestones() == []
154
    end
155
156
    test "DELETE /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 404 when missing",
157
         %{conn: conn} do
158
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/999999")
159
160
      assert json_response(conn, 404) == %{"message" => "Not Found"}
161
    end
162
  end
163
end
test/openagents_web/controllers/project_controller_test.exs added +317

@@ -0,0 +1,317 @@

1
defmodule OpenAgentsWeb.ProjectControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  import OpenAgents.ProjectFieldsFixtures
5
  import OpenAgents.ProjectItemsFixtures
6
  import OpenAgents.ProjectsFixtures
7
8
  alias OpenAgents.Issues
9
  alias OpenAgents.Projects
10
11
  describe "index" do
12
    test "GET /api/v3/users/:username/projectsV2 lists that user's projects", %{conn: conn} do
13
      project_fixture(%{title: "Roadmap", owner: "alice", state: "open"})
14
15
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2")
16
17
      assert %{"projects" => [project]} = json_response(conn, 200)
18
      assert project["title"] == "Roadmap"
19
      assert project["owner"] == "alice"
20
      assert project["state"] == "open"
21
    end
22
23
    test "GET /api/v3/users/:username/projectsV2 excludes other owners' projects", %{conn: conn} do
24
      project_fixture(%{title: "Mine", owner: "alice"})
25
      project_fixture(%{title: "Theirs", owner: "bob"})
26
27
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2")
28
29
      assert %{"projects" => [%{"title" => "Mine"}]} = json_response(conn, 200)
30
    end
31
32
    test "GET /api/v3/users/:username/projectsV2 returns an empty list for an unknown user", %{
33
      conn: conn
34
    } do
35
      conn = get(conn, ~p"/api/v3/users/nobody/projectsV2")
36
37
      assert json_response(conn, 200) == %{"projects" => []}
38
    end
39
  end
40
41
  describe "create" do
42
    test "POST /api/v3/:owner/projectsV2 creates a project", %{conn: conn} do
43
      conn = post(conn, ~p"/api/v3/alice/projectsV2", %{title: "New board"})
44
45
      assert %{"title" => "New board", "owner" => "alice", "number" => number} =
46
               json_response(conn, 201)
47
48
      assert Projects.get_project_by_number!(number).title == "New board"
49
    end
50
51
    test "POST /api/v3/:owner/projectsV2 takes the owner from the path", %{conn: conn} do
52
      conn = post(conn, ~p"/api/v3/alice/projectsV2", %{title: "Board", owner: "mallory"})
53
54
      assert json_response(conn, 201)["owner"] == "alice"
55
    end
56
57
    test "POST /api/v3/:owner/projectsV2 returns 422 without a title", %{conn: conn} do
58
      conn = post(conn, ~p"/api/v3/alice/projectsV2", %{})
59
60
      assert %{"errors" => %{"title" => _}} = json_response(conn, 422)
61
      assert Projects.list_projects() == []
62
    end
63
  end
64
65
  describe "show" do
66
    test "GET /api/v3/users/:username/projectsV2/:project_number returns the project", %{
67
      conn: conn
68
    } do
69
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
70
71
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}")
72
73
      assert %{"title" => "Roadmap", "id" => id} = json_response(conn, 200)
74
      assert id == project.id
75
    end
76
77
    test "GET /api/v3/users/:username/projectsV2/:project_number returns 404 when missing", %{
78
      conn: conn
79
    } do
80
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/999999")
81
82
      assert json_response(conn, 404) == %{"message" => "Not Found"}
83
    end
84
  end
85
86
  describe "items" do
87
    test "GET .../projectsV2/:project_number/items returns an empty list for a new project", %{
88
      conn: conn
89
    } do
90
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
91
92
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items")
93
94
      assert json_response(conn, 200) == %{"items" => []}
95
    end
96
97
    test "GET .../projectsV2/:project_number/items lists the project's items", %{conn: conn} do
98
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
99
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})
100
101
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items")
102
103
      assert %{"items" => [rendered]} = json_response(conn, 200)
104
      assert rendered["id"] == item.id
105
      assert rendered["issue_id"] == item.issue_id
106
      assert rendered["values"] == %{"Status" => "Todo"}
107
    end
108
109
    test "GET .../projectsV2/:project_number/items returns 404 for a missing project", %{
110
      conn: conn
111
    } do
112
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/999999/items")
113
114
      assert json_response(conn, 404) == %{"message" => "Not Found"}
115
    end
116
  end
117
118
  describe "create_item" do
119
    setup do
120
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
121
      {:ok, issue} = Issues.create_issue(%{title: "Trackable issue"})
122
      %{project: project, issue: issue}
123
    end
124
125
    test "POST .../projectsV2/:project_number/items adds an issue to the board", %{
126
      conn: conn,
127
      project: project,
128
      issue: issue
129
    } do
130
      conn =
131
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items", %{
132
          issue_number: issue.number
133
        })
134
135
      assert %{"items" => [item]} = json_response(conn, 201)
136
      assert item["issue_id"] == issue.id
137
138
      assert [%{issue_id: issue_id}] = Projects.list_project_items(project.id)
139
      assert issue_id == issue.id
140
    end
141
142
    test "POST .../projectsV2/:project_number/items stores field values", %{
143
      conn: conn,
144
      project: project,
145
      issue: issue
146
    } do
147
      conn =
148
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items", %{
149
          issue_number: issue.number,
150
          values: %{"Status" => "In Progress"}
151
        })
152
153
      assert %{"items" => [%{"values" => %{"Status" => "In Progress"}}]} =
154
               json_response(conn, 201)
155
    end
156
157
    test "POST .../projectsV2/:project_number/items returns 422 for non-map values", %{
158
      conn: conn,
159
      project: project,
160
      issue: issue
161
    } do
162
      conn =
163
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items", %{
164
          issue_number: issue.number,
165
          values: "not-a-map"
166
        })
167
168
      assert %{"errors" => %{"values" => _}} = json_response(conn, 422)
169
      assert Projects.list_project_items(project.id) == []
170
    end
171
172
    test "POST .../projectsV2/:project_number/items returns 422 without an issue_number", %{
173
      conn: conn,
174
      project: project
175
    } do
176
      conn = post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items", %{})
177
178
      assert %{"errors" => %{"issue_number" => _}} = json_response(conn, 422)
179
      assert Projects.list_project_items(project.id) == []
180
    end
181
182
    test "POST .../projectsV2/:project_number/items returns 422 for a non-numeric issue_number",
183
         %{conn: conn, project: project} do
184
      conn =
185
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items", %{
186
          issue_number: "not-a-number"
187
        })
188
189
      assert %{"errors" => %{"issue_number" => _}} = json_response(conn, 422)
190
      assert Projects.list_project_items(project.id) == []
191
    end
192
193
    test "POST .../projectsV2/:project_number/items returns 404 for an unknown issue", %{
194
      conn: conn,
195
      project: project
196
    } do
197
      conn =
198
        post(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items", %{
199
          issue_number: 999_999
200
        })
201
202
      assert json_response(conn, 404) == %{"message" => "Not Found"}
203
    end
204
205
    test "POST .../projectsV2/:project_number/items returns 404 for a missing project", %{
206
      conn: conn,
207
      issue: issue
208
    } do
209
      conn =
210
        post(conn, ~p"/api/v3/users/alice/projectsV2/999999/items", %{issue_number: issue.number})
211
212
      assert json_response(conn, 404) == %{"message" => "Not Found"}
213
    end
214
  end
215
216
  describe "update_item" do
217
    setup do
218
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
219
      item = project_item_fixture(%{project_id: project.id, values: %{"Status" => "Todo"}})
220
      %{project: project, item: item}
221
    end
222
223
    test "PATCH .../items/:item_id merges new field values", %{
224
      conn: conn,
225
      project: project,
226
      item: item
227
    } do
228
      conn =
229
        patch(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items/#{item.id}", %{
230
          values: %{"Status" => "Done"}
231
        })
232
233
      assert %{"items" => [%{"values" => %{"Status" => "Done"}}]} = json_response(conn, 200)
234
      assert Projects.get_project_item!(item.id).values == %{"Status" => "Done"}
235
    end
236
237
    test "PATCH .../items/:item_id keeps values it was not asked to change", %{
238
      conn: conn,
239
      project: project,
240
      item: item
241
    } do
242
      conn =
243
        patch(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items/#{item.id}", %{
244
          values: %{"Priority" => "P1"}
245
        })
246
247
      assert %{"items" => [%{"values" => values}]} = json_response(conn, 200)
248
      assert values == %{"Status" => "Todo", "Priority" => "P1"}
249
    end
250
251
    test "PATCH .../items/:item_id returns 422 for non-map values", %{
252
      conn: conn,
253
      project: project,
254
      item: item
255
    } do
256
      conn =
257
        patch(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items/#{item.id}", %{
258
          values: "not-a-map"
259
        })
260
261
      assert %{"errors" => %{"values" => _}} = json_response(conn, 422)
262
      assert Projects.get_project_item!(item.id).values == %{"Status" => "Todo"}
263
    end
264
265
    test "PATCH .../items/:item_id returns 404 for a missing item", %{
266
      conn: conn,
267
      project: project
268
    } do
269
      conn =
270
        patch(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/items/999999", %{
271
          values: %{"Status" => "Done"}
272
        })
273
274
      assert json_response(conn, 404) == %{"message" => "Not Found"}
275
    end
276
  end
277
278
  describe "fields" do
279
    test "GET .../projectsV2/:project_number/fields returns an empty list for a new project", %{
280
      conn: conn
281
    } do
282
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
283
284
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/fields")
285
286
      assert json_response(conn, 200) == %{"fields" => []}
287
    end
288
289
    test "GET .../projectsV2/:project_number/fields lists the project's fields", %{conn: conn} do
290
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
291
292
      field =
293
        project_field_fixture(%{
294
          project_id: project.id,
295
          name: "Status",
296
          data_type: "single_select",
297
          options: %{"values" => ["Todo", "Done"]}
298
        })
299
300
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/#{project.number}/fields")
301
302
      assert %{"fields" => [rendered]} = json_response(conn, 200)
303
      assert rendered["id"] == field.id
304
      assert rendered["name"] == "Status"
305
      assert rendered["data_type"] == "single_select"
306
      assert rendered["options"] == %{"values" => ["Todo", "Done"]}
307
    end
308
309
    test "GET .../projectsV2/:project_number/fields returns 404 for a missing project", %{
310
      conn: conn
311
    } do
312
      conn = get(conn, ~p"/api/v3/users/alice/projectsV2/999999/fields")
313
314
      assert json_response(conn, 404) == %{"message" => "Not Found"}
315
    end
316
  end
317
end

This page updates live while a promote is in flight · changelog