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:
311
internal/calendar/ha_test.go
Normal file
311
internal/calendar/ha_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user