test: add comprehensive API endpoint tests

Amolith created

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

Change summary

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, 1,626 insertions(+), 6 deletions(-)

Detailed changes

habits_test.go 🔗

@@ -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
+	})
+}

helpers_test.go 🔗

@@ -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])
+				}
+			}
+		})
+	}
+}

journal_test.go 🔗

@@ -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"
+	}
+}`

notes_test.go 🔗

@@ -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"
+		}
+	]
+}`

people_test.go 🔗

@@ -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"
+		}
+	]
+}`

ping_test.go 🔗

@@ -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)
+		}
+	}
+}

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

tasks_test.go 🔗

@@ -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"
+		}
+	]
+}`

timeline_test.go 🔗

@@ -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"
+	}
+}`

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
 	}