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>
263 lines
8.6 KiB
Go
263 lines
8.6 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "time/tzdata"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
|
)
|
|
|
|
// sydney is the family timezone used in the tests.
|
|
func sydney(t *testing.T) *time.Location {
|
|
t.Helper()
|
|
loc, err := time.LoadLocation("Australia/Sydney")
|
|
if err != nil {
|
|
t.Fatalf("load location: %v", err)
|
|
}
|
|
return loc
|
|
}
|
|
|
|
func due(t *testing.T, loc *time.Location, s string) *time.Time {
|
|
t.Helper()
|
|
v, err := time.ParseInLocation("2006-01-02 15:04", s, loc)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", s, err)
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// TestClassifyBuckets proves FR-6/FR-7: overdue first, then due today, due this
|
|
// week, no due date and messages, and that submitted work is not "due".
|
|
func TestClassifyBuckets(t *testing.T) {
|
|
loc := sydney(t)
|
|
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
|
|
|
items := []store.Item{
|
|
{Type: "task", Title: "Old essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
|
|
{Type: "task", Title: "Past pending", DueAt: due(t, loc, "2026-10-14 09:00"), Body: "Submitted: Pending"},
|
|
{Type: "task", Title: "Today task", DueAt: due(t, loc, "2026-10-15 15:00"), Body: "Status: Pending"},
|
|
{Type: "event", Title: "Today lesson", DueAt: due(t, loc, "2026-10-15 10:00")},
|
|
{Type: "task", Title: "Friday task", DueAt: due(t, loc, "2026-10-17 09:00"), Body: "Status: Pending"},
|
|
{Type: "task", Title: "Next month", DueAt: due(t, loc, "2026-11-02 09:00"), Body: "Status: Pending"},
|
|
{Type: "task", Title: "No date", Body: "Status: Pending"},
|
|
{Type: "task", Title: "Already done", DueAt: due(t, loc, "2026-10-01 09:00"), Body: "Status: On Time"},
|
|
{Type: "task", Title: "Late but in", DueAt: due(t, loc, "2026-10-02 09:00"), Body: "Status: Received Late"},
|
|
{Type: "event", Title: "Past lesson", DueAt: due(t, loc, "2026-10-12 09:00")},
|
|
{Type: "message", Title: "Notice", DueAt: due(t, loc, "2026-10-14 07:00")},
|
|
}
|
|
|
|
b := Classify(items, now, loc)
|
|
|
|
want := map[string][]string{
|
|
"overdue": {"Old essay", "Past pending"},
|
|
"today": {"Today lesson", "Today task"},
|
|
"week": {"Friday task"},
|
|
"later": {"Next month"},
|
|
"nodue": {"No date"},
|
|
"done": {"Already done", "Late but in"},
|
|
"messages": {"Notice"},
|
|
}
|
|
got := map[string][]string{
|
|
"overdue": titles(b.Overdue),
|
|
"today": titles(b.DueToday),
|
|
"week": titles(b.DueWeek),
|
|
"later": titles(b.Later),
|
|
"nodue": titles(b.NoDue),
|
|
"done": titles(b.Done),
|
|
"messages": titles(b.Messages),
|
|
}
|
|
for k, w := range want {
|
|
if !equal(got[k], w) {
|
|
t.Errorf("%s = %v, want %v", k, got[k], w)
|
|
}
|
|
}
|
|
// A past event is history, not overdue work: it must not appear anywhere.
|
|
for k, rows := range got {
|
|
for _, title := range rows {
|
|
if title == "Past lesson" {
|
|
t.Errorf("past event appeared in %s", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
if b.Actionable() != 6 {
|
|
t.Errorf("Actionable = %d, want 6", b.Actionable())
|
|
}
|
|
if b.Empty() {
|
|
t.Error("Empty = true with items present")
|
|
}
|
|
}
|
|
|
|
// TestOverdueFromDueDateOnly proves a task with no status line still lands in
|
|
// overdue when it is past due.
|
|
func TestOverdueFromDueDateOnly(t *testing.T) {
|
|
loc := sydney(t)
|
|
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
|
b := Classify([]store.Item{
|
|
{Type: "task", Title: "No status", DueAt: due(t, loc, "2026-10-01 09:00")},
|
|
}, now, loc)
|
|
if len(b.Overdue) != 1 {
|
|
t.Fatalf("overdue = %v, want the no-status past task", titles(b.Overdue))
|
|
}
|
|
}
|
|
|
|
func TestStatusParsing(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Status: Overdue\nClass: X": "overdue",
|
|
"Class: X\nSubmitted: On Time": "on time",
|
|
"Submitted: Received Late\nx": "received late",
|
|
"Status: Pending": "pending",
|
|
"no status here": "",
|
|
"Submitted: Overdue.": "overdue",
|
|
}
|
|
for body, want := range cases {
|
|
if got := Status(body); got != want {
|
|
t.Errorf("Status(%q) = %q, want %q", body, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderOverdueFirst proves the brief lists overdue before anything else and
|
|
// stays list-shaped (rule 6).
|
|
func TestRenderOverdueFirst(t *testing.T) {
|
|
loc := sydney(t)
|
|
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
|
b := Classify([]store.Item{
|
|
{Type: "task", Subject: "11EENG", Title: "Essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
|
|
{Type: "task", Subject: "11MGMA", Title: "Chapter 4", DueAt: due(t, loc, "2026-10-15 15:00"), Body: "Status: Pending"},
|
|
{Type: "message", Title: "Sports day"},
|
|
}, now, loc)
|
|
|
|
text := Render("Harry", b, loc)
|
|
if !strings.Contains(text, "Harry — 1 overdue · 1 due today") {
|
|
t.Errorf("headline missing: %q", text)
|
|
}
|
|
oi := strings.Index(text, "OVERDUE")
|
|
ti := strings.Index(text, "DUE TODAY")
|
|
if oi < 0 || ti < 0 || oi > ti {
|
|
t.Errorf("overdue must come first:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "- 11EENG: Essay (due Sat 10 Oct 09:00) [overdue]") {
|
|
t.Errorf("overdue line wrong:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "MESSAGES\n- Sports day") {
|
|
t.Errorf("messages missing:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func titles(items []store.Item) []string {
|
|
out := make([]string, 0, len(items))
|
|
for _, it := range items {
|
|
out = append(out, it.Title)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func equal(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestRenderCombinedBothBoys proves FR-14: the combined brief for Jo and Sam
|
|
// carries both boys, overdue first, and every line names the child.
|
|
func TestRenderCombinedBothBoys(t *testing.T) {
|
|
loc := sydney(t)
|
|
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
|
|
|
harry := Classify([]store.Item{
|
|
{Type: "task", Title: "Essay", Subject: "English", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
|
|
{Type: "task", Title: "Worksheet", Subject: "Maths", DueAt: due(t, loc, "2026-10-15 15:00")},
|
|
{Type: "message", Title: "Sports day", DueAt: due(t, loc, "2026-10-14 07:00")},
|
|
}, now, loc)
|
|
finn := Classify([]store.Item{
|
|
{Type: "task", Title: "Lab report", Subject: "Science", DueAt: due(t, loc, "2026-10-09 09:00"), Body: "Status: Overdue"},
|
|
}, now, loc)
|
|
|
|
text := RenderCombined([]Combined{
|
|
{Child: "Harry", Buckets: harry},
|
|
{Child: "Finn", Buckets: finn},
|
|
}, loc)
|
|
|
|
if !strings.HasPrefix(text, "Harry and Finn — 2 overdue · 1 due today · 0 this week · 1 message(s).\n") {
|
|
t.Errorf("headline = %q", firstLineOf(text))
|
|
}
|
|
overdue := strings.Index(text, "OVERDUE")
|
|
today := strings.Index(text, "DUE TODAY")
|
|
if overdue < 0 || today < 0 || overdue > today {
|
|
t.Errorf("overdue must come first:\n%s", text)
|
|
}
|
|
for _, want := range []string{
|
|
"- Harry: English: Essay (due Sat 10 Oct 09:00) [overdue]",
|
|
"- Finn: Science: Lab report (due Fri 09 Oct 09:00) [overdue]",
|
|
"- Harry: Maths: Worksheet (due Thu 15 Oct 15:00)",
|
|
"MESSAGES",
|
|
"- Harry: Sports day (Wed 14 Oct 07:00)",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Errorf("combined brief is missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderCombinedSingleChild proves one child's brief still renders a
|
|
// sensible combined message.
|
|
func TestRenderCombinedSingleChild(t *testing.T) {
|
|
loc := sydney(t)
|
|
text := RenderCombined([]Combined{{Child: "Harry"}}, loc)
|
|
if !strings.HasPrefix(text, "Harry — 0 overdue") {
|
|
t.Errorf("text = %q", text)
|
|
}
|
|
if strings.Contains(text, "OVERDUE") {
|
|
t.Errorf("an empty brief must not show an OVERDUE heading: %q", text)
|
|
}
|
|
}
|
|
|
|
func firstLineOf(s string) string {
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// TestRenderWithLimitsKeepsTheBriefBrief proves the long tail is summarised, so
|
|
// the morning message stays a brief (AGENTS.md §10 rule 6) without hiding work.
|
|
func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) {
|
|
loc := sydney(t)
|
|
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
|
|
|
items := make([]store.Item, 0, 8)
|
|
for _, title := range []string{"A", "B", "C", "D", "E", "F", "G", "H"} {
|
|
items = append(items, store.Item{Type: "task", Title: "Pending " + title, Body: "Status: Pending"})
|
|
}
|
|
items = append(items, store.Item{
|
|
Type: "task", Title: "Essay", Subject: "English",
|
|
DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue",
|
|
})
|
|
|
|
b := Classify(items, now, loc)
|
|
limited := RenderWithLimits("Harry", b, loc, Limits{NoDue: 3})
|
|
if !strings.Contains(limited, "… and 5 more with no due date") {
|
|
t.Errorf("the long tail is not summarised:\n%s", limited)
|
|
}
|
|
if strings.Contains(limited, "Pending D") {
|
|
t.Errorf("the cap did not apply:\n%s", limited)
|
|
}
|
|
// Overdue work is never hidden by a cap.
|
|
if !strings.Contains(limited, "English: Essay") {
|
|
t.Errorf("overdue work went missing:\n%s", limited)
|
|
}
|
|
if full := Render("Harry", b, loc); !strings.Contains(full, "Pending H") {
|
|
t.Errorf("the full render must keep every item:\n%s", full)
|
|
}
|
|
}
|