Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
@@ -136,9 +136,10 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
|
||||
|
||||
// storeDraft stores week as the plan of the week starting weekStart in one
|
||||
// transaction and returns the plan's id. A new plan is draft. A draft
|
||||
// replaces the days of an existing plan, keeping each day's calendar
|
||||
// event id; a draft plan stays draft and a synced or changed one becomes
|
||||
// changed, since Calendar no longer shows it. A plan synced since the
|
||||
// replaces the days of an existing plan, keeping the calendar event id of
|
||||
// each day whose dinner stays the same and clearing the others'; a draft
|
||||
// plan stays draft and a synced or changed one becomes changed, since
|
||||
// Calendar no longer shows it. A plan synced since the
|
||||
// draft was asked for without confirm is left as it is, failing with
|
||||
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
|
||||
// revision before draft_week ran, or noPlan), failing with
|
||||
|
||||
+21
-10
@@ -4,7 +4,7 @@
|
||||
// a day from Monday to Sunday, and the job stores them as the week's plan,
|
||||
// replacing any draft before it unless the household edited the plan
|
||||
// while draft_week ran. A plan is draft until Push to Calendar
|
||||
// syncs it (Feature 16); an edit of a synced plan makes it changed.
|
||||
// syncs it (Push); an edit of a synced plan makes it changed.
|
||||
//
|
||||
// The Rotation check follows a plan day: MarkTried moves the Quest log
|
||||
// dinners planned on it to tried, and the household answers "Keep it in
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@@ -132,19 +133,24 @@ func ParseWeekStart(s string) (store.LocalDate, error) {
|
||||
// skills.DraftWeek with the household's harness.Config.
|
||||
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
|
||||
|
||||
// Service drafts, reads and edits weekly plans.
|
||||
// Service drafts, reads, edits and pushes weekly plans.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
draft Drafter
|
||||
cal Calendar
|
||||
logger *slog.Logger
|
||||
draftJob *jobs.Kind[DraftJob]
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
// to its dinner.
|
||||
publicURL *url.URL
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft and
|
||||
// registers the KindDraftWeek job on runner, which must not have started
|
||||
// yet. The kind is service-only: StartDraft, which keeps one draft in
|
||||
// flight per week, is the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||
// enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -152,10 +158,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slo
|
||||
return nil, errors.New("plan: nil drafter")
|
||||
case runner == nil:
|
||||
return nil, errors.New("plan: nil job runner")
|
||||
case cal == nil:
|
||||
return nil, errors.New("plan: nil calendar")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, logger: logger}
|
||||
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -344,8 +354,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
|
||||
}
|
||||
|
||||
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||
// day new when the dinner is in the Quest log. Swapping a synced plan
|
||||
// makes it changed. The week must have a plan (else ErrNotFound), date
|
||||
// day new when the dinner is in the Quest log and clearing its calendar
|
||||
// event id, so the next Push writes it. Swapping a synced plan makes it
|
||||
// changed. The week must have a plan (else ErrNotFound), date
|
||||
// must be one of its days (else *InvalidDayError), the dinner must be a
|
||||
// Regular or in the Quest log (else ErrDinnerNotFound or
|
||||
// *DinnerNotPlannableError), and it must not be on another day of the
|
||||
|
||||
@@ -2,12 +2,15 @@ package plan_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,9 +21,12 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -31,6 +37,9 @@ var (
|
||||
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
)
|
||||
|
||||
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
|
||||
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
@@ -44,6 +53,9 @@ type env struct {
|
||||
st *store.Store
|
||||
runner *jobs.Runner
|
||||
model *llm.Fake
|
||||
// google is the fake Google the service pushes to through gcal.
|
||||
google *gcaltest.Google
|
||||
gcal *gcal.Service
|
||||
// ids are the seeded dinners' ids by name.
|
||||
ids map[string]string
|
||||
|
||||
@@ -55,7 +67,8 @@ type env struct {
|
||||
// America/New_York with dinner time 17:30, the Regulars below and Quest
|
||||
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
|
||||
// with two workers. It drafts through the real harness and draft_week with
|
||||
// the scripted e.model.
|
||||
// the scripted e.model, and pushes through gcal to e.google, which starts
|
||||
// unconnected.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
@@ -111,7 +124,26 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
|
||||
e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath)
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.gcal, err = gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID,
|
||||
ClientSecret: gcaltest.ClientSecret,
|
||||
PublicURL: publicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: e.google.Endpoint(),
|
||||
RevokeURL: e.google.RevokeURL(),
|
||||
CalendarAPI: e.google.CalendarAPI(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -781,12 +813,26 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
|
||||
if got := dayNames(p); !slices.Equal(got, reversed) {
|
||||
t.Errorf("days = %v, want %v", got, reversed)
|
||||
}
|
||||
var events int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
|
||||
// Reversed, only Thursday keeps its dinner, so only Thursday
|
||||
// keeps its event id: Calendar no longer shows the others as
|
||||
// planned.
|
||||
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if events != 7 {
|
||||
t.Errorf("%d days kept their calendar event id, want 7", events)
|
||||
var kept []string
|
||||
for rows.Next() {
|
||||
var date string
|
||||
if err := rows.Scan(&date); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept = append(kept, date)
|
||||
}
|
||||
if err := errors.Join(rows.Err(), rows.Close()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(kept, []string{"2026-10-15"}) {
|
||||
t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// eventLength is how long a dinner's Calendar event lasts.
|
||||
const eventLength = 60 * time.Minute
|
||||
|
||||
// ErrPlanChanged is a Push to Calendar of a plan edited (swapped or
|
||||
// redrafted) while it was being pushed: Calendar may show the plan as it
|
||||
// was, so the push is not recorded and pushing again brings Calendar up to
|
||||
// date.
|
||||
var ErrPlanChanged = errors.New("the plan changed while it was being pushed to Calendar; push again")
|
||||
|
||||
// Calendar writes events to the household's chosen calendar, then runs
|
||||
// record while that calendar stays chosen. In production it is
|
||||
// *gcal.Service.
|
||||
type Calendar interface {
|
||||
PushEvents(ctx context.Context, events []gcal.Event, record func() error) error
|
||||
}
|
||||
|
||||
// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet
|
||||
// for event ids.
|
||||
var eventIDEncoding = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding)
|
||||
|
||||
// EventID is the Calendar event id of date of the week starting weekStart:
|
||||
// the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}", 52
|
||||
// characters of a-v and 0-9, within Google's rule for event ids (that
|
||||
// alphabet, 5 to 1024 characters). A day always has the same id, so
|
||||
// pushing it again updates its event rather than adding another.
|
||||
func EventID(weekStart, date store.LocalDate) string {
|
||||
sum := sha256.Sum256([]byte("mealquest|" + weekStart.String() + "|" + date.String()))
|
||||
return eventIDEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// pushDay is a day of a plan being pushed: its date and its event.
|
||||
type pushDay struct {
|
||||
date string
|
||||
event gcal.Event
|
||||
}
|
||||
|
||||
// Push pushes the plan of the week starting weekStart to the household's
|
||||
// chosen calendar and returns the plan as synced. Each day is an event
|
||||
// "Dinner: {name}" from the household's dinner time on that date, in the
|
||||
// household's zone, for an hour; its description is the dinner's name,
|
||||
// "Start by {time}" for a crock-pot dinner whose minutes are known, and
|
||||
// the dinner's link under the public URL. Its id is EventID.
|
||||
//
|
||||
// A push writes the days Calendar does not show as planned: those never
|
||||
// pushed, swapped, or redrafted to another dinner since, whose
|
||||
// calendar_event_id is empty. When every day has one, Calendar shows the
|
||||
// whole plan and every day is written again, which puts back anything
|
||||
// changed in Calendar since: the app is the source of truth. A stored event
|
||||
// id is always on the chosen calendar: choosing another
|
||||
// (gcal.Service.SelectCalendar) or disconnecting (gcal.Service.Disconnect)
|
||||
// clears them all, so the next push writes the whole week.
|
||||
//
|
||||
// Only a push that wrote every one of those events is recorded: the plan
|
||||
// becomes synced with calendar_synced_at now, and each day written stores
|
||||
// its event id. A push that failed part way records nothing and leaves the
|
||||
// plan's status as it was; since event ids are fixed, pushing again
|
||||
// updates the events already written rather than adding them twice. The
|
||||
// push is recorded while the calendar written to is still the chosen one.
|
||||
// A plan edited while it was pushed, or whose events were forgotten by
|
||||
// choosing another calendar or disconnecting, fails with ErrPlanChanged,
|
||||
// unrecorded.
|
||||
//
|
||||
// The week must have a plan (else ErrNotFound). The calendar's errors are
|
||||
// returned as they are: gcal.ErrNotConnected, gcal.ErrNoCalendar,
|
||||
// gcal.ErrCalendarUnavailable, gcal.ErrReconnect or *gcal.CalendarError.
|
||||
//
|
||||
// A successful Push is the household confirming the week's plan, which is
|
||||
// what the week's Supply run (Feature 17) is made from; the returned plan
|
||||
// is the one pushed.
|
||||
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
planID, revision, days, err := s.pushDays(ctx, weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
events := make([]gcal.Event, len(days))
|
||||
for i, d := range days {
|
||||
events[i] = d.event
|
||||
}
|
||||
var p Plan
|
||||
record := func() error {
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
now := store.Now().String()
|
||||
n, err := q.MarkWeekPlanSynced(ctx, db.MarkWeekPlanSyncedParams{
|
||||
CalendarSyncedAt: sql.NullString{String: now, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: planID,
|
||||
Revision: revision,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: mark %s synced: %w", planID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return ErrPlanChanged
|
||||
}
|
||||
for _, d := range days {
|
||||
n, err := q.SetPlanDayCalendarEvent(ctx, db.SetPlanDayCalendarEventParams{
|
||||
CalendarEventID: sql.NullString{String: d.event.ID, Valid: true},
|
||||
UpdatedAt: now,
|
||||
PlanID: planID,
|
||||
Date: d.date,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: record event of %s of %s: %w", d.date, planID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("plan: %s has no day %s", planID, d.date)
|
||||
}
|
||||
}
|
||||
p, err = readPlan(ctx, q, weekStart)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := s.cal.PushEvents(ctx, events, record); err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// pushDays reads, in one read transaction, the plan of the week starting
|
||||
// weekStart, its revision, and the days a push writes with their events.
|
||||
func (s *Service) pushDays(ctx context.Context, weekStart store.LocalDate) (planID string, revision int64, days []pushDay, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
row, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
}
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
rows, err := q.ListPlanDays(ctx, row.ID)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
|
||||
}
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
if len(rows) != len(dates) {
|
||||
return "", 0, nil, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
|
||||
}
|
||||
all := make([]pushDay, len(rows))
|
||||
for i, r := range rows {
|
||||
if r.Date != dates[i].String() {
|
||||
return "", 0, nil, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
|
||||
}
|
||||
event, err := s.event(zone, settings.Timezone, settings.DinnerTime, weekStart, dates[i], r.Dinner)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("plan: event of %s: %w", r.Date, err)
|
||||
}
|
||||
d := pushDay{date: r.Date, event: event}
|
||||
all[i] = d
|
||||
if !r.CalendarEventID.Valid {
|
||||
days = append(days, d)
|
||||
}
|
||||
}
|
||||
if len(days) == 0 {
|
||||
days = all
|
||||
}
|
||||
return row.ID, row.Revision, days, nil
|
||||
}
|
||||
|
||||
// event is the Calendar event of dinner d on date of the week starting
|
||||
// weekStart, at dinnerTime in zone, named zoneName.
|
||||
func (s *Service) event(zone *time.Location, zoneName, dinnerTime string, weekStart, date store.LocalDate, d db.Dinner) (gcal.Event, error) {
|
||||
start, err := household.LocalTime(zone, date, dinnerTime)
|
||||
if err != nil {
|
||||
return gcal.Event{}, err
|
||||
}
|
||||
lines := []string{d.Name}
|
||||
startBy, err := startBy(zone, date, dinnerTime, d)
|
||||
if err != nil {
|
||||
return gcal.Event{}, err
|
||||
}
|
||||
if startBy != nil {
|
||||
lines = append(lines, "Start by "+startBy.In(zone).Format("3:04 pm"))
|
||||
}
|
||||
lines = append(lines, s.publicURL.JoinPath("dinner", d.ID).String())
|
||||
return gcal.Event{
|
||||
ID: EventID(weekStart, date),
|
||||
Summary: "Dinner: " + d.Name,
|
||||
Description: strings.Join(lines, "\n"),
|
||||
Start: start,
|
||||
End: start.Add(eventLength),
|
||||
TimeZone: zoneName,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
package plan_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// dinnersCal is the calendar the tests push to.
|
||||
var dinnersCal = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
|
||||
|
||||
// connectCalendar connects the household's Google account and chooses
|
||||
// dinnersCal.
|
||||
func (e *env) connectCalendar(t *testing.T) {
|
||||
t.Helper()
|
||||
e.google.SetCalendars(0, dinnersCal)
|
||||
e.google.Connect(t, e.gcal)
|
||||
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// storePlan stores a draft plan of the week starting weekStart with the
|
||||
// dinners named names, Monday first, and returns its days' dates.
|
||||
func (e *env) storePlan(t *testing.T, weekStart store.LocalDate, names ...string) []string {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
id := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: weekStart.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
y, m, d := weekStart.Date()
|
||||
var dates []string
|
||||
for i, name := range names {
|
||||
date := store.LocalDateOf(time.Date(y, m, d+i, 0, 0, 0, 0, time.UTC)).String()
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dates = append(dates, date)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// eventIDs returns each day's stored calendar_event_id of the week
|
||||
// starting weekStart, "" for none, by date.
|
||||
func (e *env) eventIDs(t *testing.T, weekStart store.LocalDate) map[string]string {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT plan_days.date, plan_days.calendar_event_id FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id WHERE week_plans.week_start = ?`, weekStart.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var id sql.NullString
|
||||
if err := rows.Scan(&date, &id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[date] = id.String
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertUnsynced fails unless the plan of weekStart has status, no
|
||||
// calendar_synced_at and no stored event ids.
|
||||
func (e *env) assertUnsynced(t *testing.T, weekStart store.LocalDate, status string) {
|
||||
t.Helper()
|
||||
p, err := e.svc.Get(t.Context(), weekStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != status || p.CalendarSyncedAt != nil {
|
||||
t.Errorf("plan = %s synced at %v, want %s and never synced", p.Status, p.CalendarSyncedAt, status)
|
||||
}
|
||||
for date, id := range e.eventIDs(t, weekStart) {
|
||||
if id != "" {
|
||||
t.Errorf("%s stored event id %q after a failed push", date, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSecret fails when text holds the refresh token, the access token
|
||||
// or the client secret.
|
||||
func assertNoSecret(t *testing.T, what, text string) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Errorf("%s contains a secret: %s", what, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventIDIsDeterministicAndWithinGoogleRules(t *testing.T) {
|
||||
monday, tuesday := mustDate("2026-10-12"), mustDate("2026-10-13")
|
||||
id := plan.EventID(monday, tuesday)
|
||||
sum := sha256.Sum256([]byte("mealquest|2026-10-12|2026-10-13"))
|
||||
if want := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])); id != want {
|
||||
t.Errorf("EventID = %s, want %s", id, want)
|
||||
}
|
||||
// Google: base32hex characters (a-v, 0-9), 5 to 1024 of them.
|
||||
if !regexp.MustCompile(`^[a-v0-9]{5,1000}$`).MatchString(id) || len(id) != 52 {
|
||||
t.Errorf("EventID = %q (%d characters), want 52 of a-v and 0-9", id, len(id))
|
||||
}
|
||||
if again := plan.EventID(monday, tuesday); again != id {
|
||||
t.Errorf("EventID changed between calls: %s, %s", id, again)
|
||||
}
|
||||
if plan.EventID(monday, monday) == id || plan.EventID(mustDate("2026-10-19"), tuesday) == id {
|
||||
t.Error("EventID is the same for another date or week")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushCreatesOneEventPerDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, week, fullWeek...)
|
||||
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced || p.CalendarSyncedAt == nil {
|
||||
t.Errorf("pushed plan = %s synced at %v, want synced with a time", p.Status, p.CalendarSyncedAt)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts != 7 || updates != 0 {
|
||||
t.Errorf("Google saw %d inserts and %d updates, want 7 and 0", inserts, updates)
|
||||
}
|
||||
events := e.google.Events(dinnersCal.ID)
|
||||
if len(events) != 7 {
|
||||
t.Fatalf("calendar has %d events, want 7", len(events))
|
||||
}
|
||||
stored := e.eventIDs(t, week)
|
||||
for i, date := range dates {
|
||||
id := plan.EventID(week, mustDate(date))
|
||||
ev, ok := events[id]
|
||||
if !ok {
|
||||
t.Errorf("%s: no event %s", date, id)
|
||||
continue
|
||||
}
|
||||
if stored[date] != id {
|
||||
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
|
||||
}
|
||||
name := fullWeek[i]
|
||||
if ev.Summary != "Dinner: "+name || ev.Status != "confirmed" {
|
||||
t.Errorf("%s event = %q %s, want \"Dinner: %s\" confirmed", date, ev.Summary, ev.Status, name)
|
||||
}
|
||||
// Dinner time 17:30 in New York, EDT all week, for an hour.
|
||||
if want := (gcaltest.EventTime{DateTime: date + "T17:30:00-04:00", TimeZone: "America/New_York"}); ev.Start != want {
|
||||
t.Errorf("%s start = %+v, want %+v", date, ev.Start, want)
|
||||
}
|
||||
if want := (gcaltest.EventTime{DateTime: date + "T18:30:00-04:00", TimeZone: "America/New_York"}); ev.End != want {
|
||||
t.Errorf("%s end = %+v, want %+v", date, ev.End, want)
|
||||
}
|
||||
}
|
||||
// Beef Chili cooks 240 minutes in the crock pot; Pot Roast's minutes
|
||||
// are unknown, so it has no start time, and Pad Thai is not a crock-pot
|
||||
// dinner.
|
||||
for _, tc := range []struct{ date, want string }{
|
||||
{"2026-10-12", "Beef Chili\nStart by 1:30 pm\nhttps://meals.example.com/dinner/" + e.ids["Beef Chili"]},
|
||||
{"2026-10-13", "Pot Roast\nhttps://meals.example.com/dinner/" + e.ids["Pot Roast"]},
|
||||
{"2026-10-14", "Pad Thai\nhttps://meals.example.com/dinner/" + e.ids["Pad Thai"]},
|
||||
} {
|
||||
if got := events[plan.EventID(week, mustDate(tc.date))].Description; got != tc.want {
|
||||
t.Errorf("%s description = %q, want %q", tc.date, got, tc.want)
|
||||
}
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != plan.StatusSynced || got.CalendarSyncedAt == nil || *got.CalendarSyncedAt != *p.CalendarSyncedAt {
|
||||
t.Errorf("stored plan = %s synced at %v, want as pushed", got.Status, got.CalendarSyncedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAgainUpdatesOnly(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The household deletes Monday's event in Google Calendar; Google keeps
|
||||
// its id.
|
||||
monday := plan.EventID(week, week)
|
||||
e.google.CancelEvent(dinnersCal.ID, monday)
|
||||
e.google.ResetEventWrites()
|
||||
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("second Push: %v", err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
|
||||
t.Errorf("second push: %d inserts and %d updates, want 0 and 7", inserts, updates)
|
||||
}
|
||||
events := e.google.Events(dinnersCal.ID)
|
||||
if len(events) != 7 {
|
||||
t.Errorf("calendar has %d events, want 7", len(events))
|
||||
}
|
||||
if events[monday].Status != "confirmed" {
|
||||
t.Errorf("Monday's event is %s, want it put back", events[monday].Status)
|
||||
}
|
||||
if p.Status != plan.StatusSynced {
|
||||
t.Errorf("status = %s, want synced", p.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapThenPushUpdatesThatDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := e.google.Events(dinnersCal.ID)
|
||||
e.google.ResetEventWrites()
|
||||
|
||||
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
swapped, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if swapped.Status != plan.StatusChanged {
|
||||
t.Fatalf("status after swap = %s, want changed", swapped.Status)
|
||||
}
|
||||
if id := e.eventIDs(t, week)["2026-10-14"]; id != "" {
|
||||
t.Errorf("swapped day kept event id %q", id)
|
||||
}
|
||||
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 1 {
|
||||
t.Errorf("push after swap: %d inserts and %d updates, want 0 and 1", inserts, updates)
|
||||
}
|
||||
wednesday := plan.EventID(week, mustDate("2026-10-14"))
|
||||
after := e.google.Events(dinnersCal.ID)
|
||||
if got := after[wednesday]; got.Summary != "Dinner: Fish Tacos" ||
|
||||
got.Description != "Fish Tacos\nhttps://meals.example.com/dinner/"+fishTacos {
|
||||
t.Errorf("Wednesday's event = %+v, want Fish Tacos", got)
|
||||
}
|
||||
delete(before, wednesday)
|
||||
delete(after, wednesday)
|
||||
if !maps.Equal(before, after) {
|
||||
t.Errorf("events of other days changed:\nbefore %+v\nafter %+v", before, after)
|
||||
}
|
||||
if p.Status != plan.StatusSynced {
|
||||
t.Errorf("status = %s, want synced", p.Status)
|
||||
}
|
||||
if id := e.eventIDs(t, week)["2026-10-14"]; id != wednesday {
|
||||
t.Errorf("Wednesday stored event id %q, want %s", id, wednesday)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAcrossDaylightSavingTime(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
week store.LocalDate
|
||||
// utc is each day's 17:30 New York dinner as a UTC instant.
|
||||
utc []string
|
||||
}{
|
||||
// New York leaves daylight saving time at 02:00 on Sunday
|
||||
// 2026-11-01.
|
||||
{"fall back", mustDate("2026-10-26"), []string{
|
||||
"2026-10-26T21:30:00Z", "2026-10-27T21:30:00Z", "2026-10-28T21:30:00Z", "2026-10-29T21:30:00Z",
|
||||
"2026-10-30T21:30:00Z", "2026-10-31T21:30:00Z", "2026-11-01T22:30:00Z",
|
||||
}},
|
||||
// It enters it at 02:00 on Sunday 2027-03-14.
|
||||
{"spring forward", mustDate("2027-03-08"), []string{
|
||||
"2027-03-08T22:30:00Z", "2027-03-09T22:30:00Z", "2027-03-10T22:30:00Z", "2027-03-11T22:30:00Z",
|
||||
"2027-03-12T22:30:00Z", "2027-03-13T22:30:00Z", "2027-03-14T21:30:00Z",
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, tc.week, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), tc.week); err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
events := e.google.Events(dinnersCal.ID)
|
||||
for i, date := range dates {
|
||||
ev := events[plan.EventID(tc.week, mustDate(date))]
|
||||
start, err := time.Parse(time.RFC3339, ev.Start.DateTime)
|
||||
if err != nil {
|
||||
t.Fatalf("%s start %q: %v", date, ev.Start.DateTime, err)
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, ev.End.DateTime)
|
||||
if err != nil {
|
||||
t.Fatalf("%s end %q: %v", date, ev.End.DateTime, err)
|
||||
}
|
||||
if got := start.UTC().Format(time.RFC3339); got != tc.utc[i] {
|
||||
t.Errorf("%s starts %s (%s), want %s", date, got, ev.Start.DateTime, tc.utc[i])
|
||||
}
|
||||
if !strings.HasPrefix(ev.Start.DateTime, date+"T17:30:00") {
|
||||
t.Errorf("%s starts %s, want 17:30 local", date, ev.Start.DateTime)
|
||||
}
|
||||
if end.Sub(start) != time.Hour {
|
||||
t.Errorf("%s lasts %v, want 1h", date, end.Sub(start))
|
||||
}
|
||||
if ev.Start.TimeZone != "America/New_York" || ev.End.TimeZone != "America/New_York" {
|
||||
t.Errorf("%s zones = %s, %s", date, ev.Start.TimeZone, ev.End.TimeZone)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushNeedsAChosenCalendar(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
|
||||
}
|
||||
e.google.SetCalendars(0, dinnersCal)
|
||||
e.google.Connect(t, e.gcal)
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
|
||||
t.Errorf("Google saw %d event writes", inserts+updates)
|
||||
}
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
}
|
||||
|
||||
func TestPushWithoutAPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
before := e.google.TokenCalls()
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Push = %v, want ErrNotFound", err)
|
||||
}
|
||||
if n := e.google.TokenCalls() - before; n != 0 {
|
||||
t.Errorf("token endpoint called %d times for a week with no plan", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
// Google fails the fourth event on every try.
|
||||
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable)
|
||||
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
var calendar *gcal.CalendarError
|
||||
if !errors.As(err, &calendar) || !calendar.Retryable() {
|
||||
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if n := len(e.google.Events(dinnersCal.ID)); n != 3 {
|
||||
t.Fatalf("calendar has %d events, want the 3 before the failure", n)
|
||||
}
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
|
||||
// Pushing again writes every day: the three already there are updated,
|
||||
// not added twice.
|
||||
e.google.ResetEventWrites()
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("retried Push: %v", err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts != 4 || updates != 3 {
|
||||
t.Errorf("retry: %d inserts and %d updates, want 4 and 3", inserts, updates)
|
||||
}
|
||||
if n := len(e.google.Events(dinnersCal.ID)); n != 7 || p.Status != plan.StatusSynced {
|
||||
t.Errorf("after retry: %d events, plan %s; want 7, synced", n, p.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
first, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for date, name := range map[string]string{"2026-10-12": "Fish Tacos", "2026-10-13": "Ramen"} {
|
||||
if _, err := e.svc.Swap(t.Context(), week, date, e.dinner(t, name, "regular", "stovetop", 25)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e.google.FailEventWrite(2, http.StatusBadRequest)
|
||||
if _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
t.Fatal("Push succeeded, want the second event to fail")
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt {
|
||||
t.Errorf("plan = %s synced at %v, want changed, synced at %s", p.Status, *p.CalendarSyncedAt, *first.CalendarSyncedAt)
|
||||
}
|
||||
ids := e.eventIDs(t, week)
|
||||
if ids["2026-10-12"] != "" || ids["2026-10-13"] != "" {
|
||||
t.Errorf("swapped days stored event ids %v after a failed push", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushToStaleCalendar(t *testing.T) {
|
||||
for name, edit := range map[string]func(*gcaltest.Google){
|
||||
"deleted": func(g *gcaltest.Google) { g.SetCalendars(0) },
|
||||
"read-only": func(g *gcaltest.Google) {
|
||||
g.SetCalendars(0, gcaltest.Calendar{ID: dinnersCal.ID, Summary: "Dinners", AccessRole: "reader"})
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
edit(e.google)
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
|
||||
t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
|
||||
}
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushGrantNoLongerAccepted(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
e.google.RevokeGrant()
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
if !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push = %v, want ErrReconnect", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
e.assertRefused(t)
|
||||
}
|
||||
|
||||
// assertRefused fails unless the connection is recorded as reconnect
|
||||
// required: its refresh token cleared, last_sync_error the reason, and the
|
||||
// chosen calendar kept.
|
||||
func (e *env) assertRefused(t *testing.T) {
|
||||
t.Helper()
|
||||
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("connection after a refused grant: %v", err)
|
||||
}
|
||||
if row.RefreshTokenCt != nil || row.LastSyncError.String != gcal.ReconnectReason || row.CalendarID.String != dinnersCal.ID {
|
||||
t.Errorf("connection after a refused grant: token %x, last_sync_error %v, calendar %v; want no token, %q, %s",
|
||||
row.RefreshTokenCt, row.LastSyncError, row.CalendarID, gcal.ReconnectReason, dinnersCal.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedGrantKeepsPushedWeeks: a grant Google refuses clears the
|
||||
// refresh token but keeps the chosen calendar, where every pushed event
|
||||
// still is, so every pushed week stays synced and keeps its event ids.
|
||||
// Connecting again keeps the calendar, and the next push updates the
|
||||
// events in place.
|
||||
func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
e.google.RevokeGrant()
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push = %v, want ErrReconnect", err)
|
||||
}
|
||||
e.assertRefused(t)
|
||||
for _, w := range pushedWeeks {
|
||||
if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced {
|
||||
t.Errorf("plan of %s after a refused grant = %s, %v; want synced", w, p.Status, err)
|
||||
}
|
||||
e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...)
|
||||
}
|
||||
|
||||
// The refused token is not offered to Google again.
|
||||
before := e.google.TokenCalls()
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push again = %v, want ErrReconnect", err)
|
||||
}
|
||||
if n := e.google.TokenCalls() - before; n != 0 {
|
||||
t.Errorf("token endpoint called %d times after the grant was refused", n)
|
||||
}
|
||||
|
||||
e.google.Connect(t, e.gcal)
|
||||
e.google.ResetEventWrites()
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
|
||||
t.Errorf("push after connecting again: %d inserts and %d updates, want 0 and 7", inserts, updates)
|
||||
}
|
||||
e.assertWeekOn(t, dinnersCal.ID, week, dates[week], fullWeek...)
|
||||
}
|
||||
|
||||
// TestDisconnectAfterRefusedGrant: a connection whose grant Google refused
|
||||
// has no token to revoke; disconnecting deletes it and forgets every pushed
|
||||
// event, as any disconnect does.
|
||||
func TestDisconnectAfterRefusedGrant(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.google.RevokeGrant()
|
||||
if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Calendars = %v, want ErrReconnect", err)
|
||||
}
|
||||
e.assertRefused(t)
|
||||
if err := e.gcal.Disconnect(t.Context()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if n := len(e.google.Revoked()); n != 0 {
|
||||
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
|
||||
}
|
||||
if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("connection after disconnecting = %v, want none", err)
|
||||
}
|
||||
e.assertForgotten(t, week, "after disconnecting")
|
||||
}
|
||||
|
||||
func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
arrived, release := e.google.HoldEvents()
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
pushed <- err
|
||||
}()
|
||||
<-arrived
|
||||
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
if err := <-pushed; !errors.Is(err, plan.ErrPlanChanged) {
|
||||
t.Fatalf("Push = %v, want ErrPlanChanged", err)
|
||||
}
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
}
|
||||
|
||||
// kitchenCal is the calendar the tests choose after pushing to dinnersCal.
|
||||
var kitchenCal = gcaltest.Calendar{ID: "kitchen@group.calendar.google.com", Summary: "Kitchen", AccessRole: "owner"}
|
||||
|
||||
// assertWeekOn fails unless calendar shows each of names, Monday first, on
|
||||
// its day of the week starting weekStart, and each day stores that event's
|
||||
// id.
|
||||
func (e *env) assertWeekOn(t *testing.T, calendar string, weekStart store.LocalDate, dates []string, names ...string) {
|
||||
t.Helper()
|
||||
events := e.google.Events(calendar)
|
||||
stored := e.eventIDs(t, weekStart)
|
||||
for i, date := range dates {
|
||||
id := plan.EventID(weekStart, mustDate(date))
|
||||
if ev := events[id]; ev.Summary != "Dinner: "+names[i] || ev.Status != "confirmed" {
|
||||
t.Errorf("%s on %s = %q %s, want \"Dinner: %s\" confirmed", date, calendar, ev.Summary, ev.Status, names[i])
|
||||
}
|
||||
if stored[date] != id {
|
||||
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
|
||||
for _, swap := range []bool{false, true} {
|
||||
name := "push again"
|
||||
if swap {
|
||||
name = "swap then push"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, week, fullWeek...)
|
||||
first, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
onDinners := e.google.Events(dinnersCal.ID)
|
||||
|
||||
// Choosing the same calendar again keeps the plan synced.
|
||||
e.google.SetCalendars(0, dinnersCal, kitchenCal)
|
||||
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("plan after choosing the same calendar = %s, %v; want synced", p.Status, err)
|
||||
}
|
||||
|
||||
if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The plan's events are on a calendar no longer chosen.
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt {
|
||||
t.Errorf("plan after choosing another calendar = %s synced at %v, want changed", p.Status, *p.CalendarSyncedAt)
|
||||
}
|
||||
for date, id := range e.eventIDs(t, week) {
|
||||
if id != "" {
|
||||
t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id)
|
||||
}
|
||||
}
|
||||
|
||||
names := slices.Clone(fullWeek)
|
||||
if swap {
|
||||
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names[2] = "Fish Tacos"
|
||||
}
|
||||
|
||||
// A push that fails part way leaves the plan changed.
|
||||
e.google.ResetEventWrites()
|
||||
e.google.FailEventWrite(4, http.StatusBadRequest)
|
||||
if _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
t.Fatal("Push succeeded, want the fourth event to fail")
|
||||
}
|
||||
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged {
|
||||
t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err)
|
||||
}
|
||||
|
||||
p, err = e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced {
|
||||
t.Errorf("status = %s, want synced", p.Status)
|
||||
}
|
||||
if n := len(e.google.Events(kitchenCal.ID)); n != 7 {
|
||||
t.Errorf("%s has %d events, want 7", kitchenCal.ID, n)
|
||||
}
|
||||
e.assertWeekOn(t, kitchenCal.ID, week, dates, names...)
|
||||
if after := e.google.Events(dinnersCal.ID); !maps.Equal(onDinners, after) {
|
||||
t.Errorf("%s changed:\nbefore %+v\nafter %+v", dinnersCal.ID, onDinners, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChoosingAnotherCalendarWaitsForAPush chooses another calendar while a
|
||||
// push is at Google: the choice waits until the push is recorded, then
|
||||
// forgets its events, so the next push writes the whole week to the
|
||||
// calendar now chosen.
|
||||
func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, week, fullWeek...)
|
||||
e.google.SetCalendars(0, dinnersCal, kitchenCal)
|
||||
arrived, release := e.google.HoldEvents()
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
pushed <- err
|
||||
}()
|
||||
<-arrived
|
||||
selected := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID)
|
||||
selected <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-selected:
|
||||
t.Fatalf("SelectCalendar finished (%v) while a push was at Google", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := <-pushed; err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if err := <-selected; err != nil {
|
||||
t.Fatalf("SelectCalendar: %v", err)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusChanged {
|
||||
t.Errorf("plan = %s, want changed: its events are on %s", p.Status, dinnersCal.ID)
|
||||
}
|
||||
for date, id := range e.eventIDs(t, week) {
|
||||
if id != "" {
|
||||
t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id)
|
||||
}
|
||||
}
|
||||
|
||||
if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push = %s, %v; want synced", p.Status, err)
|
||||
}
|
||||
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
|
||||
}
|
||||
|
||||
// pushedWeeks are the weeks the disconnect tests push: the calendar and the
|
||||
// connection are the household's, so a disconnect concerns every one.
|
||||
var pushedWeeks = []store.LocalDate{week, mustDate("2026-10-19"), mustDate("2026-10-26")}
|
||||
|
||||
// assertForgotten fails unless the plan of weekStart is changed and no day
|
||||
// keeps an event id.
|
||||
func (e *env) assertForgotten(t *testing.T, weekStart store.LocalDate, why string) {
|
||||
t.Helper()
|
||||
p, err := e.svc.Get(t.Context(), weekStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusChanged {
|
||||
t.Errorf("plan of %s %s = %s, want changed", weekStart, why, p.Status)
|
||||
}
|
||||
for date, id := range e.eventIDs(t, weekStart) {
|
||||
if id != "" {
|
||||
t.Errorf("%s kept event id %q %s", date, id, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
draftWeek := mustDate("2026-11-02")
|
||||
e.storePlan(t, draftWeek, fullWeek...)
|
||||
|
||||
if err := e.gcal.Disconnect(t.Context()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
for _, w := range pushedWeeks {
|
||||
e.assertForgotten(t, w, "after disconnecting")
|
||||
}
|
||||
e.assertUnsynced(t, draftWeek, plan.StatusDraft)
|
||||
|
||||
// Connecting again chooses no calendar, so nothing is pushed until one
|
||||
// is chosen; then a push writes the whole week to it.
|
||||
e.google.Connect(t, e.gcal)
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||
t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
|
||||
}
|
||||
e.google.SetCalendars(0, dinnersCal, kitchenCal)
|
||||
if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, w := range pushedWeeks {
|
||||
e.assertForgotten(t, w, "after connecting again")
|
||||
p, err := e.svc.Push(t.Context(), w)
|
||||
if err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err)
|
||||
}
|
||||
e.assertWeekOn(t, kitchenCal.ID, w, dates[w], fullWeek...)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectRefusedKeepsPushedWeeks: a revoke Google refuses keeps the
|
||||
// connection and its calendar, so every pushed week stays synced to it.
|
||||
func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
|
||||
var revokeErr *gcal.RevokeError
|
||||
if err := e.gcal.Disconnect(t.Context()); !errors.As(err, &revokeErr) {
|
||||
t.Fatalf("Disconnect = %v, want a *RevokeError", err)
|
||||
}
|
||||
for _, w := range pushedWeeks {
|
||||
if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced {
|
||||
t.Errorf("plan of %s after a refused disconnect = %s, %v; want synced", w, p.Status, err)
|
||||
}
|
||||
e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectWaitsForAPush disconnects while a push is at Google: the
|
||||
// disconnect waits until the push is recorded, then forgets its events.
|
||||
func TestDisconnectWaitsForAPush(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
arrived, release := e.google.HoldEvents()
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
pushed <- err
|
||||
}()
|
||||
<-arrived
|
||||
disconnected := make(chan error, 1)
|
||||
go func() {
|
||||
disconnected <- e.gcal.Disconnect(t.Context())
|
||||
}()
|
||||
select {
|
||||
case err := <-disconnected:
|
||||
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := <-pushed; err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if err := <-disconnected; err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
e.assertForgotten(t, week, "after disconnecting")
|
||||
}
|
||||
Reference in New Issue
Block a user