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) } }