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>
This commit is contained in:
+42
-38
@@ -25,10 +25,11 @@ const eventLength = 60 * time.Minute
|
||||
// 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. In
|
||||
// production it is *gcal.Service.
|
||||
// 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) error
|
||||
PushEvents(ctx context.Context, events []gcal.Event, record func() error) error
|
||||
}
|
||||
|
||||
// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet
|
||||
@@ -62,14 +63,18 @@ type pushDay struct {
|
||||
// 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.
|
||||
// changed in Calendar since: the app is the source of truth. A stored event
|
||||
// id is always on the chosen calendar: choosing another clears them all
|
||||
// (gcal.Service.SelectCalendar), 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. A
|
||||
// plan edited while it was pushed fails with ErrPlanChanged, unrecorded.
|
||||
// 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, 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,
|
||||
@@ -90,44 +95,43 @@ func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, er
|
||||
for i, d := range days {
|
||||
events[i] = d.event
|
||||
}
|
||||
if err := s.cal.PushEvents(ctx, events); err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
|
||||
}
|
||||
var p Plan
|
||||
err = 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,
|
||||
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: record event of %s of %s: %w", d.date, planID, err)
|
||||
return fmt.Errorf("plan: mark %s synced: %w", planID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("plan: %s has no day %s", planID, d.date)
|
||||
return ErrPlanChanged
|
||||
}
|
||||
}
|
||||
p, err = readPlan(ctx, q, weekStart)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -477,3 +478,156 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
|
||||
}
|
||||
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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user