Merge branch 'fix-split-series-overrides' into main
9 files changed,
+324
−36
internal/calendar/split_series_test.go+228 −0internal/calendar/store_override.go+11 −10internal/calendar/store_split_overrides.go+52 −0internal/calendartest/booking_abuse_test.go+3 −3internal/calendartest/booking_http_test.go+19 −12internal/calendartest/booking_review2_test.go+3 −3internal/calendartest/booking_review_test.go+4 −4internal/calendartest/booking_sweep_test.go+3 −3internal/calendartest/events_test.go+1 −1
CI passed — run details
diff --git a/internal/calendar/split_series_test.go b/internal/calendar/split_series_test.go| new file mode 100644 |
| index 0000000..fc11d2a |
| --- /dev/null |
| +++ b/internal/calendar/split_series_test.go |
| @@ -0,0 +1,228 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestSplitSeriesKeepsOverridesWithOriginalOccurrence(t *testing.T) { |
| + a := NewForTest(t, t.TempDir()) |
| + ctx := t.Context() |
| + p, err := a.store.EnsurePersonSetup(ctx, "split@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := a.store.DefaultCalendarID(ctx, p.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + input := EventInput{CalendarID: cal, Title: "Daily", StartLocal: "2026-09-01T09:00", EndLocal: "2026-09-01T10:00", TZ: "Europe/Dublin", RRule: "FREQ=DAILY;COUNT=30", Kind: KindMeeting, Status: StatusConfirmed} |
| + master, err := a.store.CreateEvent(ctx, p.ID, input) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cases := []struct { |
| + original, start, end string |
| + tail, cancel bool |
| + }{ |
| + {"2026-09-03T09:00", "2026-09-20T11:00", "2026-09-20T12:00", false, false}, |
| + {"2026-09-10T09:00", "2026-09-10T11:00", "2026-09-10T12:00", true, false}, |
| + {"2026-09-12T09:00", "2026-09-02T11:00", "2026-09-02T12:00", true, false}, |
| + {"2026-09-14T09:00", "2026-09-04T11:00", "2026-09-04T12:00", true, true}, |
| + } |
| + originals := map[string]Event{} |
| + for _, c := range cases { |
| + moved := input |
| + moved.RRule = "" |
| + moved.StartLocal = c.start |
| + moved.EndLocal = c.end |
| + if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, c.original, moved); err != nil { |
| + t.Fatal(err) |
| + } |
| + ov, ok, err := a.store.findOverride(ctx, master.ID, c.original) |
| + if err != nil || !ok { |
| + t.Fatal("missing override", err) |
| + } |
| + if c.cancel { |
| + if err := a.store.DeleteEvent(ctx, p.ID, ov.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.db.G.First(&ov, ov.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + originals[c.original] = ov |
| + } |
| + tailInput := input |
| + tailInput.Title = "Following" |
| + tailInput.StartLocal = "2026-09-10T09:00" |
| + tailInput.EndLocal = "2026-09-10T10:00" |
| + if err := a.store.SplitSeries(ctx, p.ID+100, master.ID, "2026-09-10T09:00", tailInput); err == nil { |
| + t.Fatal("foreign split accepted") |
| + } |
| + if err := a.store.SplitSeries(ctx, p.ID, master.ID, "2026-09-10T09:00", tailInput); err != nil { |
| + t.Fatal(err) |
| + } |
| + var tail Event |
| + if err := a.db.G.Where("calendar_id = ? AND id <> ? AND series_id IS NULL", cal, master.ID).First(&tail).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if tail.UIDHash == master.UIDHash || bytes.Equal(tail.UIDSealed, master.UIDSealed) { |
| + t.Fatal("tail reused master identity") |
| + } |
| + for _, c := range cases { |
| + var got Event |
| + old := originals[c.original] |
| + if err := a.db.G.First(&got, old.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + want := master |
| + if c.tail { |
| + want = tail |
| + } |
| + if got.SeriesID == nil || *got.SeriesID != want.ID || got.UIDHash != want.UIDHash || !bytes.Equal(got.UIDSealed, want.UIDSealed) { |
| + t.Errorf("%s: exception belongs to wrong series or UID", c.original) |
| + } |
| + if got.RecurrenceIDLocal != c.original || got.StartLocal != c.start || got.EndLocal != c.end || (got.DeletedAt != nil) != c.cancel { |
| + t.Errorf("%s: changed occurrence or cancellation", c.original) |
| + } |
| + if c.tail && (got.Sequence <= old.Sequence || got.UpdatedAt.Before(old.UpdatedAt)) { |
| + t.Errorf("%s: export revision did not advance", c.original) |
| + } |
| + if !c.tail && (got.Sequence != old.Sequence || !got.UpdatedAt.Equal(old.UpdatedAt)) { |
| + t.Errorf("%s: head exception changed", c.original) |
| + } |
| + } |
| + from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix() |
| + rows, err := a.store.EventsForFeed(ctx, p.ID, cal, from, from+40*86400) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + uids := map[int64]string{} |
| + for _, row := range rows { |
| + if row.SeriesID == nil { |
| + uids[row.ID] = row.UIDSealedOpen |
| + } |
| + } |
| + if len(uids) != 2 { |
| + t.Fatalf("feed masters = %d", len(uids)) |
| + } |
| + overrides := 0 |
| + for _, row := range rows { |
| + if row.SeriesID != nil { |
| + overrides++ |
| + if row.UIDSealedOpen == "" || row.UIDSealedOpen != uids[*row.SeriesID] { |
| + t.Errorf("exported override %d has wrong UID", row.ID) |
| + } |
| + if row.ID == originals["2026-09-14T09:00"].ID && row.Status != StatusCancelled { |
| + t.Fatal("cancelled exception resurrected in feed") |
| + } |
| + } |
| + } |
| + if overrides != len(cases) { |
| + t.Fatalf("exported exceptions = %d", overrides) |
| + } |
| +} |
| + |
| +func TestSplitSeriesOriginalBoundaryForAllDayAndDST(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, zone, start, end, original, moved, movedEnd, cut, cutEnd string |
| + allDay bool |
| + }{ |
| + {"all-day", "", "2026-10-01", "2026-10-02", "2026-10-26", "2026-10-02", "2026-10-03", "2026-10-25", "2026-10-26", true}, |
| + {"dst-fold", "Europe/Dublin", "2026-10-01T01:30", "2026-10-01T02:30", "2026-10-25T01:30", "2026-10-02T11:00", "2026-10-02T12:00", "2026-10-25T01:30", "2026-10-25T02:30", false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + a := NewForTest(t, t.TempDir()) |
| + ctx := t.Context() |
| + p, err := a.store.EnsurePersonSetup(ctx, "boundary@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := a.store.DefaultCalendarID(ctx, p.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + input := EventInput{CalendarID: cal, Title: "Recurring", StartLocal: tc.start, EndLocal: tc.end, TZ: tc.zone, AllDay: tc.allDay, RRule: "FREQ=DAILY;COUNT=40", Kind: KindMeeting, Status: StatusConfirmed} |
| + master, err := a.store.CreateEvent(ctx, p.ID, input) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + moved := input |
| + moved.RRule = "" |
| + moved.StartLocal = tc.moved |
| + moved.EndLocal = tc.movedEnd |
| + if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, tc.original, moved); err != nil { |
| + t.Fatal(err) |
| + } |
| + old, ok, err := a.store.findOverride(ctx, master.ID, tc.original) |
| + if err != nil || !ok { |
| + t.Fatal("missing override", err) |
| + } |
| + input.StartLocal = tc.cut |
| + input.EndLocal = tc.cutEnd |
| + if err := a.store.SplitSeries(ctx, p.ID, master.ID, tc.cut, input); err != nil { |
| + t.Fatal(err) |
| + } |
| + var got Event |
| + if err := a.db.G.First(&got, old.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.SeriesID == nil || *got.SeriesID == master.ID || got.UIDHash == master.UIDHash || got.RecurrenceIDLocal != tc.original || got.StartUTC != old.StartUTC { |
| + t.Fatal("original occurrence lost its tail or moved instant") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestSplitSeriesRollsBackIfOverrideCannotBeClassified(t *testing.T) { |
| + a := NewForTest(t, t.TempDir()) |
| + ctx := t.Context() |
| + p, err := a.store.EnsurePersonSetup(ctx, "rollback@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := a.store.DefaultCalendarID(ctx, p.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in := EventInput{CalendarID: cal, Title: "Recurring", StartLocal: "2026-09-01T09:00", EndLocal: "2026-09-01T10:00", TZ: "UTC", RRule: "FREQ=DAILY;COUNT=30", Kind: KindMeeting, Status: StatusConfirmed} |
| + master, err := a.store.CreateEvent(ctx, p.ID, in) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + moved := in |
| + moved.RRule = "" |
| + moved.StartLocal = "2026-09-12T11:00" |
| + moved.EndLocal = "2026-09-12T12:00" |
| + if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, "2026-09-12T09:00", moved); err != nil { |
| + t.Fatal(err) |
| + } |
| + ov, ok, err := a.store.findOverride(ctx, master.ID, "2026-09-12T09:00") |
| + if err != nil || !ok { |
| + t.Fatal("missing override", err) |
| + } |
| + if err := a.db.G.Model(&Event{}).Where("id = ?", ov.ID).Update("recurrence_id_local", "invalid-legacy-value").Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + in.StartLocal = "2026-09-10T09:00" |
| + in.EndLocal = "2026-09-10T10:00" |
| + if err := a.store.SplitSeries(ctx, p.ID, master.ID, "2026-09-10T09:00", in); err == nil { |
| + t.Fatal("unclassifiable exception silently reassigned") |
| + } |
| + current, err := a.store.Event(ctx, p.ID, master.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if current.RRule != master.RRule || current.Sequence != master.Sequence { |
| + t.Fatal("head changed despite rollback") |
| + } |
| + var count int64 |
| + if err := a.db.G.Model(&Event{}).Where("calendar_id = ?", cal).Count(&count).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 2 { |
| + t.Fatal("split left a partial tail") |
| + } |
| +} |
diff --git a/internal/calendar/store_override.go b/internal/calendar/store_override.go| index 5f84de2..f7133f0 100644 |
| --- a/internal/calendar/store_override.go |
| +++ b/internal/calendar/store_override.go |
| @@ -160,14 +160,20 @@ func (s *Store) SplitSeries(ctx context.Context, personID, seriesID int64, origi |
| return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| headUntil, _ := RuleUntilUTC(headRule, master.StartLocal, master.TZ, master.AllDay) |
| - if err := tx.Model(&Event{}).Where("id = ?", seriesID). |
| + head := splitWritableEvents(tx, personID, master.CalendarID). |
| + Where("id = ? AND deleted_at IS NULL AND sequence = ?", seriesID, master.Sequence). |
| + Select("rrule", "until_utc", "sequence", "updated_at"). |
| Updates(map[string]any{ |
| "rrule": headRule, |
| "until_utc": headUntil, |
| - "sequence": master.Sequence + 1, |
| + "sequence": gorm.Expr("sequence + 1"), |
| "updated_at": time.Now().UTC(), |
| - }).Error; err != nil { |
| - return fmt.Errorf("calendar: end the head series: %w", err) |
| + }) |
| + if head.Error != nil { |
| + return fmt.Errorf("calendar: end the head series: %w", head.Error) |
| + } |
| + if head.RowsAffected != 1 { |
| + return ErrNotFound |
| } |
| // The tail is a new series with the master's rule, starting at |
| @@ -186,12 +192,7 @@ func (s *Store) SplitSeries(ctx context.Context, personID, seriesID int64, origi |
| return fmt.Errorf("calendar: create the tail series: %w", err) |
| } |
| - // Overrides after the cut belong to the tail, not the head. |
| - // Left on the head they would be orphans: rows nothing renders |
| - // and nothing can reach. |
| - if err := tx.Model(&Event{}). |
| - Where("series_id = ? AND start_utc >= ?", seriesID, cut.Unix()). |
| - Update("series_id", ev.ID).Error; err != nil { |
| + if err := moveSplitOverrides(tx, personID, master.Event, ev, cut); err != nil { |
| return err |
| } |
| return s.rescheduleAllForTx(tx, seriesID) |
diff --git a/internal/calendar/store_split_overrides.go b/internal/calendar/store_split_overrides.go| new file mode 100644 |
| index 0000000..d13658c |
| --- /dev/null |
| +++ b/internal/calendar/store_split_overrides.go |
| @@ -0,0 +1,52 @@ |
| +package calendar |
| + |
| +import ( |
| + "fmt" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/scope" |
| + "gorm.io/gorm" |
| +) |
| + |
| +// An exception belongs to the series containing its original occurrence, |
| +// even when someone moved it across the split date. Cancelled exceptions |
| +// move too, so splitting cannot restore a deleted occurrence. |
| +func moveSplitOverrides(tx *gorm.DB, personID int64, master, tail Event, cut time.Time) error { |
| + writable := func() *gorm.DB { |
| + return splitWritableEvents(tx, personID, master.CalendarID).Where("series_id = ?", master.ID) |
| + } |
| + var overrides []Event |
| + if err := writable().Find(&overrides).Error; err != nil { |
| + return err |
| + } |
| + loc, err := Zone(master.TZ) |
| + if err != nil { |
| + return err |
| + } |
| + now := time.Now().UTC() |
| + for _, override := range overrides { |
| + original, _, err := ParseLocal(override.RecurrenceIDLocal, loc) |
| + if err != nil { |
| + return fmt.Errorf("calendar: read split occurrence: %w", err) |
| + } |
| + if original.Before(cut) { |
| + continue |
| + } |
| + updates := map[string]any{"series_id": tail.ID, "uid_hash": tail.UIDHash, "uid_sealed": tail.UIDSealed, "sequence": gorm.Expr("sequence + 1"), "updated_at": now} |
| + result := writable().Where("id = ?", override.ID). |
| + Select("series_id", "uid_hash", "uid_sealed", "sequence", "updated_at").Updates(updates) |
| + if result.Error != nil { |
| + return result.Error |
| + } |
| + if result.RowsAffected != 1 { |
| + return ErrNotFound |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func splitWritableEvents(tx *gorm.DB, personID, calendarID int64) *gorm.DB { |
| + calendars := scope.OwnedBy(tx.Model(&Grant{}), "person_id", personID). |
| + Select("calendar_id").Where("role IN ?", []string{RoleOwner, RoleEditor}) |
| + return tx.Model(&Event{}).Where("calendar_id = ? AND calendar_id IN (?)", calendarID, calendars) |
| +} |
diff --git a/internal/calendartest/booking_abuse_test.go b/internal/calendartest/booking_abuse_test.go| index 3a58026..1b7a34f 100644 |
| --- a/internal/calendartest/booking_abuse_test.go |
| +++ b/internal/calendartest/booking_abuse_test.go |
| @@ -29,7 +29,7 @@ func fromIP(t *testing.T, h http.Handler, ip, target string, form url.Values) *h |
| func TestTheSixthOpenHoldFromOneIPIs429(t *testing.T) { |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| for i := 0; i < 5; i++ { |
| rec := fromIP(t, h, "203.0.113.9", path, holdForm(starts[i], "S", "s"+string(rune('a'+i))+"@example.com")) |
| if rec.Code != 200 { |
| @@ -49,7 +49,7 @@ func TestTheSixthOpenHoldFromOneIPIs429(t *testing.T) { |
| func TestTheThirdOpenHoldForOneAddressIs429(t *testing.T) { |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| for i := 0; i < 2; i++ { |
| fromIP(t, h, "203.0.113."+string(rune('1'+i)), path, holdForm(starts[i], "S", "same@example.com")) |
| } |
| @@ -93,7 +93,7 @@ func TestADayOutsideTheHorizonIs404AndAStrangeZoneFallsBack(t *testing.T) { |
| func TestAnOversizedFormIs422(t *testing.T) { |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| form := holdForm(starts[0], "A", "a@example.com") |
| form.Set("note", strings.Repeat("x", 20<<10)) |
| if rec := strangerPost(t, h, path, form); rec.Code != http.StatusUnprocessableEntity { |
diff --git a/internal/calendartest/booking_http_test.go b/internal/calendartest/booking_http_test.go| index 6d37680..9328a6a 100644 |
| --- a/internal/calendartest/booking_http_test.go |
| +++ b/internal/calendartest/booking_http_test.go |
| @@ -67,19 +67,26 @@ var ( |
| reDay = regexp.MustCompile(`\?day=(\d{4}-\d{2}-\d{2})`) |
| ) |
| -// firstDaySlots opens the page, follows the first day link, and returns |
| -// that day's path and its slot starts. |
| -func firstDaySlots(t *testing.T, h http.Handler, path string) (string, []string) { |
| +// futureDaySlots uses a whole future day so a late test run still has |
| +// enough slots for the hold-limit and rescheduling tests. |
| +func futureDaySlots(t *testing.T, h http.Handler, path string) (string, []string) { |
| t.Helper() |
| page := strangerGet(t, h, path) |
| if page.Code != 200 { |
| t.Fatalf("page: %d\n%s", page.Code, page.Body.String()) |
| } |
| - m := reDay.FindStringSubmatch(page.Body.String()) |
| - if m == nil { |
| - t.Fatalf("no day links:\n%s", page.Body.String()) |
| + today := time.Now().UTC().Format("2006-01-02") |
| + selected := "" |
| + for _, match := range reDay.FindAllStringSubmatch(page.Body.String(), -1) { |
| + if match[1] > today { |
| + selected = match[1] |
| + break |
| + } |
| + } |
| + if selected == "" { |
| + t.Fatalf("no future day links:\n%s", page.Body.String()) |
| } |
| - dayPath := path + "?day=" + m[1] + "&zone=UTC" |
| + dayPath := path + "?day=" + selected + "&zone=UTC" |
| day := strangerGet(t, h, dayPath) |
| var starts []string |
| for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) { |
| @@ -98,7 +105,7 @@ func holdForm(start, name, address string) url.Values { |
| func TestAStrangerHoldsAndOnlyTheButtonConfirms(t *testing.T) { |
| app, mailer, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| // Step one: the slot pick, no name yet. |
| pick := strangerPost(t, h, path, url.Values{"start": {starts[0]}, "zone": {"UTC"}}) |
| @@ -147,7 +154,7 @@ func TestTheICSIsARequestWithNoManagementLinkAndNoHostPrivateFields(t *testing.T |
| h := app.Handler() |
| st := app.StoreForTest() |
| ctx := context.Background() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "Doe, Jane \"JJ\": Support", "doc@tito.io")) |
| link := reManage.FindString(mailer.body(0)) |
| strangerPost(t, h, link+"/confirm", nil) |
| @@ -198,7 +205,7 @@ func TestTheICSIsARequestWithNoManagementLinkAndNoHostPrivateFields(t *testing.T |
| func TestTheSameSlotCannotBeHeldTwiceAndAWrongTokenIsTheRouters404(t *testing.T) { |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com")) |
| rec := strangerPost(t, h, path, holdForm(starts[0], "B", "b@example.com")) |
| if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") { |
| @@ -217,7 +224,7 @@ func TestTheSameSlotCannotBeHeldTwiceAndAWrongTokenIsTheRouters404(t *testing.T) |
| func TestRescheduleNeedsTheRevisionAndCancelTellsBoth(t *testing.T) { |
| app, mailer, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| link := reManage.FindString(mailer.body(0)) |
| strangerPost(t, h, link+"/confirm", nil) |
| @@ -267,7 +274,7 @@ func TestAHoldWhoseMailIsRefusedStillStands(t *testing.T) { |
| app, mailer, path, _ := publishedPage(t) |
| mailer.refuse = "bounce@example.com" |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| rec := strangerPost(t, h, path, holdForm(starts[0], "B", "bounce@example.com")) |
| if rec.Code != 200 || !strings.Contains(rec.Body.String(), "If nothing arrives") { |
| t.Errorf("refused mail: %d\n%s", rec.Code, rec.Body.String()) |
diff --git a/internal/calendartest/booking_review2_test.go b/internal/calendartest/booking_review2_test.go| index 44babe8..19d3fd9 100644 |
| --- a/internal/calendartest/booking_review2_test.go |
| +++ b/internal/calendartest/booking_review2_test.go |
| @@ -23,7 +23,7 @@ import ( |
| func TestAMultilineNoteFromABrowserIsAccepted(t *testing.T) { |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| form := holdForm(starts[0], "Doc", "doc@tito.io") |
| form.Set("note", "First paragraph.\r\n\r\nSecond paragraph.") |
| if rec := strangerPost(t, h, path, form); rec.Code != http.StatusOK { |
| @@ -178,7 +178,7 @@ func TestADeferredHoldMailIsNotReportedSent(t *testing.T) { |
| "availability": {"every day 0-24"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}}) |
| postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}) |
| path := strings.TrimSpace(rePublicURL.FindString(getAs(t, h, session, "/settings/booking").Body.String())) |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| if len(starts) < 5 { |
| t.Fatalf("only %d slots on the first day", len(starts)) |
| } |
| @@ -251,7 +251,7 @@ func TestTheReschedulePickerOnAClosedPageSaysSo(t *testing.T) { |
| h := app.Handler() |
| session := app.SignInForTest(t, "host@example.com") |
| mailer.reset() // the sign-in link is not the mail under test |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| link := reManage.FindString(mailer.body(0)) |
| strangerPost(t, h, link+"/confirm", nil) |
diff --git a/internal/calendartest/booking_review_test.go b/internal/calendartest/booking_review_test.go| index 5b04513..fd261e1 100644 |
| --- a/internal/calendartest/booking_review_test.go |
| +++ b/internal/calendartest/booking_review_test.go |
| @@ -19,7 +19,7 @@ import ( |
| func TestANameWithALineBreakIsRefused(t *testing.T) { |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| rec := strangerPost(t, h, path, holdForm(starts[0], "Jane\nPrivate", "j@example.com")) |
| if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "one line") { |
| t.Errorf("a name with a newline: %d", rec.Code) |
| @@ -31,7 +31,7 @@ func TestANameWithALineBreakIsRefused(t *testing.T) { |
| func TestTheHostsConfirmationCarriesTheNote(t *testing.T) { |
| app, mailer, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| form := holdForm(starts[0], "Doc", "doc@tito.io") |
| form.Set("note", "Bring the folder") |
| strangerPost(t, h, path, form) |
| @@ -47,7 +47,7 @@ func TestTheHostsConfirmationCarriesTheNote(t *testing.T) { |
| func TestTheReschedulePickerShowsTheSnapshot(t *testing.T) { |
| app, mailer, path, host := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| link := reManage.FindString(mailer.body(0)) |
| strangerPost(t, h, link+"/confirm", nil) |
| @@ -71,7 +71,7 @@ func TestAnOwedHoldMailIsRetiredOnceConfirmed(t *testing.T) { |
| t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") |
| app, mailer, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| mailer.refuse = "doc@tito.io" |
| strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| mailer.refuse = "" |
diff --git a/internal/calendartest/booking_sweep_test.go b/internal/calendartest/booking_sweep_test.go| index b638e97..e2dae2f 100644 |
| --- a/internal/calendartest/booking_sweep_test.go |
| +++ b/internal/calendartest/booking_sweep_test.go |
| @@ -23,7 +23,7 @@ func TestTheTickExpiresHoldsAndWakesForThem(t *testing.T) { |
| t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") |
| app, _, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com")) |
| if _, err := app.DBForTest().Exec(`UPDATE bookings SET hold_expires_at = ?`, time.Now().Add(-time.Minute).Unix()); err != nil { |
| t.Fatal(err) |
| @@ -43,7 +43,7 @@ func TestTheTickRetriesOwedMailAndDrainOnRequestDoesOneThing(t *testing.T) { |
| t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") |
| app, mailer, path, _ := publishedPage(t) |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| link := reManage.FindString(mailer.body(0)) |
| // The host's mailbox bounces for now. |
| @@ -104,7 +104,7 @@ func TestNothingSealedReachesTheLog(t *testing.T) { |
| "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}}) |
| postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}) |
| path := regexp.MustCompile(`/book/paul/[a-z0-9]{8}`).FindString(getAs(t, h, session, "/settings/booking").Body.String()) |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| strangerPost(t, h, path, holdForm(starts[0], "Dermot Lacey", "dermot@elsewhere.example")) |
| log := buf.String() |
diff --git a/internal/calendartest/events_test.go b/internal/calendartest/events_test.go| index 02208ce..58aa791 100644 |
| --- a/internal/calendartest/events_test.go |
| +++ b/internal/calendartest/events_test.go |
| @@ -113,7 +113,7 @@ func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) { |
| func TestTheDatabaseNeverHoldsAPlaintextBooker(t *testing.T) { |
| app, mailer, path, _ := publishedPageTitled(t, "Interrogation about the acquisition", "Bring nothing", "mondays 12-3, every day 9-5") |
| h := app.Handler() |
| - _, starts := firstDaySlots(t, h, path) |
| + _, starts := futureDaySlots(t, h, path) |
| form := holdForm(starts[0], "Dermot Lacey", "dermot.lacey@elsewhere.example") |
| form.Set("note", "The folder") |
| if rec := strangerPost(t, h, path, form); rec.Code != 200 { |