The morning briefs go out through the house Apprise API on `.35:8210`: one brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an address, a phone number or an SMTP password (AGENTS.md §10 rule 3). - internal/apprise: keyed and stateless client, tag filtering, and error redaction so nothing sensitive reaches a log, a row or a screen. - internal/brief: builds the per-boy and the combined brief, delivers on email and WhatsApp, records every attempt in `notifications` (migration 004 adds scope, channel, tag, ok and error) and fails loudly. - Send panel (§5 screen 4): recipients, channels, an exact preview, Send now, plus the Send action on a stored draft (FR-10). The dashboard shows a Morning briefs banner and the Sends list. - POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring without messaging the family. - scripts/morning-brief.sh: scan both children, then send. It is the n8n job; deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily). - FR-12: a failed scan is never "nothing due" -- the brief says so, and the run exits non-zero. A failed delivery is recorded with no sent_at. - The long no-due-date tail is capped in the sent brief so it stays brief (§10 rule 6); overdue work is never capped. Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and sent_at, and the endpoint answers 401/200/207 as documented. Evidence in docs/phase-7-notify-notes.md. Co-Authored-By: Paperclip <noreply@paperclip.ing>
867 lines
28 KiB
Go
867 lines
28 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|