Files
compass_teams_nhs/internal/calendar/ha_test.go
Sam Rolfe da57efad73 Phase 6: fix the live Home Assistant write (Local Calendar, no event id)
The first live run against the new calendar.school entity failed loudly twice,
and both causes are fixed here.

- The Local Calendar rejects `return_response` on calendar.create_event. The
  client retries the call without it and ignores the changed-states array that
  Home Assistant returns instead.
- Home Assistant then reports no event id. The item keeps the description
  marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set
  and is never written twice. The live retry proved it: both events already in
  the calendar were adopted by their markers, with no duplicate.
- calendar.get_events reports `start` / `end` (a date-time or a bare date), not
  `start_date_time`; the reader now accepts all three shapes.

A model must no longer classify events. The calendar write depends on the
school-event rule, and the LLM labelled every event "normal"; events now keep
the deterministic rule under both classifiers.

Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan`
class, so select each banner by its labelled section.

Docs record the accepted decision: Sam chose a Home Assistant Local Calendar,
so the target is calendar.school.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:09:06 +11:00

375 lines
11 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
rejectReturnResponse 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
}
if f.rejectReturnResponse && r.URL.Query().Has("return_response") {
w.WriteHeader(http.StatusBadRequest)
writeJSON(w, map[string]any{"error": map[string]any{
"code": "unknown_error",
"message": "Service does not support responses. Remove return_response from request.",
}})
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": body["start_date_time"],
"end": body["end_date_time"],
})
if !r.URL.Query().Has("return_response") {
// Home Assistant replies with the changed states, a JSON array.
writeJSON(w, []map[string]any{})
return
}
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)
}
}
}
// TestCreateEventFallsBackWhenTheCalendarHasNoResponses proves the Local
// Calendar integration is usable: it rejects `return_response`, which is not a
// write failure.
func TestCreateEventFallsBackWhenTheCalendarHasNoResponses(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.rejectReturnResponse = true
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: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)),
Description: "source: " + Marker(29),
})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid != "" {
t.Errorf("want no uid, got %q", uid)
}
if ha.created() != 1 {
t.Fatalf("event was not written (%d create calls)", ha.created())
}
}
// TestParseHATime covers the shapes calendar.get_events reports.
func TestParseHATime(t *testing.T) {
timed := map[string]any{"start": "2026-10-15T15:05:00+11:00"}
got, raw := parseHATime(timed, "start")
if got == nil || raw != "2026-10-15T15:05:00+11:00" {
t.Fatalf("timed = %v %q", got, raw)
}
if got.Hour() != 15 || got.Minute() != 5 {
t.Errorf("timed = %v", got)
}
allDay := map[string]any{"start": "2026-10-15"}
got, raw = parseHATime(allDay, "start")
if got == nil || raw != "2026-10-15" {
t.Fatalf("all-day = %v %q", got, raw)
}
if y, m, d := got.Date(); y != 2026 || m != time.October || d != 15 {
t.Errorf("all-day = %v", got)
}
if got, raw := parseHATime(map[string]any{}, "start"); got != nil || raw != "" {
t.Errorf("missing = %v %q", got, raw)
}
}