Files
compass_teams_nhs/internal/web/server_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

501 lines
16 KiB
Go

package web
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
_ "time/tzdata"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
const (
testUser = "sam"
testPass = "correct horse battery staple"
)
var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
// newTestServer builds the web handler against a fresh SQLite database.
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
return newTestServerCalendar(t, config.CalendarConfig{})
}
// newTestServerCalendar is newTestServer with a Home Assistant calendar target.
func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("location: %v", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(context.Background(), "Harry", "Finn"); err != nil {
t.Fatalf("seed: %v", err)
}
cfg := &config.Config{
Addr: ":0",
DBPath: "test",
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
AdminUsername: testUser,
AdminPassword: testPass,
SessionTTL: time.Hour,
AppEnv: "test",
Version: "test",
Location: loc,
Calendar: cal,
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil {
t.Fatalf("auth: %v", err)
}
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("web: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
// login signs in and returns an HTTP client with the session cookie.
func login(t *testing.T, ts *httptest.Server) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("jar: %v", err)
}
client := &http.Client{Jar: jar}
page := getBody(t, client, ts.URL+"/login")
token := csrfToken(t, page)
form := url.Values{"csrf": {token}, "username": {testUser}, "password": {testPass}}
resp, err := client.PostForm(ts.URL+"/login", form)
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("login status = %d, want 200", resp.StatusCode)
}
// After login, do not follow redirects: a mutation's 303 is the assertion.
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return client
}
func getBody(t *testing.T, client *http.Client, url string) string {
t.Helper()
resp, err := client.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("get %s = %d, want 200", url, resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(b)
}
func csrfToken(t *testing.T, html string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(html)
if m == nil {
t.Fatalf("no csrf token in page")
}
return m[1]
}
// TestDashboardShowsChildItems proves FR-7: the selected child's items appear,
// overdue first, and another child's items do not leak in.
func TestDashboardShowsChildItems(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
overdue := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
today := time.Now().Add(2 * time.Hour)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
{Source: "compass", Type: "task", Subject: "11EENG", Title: "Overdue essay", ExternalRef: "h1", DueAt: &overdue, Body: "Status: Overdue"},
{Source: "compass", Type: "task", Subject: "11MGMA", Title: "Today chapter", ExternalRef: "h2", DueAt: &today, Body: "Status: Pending"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{
{Source: "compass", Type: "task", Title: "Finn secret task", ExternalRef: "f1"},
}); err != nil {
t.Fatalf("upsert finn: %v", err)
}
client := login(t, ts)
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
for _, want := range []string{
"Overdue essay", "Today chapter", "Overdue", "Due today", "11EENG",
} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
if strings.Contains(body, "Finn secret task") {
t.Error("Harry's dashboard leaked Finn's item")
}
oi := strings.Index(body, "Overdue essay")
ti := strings.Index(body, "Today chapter")
if oi < 0 || ti < 0 || oi > ti {
t.Error("overdue item must render before the due-today item")
}
}
// TestItemDetailAndDelete proves §5 screen 3 and the Delete half of FR-10.
func TestItemDetailAndDelete(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
{Source: "compass", Type: "task", Subject: "11EENG", Teacher: "Ms Lee",
Title: "Essay draft", Body: "Status: Overdue\nClass: Class Task", ExternalRef: "h1"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
for _, want := range []string{"Essay draft", "11EENG", "Ms Lee", "Class Task"} {
if !strings.Contains(detail, want) {
t.Errorf("detail missing %q", want)
}
}
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("delete: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
}
if got, _ := st.ItemByID(ctx, id); got != nil {
t.Error("item survived delete")
}
// The delete without a CSRF token is refused.
resp2, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete", url.Values{})
if err != nil {
t.Fatalf("delete2: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("tokenless delete = %d, want 403", resp2.StatusCode)
}
}
// TestFailLoudlyBanner proves FR-12: no scan is shown as NO SCAN, and a failed
// scan is shown as SCAN FAILED, never as an empty day.
func TestFailLoudlyBanner(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "NO SCAN HAS RUN YET") {
t.Error("missing NO SCAN HAS RUN YET banner")
}
if strings.Contains(body, "Nothing overdue.") {
t.Error("an unscanned child must not read as 'nothing overdue'")
}
if !strings.Contains(body, "Unknown — the last scan did not complete.") {
t.Error("missing the unknown empty state for a child with no scan")
}
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "Cloudflare challenge"); err != nil {
t.Fatalf("record: %v", err)
}
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "SCAN FAILED") {
t.Error("missing SCAN FAILED banner")
}
if !strings.Contains(body, "Cloudflare challenge") {
t.Error("failure message not shown")
}
if _, err := st.RecordScan(ctx, &harry.ID, 4, 9, true, ""); err != nil {
t.Fatalf("record: %v", err)
}
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "Last scan completed.") {
t.Error("healthy scan banner missing")
}
if !strings.Contains(body, "Nothing overdue.") {
t.Error("a healthy scan should allow the quiet empty state")
}
}
// TestSummaryBuildAndDelete proves the stored brief round-trips through the
// dashboard (FR-10 summary half).
func TestSummaryBuildAndDelete(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
resp, err := client.PostForm(ts.URL+"/summaries/build",
url.Values{"csrf": {csrfToken(t, page)}, "child": {itoa(harry.ID)}})
if err != nil {
t.Fatalf("build: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("build status = %d, want 303", resp.StatusCode)
}
summaries, err := st.ListSummaries(ctx, 10)
if err != nil || len(summaries) != 1 {
t.Fatalf("summaries = %d, %v", len(summaries), err)
}
page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Harry — 0 overdue") {
t.Errorf("brief not shown on dashboard: %s", snippet(page))
}
resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete",
url.Values{"csrf": {csrfToken(t, page)}})
if err != nil {
t.Fatalf("delete: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusSeeOther {
t.Fatalf("summary delete = %d, want 303", resp2.StatusCode)
}
if got, _ := st.SummaryByID(ctx, summaries[0].ID); got != nil {
t.Error("summary survived delete")
}
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func snippet(s string) string {
if len(s) > 400 {
return s[:400]
}
return s
}
// fakeHACalendar is a minimal Home Assistant calendar for the item-screen test.
// It records created events so a read-back can prove the write.
func fakeHACalendar(t *testing.T, failCreate bool) *httptest.Server {
t.Helper()
var events []map[string]any
mux := http.NewServeMux()
mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if failCreate {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"},
})
return
}
events = append(events, map[string]any{
"uid": "uid-1", "summary": body["summary"], "description": body["description"],
"start_date_time": body["start_date_time"],
})
_ = json.NewEncoder(w).Encode(map[string]any{
"service_response": map[string]any{"calendar.school": map[string]any{"uid": "uid-1"}},
})
})
mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) {
if events == nil {
events = []map[string]any{}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"service_response": map[string]any{"calendar.school": map[string]any{"events": events}},
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// TestDashboardWarnsWhenTheCalendarIsNotConfigured proves the calendar half of
// FR-12: an unconfigured target is stated, never silently skipped.
func TestDashboardWarnsWhenTheCalendarIsNotConfigured(t *testing.T) {
ts, _ := newTestServer(t)
client := login(t, ts)
body := getBody(t, client, ts.URL+"/")
if !strings.Contains(body, "CALENDAR NOT CONFIGURED") {
t.Errorf("dashboard does not warn about the calendar: %s", snippet(body))
}
if !strings.Contains(body, "SCHOOL_HA_URL") {
t.Error("warning does not name the missing configuration")
}
}
// TestItemCalendarPush proves the item screen writes one event and records it.
func TestItemCalendarPush(t *testing.T) {
ha := fakeHACalendar(t, false)
ts, st := newTestServerCalendar(t, config.CalendarConfig{
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
})
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "event", Title: "End of Year Senior Music Concert - HALL",
Body: "End of Year Senior Music Concert - HALL - KSH", DueAt: &due,
Importance: "high", ExternalRef: "e1",
}}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
if !strings.Contains(detail, "Add to calendar") {
t.Fatalf("item screen has no calendar action: %s", snippet(detail))
}
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("push: %v", err)
}
defer resp.Body.Close()
page, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("push status = %d, want 200", resp.StatusCode)
}
if !strings.Contains(string(page), "Added to calendar.school") {
t.Errorf("push page has no confirmation: %s", snippet(string(page)))
}
if !strings.Contains(string(page), "in the calendar") {
t.Errorf("item screen does not show the calendar state: %s", snippet(string(page)))
}
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if summary.Total != 1 || summary.Failed != 0 {
t.Fatalf("push log = %+v", summary)
}
ci, _ := st.CalendarItemByID(ctx, id)
if ci == nil || ci.HAEventUID != "uid-1" {
t.Fatalf("item does not carry the event id: %+v", ci)
}
}
// TestItemCalendarPushFailureIsLoud proves a failed write is shown and recorded.
func TestItemCalendarPushFailureIsLoud(t *testing.T) {
ha := fakeHACalendar(t, true)
ts, st := newTestServerCalendar(t, config.CalendarConfig{
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
})
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "event", Title: "Year 11 Assembly - HALL",
DueAt: &due, Importance: "high", ExternalRef: "e1",
}}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("push: %v", err)
}
defer resp.Body.Close()
page, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(page), "Calendar write failed") {
t.Errorf("failure is not shown on the page: %s", snippet(string(page)))
}
if !strings.Contains(string(page), "calendar is busy") {
t.Errorf("failure loses the reason: %s", snippet(string(page)))
}
summary, _ := st.CalendarPushSummary(ctx)
if summary.Failed != 1 {
t.Fatalf("failed write was not recorded: %+v", summary)
}
// The dashboard repeats the failure, so it is visible without the item page.
body := getBody(t, client, ts.URL+"/")
if !strings.Contains(body, "CALENDAR WRITE FAILED") {
t.Errorf("dashboard does not show the failed write: %s", snippet(body))
}
}
// TestItemCalendarPushNeedsCSRF proves the write is not open to a forged post.
func TestItemCalendarPushNeedsCSRF(t *testing.T) {
ha := fakeHACalendar(t, false)
ts, st := newTestServerCalendar(t, config.CalendarConfig{
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
})
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(4 * time.Hour).UTC()
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "event", Title: "Concert", DueAt: &due,
Importance: "high", ExternalRef: "e1",
}}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
client := login(t, ts)
resp, err := client.PostForm(ts.URL+"/items/"+itoa(items[0].ID)+"/calendar", url.Values{})
if err != nil {
t.Fatalf("push: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("tokenless push = %d, want 403", resp.StatusCode)
}
summary, _ := st.CalendarPushSummary(ctx)
if summary.Total != 0 {
t.Errorf("a tokenless push recorded a write: %+v", summary)
}
}