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>
This commit is contained in:
115
internal/store/calendar_test.go
Normal file
115
internal/store/calendar_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCalendarCandidatesSelectsImportantFutureEventsOnly proves the Phase 6
|
||||
// selection rule: only important, future Compass events that are not in the
|
||||
// calendar yet are candidates.
|
||||
func TestCalendarCandidatesSelectsImportantFutureEventsOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
future := now.Add(4 * time.Hour)
|
||||
past := now.Add(-4 * time.Hour)
|
||||
|
||||
items := []Item{
|
||||
{Source: "compass", Type: "event", Title: "Concert", DueAt: &future, Importance: "high", ExternalRef: "e1"},
|
||||
{Source: "compass", Type: "event", Title: "Lesson", DueAt: &future, Importance: "normal", ExternalRef: "e2"},
|
||||
{Source: "compass", Type: "event", Title: "Yesterday", DueAt: &past, Importance: "high", ExternalRef: "e3"},
|
||||
{Source: "compass", Type: "task", Title: "Essay", DueAt: &future, Importance: "high", ExternalRef: "e4"},
|
||||
{Source: "teams", Type: "event", Title: "Teams thing", DueAt: &future, Importance: "high", ExternalRef: "e5"},
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, items); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
|
||||
{Source: "compass", Type: "event", Title: "Finn event", DueAt: &future, Importance: "high", ExternalRef: "f1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert finn: %v", err)
|
||||
}
|
||||
|
||||
all, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("want 2 candidates (one per child), got %d: %+v", len(all), all)
|
||||
}
|
||||
if all[0].Title != "Concert" || all[1].Title != "Finn event" {
|
||||
t.Errorf("unexpected candidate order: %+v", all)
|
||||
}
|
||||
|
||||
onlyHarry, err := st.CalendarCandidates(ctx, &harry.ID, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
if len(onlyHarry) != 1 || onlyHarry[0].Child != "Harry" {
|
||||
t.Fatalf("per-child filter failed: %+v", onlyHarry)
|
||||
}
|
||||
|
||||
// Mark the concert as synced; it must leave the candidate set.
|
||||
if err := st.MarkItemCalendarSynced(ctx, all[0].ID, "uid-1", now); err != nil {
|
||||
t.Fatalf("mark: %v", err)
|
||||
}
|
||||
after, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
if len(after) != 1 || after[0].Title != "Finn event" {
|
||||
t.Fatalf("synced item is still a candidate: %+v", after)
|
||||
}
|
||||
ci, err := st.CalendarItemByID(ctx, all[0].ID)
|
||||
if err != nil || ci == nil {
|
||||
t.Fatalf("item: %v %v", ci, err)
|
||||
}
|
||||
if ci.HAEventUID != "uid-1" || ci.HAPushedAt == nil {
|
||||
t.Errorf("mark did not stick: %+v", ci)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalendarPushSummaryIsAppendOnly proves a failed write stays on record.
|
||||
func TestCalendarPushSummaryIsAppendOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
due := time.Now().Add(time.Hour).UTC()
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
|
||||
{Source: "compass", Type: "event", Title: "Concert", DueAt: &due, Importance: "high", ExternalRef: "e1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, _ := st.ItemsForChild(ctx, harry.ID)
|
||||
|
||||
empty, err := st.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if empty.EverRun || empty.Total != 0 || empty.Last != nil {
|
||||
t.Fatalf("fresh summary = %+v", empty)
|
||||
}
|
||||
|
||||
if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "", false, "boom"); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "uid-1", true, ""); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
summary, err := st.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if summary.Total != 2 || summary.Failed != 1 || !summary.EverRun {
|
||||
t.Fatalf("summary = %+v", summary)
|
||||
}
|
||||
if summary.Last == nil || !summary.Last.OK || summary.Last.UID != "uid-1" {
|
||||
t.Fatalf("last = %+v", summary.Last)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user