package calendartest import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // A reminder must actually be sent, by the path that really sends it. // // Written after DrainOnRequest shipped as dead code — defined, // commented, described in the spec as one of three entrances, and // called from nowhere. Every unit around it passed. This asserts the // observable end: a message arrived. func TestAReminderIsActuallySent(t *testing.T) { // The tick guard is a constant-time comparison against this // instance's own token, and it is false when the environment has // none — so a test that does not set it exercises the refusal // rather than the drain. t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") app, session := signedIn(t, "someone@example.com") h := app.Handler() // An event soon enough that its default ten-minute reminder is // already due. The reminder is created with the event. // // UTC, because that is the test person's zone. Formatting in the // SERVER's local zone is the mistake this comment exists to stop // coming back: the app reads a submitted wall time in the person's // zone, so on a machine at +01:00 every event landed an hour // further out than intended and no reminder was ever due. start := time.Now().UTC().Add(2 * time.Minute) createEvent(t, h, session, url.Values{ "title": {"Imminent standup"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, }) // The sign-in link is the last mail so far; anything after this is // the reminder. before, _ := app.LastMailForTest() req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("POST /jobs/reminders with the right bearer: status %d, want 204\n%s", rec.Code, rec.Body.String()) } after, err := app.LastMailForTest() if err != nil { t.Fatalf("LastMailForTest: %v", err) } if after == before { t.Fatal("the drain sent nothing; the reminder never fired") } if !strings.Contains(after, "Imminent standup") { t.Errorf("the reminder does not name the event:\n%s", after) } // The message carries a link back to the event, which is the whole // point of receiving it on a phone. if !strings.Contains(after, "/events/") { t.Errorf("the reminder carries no link to the event:\n%s", after) } } // The same occurrence must not be sent twice by two drains running at // once — a tick and a page view, say. The claim is a conditional // UPDATE for exactly this reason. func TestAReminderIsNotSentTwice(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") app, session := signedIn(t, "someone@example.com") h := app.Handler() start := time.Now().UTC().Add(2 * time.Minute) createEvent(t, h, session, url.Values{ "title": {"Only once"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, }) tick := func() { req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil) req.Header.Set("Authorization", "Bearer test-token") h.ServeHTTP(httptest.NewRecorder(), req) } tick() first, _ := app.LastMailForTest() if !strings.Contains(first, "Only once") { t.Fatalf("the first drain did not send the reminder:\n%s", first) } // A second drain has nothing left to do: the row is marked fired, // and a single event has no next occurrence to queue. tick() second, _ := app.LastMailForTest() if second != first { t.Errorf("a second drain sent something else; the occurrence was not marked fired:\n%s", second) } } // NextDue is what the platform asks before hibernating. It must name // the earliest unfired reminder, or the instance sleeps through it. func TestNextDuePointsAtTheEarliestReminder(t *testing.T) { app, session := signedIn(t, "someone@example.com") h := app.Handler() if due := settledNextDue(t, app); !due.IsZero() { t.Errorf("NextDue on an empty calendar = %v, want the zero time", due) } start := time.Now().UTC().Add(3 * time.Hour) createEvent(t, h, session, url.Values{ "title": {"Later"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(30 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, }) due := settledNextDue(t, app) if due.IsZero() { t.Fatal("NextDue is zero with a reminder queued; the platform would hibernate through it") } // The default reminder is ten minutes before the start. want := start.Add(-10 * time.Minute) if diff := due.Sub(want); diff > time.Minute || diff < -time.Minute { t.Errorf("NextDue = %v, want about %v (ten minutes before the event)", due, want) } } // A reminder on a repeating event queues the NEXT occurrence after it // fires — one row per rule, never a materialised series. func TestARecurringReminderRequeuesItself(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") app, session := signedIn(t, "someone@example.com") h := app.Handler() start := time.Now().UTC().Add(2 * time.Minute) createEvent(t, h, session, url.Values{ "title": {"Daily thing"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}, }) req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil) req.Header.Set("Authorization", "Bearer test-token") h.ServeHTTP(httptest.NewRecorder(), req) // After firing, the rule's next occurrence must be queued — // roughly a day later. due := settledNextDue(t, app) if due.IsZero() { t.Fatal("a repeating reminder fired and queued nothing; the series would remind once and stop") } if until := time.Until(due); until < 20*time.Hour || until > 26*time.Hour { t.Errorf("the requeued reminder is %v away, want about a day", until) } } var _ = calendar.MaxReminderAttempts func settledNextDue(t *testing.T, app *calendar.App) time.Time { t.Helper() deadline := time.Now().Add(time.Second) for { due := app.NextDue() if due.IsZero() || due.After(time.Now().Add(time.Second)) || time.Now().After(deadline) { return due } time.Sleep(time.Millisecond) } }