tasks_test.go

  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}`