| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/url" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 11 | ) |
| 12 | |
| 13 | // The strict reader is what free time is decided from. It fails closed |
| 14 | // on a series it could not expand to completion OR could not expand at |
| 15 | // all, while the tolerant reader every view uses keeps answering |
| 16 | // (booking spec §4.1, B23). |
| 17 | func TestTheStrictReaderFailsClosedWhereTheTolerantOneRenders(t *testing.T) { |
| 18 | app, session := signedIn(t, "someone@example.com") |
| 19 | h := app.Handler() |
| 20 | st := app.StoreForTest() |
| 21 | host := app.PersonByAddressForTest(t, "someone@example.com") |
| 22 | ctx := context.Background() |
| 23 | |
| 24 | // An ordinary series: both readers agree. |
| 25 | createEvent(t, h, session, url.Values{ |
| 26 | "title": {"Standup"}, "start": {testAt(1, "09:00")}, "end": {testAt(1, "09:15")}, |
| 27 | "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}, |
| 28 | }) |
| 29 | from, to := window(t, 1, 3) |
| 30 | tolerant, err := st.EventsInRange(ctx, host.ID, from, to) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | strict, err := st.EventsInRangeStrict(ctx, host.ID, from, to) |
| 35 | if err != nil { |
| 36 | t.Fatalf("strict read of an ordinary series: %v", err) |
| 37 | } |
| 38 | if len(strict) != len(tolerant) || len(strict) == 0 { |
| 39 | t.Fatalf("strict %d events, tolerant %d, want equal and non-zero", len(strict), len(tolerant)) |
| 40 | } |
| 41 | |
| 42 | // A rule outside the subset, imported verbatim: the day page shows |
| 43 | // its first occurrence (ruling 4), the strict reader refuses. |
| 44 | rewriteRule(t, app, "Standup", "FREQ=MONTHLY;BYDAY=MO") |
| 45 | if _, err := st.EventsInRange(ctx, host.ID, from, to); err != nil { |
| 46 | t.Errorf("tolerant read with a foreign rule: %v", err) |
| 47 | } |
| 48 | if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) { |
| 49 | t.Errorf("strict read with a foreign busy rule: err = %v, want ErrIncompleteExpansion", err) |
| 50 | } |
| 51 | |
| 52 | // A note-kind series with the same rule does not occupy time, so |
| 53 | // the strict reader does not care. |
| 54 | setKind(t, app, "Standup", calendar.KindNote) |
| 55 | if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); err != nil { |
| 56 | t.Errorf("strict read with a foreign NOTE rule: %v, want nil", err) |
| 57 | } |
| 58 | |
| 59 | // A series that runs out of expansion steps before the window. |
| 60 | setKind(t, app, "Standup", calendar.KindMeeting) |
| 61 | rewriteRule(t, app, "Standup", "FREQ=DAILY") |
| 62 | if _, err := app.DBForTest().Exec(`UPDATE events SET start_local = '1700-01-01T09:00', end_local = '1700-01-01T09:15', |
| 63 | start_utc = ?, end_utc = ? WHERE rrule <> ''`, time.Date(1700, 1, 1, 9, 0, 0, 0, time.UTC).Unix(), |
| 64 | time.Date(1700, 1, 1, 9, 15, 0, 0, time.UTC).Unix()); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | if _, err := st.EventsInRange(ctx, host.ID, from, to); err != nil { |
| 68 | t.Errorf("tolerant read of a 1700 series: %v", err) |
| 69 | } |
| 70 | if _, err := st.EventsInRangeStrict(ctx, host.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) { |
| 71 | t.Errorf("strict read of a 1700 series: err = %v, want ErrIncompleteExpansion", err) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // The final occurrence of a series whose UNTIL is the night before the |
| 76 | // fold is FIVE hours long that day, not four. A master-duration bound |
| 77 | // would have excluded it from a query that opens after its fourth hour; |
| 78 | // the day of slack keeps it (r4 A7). |
| 79 | func TestTheUntilBoundHasADayOfSlack(t *testing.T) { |
| 80 | app, session := signedIn(t, "someone@example.com") |
| 81 | h := app.Handler() |
| 82 | postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"}, |
| 83 | "default_reminder_min": {"0"}, "default_all_day_min": {"0"}}) |
| 84 | |
| 85 | // Daily 00:00–04:00 from the Friday before the fold, ending Saturday |
| 86 | // 24 October — UNTIL is a bare date, inclusive of that day, so the |
| 87 | // Sunday 25 October instance is the one AFTER the last permitted |
| 88 | // start... unless the rule's last start is the 25th itself. Make it |
| 89 | // so: repeat_end = 25 October. Its occurrence runs 00:00–04:00 on |
| 90 | // the fold Sunday, which is five elapsed hours. |
| 91 | createEvent(t, h, session, url.Values{ |
| 92 | "title": {"Night shift"}, "start": {"2026-10-23T00:00"}, "end": {"2026-10-23T04:00"}, |
| 93 | "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"daily"}, "repeat_end": {"2026-10-25"}, |
| 94 | }) |
| 95 | // The final occurrence starts 00:00 IST on the 25th — 23:00Z on the |
| 96 | // 24th — and, across the fold, runs FIVE elapsed hours to 04:00 GMT. |
| 97 | // A bare-date repeat_end indexes UNTIL at the END of that day, which |
| 98 | // would pass without any slack, so pin UNTIL to the final START as |
| 99 | // an importer would write it, then query only the fifth hour: a |
| 100 | // master-duration bound (four hours from 23:00Z is 03:00Z) excludes |
| 101 | // the master; the day of slack keeps it. |
| 102 | until := time.Date(2026, 10, 24, 23, 0, 0, 0, time.UTC) |
| 103 | if _, err := app.DBForTest().Exec(`UPDATE events SET rrule = ?, until_utc = ? WHERE rrule <> ''`, |
| 104 | "FREQ=DAILY;UNTIL=20261024T230000Z", until.Unix()); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | host := app.PersonByAddressForTest(t, "someone@example.com") |
| 108 | dublin, _ := time.LoadLocation("Europe/Dublin") |
| 109 | from := time.Date(2026, 10, 25, 3, 30, 0, 0, dublin).Unix() |
| 110 | to := time.Date(2026, 10, 25, 4, 0, 0, 0, dublin).Unix() |
| 111 | events, err := app.StoreForTest().EventsInRange(context.Background(), host.ID, from, to) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | found := false |
| 116 | for _, e := range events { |
| 117 | if e.Title == "Night shift" { |
| 118 | found = true |
| 119 | } |
| 120 | } |
| 121 | if !found { |
| 122 | t.Error("the fifth hour of the final occurrence across the fold shows nothing — the UNTIL bound lacks its day of slack") |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func window(t *testing.T, fromDay, toDay int) (int64, int64) { |
| 127 | t.Helper() |
| 128 | from, err := calendar.ToUTC(testDay(fromDay), "UTC", true) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | to, err := calendar.ToUTC(testDay(toDay), "UTC", true) |
| 133 | if err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | return from, to |
| 137 | } |
| 138 | |
| 139 | // rewriteRule changes a series' stored rule underneath the app, the way |
| 140 | // an import of a verbatim foreign rule would leave it. |
| 141 | func rewriteRule(t *testing.T, app *calendar.App, title, rrule string) { |
| 142 | t.Helper() |
| 143 | // Titles are sealed, so the row is found by being the only master. |
| 144 | if _, err := app.DBForTest().Exec(`UPDATE events SET rrule = ? WHERE rrule <> '' AND series_id IS NULL`, rrule); err != nil { |
| 145 | t.Fatalf("rewrite rule: %v", err) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func setKind(t *testing.T, app *calendar.App, title, kind string) { |
| 150 | t.Helper() |
| 151 | if _, err := app.DBForTest().Exec(`UPDATE events SET kind = ? WHERE rrule <> '' AND series_id IS NULL`, kind); err != nil { |
| 152 | t.Fatalf("set kind: %v", err) |
| 153 | } |
| 154 | } |
| 155 | |