Files
compass_teams_nhs/internal/calendar/sync_test.go
Sam Rolfe f6be17fe4f Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).

- internal/calendar: HA REST client (states, create_event, get_events,
  delete_event) and the sync that selects important future events, adopts an
  existing event by its description marker, records the HA event id on the
  item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
  the append-only calendar_pushes table), candidates, mark-synced and the
  push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
  failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.

The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 18:01:30 +11:00

239 lines
7.4 KiB
Go

package calendar
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
func testStore(t *testing.T) (*store.Store, int64) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(context.Background(), "Harry"); err != nil {
t.Fatalf("seed: %v", err)
}
child, err := st.ChildByName(context.Background(), "Harry")
if err != nil || child == nil {
t.Fatalf("child: %v %v", child, err)
}
return st, child.ID
}
// seedEvent stores one calendar event for a child.
func seedEvent(t *testing.T, st *store.Store, childID int64, title string, due time.Time, importance string, ref string) store.Item {
t.Helper()
item := store.Item{
Source: "compass",
Type: "event",
Title: title,
Body: title + "\nTeacher(s): KSH",
DueAt: &due,
URL: "https://example.invalid/Organise/Calendar/",
SourcePage: "calendar",
Importance: importance,
ExternalRef: ref,
}
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, err := st.ItemsForChild(context.Background(), childID)
if err != nil {
t.Fatalf("items: %v", err)
}
for _, it := range items {
if it.ExternalRef == ref {
return it
}
}
t.Fatalf("seeded item %q not found", ref)
return store.Item{}
}
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestSyncIsIdempotent(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "End of Year Senior Music Concert - HALL - KSH", due, "high", "concert")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if first.Candidates != 1 || first.Created != 1 || first.Failed != 0 {
t.Fatalf("first result = %+v", first)
}
if ha.created() != 1 {
t.Fatalf("first Sync made %d create calls", ha.created())
}
// The item now carries the Home Assistant event id, so it is no longer a
// candidate: a re-run writes nothing.
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Candidates != 0 || second.Created != 0 {
t.Fatalf("second result = %+v", second)
}
if ha.created() != 1 {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}
func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
// The event exists in Home Assistant, but the item has no id (a crashed
// run, or a Home Assistant that reports no uid). The marker adopts it.
ha.events = []map[string]any{{
"uid": "uid-existing",
"summary": "Harry — Year 11 Assembly - HALL - CMA",
"description": "source: " + Marker(item.ID),
"start_date_time": due.Format(time.RFC3339),
}}
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 0 || res.Skipped != 1 {
t.Fatalf("result = %+v", res)
}
if ha.created() != 0 {
t.Fatalf("Sync wrote a duplicate (%d create calls)", ha.created())
}
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil {
t.Fatalf("item: %v", err)
}
if ci.HAEventUID != "uid-existing" {
t.Errorf("adopted uid = %q", ci.HAEventUID)
}
}
func TestSyncRecordsALoudFailure(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Tour to China photo - THEATRE", due, "high", "photo")
ha := newFakeHA(t, "calendar.school")
ha.failCreate = true
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Failed != 1 || len(res.Errors) != 1 {
t.Fatalf("result = %+v", res)
}
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if summary.Failed != 1 || summary.Last == nil || summary.Last.OK {
t.Fatalf("push summary = %+v", summary)
}
if !strings.Contains(summary.Last.Error, "calendar is busy") {
t.Errorf("recorded error loses the reason: %q", summary.Last.Error)
}
// A failed write leaves the item as a candidate, so a later run retries it.
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil {
t.Fatalf("item: %v", err)
}
if ci.HAEventUID != "" {
t.Errorf("failed item should not carry an event id, got %q", ci.HAEventUID)
}
}
func TestSyncDryRunWritesNothing(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "Senior Concert Band rehearsal - ENS", due, "high", "band")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now(), DryRun: true}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 1 || !res.DryRun {
t.Fatalf("result = %+v", res)
}
if ha.created() != 0 {
t.Fatalf("dry run wrote %d events", ha.created())
}
}
func TestSyncIgnoresRoutineLessonsAndPastEvents(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
now := time.Now()
seedEvent(t, st, childID, "5 - 11HLEGB5 - D8 - LHT", now.Add(3*time.Hour), "normal", "lesson")
seedEvent(t, st, childID, "Year 11 Assembly - HALL", now.Add(-2*time.Hour), "high", "past")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
candidates, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
if err != nil {
t.Fatalf("candidates: %v", err)
}
// The store selects on importance; the lesson and the past event are
// excluded because the parser never marks a lesson high and the past event
// is behind `now`.
if len(candidates) != 0 {
t.Fatalf("candidates = %+v", candidates)
}
res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 0 || ha.created() != 0 {
t.Fatalf("result = %+v", res)
}
}
func TestSyncReportsAReadFailureInsteadOfWritingBlind(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
ha.Close() // the calendar is unreachable
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Failed != 1 || res.Created != 0 {
t.Fatalf("result = %+v", res)
}
if !strings.Contains(res.Errors[0], "reading existing events failed") {
t.Errorf("error = %q", res.Errors[0])
}
}