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>
312 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|