package store import ( "testing" "time" ) func createDormantTask(t *testing.T, s *Store, id, bucketID string, priority int, lastActive *time.Time) { t.Helper() if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, priority, bucket_id, bucket_state, bucket_last_active_at) VALUES (?, ?, ?, ?, 'dormant', ?) `, id, id, priority, bucketID, lastActive); err != nil { t.Fatal(err) } } func TestSelectBucketCycle_PicksTopNByStalenessThenPriority(t *testing.T) { s := newNativeTasksTestStore(t) bucket, err := s.CreateBucket("Gutters", 30, 2) if err != nil { t.Fatal(err) } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) recent := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) createDormantTask(t, s, "never-activated", bucket.ID, 1, nil) createDormantTask(t, s, "stale", bucket.ID, 1, &old) createDormantTask(t, s, "recent", bucket.ID, 1, &recent) now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) n, err := s.selectBucketCycle(bucket.ID, now) if err != nil { t.Fatalf("selectBucketCycle: %v", err) } if n != 2 { t.Fatalf("activated = %d, want 2", n) } neverTask, err := s.GetNativeTaskByID("never-activated") if err != nil { t.Fatal(err) } if neverTask.BucketState != "active" { t.Errorf("never-activated should be picked first (never activated outranks any timestamp), got state=%q", neverTask.BucketState) } staleTask, err := s.GetNativeTaskByID("stale") if err != nil { t.Fatal(err) } if staleTask.BucketState != "active" { t.Errorf("stale should be picked second, got state=%q", staleTask.BucketState) } recentTask, err := s.GetNativeTaskByID("recent") if err != nil { t.Fatal(err) } if recentTask.BucketState != "dormant" { t.Errorf("recent should NOT be picked (only pick_n=2 slots), got state=%q", recentTask.BucketState) } if neverTask.DueDate == nil || !neverTask.DueDate.Equal(now.AddDate(0, 0, 30)) { t.Errorf("DueDate = %v, want now + cycle_days", neverTask.DueDate) } } func TestRunBucketCycles_RespectsCycleDays(t *testing.T) { s := newNativeTasksTestStore(t) bucket, err := s.CreateBucket("Gutters", 30, 1) if err != nil { t.Fatal(err) } createDormantTask(t, s, "item-1", bucket.ID, 1, nil) firstRun := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) n, err := s.RunBucketCycles(firstRun) if err != nil { t.Fatalf("RunBucketCycles: %v", err) } if n != 1 { t.Fatalf("first run activated = %d, want 1", n) } // Complete it so it's dormant again, then check a too-soon second run doesn't reactivate it. if err := s.CompleteNativeTask("item-1"); err != nil { t.Fatal(err) } tooSoon := firstRun.AddDate(0, 0, 10) n, err = s.RunBucketCycles(tooSoon) if err != nil { t.Fatalf("RunBucketCycles (too soon): %v", err) } if n != 0 { t.Fatalf("too-soon run activated = %d, want 0 (cycle not due yet)", n) } dueRun := firstRun.AddDate(0, 0, 31) n, err = s.RunBucketCycles(dueRun) if err != nil { t.Fatalf("RunBucketCycles (due): %v", err) } if n != 1 { t.Fatalf("due run activated = %d, want 1", n) } } func TestCompleteNativeTask_BucketItem_ReturnsToDormantWithNowTimestamp(t *testing.T) { s := newNativeTasksTestStore(t) bucket, err := s.CreateBucket("Gutters", 30, 1) if err != nil { t.Fatal(err) } createDormantTask(t, s, "item-1", bucket.ID, 1, nil) if _, err := s.selectBucketCycle(bucket.ID, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("item-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } task, err := s.GetNativeTaskByID("item-1") if err != nil { t.Fatal(err) } if task.BucketState != "dormant" { t.Errorf("BucketState = %q, want dormant", task.BucketState) } if task.DueDate != nil { t.Errorf("DueDate = %v, want nil after completing", task.DueDate) } if task.BucketLastActiveAt == nil { t.Fatal("expected BucketLastActiveAt to be set to now on completion") } if task.BucketLastActiveAt.Before(time.Now().Add(-time.Minute)) { t.Errorf("BucketLastActiveAt = %v, expected close to now (completion, not defer)", *task.BucketLastActiveAt) } } func TestDeferNativeTask_ReturnsToDormantWithPriorTimestampAndBackfills(t *testing.T) { s := newNativeTasksTestStore(t) bucket, err := s.CreateBucket("Gutters", 30, 1) if err != nil { t.Fatal(err) } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) createDormantTask(t, s, "item-1", bucket.ID, 1, &old) createDormantTask(t, s, "item-2", bucket.ID, 1, nil) if _, err := s.selectBucketCycle(bucket.ID, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)); err != nil { t.Fatal(err) } // pick_n=1: item-2 (never activated) should have been picked, item-1 still dormant. activeBefore, err := s.GetNativeTaskByID("item-2") if err != nil { t.Fatal(err) } if activeBefore.BucketState != "active" { t.Fatalf("setup: expected item-2 active, got %q", activeBefore.BucketState) } if err := s.DeferNativeTask("item-2"); err != nil { t.Fatalf("DeferNativeTask: %v", err) } deferred, err := s.GetNativeTaskByID("item-2") if err != nil { t.Fatal(err) } if deferred.BucketState != "dormant" { t.Errorf("BucketState = %q, want dormant", deferred.BucketState) } activationTime := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) if deferred.BucketLastActiveAt == nil || !deferred.BucketLastActiveAt.Equal(activationTime) { t.Errorf("BucketLastActiveAt = %v, want unchanged from activation time %v (defer must not touch it)", deferred.BucketLastActiveAt, activationTime) } // Backfill: item-1 (the only other dormant item) should now be active. backfilled, err := s.GetNativeTaskByID("item-1") if err != nil { t.Fatal(err) } if backfilled.BucketState != "active" { t.Errorf("expected defer to backfill item-1 into the freed slot, got state=%q", backfilled.BucketState) } } func TestDeferNativeTask_NotAnActiveBucketItem_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.DeferNativeTask("real-1"); err != ErrNativeTaskNotFound { t.Errorf("err = %v, want ErrNativeTaskNotFound", err) } } func TestGetUndatedNativeTasks_ExcludesDormantBucketItems(t *testing.T) { s := newNativeTasksTestStore(t) bucket, err := s.CreateBucket("Gutters", 30, 1) if err != nil { t.Fatal(err) } createDormantTask(t, s, "item-1", bucket.ID, 1, nil) undated, err := s.GetUndatedNativeTasks() if err != nil { t.Fatalf("GetUndatedNativeTasks: %v", err) } for _, task := range undated { if task.ID == "item-1" { t.Error("dormant bucket item leaked into GetUndatedNativeTasks") } } }