oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
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).
17func 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).
79func 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
126func 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.
141func 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
149func 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