Files
compass_teams_nhs/internal/calendar/ha_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

312 lines
8.9 KiB
Go

package calendar
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
)
// fakeHA is a minimal Home Assistant REST API for the calendar tests. It
// records what the client sent, so a test can prove the exact service calls.
type fakeHA struct {
*httptest.Server
mu sync.Mutex
entity string
events []map[string]any
createCalls int
getCalls int
failCreate bool
returnUID bool
lastCreate map[string]any
lastGet map[string]any
}
func newFakeHA(t *testing.T, entity string) *fakeHA {
t.Helper()
f := &fakeHA{entity: entity, returnUID: true}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
writeJSON(w, map[string]any{"message": "API running."})
})
mux.HandleFunc("GET /api/states", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
writeJSON(w, []map[string]any{
{"entity_id": f.entity, "state": "off", "attributes": map[string]any{
"friendly_name": "School", "supported_features": 3,
}},
{"entity_id": "calendar.holidays", "state": "off", "attributes": map[string]any{
"friendly_name": "Holidays",
}},
{"entity_id": "sensor.temperature", "state": "21", "attributes": map[string]any{}},
})
})
mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.createCalls++
f.lastCreate = body
if f.failCreate {
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]any{"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"}})
return
}
uid := ""
if f.returnUID {
uid = "uid-" + str(body["summary"])
}
f.events = append(f.events, map[string]any{
"uid": uid,
"summary": body["summary"],
"description": body["description"],
"start_date_time": body["start_date_time"],
"end_date_time": body["end_date_time"],
})
writeJSON(w, map[string]any{
"changed_entities": map[string]any{},
"service_response": map[string]any{f.entity: map[string]any{"uid": uid}},
})
})
mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
defer f.mu.Unlock()
f.getCalls++
f.lastGet = body
evs := f.events
if evs == nil {
evs = []map[string]any{}
}
writeJSON(w, map[string]any{
"service_response": map[string]any{f.entity: map[string]any{"events": evs}},
})
})
mux.HandleFunc("POST /api/services/calendar/delete_event", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
writeJSON(w, map[string]any{"service_response": map[string]any{}})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
func (f *fakeHA) authOK(w http.ResponseWriter, r *http.Request) bool {
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer test-token") {
w.WriteHeader(http.StatusUnauthorized)
writeJSON(w, map[string]any{"message": "Unauthorized"})
return false
}
return true
}
func (f *fakeHA) created() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.createCalls
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func testClient(t *testing.T, baseURL, entity string) *Client {
t.Helper()
c, err := New(config.CalendarConfig{
BaseURL: baseURL,
Token: "test-token",
EntityID: entity,
Timeout: 5 * time.Second,
}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
func TestClientRejectsIncompleteConfig(t *testing.T) {
if _, err := New(config.CalendarConfig{}, nil); err == nil {
t.Fatal("New accepted an empty config")
} else if !strings.Contains(err.Error(), "SCHOOL_HA_URL") {
t.Errorf("error does not name the missing variable: %v", err)
}
}
func TestHealthyAndCalendars(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
if err := c.Healthy(context.Background()); err != nil {
t.Fatalf("Healthy: %v", err)
}
entities, err := c.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
if len(entities) != 2 {
t.Fatalf("want 2 calendar entities, got %d (%v)", len(entities), entities)
}
if entities[0].EntityID != "calendar.holidays" || entities[1].EntityID != "calendar.school" {
t.Errorf("entities not sorted: %v", entities)
}
if !entities[1].CanWrite() {
t.Error("calendar.school should advertise CREATE_EVENT")
}
if entities[0].CanWrite() {
t.Error("calendar.holidays should read-only")
}
}
func TestHealthRejectsBadToken(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c, err := New(config.CalendarConfig{
BaseURL: ha.URL, Token: "wrong", EntityID: "calendar.school", Timeout: 5 * time.Second,
}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.Healthy(context.Background()); err == nil {
t.Fatal("Healthy accepted a bad token")
}
}
func TestCreateEventReturnsUID(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
uid, err := c.CreateEvent(context.Background(), Event{
Summary: "Harry — End of Year Senior Music Concert",
Start: &start,
End: ptrTime(start.Add(time.Hour)),
Description: "HALL\n\nsource: " + Marker(29),
})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid == "" {
t.Fatal("CreateEvent returned no uid")
}
created := ha.lastCreate
if got := str(created["entity_id"]); got != "calendar.school" {
t.Errorf("entity_id = %q", got)
}
if got := str(created["start_date_time"]); got != "2026-10-15T18:00:00Z" {
t.Errorf("start_date_time = %q", got)
}
if got := str(created["end_date_time"]); got != "2026-10-15T19:00:00Z" {
t.Errorf("end_date_time = %q", got)
}
if !strings.Contains(str(created["description"]), Marker(29)) {
t.Errorf("description lost the marker: %q", created["description"])
}
}
func TestCreateEventWithoutUIDIsNotAnError(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.returnUID = false
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
uid, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid != "" {
t.Errorf("want no uid, got %q", uid)
}
}
func TestCreateEventErrorCarriesHomeAssistantCode(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.failCreate = true
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
_, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)})
if err == nil {
t.Fatal("CreateEvent ignored a 500")
}
haErr, ok := err.(*Error)
if !ok {
t.Fatalf("want *Error, got %T", err)
}
if haErr.Status != http.StatusInternalServerError || haErr.ID != "unknown_error" {
t.Errorf("error = %+v", haErr)
}
if !strings.Contains(err.Error(), "calendar is busy") {
t.Errorf("error loses the message: %v", err)
}
}
func TestEventsReadBack(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
if _, err := c.CreateEvent(context.Background(), Event{
Summary: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)),
Description: "source: " + Marker(29),
}); err != nil {
t.Fatalf("CreateEvent: %v", err)
}
events, err := c.Events(context.Background(), start.Add(-time.Hour), start.Add(24*time.Hour))
if err != nil {
t.Fatalf("Events: %v", err)
}
if len(events) != 1 {
t.Fatalf("want 1 event, got %d", len(events))
}
if events[0].Summary != "Concert" || !strings.Contains(events[0].Description, Marker(29)) {
t.Errorf("event = %+v", events[0])
}
if events[0].Start == nil || !events[0].Start.Equal(start) {
t.Errorf("start = %v, want %v", events[0].Start, start)
}
body := ha.lastGet
if got := str(body["start_date_time"]); got == "" {
t.Error("get_events did not send a start window")
}
}
func TestFindMarker(t *testing.T) {
cases := map[string]string{
"source: [compass-nhs:29]": "[compass-nhs:29]",
"line\nsource: [compass-nhs:7]\nmore": "[compass-nhs:7]",
"no marker here": "",
"[compass-nhs:abc]": "",
"[compass-nhs:]": "",
}
for in, want := range cases {
if got := findMarker(in); got != want {
t.Errorf("findMarker(%q) = %q, want %q", in, got, want)
}
}
}