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) } // newTestServerFull builds the handler with both integrations configured. func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*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, } 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) } }