Files
MealQuest/internal/plan/push_test.go
T
dev_agentandClaude Opus 5.5 26fde27f54
CI / ci (push) Successful in 4m10s
CI / ci (pull_request) Successful in 4m25s
Forget pushed event ids when another calendar is chosen (Feature 16)
Choosing a calendar other than the stored one now clears every plan
day's calendar_event_id and edits each plan that had one (revision
raised, synced -> changed) in the same transaction as the choice, so a
stored event id is always on the chosen calendar and the next push
writes the whole week there.

PushEvents now runs the push's record under the same account-lock hold
as its event writes, so the chosen calendar cannot change between the
writes and the sync bookkeeping; a push that read a plan before the
choice is refused by its revision check (ErrPlanChanged).

Fixes review finding F-01 on task 5885d2b1-cf06-44ff-abf3-242d7763c3c9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:40:14 -04:00

634 lines
22 KiB
Go

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.
e.google.FailEventWrite(4, http.StatusServiceUnavailable)
_, err := e.svc.Push(t.Context(), week)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("Push = %v, want *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.StatusInternalServerError)
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)
// The connection is left as it is: forgetting it is not the push's to
// decide.
if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); err != nil {
t.Errorf("connection after a refused grant: %v", err)
}
}
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.StatusServiceUnavailable)
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...)
}