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 "net/http"
5 "net/url"
6 "regexp"
7 "strings"
8 "testing"
9 "time"
10
11 calendar "amadan.net/oficina/calendar/internal/calendar"
12)
13
14// createEvent posts the real form and returns the new event's path.
15func createEvent(t *testing.T, h http.Handler, session *http.Cookie, form url.Values) string {
16 t.Helper()
17 // Read the SELECTED option, not the first one — that is what a
18 // browser submits when nobody touches the dropdown, and it is the
19 // personal calendar rather than the shared team one. An earlier
20 // version of this helper took the first option, silently created
21 // every test event on the team calendar, and made the isolation
22 // test look like an app bug.
23 if form.Get("calendar_id") == "" {
24 page := getAs(t, h, session, "/events/new").Body.String()
25 m := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindStringSubmatch(page)
26 if m == nil {
27 t.Fatalf("no calendar is selected by default on the create form:\n%s", page)
28 }
29 form.Set("calendar_id", m[1])
30 }
31 rec := postAs(t, h, session, "/events", form)
32 if rec.Code != http.StatusSeeOther {
33 t.Fatalf("POST /events: status %d, want 303\n%s", rec.Code, rec.Body.String())
34 }
35 return rec.Header().Get("Location")
36}
37
38// The whole stack, end to end: a form submission becomes a sealed row,
39// and the day page renders it back in the viewer's own zone.
40func TestCreateAnEventAndSeeItOnTheDay(t *testing.T) {
41 app, session := signedIn(t, "someone@example.com")
42 h := app.Handler()
43
44 loc := createEvent(t, h, session, url.Values{
45 "title": {"Standup"},
46 "start": {"2026-09-02T09:30"},
47 "end": {"2026-09-02T09:45"},
48 "kind": {"meeting"},
49 "status": {"confirmed"},
50 "repeat": {"none"},
51 })
52 if !strings.HasPrefix(loc, "/events/") {
53 t.Fatalf("created event redirected to %q", loc)
54 }
55
56 page := getAs(t, h, session, "/day/2026-09-02")
57 if page.Code != http.StatusOK {
58 t.Fatalf("GET the day: status %d", page.Code)
59 }
60 body := page.Body.String()
61 if !strings.Contains(body, "Standup") {
62 t.Errorf("the event is missing from its own day:\n%s", body)
63 }
64 if !strings.Contains(body, "09:30") {
65 t.Errorf("the day page does not show the event's time:\n%s", body)
66 }
67 // The sentence is computed from real events, so it must have
68 // noticed this one.
69 if !strings.Contains(body, "One thing") {
70 t.Errorf("the day's sentence did not count the event:\n%s", body)
71 }
72}
73
74// A title is content, and content is sealed. The proof is the raw
75// SQLite file: nothing readable may be in it.
76//
77// This is the one rule's own test, and it greps the bytes rather than
78// trusting that the sealing code was called — a column that was never
79// sealed looks identical from Go and completely different on disk.
80func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) {
81 app, session := signedIn(t, "someone@example.com")
82 const secret = "Quarterly board meeting about the acquisition"
83
84 createEvent(t, app.Handler(), session, url.Values{
85 "title": {secret},
86 "location": {"The unmarked room"},
87 "notes": {"Do not tell anybody"},
88 "start": {"2026-09-02T09:30"},
89 "end": {"2026-09-02T10:30"},
90 "kind": {"meeting"},
91 "status": {"confirmed"},
92 "repeat": {"none"},
93 })
94
95 // WAL mode keeps recent writes out of the main file, so the
96 // checkpoint is not optional: without it this test passes by
97 // looking at a file the data has not reached yet.
98 if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
99 t.Fatalf("checkpoint: %v", err)
100 }
101
102 for _, needle := range []string{secret, "The unmarked room", "Do not tell anybody"} {
103 if found, where := app.GrepDatabaseForTest(t, needle); found {
104 t.Errorf("%q appears in plaintext in %s", needle, where)
105 }
106 }
107}
108
109// The same rule for a booking: the page's text, the booker's name,
110// address and note, and the management token are all content (booking
111// spec §2.4, §9). A stranger's identity is who this team MEETS WITH,
112// which is exactly what the bucket must not give up.
113func TestTheDatabaseNeverHoldsAPlaintextBooker(t *testing.T) {
114 app, mailer, path, _ := publishedPageTitled(t, "Interrogation about the acquisition", "Bring nothing", "mondays 12-3, every day 9-5")
115 h := app.Handler()
116 _, starts := futureDaySlots(t, h, path)
117 form := holdForm(starts[0], "Dermot Lacey", "dermot.lacey@elsewhere.example")
118 form.Set("note", "The folder")
119 if rec := strangerPost(t, h, path, form); rec.Code != 200 {
120 t.Fatalf("hold: %d", rec.Code)
121 }
122 token := strings.TrimPrefix(reManage.FindString(mailer.body(0)), "/book/m/")
123 if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
124 t.Fatalf("checkpoint: %v", err)
125 }
126 for _, needle := range []string{"Interrogation about the acquisition", "Bring nothing", "mondays 12-3",
127 "Dermot Lacey", "dermot.lacey@elsewhere.example", "The folder", token} {
128 if found, where := app.GrepDatabaseForTest(t, needle); found {
129 t.Errorf("%q appears in plaintext in %s", needle, where)
130 }
131 }
132}
133
134// A row that isn't yours doesn't exist — 404, never 403, so no id can
135// be enumerated by watching which ones answer differently.
136func TestAnotherPersonsEventIsNotFound(t *testing.T) {
137 app, mine := signedIn(t, "me@example.com")
138 h := app.Handler()
139
140 loc := createEvent(t, h, mine, url.Values{
141 "title": {"Mine"},
142 "start": {"2026-09-02T09:30"},
143 "end": {"2026-09-02T10:00"},
144 "kind": {"meeting"},
145 "status": {"confirmed"},
146 "repeat": {"none"},
147 })
148
149 // A second person on the same instance. They share the team
150 // calendar, so this event must be on a personal one to be hidden —
151 // which is exactly why a new event defaults to the personal
152 // calendar rather than the shared one.
153 theirs := app.SignInForTest(t, "someone-else@example.com")
154 rec := getAs(t, h, theirs, loc)
155 if rec.Code != http.StatusNotFound {
156 t.Errorf("GET %s as another person: status %d, want 404", loc, rec.Code)
157 }
158}
159
160// A recurring event appears on every day the rule names, without any
161// of those occurrences existing as rows.
162func TestARecurringEventAppearsOnEveryDayItNames(t *testing.T) {
163 app, session := signedIn(t, "someone@example.com")
164 h := app.Handler()
165
166 createEvent(t, h, session, url.Values{
167 "title": {"Daily standup"},
168 "start": {"2026-09-02T09:30"},
169 "end": {"2026-09-02T09:45"},
170 "kind": {"meeting"},
171 "status": {"confirmed"},
172 "repeat": {"daily"},
173 })
174
175 for _, day := range []string{"2026-09-02", "2026-09-03", "2026-09-10"} {
176 body := getAs(t, h, session, "/day/"+day).Body.String()
177 if !strings.Contains(body, "Daily standup") {
178 t.Errorf("the series is missing from %s", day)
179 }
180 }
181}
182
183// A form that fails validation must give back what was typed. Losing
184// it is the single most common way a form wastes somebody's time.
185func TestARejectedFormKeepsWhatWasTyped(t *testing.T) {
186 app, session := signedIn(t, "someone@example.com")
187
188 rec := postAs(t, app.Handler(), session, "/events", url.Values{
189 "title": {"A meeting that ends before it starts"},
190 "calendar_id": {"1"},
191 "start": {"2026-09-02T10:00"},
192 "end": {"2026-09-02T09:00"},
193 "kind": {"meeting"},
194 "status": {"confirmed"},
195 "repeat": {"none"},
196 })
197 if rec.Code != http.StatusUnprocessableEntity {
198 t.Fatalf("status %d, want 422", rec.Code)
199 }
200 body := rec.Body.String()
201 if !strings.Contains(body, "A meeting that ends before it starts") {
202 t.Error("the rejected form lost the title that was typed")
203 }
204 if !strings.Contains(body, "It cannot end before it starts.") {
205 t.Errorf("the rejected form does not say what was wrong:\n%s", body)
206 }
207}
208
209// Every page must work with scripts off. The floor is that no page
210// depends on one to be usable, so nothing here may carry an affordance
211// that only a script can reach.
212func TestEveryPageWorksWithoutScript(t *testing.T) {
213 app, session := signedIn(t, "someone@example.com")
214 h := app.Handler()
215 today := time.Now().Format("2006-01-02")
216
217 for _, target := range []string{
218 "/today", "/day/" + today, "/week/" + today,
219 "/month/" + today[:7], "/agenda", "/calendars",
220 "/events/new", "/settings", "/import",
221 } {
222 rec := follow(t, h, session, target)
223 if rec.Code != http.StatusOK {
224 t.Errorf("GET %s: status %d, want 200", target, rec.Code)
225 continue
226 }
227 body := rec.Body.String()
228 // An onclick is the tell that an affordance was built as a
229 // script hook rather than as a link or a form.
230 if strings.Contains(body, "onclick=") {
231 t.Errorf("GET %s carries an onclick handler; the scriptless floor is the product", target)
232 }
233 }
234}
235
236// The half-hour slots are what make "click a time to create an event"
237// work with no JavaScript. They are ordinary links, and each one is
238// named for a screen reader.
239func TestTheDayOffersScriptlessSlotLinks(t *testing.T) {
240 app, session := signedIn(t, "someone@example.com")
241 body := getAs(t, app.Handler(), session, "/day/2026-09-02").Body.String()
242
243 if n := strings.Count(body, "cal-slot"); n < 40 {
244 t.Errorf("the day offers %d slot links, want one per half hour", n)
245 }
246 if !strings.Contains(body, "Create an event at 09:30") {
247 t.Errorf("slot links carry no accessible name:\n%s", body[:min(2000, len(body))])
248 }
249 if !strings.Contains(body, "/events/new?start=2026-09-02T09%3A30") &&
250 !strings.Contains(body, "/events/new?start=2026-09-02T09:30") {
251 t.Error("a slot link does not prefill the form with its own time")
252 }
253}
254
255func min(a, b int) int {
256 if a < b {
257 return a
258 }
259 return b
260}
261
262var _ = calendar.MaxOccurrencesPerSeries
263