summaryrefslogtreecommitdiff
path: root/internal/handlers/chains_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
commitb007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch)
treea5b999b8f4c23a52ae9249675753a92a77993008 /internal/handlers/chains_test.go
parent70e6dd75130e70f2db83096c23eaa75326b183a2 (diff)
Implement linear task chains and recurring maintenance buckets
Backend, web timeline, and Android widget wiring for the last two unimplemented items from doot-future-task-scheduling-ideas. Chains: task_chains table + chain_id/chain_position/chain_unlocked on native_tasks (migration 026), WIP-limit-1 advancement hooked into CompleteNativeTask, locked tasks excluded from all date-based queries, 5 new /api/widget/chains* endpoints, an N/M position badge on web and Android widget rows. Buckets: maintenance_buckets table + bucket_id/bucket_state/ bucket_last_active_at on native_tasks (migration 027), staleness-then-priority selection scoring, a new RunBucketCycleCheck scheduler loop, 5 new endpoints including the distinct Defer action, a Defer button on web and Android widget rows. Also corrected stale "not yet approved" status headers on the two already-shipped specs this work depended on (labels/projects, budgets/ availability) -- their headers were never updated after implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/handlers/chains_test.go')
-rw-r--r--internal/handlers/chains_test.go161
1 files changed, 161 insertions, 0 deletions
diff --git a/internal/handlers/chains_test.go b/internal/handlers/chains_test.go
new file mode 100644
index 0000000..0ebcc7a
--- /dev/null
+++ b/internal/handlers/chains_test.go
@@ -0,0 +1,161 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func withURLParam(req *http.Request, key, value string) *http.Request {
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add(key, value)
+ return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+}
+
+func TestHandleWidgetChainsCreate_CreatesChain(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Ham Radio Track","tasks":["Study Technician","Pass exam"]}`
+ req := httptest.NewRequest("POST", "/api/widget/chains", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ var resp chainCreateResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.ID == "" {
+ t.Fatal("expected a generated chain id")
+ }
+
+ tasks, err := h.store.GetChainTasks(resp.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tasks) != 2 {
+ t.Fatalf("len(tasks) = %d, want 2", len(tasks))
+ }
+}
+
+func TestHandleWidgetChainsCreate_EmptyTasks_Returns400(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Empty","tasks":[]}`
+ req := httptest.NewRequest("POST", "/api/widget/chains", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsCreate(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleWidgetChainGet_ReturnsChainAndTasks(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ chain, err := h.store.CreateChain("Track", []string{"Step 1", "Step 2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("GET", "/api/widget/chains/"+chain.ID, nil), "id", chain.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainGet(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ var resp chainGetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Tasks) != 2 || resp.Chain.ID != chain.ID {
+ t.Errorf("resp = %+v", resp)
+ }
+}
+
+func TestHandleWidgetChainGet_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := withURLParam(httptest.NewRequest("GET", "/api/widget/chains/nope", nil), "id", "nope")
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainGet(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleWidgetChainsPauseResumeAbandon(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ chain, err := h.store.CreateChain("Track", []string{"Step 1", "Step 2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ pauseReq := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/"+chain.ID+"/pause", nil), "id", chain.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsPause(w, pauseReq)
+ if w.Code != http.StatusOK {
+ t.Fatalf("pause status = %d, want 200", w.Code)
+ }
+ paused, err := h.store.GetChain(chain.ID)
+ if err != nil || paused.Status != "paused" {
+ t.Fatalf("chain after pause = %+v, err=%v", paused, err)
+ }
+
+ resumeReq := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/"+chain.ID+"/resume", nil), "id", chain.ID)
+ w = httptest.NewRecorder()
+ h.HandleWidgetChainsResume(w, resumeReq)
+ if w.Code != http.StatusOK {
+ t.Fatalf("resume status = %d, want 200", w.Code)
+ }
+ resumed, err := h.store.GetChain(chain.ID)
+ if err != nil || resumed.Status != "active" {
+ t.Fatalf("chain after resume = %+v, err=%v", resumed, err)
+ }
+
+ abandonReq := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/"+chain.ID+"/abandon", nil), "id", chain.ID)
+ w = httptest.NewRecorder()
+ h.HandleWidgetChainsAbandon(w, abandonReq)
+ if w.Code != http.StatusOK {
+ t.Fatalf("abandon status = %d, want 200", w.Code)
+ }
+ abandoned, err := h.store.GetChain(chain.ID)
+ if err != nil || abandoned.Status != "abandoned" {
+ t.Fatalf("chain after abandon = %+v, err=%v", abandoned, err)
+ }
+}
+
+func TestHandleWidgetChainsPause_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/nope/pause", nil), "id", "nope")
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsPause(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}