Test the issues/projects domain layer, and fix two bugs it hid

f71da8a5f501 · AtlantisPleb · · parent b03f932ac2bf

Test the issues/projects domain layer, and fix two bugs it hid

This layer was the least-tested code in the repo: ProjectFields,
ProjectItems and both their schemas sat at 0% with fixtures that no test
had ever called, while Issues -- which backs the whole /api/v3 issues
surface -- sat at 37.8%. Everything else in the repo averages 83.8%.

141 new domain tests across the six contexts, covering create/update/
delete, listing and filtering, changeset validation failures, the
number-assignment sequences, label/assignee/milestone normalisation, the
close/reopen state machine, and the comment counter transaction. Error
paths get the same weight as happy paths.

Writing them turned up two real bugs in Projects.create_project_item/2:

  * The atom-keyed clause read `attrs["values"]`, which is always nil for
    an atom-keyed map, so a caller passing `%{issue_number: n, values:
    %{...}}` silently lost its values. That clause had no caller and was
    a strictly worse copy of the clause below it, so it is gone; the
    surviving clause normalises keys and handles both shapes.

  * A missing issue_number fed nil into `Repo.get_by!`, which raises
    ArgumentError ("comparison with nil is forbidden"). The /api/v3
    controller only rescues Ecto.NoResultsError, so that request 500'd
    instead of returning 422. It now answers with an error changeset,
    which the controller already renders.

Coverage: ProjectFields 0% -> 100%, ProjectFields.ProjectField 0% ->
100%, ProjectItems 0% -> 100%, ProjectItems.ProjectItem 0% -> 100%,
Projects 30.6% -> 100%, Issues 37.8% -> 96.9%, Milestones 78.6% -> 100%,
Labels 87.5% -> 100%. Suite total 79.85% -> 79.88%... on a repo where
this layer was the outlier.

The three lines still uncovered in Issues are the `{_, _} ->
Repo.rollback(...)` arms in create_comment/1 and delete_comment/1. They
are unreachable: the comments.issue_id foreign key guarantees the
paired update_all always matches exactly one row.

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/projects.ex
  • added test/openagents/issues_test.exs
  • modified test/openagents/labels_test.exs
  • modified test/openagents/milestones_test.exs
  • added test/openagents/project_fields_test.exs
  • added test/openagents/project_items_test.exs
  • modified test/openagents/projects_test.exs
  • added test/support/fixtures/issues_fixtures.ex

Diff

8 files changed, +1395 -23

lib/openagents/projects.ex modified +22 -23

@@ -72,35 +72,34 @@ defmodule OpenAgents.Projects do

72 72
73 73
  def get_project_item!(id), do: Repo.get!(ProjectItem, id)
74 74
75
  def create_project_item(%{issue_number: issue_number} = attrs, project_id) do
76
    issue = Repo.get_by!(Issue, number: issue_number)
77
    values = attrs["values"] || %{}
78
79
    %ProjectItem{}
80
    |> ProjectItem.changeset(%{
81
      "project_id" => project_id,
82
      "issue_id" => issue.id,
83
      "values" => values
84
    })
85
    |> Repo.insert()
86
  end
87
88 75
  def create_project_item(attrs, project_id) do
89 76
    attrs =
90 77
      for {k, v} <- attrs, into: %{} do
91 78
        {to_string(k), v}
92 79
      end
93 80
94
    issue_number = Map.get(attrs, "issue_number")
95
    issue = Repo.get_by!(Issue, number: issue_number)
96
97
    %ProjectItem{}
98
    |> ProjectItem.changeset(%{
99
      "project_id" => project_id,
100
      "issue_id" => issue.id,
101
      "values" => Map.get(attrs, "values", %{})
102
    })
103
    |> Repo.insert()
81
    values = Map.get(attrs, "values", %{})
82
83
    case Map.get(attrs, "issue_number") do
84
      nil ->
85
        # Feeding a nil straight into `get_by!` raises ArgumentError ("comparison
86
        # with nil is forbidden"), which callers do not expect from a create.
87
        # A missing issue number is a bad request, so answer with a changeset.
88
        %ProjectItem{}
89
        |> ProjectItem.changeset(%{"project_id" => project_id, "values" => values})
90
        |> Ecto.Changeset.apply_action(:insert)
91
92
      issue_number ->
93
        issue = Repo.get_by!(Issue, number: issue_number)
94
95
        %ProjectItem{}
96
        |> ProjectItem.changeset(%{
97
          "project_id" => project_id,
98
          "issue_id" => issue.id,
99
          "values" => values
100
        })
101
        |> Repo.insert()
102
    end
104 103
  end
105 104
106 105
  def update_project_item(%ProjectItem{} = item, attrs) do
test/openagents/issues_test.exs added +611

@@ -0,0 +1,611 @@

1
defmodule OpenAgents.IssuesTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Issues.Comment
6
  alias OpenAgents.Issues.Issue
7
8
  import OpenAgents.IssuesFixtures
9
  import OpenAgents.LabelsFixtures
10
  import OpenAgents.MilestonesFixtures
11
12
  defp backdate!(%Issue{} = issue, seconds_ago) do
13
    at = DateTime.utc_now() |> DateTime.add(-seconds_ago, :second) |> DateTime.truncate(:second)
14
15
    {1, nil} =
16
      Repo.update_all(from(i in Issue, where: i.id == ^issue.id), set: [inserted_at: at])
17
18
    Issues.get_issue!(issue.id)
19
  end
20
21
  describe "list_issues/1" do
22
    test "returns only open issues by default" do
23
      open = issue_fixture(title: "open one")
24
      closed = issue_fixture(title: "closed one")
25
      {:ok, closed} = Issues.update_issue(closed, %{"state" => "closed"})
26
27
      numbers = Issues.list_issues() |> Enum.map(& &1.number)
28
29
      assert open.number in numbers
30
      refute closed.number in numbers
31
    end
32
33
    test "filters by an explicit state" do
34
      _open = issue_fixture(title: "open one")
35
      closed = issue_fixture(title: "closed one")
36
      {:ok, closed} = Issues.update_issue(closed, %{"state" => "closed"})
37
38
      assert Issues.list_issues(state: "closed") |> Enum.map(& &1.number) == [closed.number]
39
    end
40
41
    test "state: \"all\" skips the filter" do
42
      open = issue_fixture(title: "open one")
43
      closed = issue_fixture(title: "closed one")
44
      {:ok, _} = Issues.update_issue(closed, %{"state" => "closed"})
45
46
      numbers = Issues.list_issues(state: "all") |> Enum.map(& &1.number) |> Enum.sort()
47
      assert numbers == Enum.sort([open.number, closed.number])
48
    end
49
50
    test "returns an empty list when nothing matches" do
51
      assert Issues.list_issues() == []
52
      assert Issues.list_issues(state: "all") == []
53
    end
54
55
    test "orders newest first" do
56
      oldest = issue_fixture(title: "oldest") |> backdate!(300)
57
      middle = issue_fixture(title: "middle") |> backdate!(200)
58
      newest = issue_fixture(title: "newest") |> backdate!(100)
59
60
      assert Issues.list_issues() |> Enum.map(& &1.id) == [newest.id, middle.id, oldest.id]
61
    end
62
  end
63
64
  describe "get_issue!/1 and get_issue_by_number!/1" do
65
    test "get_issue!/1 returns the issue with the given id" do
66
      issue = issue_fixture()
67
      assert Issues.get_issue!(issue.id) == issue
68
    end
69
70
    test "get_issue!/1 raises for an unknown id" do
71
      issue = issue_fixture()
72
      assert_raise Ecto.NoResultsError, fn -> Issues.get_issue!(issue.id + 1) end
73
    end
74
75
    test "get_issue_by_number!/1 returns the issue with the given number" do
76
      issue = issue_fixture()
77
      assert Issues.get_issue_by_number!(issue.number) == issue
78
    end
79
80
    test "get_issue_by_number!/1 raises for an unknown number" do
81
      issue = issue_fixture()
82
      assert_raise Ecto.NoResultsError, fn -> Issues.get_issue_by_number!(issue.number + 1) end
83
    end
84
  end
85
86
  describe "create_issue/1" do
87
    test "assigns numbers from one upwards" do
88
      assert {:ok, %Issue{number: 1}} = Issues.create_issue(%{title: "one"})
89
      assert {:ok, %Issue{number: 2}} = Issues.create_issue(%{title: "two"})
90
      assert {:ok, %Issue{number: 3}} = Issues.create_issue(%{title: "three"})
91
    end
92
93
    test "ignores a caller-supplied number" do
94
      assert {:ok, %Issue{number: 1}} = Issues.create_issue(%{title: "one", number: 99})
95
    end
96
97
    test "sets the documented defaults" do
98
      assert {:ok, %Issue{} = issue} = Issues.create_issue(%{title: "defaults"})
99
100
      assert issue.state == "open"
101
      assert issue.locked == false
102
      assert issue.comments == 0
103
      assert issue.labels == []
104
      assert issue.assignees == []
105
      assert is_nil(issue.milestone)
106
      assert is_nil(issue.closed_at)
107
    end
108
109
    test "accepts string keys" do
110
      assert {:ok, %Issue{} = issue} =
111
               Issues.create_issue(%{"title" => "strings", "body" => "hello"})
112
113
      assert issue.title == "strings"
114
      assert issue.body == "hello"
115
    end
116
117
    test "requires a title" do
118
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_issue(%{body: "no title"})
119
      assert %{title: ["can't be blank"]} = errors_on(changeset)
120
    end
121
122
    test "called with no attrs at all it still refuses" do
123
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_issue()
124
      assert %{title: ["can't be blank"]} = errors_on(changeset)
125
    end
126
127
    test "expands known label names into label maps" do
128
      label = label_fixture(name: "bug", color: "d73a4a", description: "Something broken")
129
130
      assert {:ok, %Issue{} = issue} =
131
               Issues.create_issue(%{title: "labelled", labels: ["bug"]})
132
133
      assert issue.labels == [
134
               %{
135
                 "id" => label.id,
136
                 "name" => "bug",
137
                 "color" => "d73a4a",
138
                 "description" => "Something broken"
139
               }
140
             ]
141
    end
142
143
    test "invents a white label for an unknown name" do
144
      assert {:ok, %Issue{} = issue} =
145
               Issues.create_issue(%{title: "labelled", labels: ["nope"]})
146
147
      assert issue.labels == [%{"name" => "nope", "color" => "ffffff"}]
148
    end
149
150
    test "passes label maps through untouched" do
151
      given = [%{"name" => "bug", "color" => "abcdef"}]
152
153
      assert {:ok, %Issue{} = issue} = Issues.create_issue(%{title: "labelled", labels: given})
154
      assert issue.labels == given
155
    end
156
157
    test "accepts an empty label list" do
158
      assert {:ok, %Issue{labels: []}} = Issues.create_issue(%{title: "none", labels: []})
159
    end
160
161
    test "expands assignee logins into assignee maps" do
162
      assert {:ok, %Issue{} = issue} =
163
               Issues.create_issue(%{title: "assigned", assignees: ["alice", "bob"]})
164
165
      assert issue.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
166
    end
167
168
    test "passes assignee maps through untouched" do
169
      given = [%{"login" => "alice", "id" => 7}]
170
171
      assert {:ok, %Issue{} = issue} = Issues.create_issue(%{title: "assigned", assignees: given})
172
      assert issue.assignees == given
173
    end
174
175
    test "expands a milestone number into a milestone map" do
176
      milestone = milestone_fixture(title: "v1", state: "open", due_on: "2026-01-01")
177
178
      assert {:ok, %Issue{} = issue} =
179
               Issues.create_issue(%{title: "planned", milestone: milestone.number})
180
181
      assert issue.milestone == %{
182
               "number" => milestone.number,
183
               "title" => "v1",
184
               "state" => "open",
185
               "description" => milestone.description,
186
               "due_on" => "2026-01-01"
187
             }
188
    end
189
190
    test "raises for an unknown milestone number" do
191
      assert_raise Ecto.NoResultsError, fn ->
192
        Issues.create_issue(%{title: "planned", milestone: 404})
193
      end
194
    end
195
196
    test "accepts an explicit nil milestone" do
197
      assert {:ok, %Issue{milestone: nil}} =
198
               Issues.create_issue(%{title: "unplanned", milestone: nil})
199
    end
200
  end
201
202
  describe "update_issue/2" do
203
    test "updates plain fields" do
204
      issue = issue_fixture()
205
206
      assert {:ok, %Issue{} = updated} =
207
               Issues.update_issue(issue, %{"title" => "new title", "body" => "new body"})
208
209
      assert updated.title == "new title"
210
      assert updated.body == "new body"
211
    end
212
213
    test "returns an error changeset for invalid data" do
214
      issue = issue_fixture()
215
216
      assert {:error, %Ecto.Changeset{}} = Issues.update_issue(issue, %{"title" => nil})
217
      assert issue == Issues.get_issue!(issue.id)
218
    end
219
220
    test "closing stamps closed_at and defaults the state reason" do
221
      issue = issue_fixture()
222
223
      assert {:ok, %Issue{} = closed} = Issues.update_issue(issue, %{"state" => "closed"})
224
225
      assert closed.state == "closed"
226
      assert closed.state_reason == "completed"
227
      refute is_nil(closed.closed_at)
228
    end
229
230
    test "closing keeps an explicit state reason" do
231
      issue = issue_fixture()
232
233
      assert {:ok, %Issue{} = closed} =
234
               Issues.update_issue(issue, %{
235
                 "state" => "closed",
236
                 "state_reason" => "not_planned"
237
               })
238
239
      assert closed.state_reason == "not_planned"
240
    end
241
242
    test "closing an already-closed issue does not re-stamp closed_at" do
243
      issue = issue_fixture()
244
      {:ok, closed} = Issues.update_issue(issue, %{"state" => "closed"})
245
246
      assert {:ok, %Issue{} = again} =
247
               Issues.update_issue(closed, %{"state" => "closed", "title" => "still closed"})
248
249
      assert again.closed_at == closed.closed_at
250
      assert again.title == "still closed"
251
    end
252
253
    test "reopening clears closed_at and the state reason" do
254
      issue = issue_fixture()
255
      {:ok, closed} = Issues.update_issue(issue, %{"state" => "closed"})
256
257
      assert {:ok, %Issue{} = reopened} = Issues.update_issue(closed, %{"state" => "open"})
258
259
      assert reopened.state == "open"
260
      assert is_nil(reopened.closed_at)
261
      assert is_nil(reopened.state_reason)
262
    end
263
264
    test "closing works with atom keys too" do
265
      issue = issue_fixture()
266
267
      assert {:ok, %Issue{} = closed} = Issues.update_issue(issue, %{state: "closed"})
268
269
      assert closed.state == "closed"
270
      assert closed.state_reason == "completed"
271
      refute is_nil(closed.closed_at)
272
    end
273
274
    test "closing with atom keys keeps a caller-supplied closed_at" do
275
      issue = issue_fixture()
276
      at = ~U[2026-01-01 00:00:00Z]
277
278
      assert {:ok, %Issue{} = closed} =
279
               Issues.update_issue(issue, %{state: "closed", closed_at: at})
280
281
      assert closed.closed_at == at
282
    end
283
284
    test "reopening works with atom keys too" do
285
      issue = issue_fixture()
286
      {:ok, closed} = Issues.update_issue(issue, %{state: "closed"})
287
288
      assert {:ok, %Issue{} = reopened} = Issues.update_issue(closed, %{state: "open"})
289
290
      assert is_nil(reopened.closed_at)
291
      assert is_nil(reopened.state_reason)
292
    end
293
294
    test "locking an issue is a plain field update" do
295
      issue = issue_fixture()
296
297
      assert {:ok, %Issue{} = locked} =
298
               Issues.update_issue(issue, %{"locked" => true, "locked_reason" => "spam"})
299
300
      assert locked.locked
301
      assert locked.locked_reason == "spam"
302
    end
303
  end
304
305
  describe "change_issue/2" do
306
    test "returns a changeset" do
307
      issue = issue_fixture()
308
      assert %Ecto.Changeset{} = Issues.change_issue(issue)
309
    end
310
311
    test "applies attrs and surfaces validation errors" do
312
      issue = issue_fixture()
313
314
      assert Issues.change_issue(issue, %{title: "Renamed"}).valid?
315
316
      changeset = Issues.change_issue(issue, %{title: nil})
317
      refute changeset.valid?
318
      assert %{title: ["can't be blank"]} = errors_on(changeset)
319
    end
320
  end
321
322
  describe "labels on an issue" do
323
    test "add_labels/2 appends known labels" do
324
      label_fixture(name: "bug", color: "d73a4a")
325
      issue = issue_fixture()
326
327
      assert {:ok, %Issue{} = updated} = Issues.add_labels(issue, ["bug"])
328
      assert Enum.map(updated.labels, & &1["name"]) == ["bug"]
329
      assert hd(updated.labels)["color"] == "d73a4a"
330
    end
331
332
    test "add_labels/2 does not duplicate an existing label" do
333
      label_fixture(name: "bug", color: "d73a4a")
334
      issue = issue_fixture()
335
336
      {:ok, issue} = Issues.add_labels(issue, ["bug"])
337
      assert {:ok, %Issue{} = updated} = Issues.add_labels(issue, ["bug"])
338
339
      assert Enum.map(updated.labels, & &1["name"]) == ["bug"]
340
    end
341
342
    test "add_labels/2 accepts several names at once" do
343
      label_fixture(name: "bug", color: "d73a4a")
344
      label_fixture(name: "docs", color: "0075ca")
345
      issue = issue_fixture()
346
347
      assert {:ok, %Issue{} = updated} = Issues.add_labels(issue, ["bug", "docs"])
348
      assert Enum.map(updated.labels, & &1["name"]) == ["bug", "docs"]
349
    end
350
351
    test "add_labels/2 raises for an unknown label" do
352
      issue = issue_fixture()
353
      assert_raise Ecto.NoResultsError, fn -> Issues.add_labels(issue, ["nope"]) end
354
    end
355
356
    test "add_labels/2 with an empty list is a no-op" do
357
      issue = issue_fixture()
358
      assert {:ok, %Issue{labels: []}} = Issues.add_labels(issue, [])
359
    end
360
361
    test "remove_label/2 drops the named label" do
362
      label_fixture(name: "bug", color: "d73a4a")
363
      label_fixture(name: "docs", color: "0075ca")
364
      issue = issue_fixture()
365
      {:ok, issue} = Issues.add_labels(issue, ["bug", "docs"])
366
367
      assert {:ok, %Issue{} = updated} = Issues.remove_label(issue, "bug")
368
      assert Enum.map(updated.labels, & &1["name"]) == ["docs"]
369
    end
370
371
    test "remove_label/2 decodes a percent-encoded name" do
372
      label_fixture(name: "help wanted", color: "008672")
373
      issue = issue_fixture()
374
      {:ok, issue} = Issues.add_labels(issue, ["help wanted"])
375
376
      assert {:ok, %Issue{labels: []}} = Issues.remove_label(issue, "help%20wanted")
377
    end
378
379
    test "remove_label/2 is a no-op for a label the issue does not carry" do
380
      label_fixture(name: "bug", color: "d73a4a")
381
      issue = issue_fixture()
382
      {:ok, issue} = Issues.add_labels(issue, ["bug"])
383
384
      assert {:ok, %Issue{} = updated} = Issues.remove_label(issue, "docs")
385
      assert Enum.map(updated.labels, & &1["name"]) == ["bug"]
386
    end
387
388
    test "remove_label/2 tolerates an issue with no labels" do
389
      issue = issue_fixture()
390
      assert {:ok, %Issue{labels: []}} = Issues.remove_label(issue, "bug")
391
    end
392
  end
393
394
  describe "assignees on an issue" do
395
    test "add_assignees/2 appends logins" do
396
      issue = issue_fixture()
397
398
      assert {:ok, %Issue{} = updated} = Issues.add_assignees(issue, ["alice", "bob"])
399
      assert updated.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
400
    end
401
402
    test "add_assignees/2 does not duplicate an existing login" do
403
      issue = issue_fixture()
404
      {:ok, issue} = Issues.add_assignees(issue, ["alice"])
405
406
      assert {:ok, %Issue{} = updated} = Issues.add_assignees(issue, ["alice", "bob"])
407
      assert updated.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
408
    end
409
410
    test "remove_assignees/2 drops the named logins" do
411
      issue = issue_fixture()
412
      {:ok, issue} = Issues.add_assignees(issue, ["alice", "bob", "carol"])
413
414
      assert {:ok, %Issue{} = updated} = Issues.remove_assignees(issue, ["alice", "carol"])
415
      assert updated.assignees == [%{"login" => "bob"}]
416
    end
417
418
    test "remove_assignees/2 ignores logins the issue does not carry" do
419
      issue = issue_fixture()
420
      {:ok, issue} = Issues.add_assignees(issue, ["alice"])
421
422
      assert {:ok, %Issue{} = updated} = Issues.remove_assignees(issue, ["bob"])
423
      assert updated.assignees == [%{"login" => "alice"}]
424
    end
425
426
    test "remove_assignees/2 tolerates an issue with no assignees" do
427
      issue = issue_fixture()
428
      assert {:ok, %Issue{assignees: []}} = Issues.remove_assignees(issue, ["alice"])
429
    end
430
  end
431
432
  describe "set_milestone/2" do
433
    test "attaches the milestone by number" do
434
      milestone = milestone_fixture(title: "v1", state: "open")
435
      issue = issue_fixture()
436
437
      assert {:ok, %Issue{} = updated} = Issues.set_milestone(issue, milestone.number)
438
      assert updated.milestone["number"] == milestone.number
439
      assert updated.milestone["title"] == "v1"
440
    end
441
442
    test "clears the milestone with nil" do
443
      milestone = milestone_fixture(title: "v1")
444
      issue = issue_fixture()
445
      {:ok, issue} = Issues.set_milestone(issue, milestone.number)
446
447
      assert {:ok, %Issue{milestone: nil}} = Issues.set_milestone(issue, nil)
448
    end
449
450
    test "raises for an unknown milestone number" do
451
      issue = issue_fixture()
452
      assert_raise Ecto.NoResultsError, fn -> Issues.set_milestone(issue, 404) end
453
    end
454
  end
455
456
  describe "comments" do
457
    test "create_comment/1 stores the comment and bumps the issue counter" do
458
      issue = issue_fixture()
459
460
      assert {:ok, %Comment{} = comment} =
461
               Issues.create_comment(%{body: "hello", issue_id: issue.id})
462
463
      assert comment.body == "hello"
464
      assert comment.issue_id == issue.id
465
      refute is_nil(comment.created_at)
466
      refute is_nil(comment.updated_at)
467
468
      assert Issues.get_issue!(issue.id).comments == 1
469
    end
470
471
    test "create_comment/1 keeps explicit timestamps" do
472
      issue = issue_fixture()
473
      at = ~U[2026-01-01 00:00:00Z]
474
475
      assert {:ok, %Comment{} = comment} =
476
               Issues.create_comment(%{
477
                 "body" => "hello",
478
                 "issue_id" => issue.id,
479
                 "created_at" => at,
480
                 "updated_at" => at
481
               })
482
483
      assert comment.created_at == at
484
      assert comment.updated_at == at
485
    end
486
487
    test "create_comment/1 stores the author payload" do
488
      issue = issue_fixture()
489
490
      assert {:ok, %Comment{} = comment} =
491
               Issues.create_comment(%{
492
                 body: "hello",
493
                 issue_id: issue.id,
494
                 user: %{"login" => "alice"}
495
               })
496
497
      assert comment.user == %{"login" => "alice"}
498
    end
499
500
    test "create_comment/1 rejects a blank body and leaves the counter alone" do
501
      issue = issue_fixture()
502
503
      assert {:error, %Ecto.Changeset{} = changeset} =
504
               Issues.create_comment(%{body: nil, issue_id: issue.id})
505
506
      assert %{body: ["can't be blank"]} = errors_on(changeset)
507
      assert Issues.get_issue!(issue.id).comments == 0
508
    end
509
510
    test "create_comment/1 requires an issue_id" do
511
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_comment(%{body: "orphan"})
512
      assert %{issue_id: ["can't be blank"]} = errors_on(changeset)
513
    end
514
515
    test "create_comment/0 refuses an empty comment" do
516
      assert {:error, %Ecto.Changeset{} = changeset} = Issues.create_comment()
517
      assert %{body: ["can't be blank"], issue_id: ["can't be blank"]} = errors_on(changeset)
518
    end
519
520
    test "get_comment!/1 returns the comment" do
521
      issue = issue_fixture()
522
      {:ok, comment} = Issues.create_comment(%{body: "hello", issue_id: issue.id})
523
524
      assert Issues.get_comment!(comment.id) == comment
525
    end
526
527
    test "get_comment!/1 raises for an unknown id" do
528
      issue = issue_fixture()
529
      {:ok, comment} = Issues.create_comment(%{body: "hello", issue_id: issue.id})
530
531
      assert_raise Ecto.NoResultsError, fn -> Issues.get_comment!(comment.id + 1) end
532
    end
533
534
    test "list_comments/1 is scoped to one issue and ordered by creation time" do
535
      issue = issue_fixture(title: "mine")
536
      other = issue_fixture(title: "theirs")
537
538
      {:ok, second} =
539
        Issues.create_comment(%{
540
          body: "second",
541
          issue_id: issue.id,
542
          created_at: ~U[2026-01-02 00:00:00Z],
543
          updated_at: ~U[2026-01-02 00:00:00Z]
544
        })
545
546
      {:ok, first} =
547
        Issues.create_comment(%{
548
          body: "first",
549
          issue_id: issue.id,
550
          created_at: ~U[2026-01-01 00:00:00Z],
551
          updated_at: ~U[2026-01-01 00:00:00Z]
552
        })
553
554
      {:ok, _elsewhere} = Issues.create_comment(%{body: "elsewhere", issue_id: other.id})
555
556
      assert Issues.list_comments(issue.id) |> Enum.map(& &1.id) == [first.id, second.id]
557
    end
558
559
    test "list_comments/1 returns an empty list for an issue with no comments" do
560
      issue = issue_fixture()
561
      assert Issues.list_comments(issue.id) == []
562
    end
563
564
    test "update_comment/2 edits the body and bumps updated_at" do
565
      issue = issue_fixture()
566
567
      {:ok, comment} =
568
        Issues.create_comment(%{
569
          body: "before",
570
          issue_id: issue.id,
571
          created_at: ~U[2026-01-01 00:00:00Z],
572
          updated_at: ~U[2026-01-01 00:00:00Z]
573
        })
574
575
      assert {:ok, %Comment{} = updated} = Issues.update_comment(comment, %{body: "after"})
576
577
      assert updated.body == "after"
578
      assert updated.created_at == comment.created_at
579
      assert DateTime.after?(updated.updated_at, comment.updated_at)
580
    end
581
582
    test "update_comment/2 rejects a blank body" do
583
      issue = issue_fixture()
584
      {:ok, comment} = Issues.create_comment(%{body: "before", issue_id: issue.id})
585
586
      assert {:error, %Ecto.Changeset{}} = Issues.update_comment(comment, %{body: nil})
587
      assert Issues.get_comment!(comment.id).body == "before"
588
    end
589
590
    test "delete_comment/1 removes it and decrements the issue counter" do
591
      issue = issue_fixture()
592
      {:ok, comment} = Issues.create_comment(%{body: "hello", issue_id: issue.id})
593
      assert Issues.get_issue!(issue.id).comments == 1
594
595
      assert {:ok, :ok} = Issues.delete_comment(comment)
596
597
      assert_raise Ecto.NoResultsError, fn -> Issues.get_comment!(comment.id) end
598
      assert Issues.get_issue!(issue.id).comments == 0
599
    end
600
601
    test "the counter tracks several comments" do
602
      issue = issue_fixture()
603
      {:ok, a} = Issues.create_comment(%{body: "a", issue_id: issue.id})
604
      {:ok, _b} = Issues.create_comment(%{body: "b", issue_id: issue.id})
605
      assert Issues.get_issue!(issue.id).comments == 2
606
607
      {:ok, :ok} = Issues.delete_comment(a)
608
      assert Issues.get_issue!(issue.id).comments == 1
609
    end
610
  end
611
end
test/openagents/labels_test.exs modified +25

@@ -64,5 +64,30 @@ defmodule OpenAgents.LabelsTest do

64 64
      label = label_fixture()
65 65
      assert %Ecto.Changeset{} = Labels.change_label(label)
66 66
    end
67
68
    test "change_label/2 applies attrs and surfaces validation errors" do
69
      label = label_fixture()
70
71
      assert Labels.change_label(label, %{name: "renamed"}).valid?
72
73
      changeset = Labels.change_label(label, %{name: nil})
74
      refute changeset.valid?
75
      assert %{name: ["can't be blank"]} = errors_on(changeset)
76
    end
77
78
    test "get_label_by_name!/1 returns the label with the given name" do
79
      label = label_fixture(name: "bug")
80
      assert Labels.get_label_by_name!("bug") == label
81
    end
82
83
    test "get_label_by_name!/1 decodes a percent-encoded name" do
84
      label = label_fixture(name: "help wanted")
85
      assert Labels.get_label_by_name!("help%20wanted") == label
86
    end
87
88
    test "get_label_by_name!/1 raises for an unknown name" do
89
      label_fixture(name: "bug")
90
      assert_raise Ecto.NoResultsError, fn -> Labels.get_label_by_name!("nope") end
91
    end
67 92
  end
68 93
end
test/openagents/milestones_test.exs modified +49

@@ -78,5 +78,54 @@ defmodule OpenAgents.MilestonesTest do

78 78
      milestone = milestone_fixture()
79 79
      assert %Ecto.Changeset{} = Milestones.change_milestone(milestone)
80 80
    end
81
82
    test "change_milestone/2 applies attrs and surfaces validation errors" do
83
      milestone = milestone_fixture()
84
85
      assert Milestones.change_milestone(milestone, %{title: "renamed"}).valid?
86
87
      changeset = Milestones.change_milestone(milestone, %{title: nil})
88
      refute changeset.valid?
89
      assert %{title: ["can't be blank"]} = errors_on(changeset)
90
    end
91
92
    test "create_milestone/1 assigns numbers from one upwards" do
93
      assert {:ok, %Milestone{number: 1}} =
94
               Milestones.create_milestone(%{title: "v1", state: "open"})
95
96
      assert {:ok, %Milestone{number: 2}} =
97
               Milestones.create_milestone(%{title: "v2", state: "open"})
98
    end
99
100
    test "create_milestone/1 honours an explicit number and continues from it" do
101
      assert {:ok, %Milestone{number: 10}} =
102
               Milestones.create_milestone(%{title: "v10", state: "open", number: 10})
103
104
      assert {:ok, %Milestone{number: 11}} =
105
               Milestones.create_milestone(%{title: "v11", state: "open"})
106
    end
107
108
    test "create_milestone/0 refuses an empty milestone" do
109
      assert {:error, %Ecto.Changeset{} = changeset} = Milestones.create_milestone()
110
      assert %{title: ["can't be blank"]} = errors_on(changeset)
111
    end
112
113
    test "create_milestone/1 accepts string keys" do
114
      assert {:ok, %Milestone{} = milestone} =
115
               Milestones.create_milestone(%{"title" => "v1", "state" => "open"})
116
117
      assert milestone.title == "v1"
118
      assert milestone.state == "open"
119
    end
120
121
    test "get_milestone_by_number!/1 returns the milestone with the given number" do
122
      milestone = milestone_fixture(number: 7)
123
      assert Milestones.get_milestone_by_number!(7) == milestone
124
    end
125
126
    test "get_milestone_by_number!/1 raises for an unknown number" do
127
      milestone_fixture(number: 7)
128
      assert_raise Ecto.NoResultsError, fn -> Milestones.get_milestone_by_number!(8) end
129
    end
81 130
  end
82 131
end
test/openagents/project_fields_test.exs added +177

@@ -0,0 +1,177 @@

1
defmodule OpenAgents.ProjectFieldsTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.ProjectFields
5
6
  describe "project_fields" do
7
    alias OpenAgents.ProjectFields.ProjectField
8
9
    import OpenAgents.ProjectFieldsFixtures
10
    import OpenAgents.ProjectsFixtures
11
12
    @invalid_attrs %{name: nil, data_type: nil, options: nil, project_id: nil}
13
14
    test "list_project_fields/0 returns all project_fields" do
15
      project_field = project_field_fixture()
16
      assert ProjectFields.list_project_fields() == [project_field]
17
    end
18
19
    test "list_project_fields/0 returns an empty list when none exist" do
20
      assert ProjectFields.list_project_fields() == []
21
    end
22
23
    test "list_project_fields/0 spans projects" do
24
      a = project_field_fixture(name: "Status")
25
      b = project_field_fixture(name: "Priority")
26
27
      ids = ProjectFields.list_project_fields() |> Enum.map(& &1.id) |> Enum.sort()
28
      assert ids == Enum.sort([a.id, b.id])
29
    end
30
31
    test "get_project_field!/1 returns the project_field with given id" do
32
      project_field = project_field_fixture()
33
      assert ProjectFields.get_project_field!(project_field.id) == project_field
34
    end
35
36
    test "get_project_field!/1 raises for an unknown id" do
37
      project_field = project_field_fixture()
38
39
      assert_raise Ecto.NoResultsError, fn ->
40
        ProjectFields.get_project_field!(project_field.id + 1)
41
      end
42
    end
43
44
    test "create_project_field/1 with valid data creates a project_field" do
45
      project = project_fixture()
46
47
      valid_attrs = %{
48
        name: "Status",
49
        data_type: "single_select",
50
        options: %{"values" => ["Todo", "Done"]},
51
        project_id: project.id
52
      }
53
54
      assert {:ok, %ProjectField{} = project_field} =
55
               ProjectFields.create_project_field(valid_attrs)
56
57
      assert project_field.name == "Status"
58
      assert project_field.data_type == "single_select"
59
      assert project_field.options == %{"values" => ["Todo", "Done"]}
60
      assert project_field.project_id == project.id
61
    end
62
63
    test "create_project_field/1 leaves options nil when omitted" do
64
      project = project_fixture()
65
66
      assert {:ok, %ProjectField{} = project_field} =
67
               ProjectFields.create_project_field(%{
68
                 name: "Notes",
69
                 data_type: "text",
70
                 project_id: project.id
71
               })
72
73
      assert is_nil(project_field.options)
74
    end
75
76
    test "create_project_field/1 with invalid data returns error changeset" do
77
      assert {:error, %Ecto.Changeset{} = changeset} =
78
               ProjectFields.create_project_field(@invalid_attrs)
79
80
      assert %{
81
               name: ["can't be blank"],
82
               data_type: ["can't be blank"],
83
               project_id: ["can't be blank"]
84
             } = errors_on(changeset)
85
    end
86
87
    test "create_project_field/1 requires a project_id" do
88
      assert {:error, changeset} =
89
               ProjectFields.create_project_field(%{name: "Status", data_type: "text"})
90
91
      assert %{project_id: ["can't be blank"]} = errors_on(changeset)
92
      refute Map.has_key?(errors_on(changeset), :name)
93
    end
94
95
    test "create_project_field/1 does not declare a foreign key constraint" do
96
      # `project_fields.project_id` references `projects` in the database but the
97
      # changeset never calls `foreign_key_constraint/2`, so a dangling id blows
98
      # up rather than returning an error changeset. Characterised, not endorsed.
99
      assert_raise Ecto.ConstraintError, fn ->
100
        ProjectFields.create_project_field(%{
101
          name: "Status",
102
          data_type: "text",
103
          project_id: 2_147_483_000
104
        })
105
      end
106
    end
107
108
    test "update_project_field/2 with valid data updates the project_field" do
109
      project_field = project_field_fixture()
110
111
      update_attrs = %{
112
        name: "some updated name",
113
        data_type: "some updated data_type",
114
        options: %{"values" => ["a"]}
115
      }
116
117
      assert {:ok, %ProjectField{} = project_field} =
118
               ProjectFields.update_project_field(project_field, update_attrs)
119
120
      assert project_field.name == "some updated name"
121
      assert project_field.data_type == "some updated data_type"
122
      assert project_field.options == %{"values" => ["a"]}
123
    end
124
125
    test "update_project_field/2 can move a field to another project" do
126
      project_field = project_field_fixture()
127
      other = project_fixture(number: 99)
128
129
      assert {:ok, %ProjectField{} = moved} =
130
               ProjectFields.update_project_field(project_field, %{project_id: other.id})
131
132
      assert moved.project_id == other.id
133
    end
134
135
    test "update_project_field/2 with invalid data returns error changeset" do
136
      project_field = project_field_fixture()
137
138
      assert {:error, %Ecto.Changeset{}} =
139
               ProjectFields.update_project_field(project_field, @invalid_attrs)
140
141
      assert project_field == ProjectFields.get_project_field!(project_field.id)
142
    end
143
144
    test "delete_project_field/1 deletes the project_field" do
145
      project_field = project_field_fixture()
146
      assert {:ok, %ProjectField{}} = ProjectFields.delete_project_field(project_field)
147
148
      assert_raise Ecto.NoResultsError, fn ->
149
        ProjectFields.get_project_field!(project_field.id)
150
      end
151
    end
152
153
    test "change_project_field/1 returns a project_field changeset" do
154
      project_field = project_field_fixture()
155
      assert %Ecto.Changeset{} = ProjectFields.change_project_field(project_field)
156
    end
157
158
    test "change_project_field/2 applies the given attrs" do
159
      project_field = project_field_fixture()
160
161
      changeset = ProjectFields.change_project_field(project_field, %{name: "Renamed"})
162
163
      assert changeset.valid?
164
      assert Ecto.Changeset.get_change(changeset, :name) == "Renamed"
165
    end
166
167
    test "change_project_field/2 surfaces validation errors without touching the database" do
168
      project_field = project_field_fixture()
169
170
      changeset = ProjectFields.change_project_field(project_field, %{name: nil})
171
172
      refute changeset.valid?
173
      assert %{name: ["can't be blank"]} = errors_on(changeset)
174
      assert project_field == ProjectFields.get_project_field!(project_field.id)
175
    end
176
  end
177
end
test/openagents/project_items_test.exs added +186

@@ -0,0 +1,186 @@

1
defmodule OpenAgents.ProjectItemsTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.ProjectItems
5
6
  describe "project_items" do
7
    alias OpenAgents.ProjectItems.ProjectItem
8
9
    import OpenAgents.IssuesFixtures
10
    import OpenAgents.ProjectItemsFixtures
11
    import OpenAgents.ProjectsFixtures
12
13
    @invalid_attrs %{values: nil, project_id: nil, issue_id: nil}
14
15
    test "list_project_items/0 returns all project_items" do
16
      project_item = project_item_fixture()
17
      assert ProjectItems.list_project_items() == [project_item]
18
    end
19
20
    test "list_project_items/0 returns an empty list when none exist" do
21
      assert ProjectItems.list_project_items() == []
22
    end
23
24
    test "list_project_items/0 is not scoped to a project" do
25
      a = project_item_fixture()
26
      b = project_item_fixture()
27
28
      refute a.project_id == b.project_id
29
30
      ids = ProjectItems.list_project_items() |> Enum.map(& &1.id) |> Enum.sort()
31
      assert ids == Enum.sort([a.id, b.id])
32
    end
33
34
    test "get_project_item!/1 returns the project_item with given id" do
35
      project_item = project_item_fixture()
36
      assert ProjectItems.get_project_item!(project_item.id) == project_item
37
    end
38
39
    test "get_project_item!/1 raises for an unknown id" do
40
      project_item = project_item_fixture()
41
42
      assert_raise Ecto.NoResultsError, fn ->
43
        ProjectItems.get_project_item!(project_item.id + 1)
44
      end
45
    end
46
47
    test "create_project_item/1 with valid data creates a project_item" do
48
      project = project_fixture()
49
      issue = issue_fixture()
50
51
      valid_attrs = %{
52
        values: %{"Status" => "In Progress"},
53
        project_id: project.id,
54
        issue_id: issue.id
55
      }
56
57
      assert {:ok, %ProjectItem{} = project_item} =
58
               ProjectItems.create_project_item(valid_attrs)
59
60
      assert project_item.values == %{"Status" => "In Progress"}
61
      assert project_item.project_id == project.id
62
      assert project_item.issue_id == issue.id
63
    end
64
65
    test "create_project_item/1 leaves values nil when omitted" do
66
      project = project_fixture()
67
      issue = issue_fixture()
68
69
      assert {:ok, %ProjectItem{} = project_item} =
70
               ProjectItems.create_project_item(%{project_id: project.id, issue_id: issue.id})
71
72
      assert is_nil(project_item.values)
73
    end
74
75
    test "create_project_item/1 with invalid data returns error changeset" do
76
      assert {:error, %Ecto.Changeset{} = changeset} =
77
               ProjectItems.create_project_item(@invalid_attrs)
78
79
      assert %{project_id: ["can't be blank"], issue_id: ["can't be blank"]} =
80
               errors_on(changeset)
81
    end
82
83
    test "create_project_item/1 does not require values" do
84
      assert {:error, changeset} = ProjectItems.create_project_item(%{values: %{}})
85
86
      refute Map.has_key?(errors_on(changeset), :values)
87
88
      assert %{project_id: ["can't be blank"], issue_id: ["can't be blank"]} =
89
               errors_on(changeset)
90
    end
91
92
    test "create_project_item/1 requires an issue_id even with a project" do
93
      project = project_fixture()
94
95
      assert {:error, changeset} = ProjectItems.create_project_item(%{project_id: project.id})
96
      assert %{issue_id: ["can't be blank"]} = errors_on(changeset)
97
      refute Map.has_key?(errors_on(changeset), :project_id)
98
    end
99
100
    test "create_project_item/1 does not declare a foreign key constraint" do
101
      # `project_items.project_id` / `issue_id` reference their parent tables but
102
      # the changeset never calls `foreign_key_constraint/2`, so a dangling id
103
      # raises instead of returning an error changeset. Characterised, not endorsed.
104
      issue = issue_fixture()
105
106
      assert_raise Ecto.ConstraintError, fn ->
107
        ProjectItems.create_project_item(%{project_id: 2_147_483_000, issue_id: issue.id})
108
      end
109
    end
110
111
    test "create_project_item/1 allows the same issue in two projects" do
112
      issue = issue_fixture()
113
      one = project_fixture(number: 1)
114
      two = project_fixture(number: 2)
115
116
      assert {:ok, %ProjectItem{}} =
117
               ProjectItems.create_project_item(%{project_id: one.id, issue_id: issue.id})
118
119
      assert {:ok, %ProjectItem{}} =
120
               ProjectItems.create_project_item(%{project_id: two.id, issue_id: issue.id})
121
122
      assert length(ProjectItems.list_project_items()) == 2
123
    end
124
125
    test "update_project_item/2 with valid data updates the project_item" do
126
      project_item = project_item_fixture()
127
      update_attrs = %{values: %{"Status" => "Done"}}
128
129
      assert {:ok, %ProjectItem{} = project_item} =
130
               ProjectItems.update_project_item(project_item, update_attrs)
131
132
      assert project_item.values == %{"Status" => "Done"}
133
    end
134
135
    test "update_project_item/2 replaces values wholesale rather than merging" do
136
      project_item = project_item_fixture(values: %{"Status" => "Todo", "Size" => "L"})
137
138
      assert {:ok, %ProjectItem{} = updated} =
139
               ProjectItems.update_project_item(project_item, %{values: %{"Status" => "Done"}})
140
141
      assert updated.values == %{"Status" => "Done"}
142
    end
143
144
    test "update_project_item/2 with invalid data returns error changeset" do
145
      project_item = project_item_fixture()
146
147
      assert {:error, %Ecto.Changeset{}} =
148
               ProjectItems.update_project_item(project_item, @invalid_attrs)
149
150
      assert project_item == ProjectItems.get_project_item!(project_item.id)
151
    end
152
153
    test "delete_project_item/1 deletes the project_item" do
154
      project_item = project_item_fixture()
155
      assert {:ok, %ProjectItem{}} = ProjectItems.delete_project_item(project_item)
156
157
      assert_raise Ecto.NoResultsError, fn ->
158
        ProjectItems.get_project_item!(project_item.id)
159
      end
160
    end
161
162
    test "change_project_item/1 returns a project_item changeset" do
163
      project_item = project_item_fixture()
164
      assert %Ecto.Changeset{} = ProjectItems.change_project_item(project_item)
165
    end
166
167
    test "change_project_item/2 applies the given attrs" do
168
      project_item = project_item_fixture()
169
170
      changeset = ProjectItems.change_project_item(project_item, %{values: %{"Status" => "Done"}})
171
172
      assert changeset.valid?
173
      assert Ecto.Changeset.get_change(changeset, :values) == %{"Status" => "Done"}
174
    end
175
176
    test "change_project_item/2 surfaces validation errors without touching the database" do
177
      project_item = project_item_fixture()
178
179
      changeset = ProjectItems.change_project_item(project_item, %{issue_id: nil})
180
181
      refute changeset.valid?
182
      assert %{issue_id: ["can't be blank"]} = errors_on(changeset)
183
      assert project_item == ProjectItems.get_project_item!(project_item.id)
184
    end
185
  end
186
end
test/openagents/projects_test.exs modified +304

@@ -67,5 +67,309 @@ defmodule OpenAgents.ProjectsTest do

67 67
      project = project_fixture()
68 68
      assert %Ecto.Changeset{} = Projects.change_project(project)
69 69
    end
70
71
    test "change_project/2 applies the given attrs" do
72
      project = project_fixture()
73
74
      changeset = Projects.change_project(project, %{title: "Renamed"})
75
76
      assert changeset.valid?
77
      assert Ecto.Changeset.get_change(changeset, :title) == "Renamed"
78
    end
79
80
    test "change_project/2 surfaces validation errors" do
81
      project = project_fixture()
82
83
      changeset = Projects.change_project(project, %{title: nil})
84
85
      refute changeset.valid?
86
      assert %{title: ["can't be blank"]} = errors_on(changeset)
87
    end
88
89
    test "create_project/1 defaults state to open" do
90
      assert {:ok, %Project{} = project} =
91
               Projects.create_project(%{title: "Roadmap", owner: "OpenAgents"})
92
93
      assert project.state == "open"
94
    end
95
96
    test "create_project/1 assigns the next number when none is given" do
97
      assert {:ok, %Project{number: 1}} =
98
               Projects.create_project(%{title: "One", owner: "OpenAgents"})
99
100
      assert {:ok, %Project{number: 2}} =
101
               Projects.create_project(%{title: "Two", owner: "OpenAgents"})
102
103
      assert {:ok, %Project{number: 3}} =
104
               Projects.create_project(%{title: "Three", owner: "OpenAgents"})
105
    end
106
107
    test "create_project/1 honours an explicit number and continues from it" do
108
      assert {:ok, %Project{number: 10}} =
109
               Projects.create_project(%{title: "Ten", owner: "OpenAgents", number: 10})
110
111
      assert {:ok, %Project{number: 11}} =
112
               Projects.create_project(%{title: "Next", owner: "OpenAgents"})
113
    end
114
115
    test "create_project/0 refuses an empty project" do
116
      assert {:error, %Ecto.Changeset{} = changeset} = Projects.create_project()
117
      assert %{title: ["can't be blank"], owner: ["can't be blank"]} = errors_on(changeset)
118
    end
119
120
    test "create_project/1 accepts string keys" do
121
      assert {:ok, %Project{} = project} =
122
               Projects.create_project(%{"title" => "Strings", "owner" => "OpenAgents"})
123
124
      assert project.title == "Strings"
125
      assert project.owner == "OpenAgents"
126
      assert project.number == 1
127
    end
128
129
    test "get_project_by_number!/1 returns the project with the given number" do
130
      project = project_fixture(number: 7)
131
      assert Projects.get_project_by_number!(7) == project
132
    end
133
134
    test "get_project_by_number!/1 raises for an unknown number" do
135
      project_fixture(number: 7)
136
      assert_raise Ecto.NoResultsError, fn -> Projects.get_project_by_number!(8) end
137
    end
138
  end
139
140
  describe "project items" do
141
    alias OpenAgents.ProjectItems.ProjectItem
142
143
    import OpenAgents.IssuesFixtures
144
    import OpenAgents.ProjectsFixtures
145
146
    setup do
147
      project = project_fixture(number: 1)
148
      issue = issue_fixture(title: "First issue")
149
      %{project: project, issue: issue}
150
    end
151
152
    test "list_project_items/1 is scoped to one project", %{project: project, issue: issue} do
153
      other = project_fixture(number: 2)
154
155
      {:ok, mine} =
156
        Projects.create_project_item(%{"issue_number" => issue.number}, project.id)
157
158
      {:ok, _theirs} =
159
        Projects.create_project_item(%{"issue_number" => issue.number}, other.id)
160
161
      assert Enum.map(Projects.list_project_items(project.id), & &1.id) == [mine.id]
162
    end
163
164
    test "list_project_items/1 returns an empty list for a project with no items", %{
165
      project: project
166
    } do
167
      assert Projects.list_project_items(project.id) == []
168
    end
169
170
    test "get_project_item!/1 returns the item", %{project: project, issue: issue} do
171
      {:ok, item} = Projects.create_project_item(%{"issue_number" => issue.number}, project.id)
172
      assert Projects.get_project_item!(item.id) == item
173
    end
174
175
    test "get_project_item!/1 raises for an unknown id", %{project: project, issue: issue} do
176
      {:ok, item} = Projects.create_project_item(%{"issue_number" => issue.number}, project.id)
177
      assert_raise Ecto.NoResultsError, fn -> Projects.get_project_item!(item.id + 1) end
178
    end
179
180
    test "create_project_item/2 resolves the issue by number", %{project: project, issue: issue} do
181
      assert {:ok, %ProjectItem{} = item} =
182
               Projects.create_project_item(
183
                 %{"issue_number" => issue.number, "values" => %{"Status" => "Todo"}},
184
                 project.id
185
               )
186
187
      assert item.project_id == project.id
188
      assert item.issue_id == issue.id
189
      assert item.values == %{"Status" => "Todo"}
190
    end
191
192
    test "create_project_item/2 defaults values to an empty map", %{
193
      project: project,
194
      issue: issue
195
    } do
196
      assert {:ok, %ProjectItem{} = item} =
197
               Projects.create_project_item(%{"issue_number" => issue.number}, project.id)
198
199
      assert item.values == %{}
200
    end
201
202
    test "create_project_item/2 keeps values given with atom keys", %{
203
      project: project,
204
      issue: issue
205
    } do
206
      # Regression: an atom-keyed clause used to read `attrs["values"]`, which is
207
      # always nil for an atom-keyed map, so the caller's values were dropped.
208
      assert {:ok, %ProjectItem{} = item} =
209
               Projects.create_project_item(
210
                 %{issue_number: issue.number, values: %{"Status" => "Todo"}},
211
                 project.id
212
               )
213
214
      assert item.issue_id == issue.id
215
      assert item.values == %{"Status" => "Todo"}
216
    end
217
218
    test "create_project_item/2 ignores extra params from the router", %{
219
      project: project,
220
      issue: issue
221
    } do
222
      assert {:ok, %ProjectItem{} = item} =
223
               Projects.create_project_item(
224
                 %{
225
                   "username" => "OpenAgents",
226
                   "project_number" => "1",
227
                   "issue_number" => issue.number
228
                 },
229
                 project.id
230
               )
231
232
      assert item.issue_id == issue.id
233
    end
234
235
    test "create_project_item/2 raises when the issue number is unknown", %{project: project} do
236
      assert_raise Ecto.NoResultsError, fn ->
237
        Projects.create_project_item(%{"issue_number" => 9999}, project.id)
238
      end
239
    end
240
241
    test "create_project_item/2 returns an error changeset when no issue number is given", %{
242
      project: project
243
    } do
244
      # Regression: a nil issue number used to reach `Repo.get_by!` and raise
245
      # ArgumentError, which the /api/v3 controller does not rescue.
246
      assert {:error, %Ecto.Changeset{} = changeset} =
247
               Projects.create_project_item(%{"values" => %{}}, project.id)
248
249
      assert %{issue_id: ["can't be blank"]} = errors_on(changeset)
250
      assert Projects.list_project_items(project.id) == []
251
    end
252
253
    test "update_project_item/2 merges into existing values", %{
254
      project: project,
255
      issue: issue
256
    } do
257
      {:ok, item} =
258
        Projects.create_project_item(
259
          %{"issue_number" => issue.number, "values" => %{"Status" => "Todo", "Size" => "L"}},
260
          project.id
261
        )
262
263
      assert {:ok, %ProjectItem{} = updated} =
264
               Projects.update_project_item(item, %{"values" => %{"Status" => "Done"}})
265
266
      assert updated.values == %{"Status" => "Done", "Size" => "L"}
267
    end
268
269
    test "update_project_item/2 accepts atom keys", %{project: project, issue: issue} do
270
      {:ok, item} =
271
        Projects.create_project_item(
272
          %{"issue_number" => issue.number, "values" => %{"Status" => "Todo"}},
273
          project.id
274
        )
275
276
      assert {:ok, %ProjectItem{} = updated} =
277
               Projects.update_project_item(item, %{values: %{"Size" => "S"}})
278
279
      assert updated.values == %{"Status" => "Todo", "Size" => "S"}
280
    end
281
282
    test "update_project_item/2 leaves values alone when none are given", %{
283
      project: project,
284
      issue: issue
285
    } do
286
      {:ok, item} =
287
        Projects.create_project_item(
288
          %{"issue_number" => issue.number, "values" => %{"Status" => "Todo"}},
289
          project.id
290
        )
291
292
      assert {:ok, %ProjectItem{} = updated} =
293
               Projects.update_project_item(item, %{"username" => "OpenAgents"})
294
295
      assert updated.values == %{"Status" => "Todo"}
296
    end
297
298
    test "update_project_item/2 tolerates an item whose values are nil", %{
299
      project: project,
300
      issue: issue
301
    } do
302
      {:ok, item} =
303
        OpenAgents.ProjectItems.create_project_item(%{
304
          project_id: project.id,
305
          issue_id: issue.id
306
        })
307
308
      assert is_nil(item.values)
309
310
      assert {:ok, %ProjectItem{} = updated} =
311
               Projects.update_project_item(item, %{"values" => %{"Status" => "Todo"}})
312
313
      assert updated.values == %{"Status" => "Todo"}
314
    end
315
  end
316
317
  describe "project fields" do
318
    alias OpenAgents.ProjectFields.ProjectField
319
320
    import OpenAgents.ProjectsFixtures
321
322
    test "list_project_fields/1 is scoped to one project" do
323
      project = project_fixture(number: 1)
324
      other = project_fixture(number: 2)
325
326
      {:ok, mine} =
327
        Projects.create_project_field(%{
328
          name: "Status",
329
          data_type: "single_select",
330
          project_id: project.id
331
        })
332
333
      {:ok, _theirs} =
334
        Projects.create_project_field(%{
335
          name: "Status",
336
          data_type: "single_select",
337
          project_id: other.id
338
        })
339
340
      assert Enum.map(Projects.list_project_fields(project.id), & &1.id) == [mine.id]
341
    end
342
343
    test "list_project_fields/1 returns an empty list for a project with no fields" do
344
      project = project_fixture()
345
      assert Projects.list_project_fields(project.id) == []
346
    end
347
348
    test "create_project_field/1 with valid data creates a field" do
349
      project = project_fixture()
350
351
      assert {:ok, %ProjectField{} = field} =
352
               Projects.create_project_field(%{
353
                 name: "Status",
354
                 data_type: "single_select",
355
                 options: %{"values" => ["Todo", "Done"]},
356
                 project_id: project.id
357
               })
358
359
      assert field.name == "Status"
360
      assert field.data_type == "single_select"
361
      assert field.options == %{"values" => ["Todo", "Done"]}
362
      assert field.project_id == project.id
363
    end
364
365
    test "create_project_field/1 with invalid data returns error changeset" do
366
      assert {:error, %Ecto.Changeset{} = changeset} = Projects.create_project_field(%{})
367
368
      assert %{
369
               name: ["can't be blank"],
370
               data_type: ["can't be blank"],
371
               project_id: ["can't be blank"]
372
             } = errors_on(changeset)
373
    end
70 374
  end
71 375
end
test/support/fixtures/issues_fixtures.ex added +21

@@ -0,0 +1,21 @@

1
defmodule OpenAgents.IssuesFixtures do
2
  @moduledoc """
3
  This module defines test helpers for creating
4
  entities via the `OpenAgents.Issues` context.
5
  """
6
7
  @doc """
8
  Generate an issue.
9
10
  The issue number is assigned by the context, so callers get a fresh,
11
  monotonically increasing number without having to coordinate.
12
  """
13
  def issue_fixture(attrs \\ %{}) do
14
    {:ok, issue} =
15
      attrs
16
      |> Enum.into(%{title: "some title"})
17
      |> OpenAgents.Issues.create_issue()
18
19
    issue
20
  end
21
end

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