Files
compass_teams_nhs/internal/web/server_test.go
Sam Rolfe 9cbfa7db9b Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:

  GET  /capabilities  the routing topics the Hub reads, project "compass_nhs"
  POST /ask           one short, read-only answer about a child's school work
  POST /notify        accept the contract's notification shape, deliver via Apprise

All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".

/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.

Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.

/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.

Live proof on .13 (deploy/verify-phase8.sh):
  /health        200, ok true
  /capabilities  200, project compass_nhs; unsigned 401
  /ask           200, the sentence above, duration 1ms
  /notify        400 on a bad level (auth proven, nothing sent), then 200 with
                 a real delivery to Sam on email and WhatsApp

The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.

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

878 lines
29 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"
"sync"
"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 newTestServerFull(t, config.CalendarConfig{}, config.AppriseConfig{})
}
// newTestServerCalendar is newTestServer with a Home Assistant calendar target.
func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) {
return newTestServerFull(t, cal, config.AppriseConfig{})
}
// newTestServerNotify is newTestServer with an Apprise gateway and token.
func newTestServerNotify(t *testing.T, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
return newTestServerFull(t, config.CalendarConfig{}, ap)
}
// newTestServerHub is newTestServerNotify with the Hub bearer token set.
func newTestServerHub(t *testing.T, ap config.AppriseConfig, hubToken string) (*httptest.Server, *store.Store) {
return newTestServerAll(t, config.CalendarConfig{}, ap, hubToken)
}
// newTestServerFull builds the handler with both integrations configured.
func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
return newTestServerAll(t, cal, ap, "")
}
// newTestServerAll builds the handler with both integrations and the Hub token.
func newTestServerAll(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig, hubToken string) (*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,
Apprise: ap,
HubToken: hubToken,
}
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.RecentNotifications(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.NotificationByID(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)
}
}
// ---------- Phase 7: Send panel and the 6:00am endpoint ----------
// testAppriseConfig is the recipient and channel set the tests use.
func testAppriseConfig(url, key, token string) config.AppriseConfig {
return config.AppriseConfig{
BaseURL: url,
Key: key,
Timeout: 2 * time.Second,
Channels: []string{config.ChannelEmail, config.ChannelWhatsApp},
Recipients: []config.Recipient{
{Name: "Harry", TagBase: "harry"},
{Name: "Finn", TagBase: "finn"},
{Name: "Jo", TagBase: "joanna"},
{Name: "Sam", TagBase: "sam"},
},
Parents: []string{"Jo", "Sam"},
NotifyToken: token,
}
}
// fakeAppriseServer answers every notification with the given status and records
// the tags it was asked to notify.
func fakeAppriseServer(t *testing.T, status int) (*httptest.Server, *[]string) {
t.Helper()
var mu sync.Mutex
var tags []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Tag []string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
tags = append(tags, strings.Join(body.Tag, ","))
mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv, &tags
}
// TestSendPanelShowsPreviewAndSends proves the Send panel (§5 screen 4): the
// page previews the brief, choosing a recipient and channel sends it, and the
// delivery is recorded against the right tag (FR-9, FR-10).
func TestSendPanelShowsPreviewAndSends(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Send a brief") {
t.Fatalf("Send panel missing: %s", snippet(page))
}
if !strings.Contains(page, "OVERDUE") && !strings.Contains(page, "Harry —") {
t.Errorf("preview missing from the Send panel: %s", snippet(page))
}
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("send status = %d, want 303", resp.StatusCode)
}
if len(*tags) != 1 || (*tags)[0] != "harry_email" {
t.Fatalf("apprise tags = %#v, want [harry_email]", *tags)
}
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 1 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
got := rows[0]
if got.Scope != "Harry" || got.Recipient != "Harry" || got.Channel != "email" || !got.OK || got.SentAt == nil {
t.Errorf("row = %+v", got)
}
if !strings.Contains(got.Text, "Harry") {
t.Errorf("stored brief = %q", got.Text)
}
// The dashboard reports the send back, and the banner is healthy.
page = getBody(t, client, ts.URL+"/")
if !strings.Contains(page, "delivered") {
t.Errorf("dashboard does not report the send: %s", snippet(page))
}
}
// TestSendPanelIsLoudWithoutApprise proves a missing gateway is shown, and the
// send is refused instead of being reported as sent (FR-12).
func TestSendPanelIsLoudWithoutApprise(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))
if !strings.Contains(page, "NOTIFICATIONS NOT CONFIGURED") {
t.Errorf("missing banner: %s", snippet(page))
}
resp, err := client.PostForm(ts.URL+"/summaries/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 with the failure reported", resp.StatusCode)
}
if rows, _ := st.RecentNotifications(ctx, 10); len(rows) != 0 {
t.Errorf("a refused send must not be recorded as delivered: %+v", rows)
}
}
// TestSendReportsChannelFailure proves a failed channel is recorded and shown,
// never reported as a success (FR-12).
func TestSendReportsChannelFailure(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
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/send", url.Values{
"csrf": {csrfToken(t, page)},
"scope": {itoa(harry.ID)},
"recipient": {"Harry"},
"channel": {"email", "whatsapp"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
defer resp.Body.Close()
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
for _, n := range rows {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("row = %+v, want a recorded failure", n)
}
}
page = getBody(t, client, ts.URL+"/")
if !strings.Contains(page, "SEND FAILED") {
t.Errorf("dashboard does not show the failure: %s", snippet(page))
}
}
// TestMorningEndpoint covers the endpoint n8n calls at 6:00am: it refuses a bad
// token, refuses to run unconfigured, and delivers one brief per boy plus the
// combined brief, reporting failures as 207 (FR-9, FR-12, FR-14).
func TestMorningEndpoint(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
post := func(auth string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning", nil)
if err != nil {
t.Fatalf("request: %v", err)
}
if auth != "" {
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
resp := post("wrong")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad token status = %d, want 401", resp.StatusCode)
}
resp.Body.Close()
// A healthy morning: both children scanned, every channel answers 200.
for _, name := range []string{"Harry", "Finn"} {
c, _ := st.ChildByName(ctx, name)
if _, err := st.RecordScan(ctx, &c.ID, 4, 3, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
}
resp = post("n8n-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
OK bool `json:"ok"`
Channels []string `json:"channels"`
ScansFailed []string `json:"scans_failed"`
Sends []struct {
Scope string `json:"scope"`
Recipients []string
Delivered int `json:"delivered"`
Failed int `json:"failed"`
} `json:"sends"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.OK || len(body.Sends) != 3 {
t.Fatalf("body = %+v", body)
}
if len(body.ScansFailed) != 0 {
t.Errorf("scans_failed = %#v, want none", body.ScansFailed)
}
if len(*tags) != 8 {
t.Errorf("deliveries = %d, want 8 (2 boys + 2 parents, 2 channels)", len(*tags))
}
// The combined brief goes to both parents.
var combined *struct {
Scope string `json:"scope"`
Recipients []string
Delivered int `json:"delivered"`
Failed int `json:"failed"`
}
for i := range body.Sends {
if body.Sends[i].Scope == "combined" {
combined = &body.Sends[i]
}
}
if combined == nil || len(combined.Recipients) != 2 || combined.Delivered != 4 {
t.Errorf("combined send = %+v", combined)
}
rows, err := st.RecentNotifications(ctx, 20)
if err != nil || len(rows) != 8 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
// A failed scan still sends the briefs, but the run is not green (FR-12).
harry, _ := st.ChildByName(ctx, "Harry")
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "cloudflare challenge"); err != nil {
t.Fatalf("record failed scan: %v", err)
}
resp = post("n8n-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("failed-scan status = %d, want 207", resp.StatusCode)
}
var after struct {
OK bool `json:"ok"`
ScansFailed []string `json:"scans_failed"`
}
if err := json.NewDecoder(resp.Body).Decode(&after); err != nil {
t.Fatalf("decode: %v", err)
}
if after.OK || len(after.ScansFailed) != 1 || after.ScansFailed[0] != "Harry" {
t.Errorf("after a failed scan = %+v", after)
}
}
// TestMorningEndpointDisabled proves the endpoint is off, not silently
// accepting, when the token is unset.
func TestMorningEndpointDisabled(t *testing.T) {
ts, _ := newTestServerNotify(t, testAppriseConfig("http://127.0.0.1:1", "key1", ""))
resp, err := http.Post(ts.URL+"/notify/morning", "application/json", nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
// TestMorningEndpointFailsLoudly proves a failed delivery is a 207, so the n8n
// execution is not green when a brief did not go out (FR-12).
func TestMorningEndpointFailsLoudly(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, _ := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning", nil)
req.Header.Set("Authorization", "Bearer n8n-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("status = %d, want 207", resp.StatusCode)
}
}
// TestMorningEndpointDryRun proves the n8n wiring can be checked without
// messaging the family: the same build path, no delivery, nothing recorded.
func TestMorningEndpointDryRun(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerNotify(t, testAppriseConfig(apSrv.URL, "key1", "n8n-token"))
ctx := context.Background()
for _, name := range []string{"Harry", "Finn"} {
c, _ := st.ChildByName(ctx, name)
if _, err := st.RecordScan(ctx, &c.ID, 4, 3, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
}
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/notify/morning?dry_run=1&channels=email,whatsapp", nil)
req.Header.Set("Authorization", "Bearer n8n-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
DryRun bool `json:"dry_run"`
OK bool `json:"ok"`
Sends []struct {
Scope string `json:"scope"`
Recipients []string `json:"recipients"`
Text string `json:"text"`
} `json:"sends"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.DryRun || !body.OK || len(body.Sends) != 3 {
t.Fatalf("body = %+v", body)
}
if body.Sends[2].Scope != "combined" || len(body.Sends[2].Recipients) != 2 {
t.Errorf("combined plan = %+v", body.Sends[2])
}
if body.Sends[0].Text == "" {
t.Error("dry run should return the brief text")
}
if len(*tags) != 0 {
t.Errorf("a dry run must not call Apprise: %#v", *tags)
}
if rows, _ := st.RecentNotifications(ctx, 10); len(rows) != 0 {
t.Errorf("a dry run must not record deliveries: %+v", rows)
}
}