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>
239 lines
7.4 KiB
Go
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])
|
|
}
|
|
}
|