1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package lunatask_test
6
7import (
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "testing"
12 "time"
13
14 lunatask "git.secluded.site/go-lunatask"
15)
16
17const (
18 sourceGitHub = "github"
19 sourceID123 = "123"
20 taskID = "066b5835-184f-4fd9-be60-7d735aa94708"
21 areaID = "11b37775-5a34-41bb-b109-f0e5a6084799"
22)
23
24// --- ListTasks ---
25
26func TestListTasks_Success(t *testing.T) {
27 t.Parallel()
28
29 server := newJSONServer(t, "/tasks", tasksResponseBody)
30 defer server.Close()
31
32 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
33
34 tasks, err := client.ListTasks(ctx(), nil)
35 if err != nil {
36 t.Fatalf("error = %v", err)
37 }
38
39 if len(tasks) != 2 {
40 t.Fatalf("len = %d, want 2", len(tasks))
41 }
42
43 task := tasks[0]
44 if task.ID != taskID {
45 t.Errorf("ID = %q, want %q", task.ID, taskID)
46 }
47
48 if task.AreaID == nil || *task.AreaID != areaID {
49 t.Errorf("AreaID = %v, want %q", task.AreaID, areaID)
50 }
51
52 if task.Status == nil || *task.Status != lunatask.StatusNext {
53 t.Errorf("Status = %v, want %v", task.Status, lunatask.StatusNext)
54 }
55
56 wantCreated := time.Date(2021, 1, 10, 10, 39, 25, 0, time.UTC)
57 if !task.CreatedAt.Equal(wantCreated) {
58 t.Errorf("CreatedAt = %v, want %v", task.CreatedAt, wantCreated)
59 }
60}
61
62func TestListTasks_Filter(t *testing.T) {
63 t.Parallel()
64
65 tests := []filterTest{
66 {
67 Name: "source_only",
68 Source: ptr(sourceGitHub),
69 SourceID: nil,
70 WantQuery: url.Values{"source": {sourceGitHub}},
71 },
72 {
73 Name: "source_and_id",
74 Source: ptr(sourceGitHub),
75 SourceID: ptr(sourceID123),
76 WantQuery: url.Values{"source": {sourceGitHub}, "source_id": {sourceID123}},
77 },
78 }
79
80 runFilterTests(t, "/tasks", `{"tasks": []}`, tests, func(c *lunatask.Client, source, sourceID *string) error {
81 opts := &lunatask.ListTasksOptions{Source: source, SourceID: sourceID}
82 _, err := c.ListTasks(ctx(), opts)
83
84 return err //nolint:wrapcheck // test helper
85 })
86}
87
88func TestListTasks_Errors(t *testing.T) {
89 t.Parallel()
90
91 testErrorCases(t, func(c *lunatask.Client) error {
92 _, err := c.ListTasks(ctx(), nil)
93
94 return err //nolint:wrapcheck // test helper
95 })
96}
97
98func TestListTasks_Empty(t *testing.T) {
99 t.Parallel()
100
101 server := newJSONServer(t, "/tasks", `{"tasks": []}`)
102 defer server.Close()
103
104 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
105
106 tasks, err := client.ListTasks(ctx(), nil)
107 if err != nil {
108 t.Fatalf("error = %v", err)
109 }
110
111 if len(tasks) != 0 {
112 t.Errorf("len = %d, want 0", len(tasks))
113 }
114}
115
116// --- GetTask ---
117
118func TestGetTask_Success(t *testing.T) {
119 t.Parallel()
120
121 server := newJSONServer(t, "/tasks/"+taskID, singleTaskResponseBody)
122 defer server.Close()
123
124 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
125
126 task, err := client.GetTask(ctx(), taskID)
127 if err != nil {
128 t.Fatalf("error = %v", err)
129 }
130
131 if task == nil {
132 t.Fatal("returned nil")
133 }
134
135 if task.ID != taskID {
136 t.Errorf("ID = %q, want %q", task.ID, taskID)
137 }
138
139 if task.AreaID == nil || *task.AreaID != areaID {
140 t.Errorf("AreaID = %v, want %q", task.AreaID, areaID)
141 }
142
143 if len(task.Sources) != 1 || task.Sources[0].Source != sourceGitHub {
144 t.Errorf("Sources = %v, want github source", task.Sources)
145 }
146}
147
148func TestGetTask_Errors(t *testing.T) {
149 t.Parallel()
150
151 testErrorCases(t, func(c *lunatask.Client) error {
152 _, err := c.GetTask(ctx(), "some-id")
153
154 return err //nolint:wrapcheck // test helper
155 })
156}
157
158// --- CreateTask ---
159
160func TestCreateTask_Success(t *testing.T) {
161 t.Parallel()
162
163 server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody)
164 defer server.Close()
165
166 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
167
168 task, err := client.NewTask("My task").
169 InArea(areaID).
170 FromSource(sourceGitHub, sourceID123).
171 Create(ctx())
172 if err != nil {
173 t.Fatalf("error = %v", err)
174 }
175
176 if task == nil {
177 t.Fatal("returned nil")
178 }
179
180 if task.ID != taskID {
181 t.Errorf("ID = %q, want %q", task.ID, taskID)
182 }
183
184 assertBodyField(t, capture.Body, "name", "My task")
185 assertBodyField(t, capture.Body, "area_id", areaID)
186 assertBodyField(t, capture.Body, "source", sourceGitHub)
187 assertBodyField(t, capture.Body, "source_id", sourceID123)
188}
189
190func TestCreateTask_AllBuilderFields(t *testing.T) {
191 t.Parallel()
192
193 server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody)
194 defer server.Close()
195
196 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
197 goalID := "goal-uuid-here"
198 scheduledDate := lunatask.NewDate(time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC))
199 completedTime := time.Date(2024, 3, 15, 14, 30, 0, 0, time.UTC)
200
201 _, err := client.NewTask("Full task").
202 InArea(areaID).
203 InGoal(goalID).
204 WithNote("Some markdown note").
205 WithStatus(lunatask.StatusNext).
206 WithMotivation(lunatask.MotivationWant).
207 WithEisenhower(1).
208 WithEstimate(60).
209 Priority(lunatask.PriorityHighest).
210 ScheduledOn(scheduledDate).
211 CompletedAt(completedTime).
212 FromSource(sourceGitHub, sourceID123).
213 Create(ctx())
214 if err != nil {
215 t.Fatalf("error = %v", err)
216 }
217
218 assertBodyField(t, capture.Body, "name", "Full task")
219 assertBodyField(t, capture.Body, "area_id", areaID)
220 assertBodyField(t, capture.Body, "goal_id", goalID)
221 assertBodyField(t, capture.Body, "note", "Some markdown note")
222 assertBodyField(t, capture.Body, "status", "next")
223 assertBodyField(t, capture.Body, "motivation", "want")
224 assertBodyField(t, capture.Body, "scheduled_on", "2024-03-15")
225 assertBodyField(t, capture.Body, "completed_at", "2024-03-15T14:30:00Z")
226 assertBodyFieldFloat(t, capture.Body, "eisenhower", 1)
227 assertBodyFieldFloat(t, capture.Body, "estimate", 60)
228 assertBodyFieldFloat(t, capture.Body, "priority", 2)
229}
230
231func TestCreateTask_Duplicate(t *testing.T) {
232 t.Parallel()
233
234 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
235 writer.WriteHeader(http.StatusNoContent)
236 }))
237 defer server.Close()
238
239 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
240
241 task, err := client.NewTask("Duplicate task").
242 InArea(areaID).
243 FromSource(sourceGitHub, sourceID123).
244 Create(ctx())
245 // Per AGENTS.md: Create methods return (nil, nil) for duplicates
246 if err != nil {
247 t.Fatalf("error = %v, want nil", err)
248 }
249
250 if task != nil {
251 t.Errorf("task = %v, want nil for duplicate", task)
252 }
253}
254
255func TestCreateTask_Errors(t *testing.T) {
256 t.Parallel()
257
258 testErrorCases(t, func(c *lunatask.Client) error {
259 _, err := c.NewTask("Test").InArea(areaID).Create(ctx())
260
261 return err //nolint:wrapcheck // test helper
262 })
263}
264
265// --- UpdateTask ---
266
267func TestUpdateTask_Success(t *testing.T) {
268 t.Parallel()
269
270 server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody)
271 defer server.Close()
272
273 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
274
275 task, err := client.NewTaskUpdate(taskID).
276 Name("Updated name").
277 WithStatus(lunatask.StatusCompleted).
278 WithMotivation(lunatask.MotivationMust).
279 Update(ctx())
280 if err != nil {
281 t.Fatalf("error = %v", err)
282 }
283
284 if task == nil {
285 t.Fatal("returned nil")
286 }
287
288 if task.ID != taskID {
289 t.Errorf("ID = %q, want %q", task.ID, taskID)
290 }
291
292 assertBodyField(t, capture.Body, "name", "Updated name")
293 assertBodyField(t, capture.Body, "status", "completed")
294 assertBodyField(t, capture.Body, "motivation", "must")
295}
296
297func TestUpdateTask_AllBuilderFields(t *testing.T) {
298 t.Parallel()
299
300 server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody)
301 defer server.Close()
302
303 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
304 scheduledDate := lunatask.NewDate(time.Date(2024, 6, 20, 0, 0, 0, 0, time.UTC))
305 completedTime := time.Date(2024, 6, 20, 16, 45, 0, 0, time.UTC)
306
307 _, err := client.NewTaskUpdate(taskID).
308 Name("Full update").
309 InArea(areaID).
310 InGoal("goal-id").
311 WithNote("Updated note").
312 WithStatus(lunatask.StatusInProgress).
313 WithMotivation(lunatask.MotivationShould).
314 WithEisenhower(2).
315 WithEstimate(90).
316 Priority(lunatask.PriorityLow).
317 ScheduledOn(scheduledDate).
318 CompletedAt(completedTime).
319 Update(ctx())
320 if err != nil {
321 t.Fatalf("error = %v", err)
322 }
323
324 assertBodyField(t, capture.Body, "name", "Full update")
325 assertBodyField(t, capture.Body, "area_id", areaID)
326 assertBodyField(t, capture.Body, "goal_id", "goal-id")
327 assertBodyField(t, capture.Body, "note", "Updated note")
328 assertBodyField(t, capture.Body, "status", "started")
329 assertBodyField(t, capture.Body, "motivation", "should")
330 assertBodyField(t, capture.Body, "scheduled_on", "2024-06-20")
331 assertBodyField(t, capture.Body, "completed_at", "2024-06-20T16:45:00Z")
332 assertBodyFieldFloat(t, capture.Body, "eisenhower", 2)
333 assertBodyFieldFloat(t, capture.Body, "estimate", 90)
334 assertBodyFieldFloat(t, capture.Body, "priority", -1)
335}
336
337func TestUpdateTask_Errors(t *testing.T) {
338 t.Parallel()
339
340 testErrorCases(t, func(c *lunatask.Client) error {
341 _, err := c.NewTaskUpdate(taskID).Name("x").Update(ctx())
342
343 return err //nolint:wrapcheck // test helper
344 })
345}
346
347// --- DeleteTask ---
348
349func TestDeleteTask_Success(t *testing.T) {
350 t.Parallel()
351
352 server := newDELETEServer(t, "/tasks/"+taskID, singleTaskResponseBody)
353 defer server.Close()
354
355 client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
356
357 task, err := client.DeleteTask(ctx(), taskID)
358 if err != nil {
359 t.Fatalf("error = %v", err)
360 }
361
362 if task == nil {
363 t.Fatal("returned nil")
364 }
365
366 if task.ID != taskID {
367 t.Errorf("ID = %q, want %q", task.ID, taskID)
368 }
369}
370
371func TestDeleteTask_Errors(t *testing.T) {
372 t.Parallel()
373
374 testErrorCases(t, func(c *lunatask.Client) error {
375 _, err := c.DeleteTask(ctx(), taskID)
376
377 return err //nolint:wrapcheck // test helper
378 })
379}
380
381// --- Test Data ---
382
383const singleTaskResponseBody = `{
384 "task": {
385 "id": "066b5835-184f-4fd9-be60-7d735aa94708",
386 "area_id": "11b37775-5a34-41bb-b109-f0e5a6084799",
387 "goal_id": null,
388 "status": "next",
389 "previous_status": "later",
390 "estimate": 10,
391 "priority": 0,
392 "progress": null,
393 "motivation": "unknown",
394 "eisenhower": 0,
395 "sources": [{"source": "github", "source_id": "123"}],
396 "scheduled_on": null,
397 "completed_at": null,
398 "created_at": "2021-01-10T10:39:25Z",
399 "updated_at": "2021-01-10T10:39:25Z"
400 }
401}`
402
403const tasksResponseBody = `{
404 "tasks": [
405 {
406 "id": "066b5835-184f-4fd9-be60-7d735aa94708",
407 "area_id": "11b37775-5a34-41bb-b109-f0e5a6084799",
408 "goal_id": null,
409 "status": "next",
410 "previous_status": "later",
411 "estimate": 10,
412 "priority": 0,
413 "progress": 25,
414 "motivation": "unknown",
415 "eisenhower": 0,
416 "sources": [{"source": "github", "source_id": "123"}],
417 "scheduled_on": null,
418 "completed_at": null,
419 "created_at": "2021-01-10T10:39:25Z",
420 "updated_at": "2021-01-10T10:39:25Z"
421 },
422 {
423 "id": "0e0cff5c-c334-4a24-b15a-4fca6cfbf25f",
424 "area_id": "f557287e-ae43-4472-9478-497887362dcb",
425 "goal_id": null,
426 "status": "later",
427 "previous_status": null,
428 "estimate": 120,
429 "priority": 0,
430 "motivation": "unknown",
431 "eisenhower": 0,
432 "progress": null,
433 "sources": [],
434 "scheduled_on": null,
435 "completed_at": null,
436 "created_at": "2021-01-10T10:39:26Z",
437 "updated_at": "2021-01-10T10:39:26Z"
438 }
439 ]
440}`