From 47bac304579bd5e1c3331c763ca62cc706aad0a0 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 19 Dec 2025 16:41:16 -0700 Subject: [PATCH] test: add comprehensive API endpoint tests Cover all major client methods with unit tests: - helpers_test.go: shared mock servers and assertions - Individual test files for habits, journal, notes, people, ping, tasks, timeline Include two small fixes discovered during testing: - Remove client-side name validation for tasks (API says it's optional, if impractical) - Use value receiver for Date.MarshalJSON to support non-pointer dates Assisted-by: Claude Opus 4.5 via Crush --- habits_test.go | 53 ++++++ helpers_test.go | 237 +++++++++++++++++++++++++ journal_test.go | 86 +++++++++ notes_test.go | 325 ++++++++++++++++++++++++++++++++++ people_test.go | 279 ++++++++++++++++++++++++++++++ ping_test.go | 123 +++++++++++++ tasks.go | 5 - tasks_test.go | 440 +++++++++++++++++++++++++++++++++++++++++++++++ timeline_test.go | 82 +++++++++ types.go | 2 +- 10 files changed, 1626 insertions(+), 6 deletions(-) create mode 100644 habits_test.go create mode 100644 helpers_test.go create mode 100644 journal_test.go create mode 100644 notes_test.go create mode 100644 people_test.go create mode 100644 ping_test.go create mode 100644 tasks_test.go create mode 100644 timeline_test.go diff --git a/habits_test.go b/habits_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3e7b7fda343115b7aa3ed2c256512095823d8999 --- /dev/null +++ b/habits_test.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +const habitID = "25b8ad7e-a89b-4f05-8173-83fcd2e21ae2" + +func TestTrackHabitActivity_Success(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/habits/"+habitID+"/track", `{"status": "ok"}`) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + performedOn := lunatask.NewDate(time.Date(2024, 8, 26, 0, 0, 0, 0, time.UTC)) + req := &lunatask.TrackHabitActivityRequest{PerformedOn: performedOn} + + resp, err := client.TrackHabitActivity(ctx(), habitID, req) + if err != nil { + t.Fatalf("error = %v", err) + } + + if resp == nil { + t.Fatal("returned nil") + } + + if resp.Status != "ok" { + t.Errorf("Status = %q, want %q", resp.Status, "ok") + } + + assertBodyField(t, capture.Body, "performed_on", "2024-08-26") +} + +func TestTrackHabitActivity_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + performedOn := lunatask.NewDate(time.Date(2024, 8, 26, 0, 0, 0, 0, time.UTC)) + req := &lunatask.TrackHabitActivityRequest{PerformedOn: performedOn} + _, err := c.TrackHabitActivity(ctx(), habitID, req) + + return err //nolint:wrapcheck // test helper + }) +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000000000000000000000000000000000000..26db4c59e09809e64c308f49fb3455ea87352576 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + lunatask "git.secluded.site/go-lunatask" +) + +const testToken = "test-token" + +// clientCall is a function that calls a client method and returns an error. +type clientCall func(*lunatask.Client) error + +// testErrorCases runs standard error case tests (401, 404, 500) for a client method. +func testErrorCases(t *testing.T, call clientCall) { + t.Helper() + + cases := []struct { + name string + status int + }{ + {"unauthorized", http.StatusUnauthorized}, + {"not_found", http.StatusNotFound}, + {"server_error", http.StatusInternalServerError}, + } + + for _, errorCase := range cases { + t.Run(errorCase.name, func(t *testing.T) { + t.Parallel() + + server := newStatusServer(errorCase.status) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + if err := call(client); err == nil { + t.Errorf("expected error for %d, got nil", errorCase.status) + } + }) + } +} + +// newStatusServer returns a server that responds with the given status code. +func newStatusServer(status int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + })) +} + +// newJSONServer returns a server that verifies GET method, path, auth and responds with JSON. +func newJSONServer(t *testing.T, wantPath string, body string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + t.Errorf("Method = %s, want GET", req.Method) + } + + if req.URL.Path != wantPath { + t.Errorf("Path = %s, want %s", req.URL.Path, wantPath) + } + + if auth := req.Header.Get("Authorization"); auth != "bearer "+testToken { + t.Errorf("Authorization = %q, want %q", auth, "bearer "+testToken) + } + + writer.WriteHeader(http.StatusOK) + + if _, err := writer.Write([]byte(body)); err != nil { + t.Errorf("write response: %v", err) + } + })) +} + +func ptr[T any](v T) *T { + return &v +} + +func ctx() context.Context { + return context.Background() +} + +// requestCapture holds captured request data from a mock server. +type requestCapture struct { + Body map[string]any +} + +// newBodyServer returns a server that verifies method, path, auth, Content-Type +// and captures the request body. +func newBodyServer( + t *testing.T, wantMethod, wantPath, responseBody string, +) (*httptest.Server, *requestCapture) { + t.Helper() + + capture := &requestCapture{Body: nil} + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != wantMethod { + t.Errorf("Method = %s, want %s", req.Method, wantMethod) + } + + if req.URL.Path != wantPath { + t.Errorf("Path = %s, want %s", req.URL.Path, wantPath) + } + + if auth := req.Header.Get("Authorization"); auth != "bearer "+testToken { + t.Errorf("Authorization = %q, want %q", auth, "bearer "+testToken) + } + + if contentType := req.Header.Get("Content-Type"); contentType != "application/json" { + t.Errorf("Content-Type = %q, want application/json", contentType) + } + + body, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(body, &capture.Body) + + writer.WriteHeader(http.StatusOK) + + if _, err := writer.Write([]byte(responseBody)); err != nil { + t.Errorf("write response: %v", err) + } + })) + + return server, capture +} + +// newPOSTServer returns a server that verifies POST method, path, auth and captures request body. +func newPOSTServer(t *testing.T, wantPath, responseBody string) (*httptest.Server, *requestCapture) { + t.Helper() + + return newBodyServer(t, http.MethodPost, wantPath, responseBody) +} + +// newPUTServer returns a server that verifies PUT method, path, auth and captures request body. +func newPUTServer(t *testing.T, wantPath, responseBody string) (*httptest.Server, *requestCapture) { + t.Helper() + + return newBodyServer(t, http.MethodPut, wantPath, responseBody) +} + +// newDELETEServer returns a server that verifies DELETE method, path, auth and responds with JSON. +func newDELETEServer(t *testing.T, wantPath, responseBody string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + t.Errorf("Method = %s, want DELETE", req.Method) + } + + if req.URL.Path != wantPath { + t.Errorf("Path = %s, want %s", req.URL.Path, wantPath) + } + + if auth := req.Header.Get("Authorization"); auth != "bearer "+testToken { + t.Errorf("Authorization = %q, want %q", auth, "bearer "+testToken) + } + + writer.WriteHeader(http.StatusOK) + + if _, err := writer.Write([]byte(responseBody)); err != nil { + t.Errorf("write response: %v", err) + } + })) +} + +// assertBodyField checks that a JSON body contains the expected string value. +func assertBodyField(t *testing.T, body map[string]any, key, want string) { + t.Helper() + + if body[key] != want { + t.Errorf("body.%s = %v, want %q", key, body[key], want) + } +} + +// assertBodyFieldFloat checks that a JSON body contains the expected float64 value. +// JSON unmarshals numbers to float64. +func assertBodyFieldFloat(t *testing.T, body map[string]any, key string, want float64) { + t.Helper() + + if body[key] != want { + t.Errorf("body.%s = %v, want %v", key, body[key], want) + } +} + +// filterTest describes a test case for source filtering. +type filterTest struct { + Name string + Source *string + SourceID *string + WantQuery url.Values +} + +// runFilterTests runs source filter tests against a list endpoint. +func runFilterTests( + t *testing.T, path, emptyResponse string, tests []filterTest, callAPI func(*lunatask.Client, *string, *string) error, +) { + t.Helper() + + for _, testCase := range tests { + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + + var gotQuery url.Values + + server := newJSONServer(t, path, emptyResponse) + + server.Config.Handler = http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + gotQuery = req.URL.Query() + + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(emptyResponse)) + }) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + if err := callAPI(client, testCase.Source, testCase.SourceID); err != nil { + t.Fatalf("error = %v", err) + } + + for key, want := range testCase.WantQuery { + if got := gotQuery.Get(key); got != want[0] { + t.Errorf("query[%s] = %q, want %q", key, got, want[0]) + } + } + }) + } +} diff --git a/journal_test.go b/journal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e0bc10f3dd53ec2b6970ca4961a8472a4c80b613 --- /dev/null +++ b/journal_test.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +const journalEntryID = "6aa0d6e8-3b07-40a2-ae46-1bc272a0f472" + +func TestCreateJournalEntry_Success(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/journal_entries", journalEntryResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + entryDate := lunatask.NewDate(time.Date(2021, 1, 10, 0, 0, 0, 0, time.UTC)) + + entry, err := lunatask.NewJournalEntry(entryDate). + WithContent("Today was a tough day, but on the other side..."). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if entry == nil { + t.Fatal("returned nil") + } + + if entry.ID != journalEntryID { + t.Errorf("ID = %q, want %q", entry.ID, journalEntryID) + } + + assertBodyField(t, capture.Body, "date_on", "2021-01-10") + assertBodyField(t, capture.Body, "content", "Today was a tough day, but on the other side...") +} + +func TestCreateJournalEntry_WithName(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/journal_entries", journalEntryResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + entryDate := lunatask.NewDate(time.Date(2021, 1, 10, 0, 0, 0, 0, time.UTC)) + + _, err := lunatask.NewJournalEntry(entryDate). + WithName("Custom Title"). + WithContent("Some content"). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "date_on", "2021-01-10") + assertBodyField(t, capture.Body, "name", "Custom Title") + assertBodyField(t, capture.Body, "content", "Some content") +} + +func TestCreateJournalEntry_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + entryDate := lunatask.NewDate(time.Date(2021, 1, 10, 0, 0, 0, 0, time.UTC)) + _, err := lunatask.NewJournalEntry(entryDate).Create(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +const journalEntryResponseBody = `{ + "journal_entry": { + "id": "6aa0d6e8-3b07-40a2-ae46-1bc272a0f472", + "date_on": "2021-01-10", + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + } +}` diff --git a/notes_test.go b/notes_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d92be1f8bb70ba39e2fedc9ad6dc57c51154cad0 --- /dev/null +++ b/notes_test.go @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +const ( + noteID = "5999b945-b2b1-48c6-aa72-b251b75b3c2e" + notebookID = "d1ff35f5-6b25-4199-ab6e-c19fe3fe27f1" +) + +// --- ListNotes --- + +func TestListNotes_Success(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/notes", notesResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + notes, err := client.ListNotes(ctx(), nil) + if err != nil { + t.Fatalf("error = %v", err) + } + + if len(notes) != 2 { + t.Fatalf("len = %d, want 2", len(notes)) + } + + note := notes[0] + if note.ID != noteID { + t.Errorf("ID = %q, want %q", note.ID, noteID) + } + + if note.NotebookID == nil || *note.NotebookID != notebookID { + t.Errorf("NotebookID = %v, want %q", note.NotebookID, notebookID) + } + + wantCreated := time.Date(2021, 1, 10, 10, 39, 25, 0, time.UTC) + if !note.CreatedAt.Equal(wantCreated) { + t.Errorf("CreatedAt = %v, want %v", note.CreatedAt, wantCreated) + } +} + +func TestListNotes_Filter(t *testing.T) { + t.Parallel() + + tests := []filterTest{ + { + Name: "source_only", + Source: ptr("evernote"), + SourceID: nil, + WantQuery: url.Values{"source": {"evernote"}}, + }, + { + Name: "source_and_id", + Source: ptr("evernote"), + SourceID: ptr("note-123"), + WantQuery: url.Values{"source": {"evernote"}, "source_id": {"note-123"}}, + }, + } + + runFilterTests(t, "/notes", `{"notes": []}`, tests, func(c *lunatask.Client, source, sourceID *string) error { + opts := &lunatask.ListNotesOptions{Source: source, SourceID: sourceID} + _, err := c.ListNotes(ctx(), opts) + + return err //nolint:wrapcheck // test helper + }) +} + +func TestListNotes_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.ListNotes(ctx(), nil) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- GetNote (undocumented but supported) --- + +func TestGetNote_Success(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/notes/"+noteID, singleNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + note, err := client.GetNote(ctx(), noteID) + if err != nil { + t.Fatalf("error = %v", err) + } + + if note == nil { + t.Fatal("returned nil") + } + + if note.ID != noteID { + t.Errorf("ID = %q, want %q", note.ID, noteID) + } +} + +func TestGetNote_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.GetNote(ctx(), noteID) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- CreateNote --- + +func TestCreateNote_Success(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/notes", singleNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + note, err := lunatask.NewNote(). + WithName("My note"). + WithContent("Note content"). + InNotebook(notebookID). + FromSource("evernote", "ext-123"). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if note == nil { + t.Fatal("returned nil") + } + + if note.ID != noteID { + t.Errorf("ID = %q, want %q", note.ID, noteID) + } + + assertBodyField(t, capture.Body, "name", "My note") + assertBodyField(t, capture.Body, "content", "Note content") + assertBodyField(t, capture.Body, "notebook_id", notebookID) + assertBodyField(t, capture.Body, "source", "evernote") + assertBodyField(t, capture.Body, "source_id", "ext-123") +} + +func TestCreateNote_Duplicate(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + note, err := lunatask.NewNote(). + WithName("Duplicate"). + InNotebook(notebookID). + FromSource("evernote", "dup-123"). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v, want nil", err) + } + + if note != nil { + t.Errorf("note = %v, want nil for duplicate", note) + } +} + +func TestCreateNote_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := lunatask.NewNote().WithName("x").Create(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- UpdateNote --- + +func TestUpdateNote_Success(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/notes/"+noteID, singleNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + note, err := lunatask.NewNoteUpdate(noteID). + WithName("Updated name"). + WithContent("New content"). + Update(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if note == nil { + t.Fatal("returned nil") + } + + assertBodyField(t, capture.Body, "name", "Updated name") + assertBodyField(t, capture.Body, "content", "New content") +} + +func TestUpdateNote_AllFields(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/notes/"+noteID, singleNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + dateOn := lunatask.NewDate(time.Date(2024, 5, 10, 0, 0, 0, 0, time.UTC)) + + _, err := lunatask.NewNoteUpdate(noteID). + WithName("Full update"). + WithContent("Full content"). + InNotebook("new-notebook-id"). + OnDate(dateOn). + Update(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "name", "Full update") + assertBodyField(t, capture.Body, "content", "Full content") + assertBodyField(t, capture.Body, "notebook_id", "new-notebook-id") + assertBodyField(t, capture.Body, "date_on", "2024-05-10") +} + +func TestUpdateNote_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := lunatask.NewNoteUpdate(noteID).WithName("x").Update(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- DeleteNote --- + +func TestDeleteNote_Success(t *testing.T) { + t.Parallel() + + server := newDELETEServer(t, "/notes/"+noteID, singleNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + note, err := client.DeleteNote(ctx(), noteID) + if err != nil { + t.Fatalf("error = %v", err) + } + + if note == nil { + t.Fatal("returned nil") + } + + if note.ID != noteID { + t.Errorf("ID = %q, want %q", note.ID, noteID) + } +} + +func TestDeleteNote_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.DeleteNote(ctx(), noteID) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- Test Data --- + +const singleNoteResponseBody = `{ + "note": { + "id": "5999b945-b2b1-48c6-aa72-b251b75b3c2e", + "notebook_id": "d1ff35f5-6b25-4199-ab6e-c19fe3fe27f1", + "date_on": null, + "pinned": false, + "sources": [{"source": "evernote", "source_id": "352fd2d7-cdc0-4e91-a0a3-9d6cc9d440e7"}], + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + } +}` + +const notesResponseBody = `{ + "notes": [ + { + "id": "5999b945-b2b1-48c6-aa72-b251b75b3c2e", + "notebook_id": "d1ff35f5-6b25-4199-ab6e-c19fe3fe27f1", + "date_on": null, + "pinned": false, + "sources": [{"source": "evernote", "source_id": "352fd2d7-cdc0-4e91-a0a3-9d6cc9d440e7"}], + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + }, + { + "id": "2ca8eb4c-4825-47e4-84de-2bbe0017b6c0", + "notebook_id": "fc2aa380-3320-4525-8611-7332d5060478", + "date_on": null, + "pinned": false, + "sources": [], + "created_at": "2021-01-13T08:12:25Z", + "updated_at": "2021-01-15T10:39:25Z" + } + ] +}` diff --git a/people_test.go b/people_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ec1752f3e266a834104291c1bc23c23b73e5f533 --- /dev/null +++ b/people_test.go @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +const personID = "5999b945-b2b1-48c6-aa72-b251b75b3c2e" + +// --- ListPeople --- + +func TestListPeople_Success(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/people", peopleResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + people, err := client.ListPeople(ctx(), nil) + if err != nil { + t.Fatalf("error = %v", err) + } + + if len(people) != 2 { + t.Fatalf("len = %d, want 2", len(people)) + } + + person := people[0] + if person.ID != personID { + t.Errorf("ID = %q, want %q", person.ID, personID) + } + + if person.RelationshipStrength == nil || *person.RelationshipStrength != lunatask.RelationshipBusiness { + t.Errorf("RelationshipStrength = %v, want %v", person.RelationshipStrength, lunatask.RelationshipBusiness) + } + + wantCreated := time.Date(2021, 1, 10, 10, 39, 25, 0, time.UTC) + if !person.CreatedAt.Equal(wantCreated) { + t.Errorf("CreatedAt = %v, want %v", person.CreatedAt, wantCreated) + } +} + +func TestListPeople_Filter(t *testing.T) { + t.Parallel() + + tests := []filterTest{ + { + Name: "source_only", + Source: ptr("salesforce"), + SourceID: nil, + WantQuery: url.Values{"source": {"salesforce"}}, + }, + { + Name: "source_and_id", + Source: ptr("salesforce"), + SourceID: ptr("sf-123"), + WantQuery: url.Values{"source": {"salesforce"}, "source_id": {"sf-123"}}, + }, + } + + runFilterTests(t, "/people", `{"people": []}`, tests, func(c *lunatask.Client, source, sourceID *string) error { + opts := &lunatask.ListPeopleOptions{Source: source, SourceID: sourceID} + _, err := c.ListPeople(ctx(), opts) + + return err //nolint:wrapcheck // test helper + }) +} + +func TestListPeople_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.ListPeople(ctx(), nil) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- GetPerson --- + +func TestGetPerson_Success(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/people/"+personID, singlePersonResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + person, err := client.GetPerson(ctx(), personID) + if err != nil { + t.Fatalf("error = %v", err) + } + + if person == nil { + t.Fatal("returned nil") + } + + if person.ID != personID { + t.Errorf("ID = %q, want %q", person.ID, personID) + } + + if len(person.Sources) != 1 || person.Sources[0].Source != "salesforce" { + t.Errorf("Sources = %v, want salesforce source", person.Sources) + } +} + +func TestGetPerson_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.GetPerson(ctx(), personID) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- CreatePerson --- + +func TestCreatePerson_Success(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/people", singlePersonResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + person, err := lunatask.NewPerson("John", "Doe"). + WithRelationshipStrength(lunatask.RelationshipBusiness). + FromSource("salesforce", "sf-123"). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if person == nil { + t.Fatal("returned nil") + } + + if person.ID != personID { + t.Errorf("ID = %q, want %q", person.ID, personID) + } + + assertBodyField(t, capture.Body, "first_name", "John") + assertBodyField(t, capture.Body, "last_name", "Doe") + assertBodyField(t, capture.Body, "relationship_strength", "business-contacts") + assertBodyField(t, capture.Body, "source", "salesforce") + assertBodyField(t, capture.Body, "source_id", "sf-123") +} + +func TestCreatePerson_WithCustomFields(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/people", singlePersonResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := lunatask.NewPerson("Ada", "Lovelace"). + WithRelationshipStrength(lunatask.RelationshipCloseFriend). + WithCustomField("email", "ada@example.com"). + WithCustomField("phone", "+1234567890"). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "first_name", "Ada") + assertBodyField(t, capture.Body, "last_name", "Lovelace") + assertBodyField(t, capture.Body, "relationship_strength", "close-friends") + assertBodyField(t, capture.Body, "email", "ada@example.com") + assertBodyField(t, capture.Body, "phone", "+1234567890") +} + +func TestCreatePerson_Duplicate(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + person, err := lunatask.NewPerson("Dup", "Person"). + FromSource("salesforce", "dup-123"). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v, want nil", err) + } + + if person != nil { + t.Errorf("person = %v, want nil for duplicate", person) + } +} + +func TestCreatePerson_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := lunatask.NewPerson("Test", "User").Create(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- DeletePerson --- + +func TestDeletePerson_Success(t *testing.T) { + t.Parallel() + + server := newDELETEServer(t, "/people/"+personID, singlePersonResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + person, err := client.DeletePerson(ctx(), personID) + if err != nil { + t.Fatalf("error = %v", err) + } + + if person == nil { + t.Fatal("returned nil") + } + + if person.ID != personID { + t.Errorf("ID = %q, want %q", person.ID, personID) + } +} + +func TestDeletePerson_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.DeletePerson(ctx(), personID) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- Test Data --- + +const singlePersonResponseBody = `{ + "person": { + "id": "5999b945-b2b1-48c6-aa72-b251b75b3c2e", + "relationship_strength": "business-contacts", + "sources": [{"source": "salesforce", "source_id": "352fd2d7-cdc0-4e91-a0a3-9d6cc9d440e7"}], + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + } +}` + +const peopleResponseBody = `{ + "people": [ + { + "id": "5999b945-b2b1-48c6-aa72-b251b75b3c2e", + "relationship_strength": "business-contacts", + "sources": [{"source": "salesforce", "source_id": "352fd2d7-cdc0-4e91-a0a3-9d6cc9d440e7"}], + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + }, + { + "id": "109cbf01-dba9-4136-8cf1-a02084ba3977", + "relationship_strength": "family", + "sources": [], + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + } + ] +}` diff --git a/ping_test.go b/ping_test.go new file mode 100644 index 0000000000000000000000000000000000000000..59d494a5acbe0263b8f514c7924e342cce93b576 --- /dev/null +++ b/ping_test.go @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "git.secluded.site/go-lunatask" +) + +type pingTestCase struct { + name string + statusCode int + responseBody string + wantMessage string + wantErr error + checkAuthToken string +} + +func TestPing(t *testing.T) { + t.Parallel() + + tests := []pingTestCase{ + { + name: "success", + statusCode: http.StatusOK, + responseBody: `{"message": "pong"}`, + wantMessage: "pong", + wantErr: nil, + checkAuthToken: "test-token", + }, + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + responseBody: `{"error": "unauthorized"}`, + wantMessage: "", + wantErr: lunatask.ErrUnauthorized, + checkAuthToken: "bad-token", + }, + { + name: "server_error", + statusCode: http.StatusInternalServerError, + responseBody: `{"error": "internal server error"}`, + wantMessage: "", + wantErr: lunatask.ErrServerError, + checkAuthToken: "test-token", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + runPingTest(t, testCase) + }) + } +} + +func runPingTest(t *testing.T, testCase pingTestCase) { + t.Helper() + + server := httptest.NewServer(makePingHandler(t, testCase.checkAuthToken, testCase.statusCode, testCase.responseBody)) + defer server.Close() + + client := lunatask.NewClient(testCase.checkAuthToken, lunatask.BaseURL(server.URL)) + resp, err := client.Ping(context.Background()) + + if testCase.wantErr != nil { + if err == nil { + t.Fatalf("expected error %v, got nil", testCase.wantErr) + } + + if !errors.Is(err, testCase.wantErr) { + t.Errorf("expected error %v, got %v", testCase.wantErr, err) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp == nil { + t.Fatal("expected response, got nil") + } + + if resp.Message != testCase.wantMessage { + t.Errorf("expected message %q, got %q", testCase.wantMessage, resp.Message) + } +} + +func makePingHandler(t *testing.T, expectedToken string, statusCode int, responseBody string) http.HandlerFunc { + t.Helper() + + return func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + t.Errorf("expected GET request, got %s", req.Method) + } + + if req.URL.Path != "/ping" { + t.Errorf("expected /ping path, got %s", req.URL.Path) + } + + authHeader := req.Header.Get("Authorization") + expectedAuth := "bearer " + expectedToken + + if authHeader != expectedAuth { + t.Errorf("expected Authorization header %q, got %q", expectedAuth, authHeader) + } + + writer.WriteHeader(statusCode) + + if _, err := writer.Write([]byte(responseBody)); err != nil { + t.Errorf("failed to write response: %v", err) + } + } +} diff --git a/tasks.go b/tasks.go index c1aea94b93816f617fa9b093b9b23d7ff7ad24b0..1ea062e2ebeb97b89d295b000afd6659e851d142 100644 --- a/tasks.go +++ b/tasks.go @@ -6,7 +6,6 @@ package lunatask import ( "context" - "fmt" "time" ) @@ -207,10 +206,6 @@ func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder { // Create sends the task to Lunatask. Returns (nil, nil) if a not-completed // task already exists in the same area with matching source/source_id. func (b *TaskBuilder) Create(ctx context.Context, c *Client) (*Task, error) { - if b.req.Name == "" { - return nil, fmt.Errorf("%w: name is required", ErrBadRequest) - } - return create(ctx, c, "/tasks", b.req, func(r taskResponse) Task { return r.Task }) } diff --git a/tasks_test.go b/tasks_test.go new file mode 100644 index 0000000000000000000000000000000000000000..02de979a181011a77fd90a1770a30da3da02e12a --- /dev/null +++ b/tasks_test.go @@ -0,0 +1,440 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +const ( + sourceGitHub = "github" + sourceID123 = "123" + taskID = "066b5835-184f-4fd9-be60-7d735aa94708" + areaID = "11b37775-5a34-41bb-b109-f0e5a6084799" +) + +// --- ListTasks --- + +func TestListTasks_Success(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/tasks", tasksResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + tasks, err := client.ListTasks(ctx(), nil) + if err != nil { + t.Fatalf("error = %v", err) + } + + if len(tasks) != 2 { + t.Fatalf("len = %d, want 2", len(tasks)) + } + + task := tasks[0] + if task.ID != taskID { + t.Errorf("ID = %q, want %q", task.ID, taskID) + } + + if task.AreaID == nil || *task.AreaID != areaID { + t.Errorf("AreaID = %v, want %q", task.AreaID, areaID) + } + + if task.Status == nil || *task.Status != lunatask.StatusNext { + t.Errorf("Status = %v, want %v", task.Status, lunatask.StatusNext) + } + + wantCreated := time.Date(2021, 1, 10, 10, 39, 25, 0, time.UTC) + if !task.CreatedAt.Equal(wantCreated) { + t.Errorf("CreatedAt = %v, want %v", task.CreatedAt, wantCreated) + } +} + +func TestListTasks_Filter(t *testing.T) { + t.Parallel() + + tests := []filterTest{ + { + Name: "source_only", + Source: ptr(sourceGitHub), + SourceID: nil, + WantQuery: url.Values{"source": {sourceGitHub}}, + }, + { + Name: "source_and_id", + Source: ptr(sourceGitHub), + SourceID: ptr(sourceID123), + WantQuery: url.Values{"source": {sourceGitHub}, "source_id": {sourceID123}}, + }, + } + + runFilterTests(t, "/tasks", `{"tasks": []}`, tests, func(c *lunatask.Client, source, sourceID *string) error { + opts := &lunatask.ListTasksOptions{Source: source, SourceID: sourceID} + _, err := c.ListTasks(ctx(), opts) + + return err //nolint:wrapcheck // test helper + }) +} + +func TestListTasks_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.ListTasks(ctx(), nil) + + return err //nolint:wrapcheck // test helper + }) +} + +func TestListTasks_Empty(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/tasks", `{"tasks": []}`) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + tasks, err := client.ListTasks(ctx(), nil) + if err != nil { + t.Fatalf("error = %v", err) + } + + if len(tasks) != 0 { + t.Errorf("len = %d, want 0", len(tasks)) + } +} + +// --- GetTask --- + +func TestGetTask_Success(t *testing.T) { + t.Parallel() + + server := newJSONServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + task, err := client.GetTask(ctx(), taskID) + if err != nil { + t.Fatalf("error = %v", err) + } + + if task == nil { + t.Fatal("returned nil") + } + + if task.ID != taskID { + t.Errorf("ID = %q, want %q", task.ID, taskID) + } + + if task.AreaID == nil || *task.AreaID != areaID { + t.Errorf("AreaID = %v, want %q", task.AreaID, areaID) + } + + if len(task.Sources) != 1 || task.Sources[0].Source != sourceGitHub { + t.Errorf("Sources = %v, want github source", task.Sources) + } +} + +func TestGetTask_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.GetTask(ctx(), "some-id") + + return err //nolint:wrapcheck // test helper + }) +} + +// --- CreateTask --- + +func TestCreateTask_Success(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + task, err := lunatask.NewTask("My task"). + InArea(areaID). + FromSource(sourceGitHub, sourceID123). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if task == nil { + t.Fatal("returned nil") + } + + if task.ID != taskID { + t.Errorf("ID = %q, want %q", task.ID, taskID) + } + + assertBodyField(t, capture.Body, "name", "My task") + assertBodyField(t, capture.Body, "area_id", areaID) + assertBodyField(t, capture.Body, "source", sourceGitHub) + assertBodyField(t, capture.Body, "source_id", sourceID123) +} + +func TestCreateTask_AllBuilderFields(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + goalID := "goal-uuid-here" + scheduledDate := lunatask.NewDate(time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC)) + completedTime := time.Date(2024, 3, 15, 14, 30, 0, 0, time.UTC) + + _, err := lunatask.NewTask("Full task"). + InArea(areaID). + InGoal(goalID). + WithNote("Some markdown note"). + WithStatus(lunatask.StatusNext). + WithMotivation(lunatask.MotivationWant). + WithEisenhower(1). + WithEstimate(60). + WithPriority(2). + ScheduledOn(scheduledDate). + CompletedAt(completedTime). + FromSource(sourceGitHub, sourceID123). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "name", "Full task") + assertBodyField(t, capture.Body, "area_id", areaID) + assertBodyField(t, capture.Body, "goal_id", goalID) + assertBodyField(t, capture.Body, "note", "Some markdown note") + assertBodyField(t, capture.Body, "status", "next") + assertBodyField(t, capture.Body, "motivation", "want") + assertBodyField(t, capture.Body, "scheduled_on", "2024-03-15") + assertBodyField(t, capture.Body, "completed_at", "2024-03-15T14:30:00Z") + assertBodyFieldFloat(t, capture.Body, "eisenhower", 1) + assertBodyFieldFloat(t, capture.Body, "estimate", 60) + assertBodyFieldFloat(t, capture.Body, "priority", 2) +} + +func TestCreateTask_Duplicate(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + task, err := lunatask.NewTask("Duplicate task"). + InArea(areaID). + FromSource(sourceGitHub, sourceID123). + Create(ctx(), client) + // Per AGENTS.md: Create methods return (nil, nil) for duplicates + if err != nil { + t.Fatalf("error = %v, want nil", err) + } + + if task != nil { + t.Errorf("task = %v, want nil for duplicate", task) + } +} + +func TestCreateTask_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := lunatask.NewTask("Test").InArea(areaID).Create(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- UpdateTask --- + +func TestUpdateTask_Success(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + task, err := lunatask.NewTaskUpdate(taskID). + Name("Updated name"). + WithStatus(lunatask.StatusCompleted). + WithMotivation(lunatask.MotivationMust). + Update(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if task == nil { + t.Fatal("returned nil") + } + + if task.ID != taskID { + t.Errorf("ID = %q, want %q", task.ID, taskID) + } + + assertBodyField(t, capture.Body, "name", "Updated name") + assertBodyField(t, capture.Body, "status", "completed") + assertBodyField(t, capture.Body, "motivation", "must") +} + +func TestUpdateTask_AllBuilderFields(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + scheduledDate := lunatask.NewDate(time.Date(2024, 6, 20, 0, 0, 0, 0, time.UTC)) + completedTime := time.Date(2024, 6, 20, 16, 45, 0, 0, time.UTC) + + _, err := lunatask.NewTaskUpdate(taskID). + Name("Full update"). + InArea(areaID). + InGoal("goal-id"). + WithNote("Updated note"). + WithStatus(lunatask.StatusStarted). + WithMotivation(lunatask.MotivationShould). + WithEisenhower(2). + WithEstimate(90). + WithPriority(-1). + ScheduledOn(scheduledDate). + CompletedAt(completedTime). + Update(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "name", "Full update") + assertBodyField(t, capture.Body, "area_id", areaID) + assertBodyField(t, capture.Body, "goal_id", "goal-id") + assertBodyField(t, capture.Body, "note", "Updated note") + assertBodyField(t, capture.Body, "status", "started") + assertBodyField(t, capture.Body, "motivation", "should") + assertBodyField(t, capture.Body, "scheduled_on", "2024-06-20") + assertBodyField(t, capture.Body, "completed_at", "2024-06-20T16:45:00Z") + assertBodyFieldFloat(t, capture.Body, "eisenhower", 2) + assertBodyFieldFloat(t, capture.Body, "estimate", 90) + assertBodyFieldFloat(t, capture.Body, "priority", -1) +} + +func TestUpdateTask_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := lunatask.NewTaskUpdate(taskID).Name("x").Update(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- DeleteTask --- + +func TestDeleteTask_Success(t *testing.T) { + t.Parallel() + + server := newDELETEServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + task, err := client.DeleteTask(ctx(), taskID) + if err != nil { + t.Fatalf("error = %v", err) + } + + if task == nil { + t.Fatal("returned nil") + } + + if task.ID != taskID { + t.Errorf("ID = %q, want %q", task.ID, taskID) + } +} + +func TestDeleteTask_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.DeleteTask(ctx(), taskID) + + return err //nolint:wrapcheck // test helper + }) +} + +// --- Test Data --- + +const singleTaskResponseBody = `{ + "task": { + "id": "066b5835-184f-4fd9-be60-7d735aa94708", + "area_id": "11b37775-5a34-41bb-b109-f0e5a6084799", + "goal_id": null, + "status": "next", + "previous_status": "later", + "estimate": 10, + "priority": 0, + "progress": null, + "motivation": "unknown", + "eisenhower": 0, + "sources": [{"source": "github", "source_id": "123"}], + "scheduled_on": null, + "completed_at": null, + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + } +}` + +const tasksResponseBody = `{ + "tasks": [ + { + "id": "066b5835-184f-4fd9-be60-7d735aa94708", + "area_id": "11b37775-5a34-41bb-b109-f0e5a6084799", + "goal_id": null, + "status": "next", + "previous_status": "later", + "estimate": 10, + "priority": 0, + "progress": 25, + "motivation": "unknown", + "eisenhower": 0, + "sources": [{"source": "github", "source_id": "123"}], + "scheduled_on": null, + "completed_at": null, + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + }, + { + "id": "0e0cff5c-c334-4a24-b15a-4fca6cfbf25f", + "area_id": "f557287e-ae43-4472-9478-497887362dcb", + "goal_id": null, + "status": "later", + "previous_status": null, + "estimate": 120, + "priority": 0, + "motivation": "unknown", + "eisenhower": 0, + "progress": null, + "sources": [], + "scheduled_on": null, + "completed_at": null, + "created_at": "2021-01-10T10:39:26Z", + "updated_at": "2021-01-10T10:39:26Z" + } + ] +}` diff --git a/timeline_test.go b/timeline_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d35e2884cd7050c6d078a7d756a7e02b64857171 --- /dev/null +++ b/timeline_test.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +const timelineNoteID = "6aa0d6e8-3b07-40a2-ae46-1bc272a0f472" + +func TestCreateTimelineNote_Success(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/person_timeline_notes", timelineNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + noteDate := lunatask.NewDate(time.Date(2021, 1, 10, 0, 0, 0, 0, time.UTC)) + + note, err := lunatask.NewTimelineNote(personID). + OnDate(noteDate). + WithContent("Today we talked about ..."). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + if note == nil { + t.Fatal("returned nil") + } + + if note.ID != timelineNoteID { + t.Errorf("ID = %q, want %q", note.ID, timelineNoteID) + } + + assertBodyField(t, capture.Body, "person_id", personID) + assertBodyField(t, capture.Body, "date_on", "2021-01-10") + assertBodyField(t, capture.Body, "content", "Today we talked about ...") +} + +func TestCreateTimelineNote_MinimalFields(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/person_timeline_notes", timelineNoteResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + // Per docs: date_on is optional (defaults to today), content is optional + _, err := lunatask.NewTimelineNote(personID). + Create(ctx(), client) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "person_id", personID) +} + +func TestCreateTimelineNote_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := lunatask.NewTimelineNote(personID).Create(ctx(), c) + + return err //nolint:wrapcheck // test helper + }) +} + +const timelineNoteResponseBody = `{ + "person_timeline_note": { + "id": "6aa0d6e8-3b07-40a2-ae46-1bc272a0f472", + "date_on": "2021-01-10", + "created_at": "2021-01-10T10:39:25Z", + "updated_at": "2021-01-10T10:39:25Z" + } +}` diff --git a/types.go b/types.go index e84adbeb98585180dfe91f041217f56b602fcea0..6668e607209de0e65439630e6df85d7a3290843b 100644 --- a/types.go +++ b/types.go @@ -30,7 +30,7 @@ func NewDate(t time.Time) Date { const dateFormat = "2006-01-02" // MarshalJSON implements [json.Marshaler]. -func (d *Date) MarshalJSON() ([]byte, error) { +func (d Date) MarshalJSON() ([]byte, error) { if d.IsZero() { return []byte("null"), nil }