Detailed changes
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+ })
+}
@@ -0,0 +1,237 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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])
+ }
+ }
+ })
+ }
+}
@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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"
+ }
+}`
@@ -0,0 +1,325 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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"
+ }
+ ]
+}`
@@ -0,0 +1,279 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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"
+ }
+ ]
+}`
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+ }
+}
@@ -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 })
}
@@ -0,0 +1,440 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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"
+ }
+ ]
+}`
@@ -0,0 +1,82 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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"
+ }
+}`
@@ -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
}