package calendartest import ( "context" "errors" "net/url" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // The strict reader is what free time is decided from. It fails closed // on a series it could not expand to completion OR could not expand at // all, while the tolerant reader every view uses keeps answering // (booking spec §4.1, B23). func TestTheStrictReaderFailsClosedWhereTheTolerantOneRenders(t *testing.T) { app, session := signedIn(t, "someone@example.com") h := app.Handler() st := app.StoreForTest() host := app.PersonByAddressForTest(t, "someone@example.com") ctx := context.Background() // An ordinary series: both readers agree. createEvent(t, h, session, url.Values{ "title": {"Standup"}, "start": {testAt(1, "09:00")}, "end": {testAt(1, "09:15")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}, }) from, to := window(t, 1, 3) tolerant, err := st.EventsInRange(ctx, host.ID, from, to) if err != nil { t.Fatal(err) } strict, err := st.EventsInRangeStrict(ctx, host.ID, from, to) if err != nil { t.Fatalf("strict read of an ordinary series: %v", err) } if len(strict) != len(tolerant) || len(strict) == 0 { t.Fatalf("strict %d events, tolerant %d, want equal and non-zero", len(strict), len(tolerant)) } // A rule outside the subset, imported verbatim: the day page shows // its first occurrence (ruling 4), the strict reader refuses. rewriteRule(t, app, "Standup", "FREQ=MONTHLY;BYDAY=MO") if _, err := st.EventsInRange(ctx, host.ID, from, to); err != nil { t.Errorf("tolerant read with a foreign rule: %v", err) } if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) { t.Errorf("strict read with a foreign busy rule: err = %v, want ErrIncompleteExpansion", err) } // A note-kind series with the same rule does not occupy time, so // the strict reader does not care. setKind(t, app, "Standup", calendar.KindNote) if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); err != nil { t.Errorf("strict read with a foreign NOTE rule: %v, want nil", err) } // A series that runs out of expansion steps before the window. setKind(t, app, "Standup", calendar.KindMeeting) rewriteRule(t, app, "Standup", "FREQ=DAILY") if _, err := app.DBForTest().Exec(`UPDATE events SET start_local = '1700-01-01T09:00', end_local = '1700-01-01T09:15', start_utc = ?, end_utc = ? WHERE rrule <> ''`, time.Date(1700, 1, 1, 9, 0, 0, 0, time.UTC).Unix(), time.Date(1700, 1, 1, 9, 15, 0, 0, time.UTC).Unix()); err != nil { t.Fatal(err) } if _, err := st.EventsInRange(ctx, host.ID, from, to); err != nil { t.Errorf("tolerant read of a 1700 series: %v", err) } if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) { t.Errorf("strict read of a 1700 series: err = %v, want ErrIncompleteExpansion", err) } } // The final occurrence of a series whose UNTIL is the night before the // fold is FIVE hours long that day, not four. A master-duration bound // would have excluded it from a query that opens after its fourth hour; // the day of slack keeps it (r4 A7). func TestTheUntilBoundHasADayOfSlack(t *testing.T) { app, session := signedIn(t, "someone@example.com") h := app.Handler() postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"}, "default_reminder_min": {"0"}, "default_all_day_min": {"0"}}) // Daily 00:00–04:00 from the Friday before the fold, ending Saturday // 24 October — UNTIL is a bare date, inclusive of that day, so the // Sunday 25 October instance is the one AFTER the last permitted // start... unless the rule's last start is the 25th itself. Make it // so: repeat_end = 25 October. Its occurrence runs 00:00–04:00 on // the fold Sunday, which is five elapsed hours. createEvent(t, h, session, url.Values{ "title": {"Night shift"}, "start": {"2026-10-23T00:00"}, "end": {"2026-10-23T04:00"}, "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"daily"}, "repeat_end": {"2026-10-25"}, }) // The final occurrence starts 00:00 IST on the 25th — 23:00Z on the // 24th — and, across the fold, runs FIVE elapsed hours to 04:00 GMT. // A bare-date repeat_end indexes UNTIL at the END of that day, which // would pass without any slack, so pin UNTIL to the final START as // an importer would write it, then query only the fifth hour: a // master-duration bound (four hours from 23:00Z is 03:00Z) excludes // the master; the day of slack keeps it. until := time.Date(2026, 10, 24, 23, 0, 0, 0, time.UTC) if _, err := app.DBForTest().Exec(`UPDATE events SET rrule = ?, until_utc = ? WHERE rrule <> ''`, "FREQ=DAILY;UNTIL=20261024T230000Z", until.Unix()); err != nil { t.Fatal(err) } host := app.PersonByAddressForTest(t, "someone@example.com") dublin, _ := time.LoadLocation("Europe/Dublin") from := time.Date(2026, 10, 25, 3, 30, 0, 0, dublin).Unix() to := time.Date(2026, 10, 25, 4, 0, 0, 0, dublin).Unix() events, err := app.StoreForTest().EventsInRange(context.Background(), host.ID, from, to) if err != nil { t.Fatal(err) } found := false for _, e := range events { if e.Title == "Night shift" { found = true } } if !found { t.Error("the fifth hour of the final occurrence across the fold shows nothing — the UNTIL bound lacks its day of slack") } } func window(t *testing.T, fromDay, toDay int) (int64, int64) { t.Helper() from, err := calendar.ToUTC(testDay(fromDay), "UTC", true) if err != nil { t.Fatal(err) } to, err := calendar.ToUTC(testDay(toDay), "UTC", true) if err != nil { t.Fatal(err) } return from, to } // rewriteRule changes a series' stored rule underneath the app, the way // an import of a verbatim foreign rule would leave it. func rewriteRule(t *testing.T, app *calendar.App, title, rrule string) { t.Helper() // Titles are sealed, so the row is found by being the only master. if _, err := app.DBForTest().Exec(`UPDATE events SET rrule = ? WHERE rrule <> '' AND series_id IS NULL`, rrule); err != nil { t.Fatalf("rewrite rule: %v", err) } } func setKind(t *testing.T, app *calendar.App, title, kind string) { t.Helper() if _, err := app.DBForTest().Exec(`UPDATE events SET kind = ? WHERE rrule <> '' AND series_id IS NULL`, kind); err != nil { t.Fatalf("set kind: %v", err) } }