summaryrefslogtreecommitdiff
path: root/internal/handlers/buckets_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/buckets_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/buckets_test.go')
-rw-r--r--internal/handlers/buckets_test.go188
1 files changed, 188 insertions, 0 deletions
diff --git a/internal/handlers/buckets_test.go b/internal/handlers/buckets_test.go
new file mode 100644
index 0000000..822083b
--- /dev/null
+++ b/internal/handlers/buckets_test.go
@@ -0,0 +1,188 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+func TestHandleWidgetBucketsCreate_CreatesBucket(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Gutters","cycle_days":30,"pick_n":2}`
+ req := httptest.NewRequest("POST", "/api/widget/buckets", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketsCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ var bucket models.MaintenanceBucket
+ if err := json.NewDecoder(w.Body).Decode(&bucket); err != nil {
+ t.Fatal(err)
+ }
+ if bucket.Name != "Gutters" || bucket.CycleDays != 30 || bucket.PickN != 2 {
+ t.Errorf("bucket = %+v", bucket)
+ }
+}
+
+func TestHandleWidgetBucketsCreate_InvalidPickN_Returns400(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Gutters","cycle_days":30,"pick_n":0}`
+ req := httptest.NewRequest("POST", "/api/widget/buckets", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketsCreate(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleWidgetBucketsGet_ReturnsBuckets(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ if _, err := h.store.CreateBucket("Gutters", 30, 2); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/buckets", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketsGet(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var buckets []models.MaintenanceBucket
+ if err := json.NewDecoder(w.Body).Decode(&buckets); err != nil {
+ t.Fatal(err)
+ }
+ if len(buckets) != 1 || buckets[0].Name != "Gutters" {
+ t.Errorf("buckets = %+v", buckets)
+ }
+}
+
+func TestHandleWidgetBucketItemsAdd_AssignsTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ task := models.Task{ID: "task-1", Content: "Clean gutters", Priority: 1}
+ if err := h.store.CreateNativeTask(task); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"task_id":"task-1"}`
+ req := withURLParam(httptest.NewRequest("POST", "/api/widget/buckets/"+bucket.ID+"/items", strings.NewReader(body)), "id", bucket.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketItemsAdd(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ updated, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.BucketID != bucket.ID || updated.BucketState != "dormant" {
+ t.Errorf("task = %+v", updated)
+ }
+}
+
+func TestHandleWidgetBucketItemsAdd_UnknownTask_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"task_id":"nope"}`
+ req := withURLParam(httptest.NewRequest("POST", "/api/widget/buckets/"+bucket.ID+"/items", strings.NewReader(body)), "id", bucket.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketItemsAdd(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleWidgetTaskDefer_ReturnsToDormant(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ // pick_n=1 with a spare reserve item in the pool: activate one, then
+ // defer it -- the reserve is what backfill should pick, so the
+ // just-deferred item (now the pool's only dormant item at the moment
+ // selectBucketCycle would otherwise look) isn't immediately re-picked.
+ bucket, err := h.store.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "task-1", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "task-1"); err != nil {
+ t.Fatal(err)
+ }
+ // Activate task-1 via a real cycle run so it's a legit active bucket item.
+ if _, err := h.store.RunBucketCycles(time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Add the reserve item AFTER the cycle runs, so it's still dormant when task-1 is deferred.
+ if err := h.store.CreateNativeTask(models.Task{ID: "reserve", Content: "reserve", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "reserve"); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"task-1"}`
+ req := httptest.NewRequest("POST", "/api/widget/task/defer", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskDefer(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ updated, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.BucketState != "dormant" {
+ t.Errorf("BucketState = %q, want dormant", updated.BucketState)
+ }
+}
+
+func TestHandleWidgetTaskDefer_NotABucketItem_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ task := models.Task{ID: "task-1", Content: "Plain task", Priority: 1}
+ if err := h.store.CreateNativeTask(task); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"task-1"}`
+ req := httptest.NewRequest("POST", "/api/widget/task/defer", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskDefer(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}