gogcli/internal/cmd/tasks_repeat_test.go
Peter Steinberger 2fe9d9524e feat(tasks): add recur aliases and RRULE support for task repeats (#408)
- add --recur and --recur-rrule aliases for repeat materialization
- support RRULE FREQ with optional INTERVAL when generating concrete task occurrences
- document the materialized repeat behavior in README and changelog

Co-authored-by: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
2026-03-08 21:22:02 +00:00

409 lines
12 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/tasks/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestTasksAddCmd_RepeatCreatesMultiple(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })
var (
counter int32
gotTitles []string
gotDue []string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{"id": "l1", "title": "One"},
},
})
return
}
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if title, ok := body["title"].(string); ok {
gotTitles = append(gotTitles, title)
}
if due, ok := body["due"].(string); ok {
gotDue = append(gotDue, due)
}
id := atomic.AddInt32(&counter, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": fmt.Sprintf("t%d", id),
"title": body["title"],
"due": body["due"],
})
}))
defer srv.Close()
svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
if err := runKong(t, &TasksAddCmd{}, []string{
"l1",
"--title", "Task",
"--due", "2025-01-01",
"--repeat", "daily",
"--repeat-count", "3",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if len(gotTitles) != 3 || len(gotDue) != 3 {
t.Fatalf("expected 3 tasks, got titles=%d due=%d", len(gotTitles), len(gotDue))
}
if gotTitles[0] != "Task (#1/3)" || gotTitles[2] != "Task (#3/3)" {
t.Fatalf("unexpected titles: %#v", gotTitles)
}
if gotDue[0] != "2025-01-01T00:00:00Z" || gotDue[1] != "2025-01-02T00:00:00Z" || gotDue[2] != "2025-01-03T00:00:00Z" {
t.Fatalf("unexpected due schedule: %#v", gotDue)
}
var parsed struct {
Count int `json:"count"`
Tasks []struct {
ID string `json:"id"`
} `json:"tasks"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}
if parsed.Count != 3 || len(parsed.Tasks) != 3 {
t.Fatalf("unexpected repeat output: %#v", parsed)
}
}
func TestTasksAddCmd_RepeatUntilDateOnlyWithTimeDue(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })
var gotDue []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{"id": "l1", "title": "One"},
},
})
return
}
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if due, ok := body["due"].(string); ok {
gotDue = append(gotDue, due)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"due": body["due"],
})
}))
defer srv.Close()
svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
_ = captureStdout(t, func() {
if err := runKong(t, &TasksAddCmd{}, []string{
"l1",
"--title", "Task",
"--due", "2025-01-01T10:00:00Z",
"--repeat", "daily",
"--repeat-until", "2025-01-03",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if len(gotDue) != 3 {
t.Fatalf("expected 3 tasks, got due=%d", len(gotDue))
}
if gotDue[0] != "2025-01-01T10:00:00Z" || gotDue[1] != "2025-01-02T10:00:00Z" || gotDue[2] != "2025-01-03T10:00:00Z" {
t.Fatalf("unexpected due schedule: %#v", gotDue)
}
}
func TestTasksAddCmd_RecurAliasCreatesMultiple(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })
var gotDue []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{"id": "l1", "title": "One"},
},
})
return
}
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if due, ok := body["due"].(string); ok {
gotDue = append(gotDue, due)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"due": body["due"],
})
}))
defer srv.Close()
svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
_ = captureStdout(t, func() {
if err := runKong(t, &TasksAddCmd{}, []string{
"l1",
"--title", "Task",
"--due", "2025-01-01",
"--recur", "weekly",
"--repeat-count", "3",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if len(gotDue) != 3 {
t.Fatalf("expected 3 tasks, got due=%d", len(gotDue))
}
if gotDue[0] != "2025-01-01T00:00:00Z" || gotDue[1] != "2025-01-08T00:00:00Z" || gotDue[2] != "2025-01-15T00:00:00Z" {
t.Fatalf("unexpected due schedule: %#v", gotDue)
}
}
func TestTasksAddCmd_RecurRRuleIntervalCreatesMultiple(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })
var gotDue []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{"id": "l1", "title": "One"},
},
})
return
}
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if due, ok := body["due"].(string); ok {
gotDue = append(gotDue, due)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"due": body["due"],
})
}))
defer srv.Close()
svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
_ = captureStdout(t, func() {
if err := runKong(t, &TasksAddCmd{}, []string{
"l1",
"--title", "Task",
"--due", "2025-01-01",
"--recur-rrule", "FREQ=DAILY;INTERVAL=2",
"--repeat-count", "3",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if len(gotDue) != 3 {
t.Fatalf("expected 3 tasks, got due=%d", len(gotDue))
}
if gotDue[0] != "2025-01-01T00:00:00Z" || gotDue[1] != "2025-01-03T00:00:00Z" || gotDue[2] != "2025-01-05T00:00:00Z" {
t.Fatalf("unexpected due schedule: %#v", gotDue)
}
}
func TestParseTaskDate_FlexibleFormats(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
value string
wantErr bool
wantHasTime bool
}{
{name: "date", value: "2026-02-13", wantHasTime: false},
{name: "rfc3339", value: "2026-02-13T10:20:30Z", wantHasTime: true},
{name: "local minutes T", value: "2026-02-13T10:20", wantHasTime: true},
{name: "local seconds space", value: "2026-02-13 10:20:30", wantHasTime: true},
{name: "iso offset", value: "2026-02-13T10:20:30-0800", wantHasTime: true},
{name: "invalid", value: "nope", wantErr: true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, gotHasTime, err := parseTaskDate(tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error")
}
return
}
if err != nil {
t.Fatalf("parseTaskDate: %v", err)
}
if gotHasTime != tc.wantHasTime {
t.Fatalf("hasTime=%v want %v", gotHasTime, tc.wantHasTime)
}
})
}
}
func TestParseRepeatRRule(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
raw string
wantUnit repeatUnit
wantInterval int
wantErr bool
}{
{name: "freq only", raw: "FREQ=WEEKLY", wantUnit: repeatWeekly, wantInterval: 1},
{name: "rrule prefix and interval", raw: "RRULE:FREQ=MONTHLY;INTERVAL=2", wantUnit: repeatMonthly, wantInterval: 2},
{name: "missing freq", raw: "INTERVAL=2", wantErr: true},
{name: "invalid interval", raw: "FREQ=DAILY;INTERVAL=0", wantErr: true},
{name: "duplicate freq", raw: "FREQ=DAILY;FREQ=WEEKLY", wantErr: true},
{name: "duplicate interval", raw: "FREQ=DAILY;INTERVAL=1;INTERVAL=2", wantErr: true},
{name: "unsupported key", raw: "FREQ=WEEKLY;BYDAY=MO", wantErr: true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
gotUnit, gotInterval, err := parseRepeatRRule(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error")
}
return
}
if err != nil {
t.Fatalf("parseRepeatRRule: %v", err)
}
if gotUnit != tc.wantUnit || gotInterval != tc.wantInterval {
t.Fatalf("got unit=%v interval=%d want unit=%v interval=%d", gotUnit, gotInterval, tc.wantUnit, tc.wantInterval)
}
})
}
}