test(integration): add live API validation tests

Amolith created

Adds build-tagged integration tests that run against the live Lunatask
API. Requires LUNATASK_API_KEY and LUNATASK_TEST_AREA environment
variables.

Tests cover ping, list operations, and full create/read/update/delete
round-trips for tasks and notes.

Also excludes dupl and goconst linters from test files and updates
coverage badge.

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>

Change summary

.golangci.yaml      |   7 +
README.md           |   2 
Taskfile.yaml       |   5 +
integration_test.go | 170 +++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 183 insertions(+), 1 deletion(-)

Detailed changes

.golangci.yaml 🔗

@@ -132,3 +132,10 @@ linters:
         - db  # database
         - tx  # transaction
         - fn  # function
+
+  exclusions:
+    rules:
+      - path: _test\.go
+        linters:
+          - dupl
+          - goconst

README.md 🔗

@@ -9,7 +9,7 @@ SPDX-License-Identifier: CC0-1.0
 [![Godocs.io Reference](https://godocs.io/git.secluded.site/go-lunatask?status.svg)][godocs.io]
 [![Pkg.go.dev Reference](https://pkg.go.dev/badge/git.secluded.site/go-lunatask.svg)][pkg.go.dev]
 [![Go Report Card](https://goreportcard.com/badge/git.secluded.site/go-lunatask)](https://goreportcard.com/report/git.secluded.site/go-lunatask)
-![Test coverage](https://img.shields.io/badge/coverage-84.6%25-brightgreen)
+![Test coverage](https://img.shields.io/badge/coverage-84.9%25-brightgreen)
 [![REUSE compatibility](https://api.reuse.software/badge/git.secluded.site/go-lunatask)](https://api.reuse.software/info/git.secluded.site/go-lunatask)
 [![Liberapay donation status](https://img.shields.io/liberapay/receives/Amolith.svg?logo=liberapay)](https://liberapay.com/Amolith/)
 

Taskfile.yaml 🔗

@@ -48,6 +48,11 @@ tasks:
     cmds:
       - go test -v ./...
 
+  integration:
+    desc: Run integration tests against real API (requires LUNATASK_API_KEY, LUNATASK_TEST_AREA)
+    cmds:
+      - go test -v -tags=integration ./...
+
   badge:
     desc: Update coverage badge
     cmds:

integration_test.go 🔗

@@ -0,0 +1,170 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+//go:build integration
+
+package lunatask_test
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	lunatask "git.secluded.site/go-lunatask"
+)
+
+var (
+	integrationClient *lunatask.Client
+	testAreaID        string
+)
+
+func TestMain(m *testing.M) {
+	apiKey := os.Getenv("LUNATASK_API_KEY")
+	if apiKey == "" {
+		panic("LUNATASK_API_KEY environment variable required for integration tests")
+	}
+
+	testAreaID = os.Getenv("LUNATASK_TEST_AREA")
+	if testAreaID == "" {
+		panic("LUNATASK_TEST_AREA environment variable required for integration tests")
+	}
+
+	integrationClient = lunatask.NewClient(apiKey)
+
+	os.Exit(m.Run())
+}
+
+// testName generates a unique name for test entities.
+func testName(prefix string) string {
+	return prefix + "-" + time.Now().Format("20060102-150405.000")
+}
+
+func TestIntegration_Ping(t *testing.T) {
+	resp, err := integrationClient.Ping(ctx())
+	if err != nil {
+		t.Fatalf("Ping() error = %v", err)
+	}
+
+	t.Logf("Ping response: %+v", resp)
+}
+
+func TestIntegration_ListTasks(t *testing.T) {
+	tasks, err := integrationClient.ListTasks(ctx(), nil)
+	if err != nil {
+		t.Fatalf("ListTasks() error = %v", err)
+	}
+
+	t.Logf("Found %d tasks", len(tasks))
+}
+
+func TestIntegration_ListNotes(t *testing.T) {
+	notes, err := integrationClient.ListNotes(ctx(), nil)
+	if err != nil {
+		t.Fatalf("ListNotes() error = %v", err)
+	}
+
+	t.Logf("Found %d notes", len(notes))
+}
+
+func TestIntegration_ListPeople(t *testing.T) {
+	people, err := integrationClient.ListPeople(ctx(), nil)
+	if err != nil {
+		t.Fatalf("ListPeople() error = %v", err)
+	}
+
+	t.Logf("Found %d people", len(people))
+}
+
+func TestIntegration_TaskRoundTrip(t *testing.T) {
+	name := testName("integration-task")
+
+	task, err := integrationClient.NewTask(name).
+		InArea(testAreaID).
+		WithStatus(lunatask.StatusNext).
+		WithEstimate(15).
+		Create(ctx())
+	if err != nil {
+		t.Fatalf("NewTask().Create() error = %v", err)
+	}
+
+	if task == nil {
+		t.Fatal("NewTask().Create() returned nil (duplicate?)")
+	}
+
+	t.Cleanup(func() {
+		if _, err := integrationClient.DeleteTask(ctx(), task.ID); err != nil {
+			t.Errorf("DeleteTask() cleanup error = %v", err)
+		}
+	})
+
+	t.Logf("Created task: %s", task.ID)
+
+	// Verify we can fetch it
+	fetched, err := integrationClient.GetTask(ctx(), task.ID)
+	if err != nil {
+		t.Fatalf("GetTask() error = %v", err)
+	}
+
+	if fetched.ID != task.ID {
+		t.Errorf("GetTask().ID = %s, want %s", fetched.ID, task.ID)
+	}
+
+	// Update it
+	updated, err := integrationClient.NewTaskUpdate(task.ID).
+		WithEstimate(30).
+		Update(ctx())
+	if err != nil {
+		t.Fatalf("NewTaskUpdate().Update() error = %v", err)
+	}
+
+	if updated.Estimate == nil || *updated.Estimate != 30 {
+		t.Errorf("updated estimate = %v, want 30", updated.Estimate)
+	}
+
+	t.Logf("Updated task estimate to 30 minutes")
+}
+
+func TestIntegration_NoteRoundTrip(t *testing.T) {
+	name := testName("integration-note")
+
+	note, err := integrationClient.NewNote().
+		WithName(name).
+		WithContent("# Test Note\n\nThis is a test.").
+		Create(ctx())
+	if err != nil {
+		t.Fatalf("NewNote().Create() error = %v", err)
+	}
+
+	if note == nil {
+		t.Fatal("NewNote().Create() returned nil (duplicate?)")
+	}
+
+	t.Cleanup(func() {
+		if _, err := integrationClient.DeleteNote(ctx(), note.ID); err != nil {
+			t.Errorf("DeleteNote() cleanup error = %v", err)
+		}
+	})
+
+	t.Logf("Created note: %s", note.ID)
+
+	// Verify we can fetch it
+	fetched, err := integrationClient.GetNote(ctx(), note.ID)
+	if err != nil {
+		t.Fatalf("GetNote() error = %v", err)
+	}
+
+	if fetched.ID != note.ID {
+		t.Errorf("GetNote().ID = %s, want %s", fetched.ID, note.ID)
+	}
+
+	// Update it
+	updated, err := integrationClient.NewNoteUpdate(note.ID).
+		WithContent("# Updated\n\nNew content.").
+		Update(ctx())
+	if err != nil {
+		t.Fatalf("NewNoteUpdate().Update() error = %v", err)
+	}
+
+	t.Logf("Updated note: %s", updated.ID)
+}