Files
compass_teams_nhs/internal/brief/brief_test.go
Sam Rolfe 047af0f31d Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the
  school-wide week calendar's lesson grid and clean the leaked routine rows.
- Add the editable codes map (FR-17): subjects, rooms, teachers and period
  labels, seeded, an unknown code falls back to the raw code; `school codes` CLI.
- Brief: Finn's block first, then Harry's, four sections always present (FR-9);
  periods decoded into plain English; the undated tail is one counted line (FR-18).
- Calendar: write periods + work due + work overdue + major events, per child,
  on every pass. Identity is the stable per-item key, so a re-run never re-inserts
  (FR-8). morning-brief.sh now runs scan -> calendar push -> notify.
- Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods.

Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and
separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-16 10:25:55 +11:00

345 lines
11 KiB
Go

package brief
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// fakeApprise records every notification request and answers with the status
// the test chooses, so a delivery and a failure are both provable.
type fakeApprise struct {
mu sync.Mutex
tags []string
titles []string
bodies []string
tagFail map[string]int // tag -> status
fallback int
}
func (f *fakeApprise) server(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Tag []string `json:"tag"`
Title string `json:"title"`
Body string `json:"body"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
f.tags = append(f.tags, strings.Join(body.Tag, ","))
f.titles = append(f.titles, body.Title)
f.bodies = append(f.bodies, body.Body)
status := f.fallback
for _, tag := range body.Tag {
if s, ok := f.tagFail[tag]; ok {
status = s
}
}
f.mu.Unlock()
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if status != http.StatusOK {
_, _ = w.Write([]byte(`{"error":"One or more notifications not sent"}`))
}
}))
t.Cleanup(srv.Close)
return srv
}
func (f *fakeApprise) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.tags...)
}
// newService builds a brief.Service against a fresh database and the fake
// gateway.
func newService(t *testing.T, url string, seed func(*store.Store)) (*Service, *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() })
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(ctx, "Harry", "Finn"); err != nil {
t.Fatalf("seed: %v", err)
}
if seed != nil {
seed(st)
}
cfg := config.AppriseConfig{
BaseURL: url,
Key: "test-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"},
}
client := apprise.New(url, cfg.Key, "", cfg.Timeout)
svc := New(st, client, cfg, loc, slog.New(slog.NewTextHandler(io.Discard, nil)))
svc.Now = func() time.Time { return time.Date(2026, 10, 16, 6, 0, 0, 0, loc) }
return svc, st
}
// seedItems stores one overdue task per child and a successful scan, so the
// briefs are not empty.
func seedItems(st *store.Store) {
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)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "task", Subject: "English", Title: "Essay",
DueAt: &overdue, Body: "Status: Overdue", ExternalRef: "h-essay",
}}); err != nil {
panic(err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{{
Source: "compass", Type: "task", Subject: "Science", Title: "Lab report",
DueAt: &overdue, Body: "Status: Overdue", ExternalRef: "f-lab",
}}); err != nil {
panic(err)
}
if _, err := st.RecordScan(ctx, &harry.ID, 4, 1, true, ""); err != nil {
panic(err)
}
if _, err := st.RecordScan(ctx, &finn.ID, 4, 1, true, ""); err != nil {
panic(err)
}
}
// TestMorningSendsPerBoyAndCombined proves FR-9 and FR-14: one brief per boy,
// one combined brief to Jo and Sam, one recorded row per recipient per channel.
func TestMorningSendsPerBoyAndCombined(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, st := newService(t, srv.URL, seedItems)
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail, config.ChannelWhatsApp})
if err != nil {
t.Fatalf("morning: %v", err)
}
if len(sends) != 3 {
t.Fatalf("sends = %d, want Finn, Harry and combined", len(sends))
}
for _, s := range sends {
if !s.OK() {
t.Errorf("send %s not OK: %+v", s.Scope, s)
}
}
// Finn's block comes first, then Harry's (FR-9).
if sends[0].Scope != "Finn" || sends[1].Scope != "Harry" {
t.Fatalf("send order = %s, %s; want Finn then Harry", sends[0].Scope, sends[1].Scope)
}
// Harry: 1 recipient x 2 channels. Combined: 2 recipients x 2 channels.
if got := len(sends[1].Deliveries); got != 2 {
t.Errorf("Harry deliveries = %d, want 2", got)
}
combined := sends[2]
if combined.Scope != ScopeCombined || len(combined.Deliveries) != 4 {
t.Fatalf("combined = %+v", combined)
}
recipients := map[string]bool{}
for _, d := range combined.Deliveries {
recipients[d.Recipient] = true
}
if !recipients["Jo"] || !recipients["Sam"] {
t.Errorf("combined recipients = %#v", recipients)
}
if len(combined.Recipients) != 2 {
t.Errorf("combined recipient names = %#v", combined.Recipients)
}
// The tags are the configured per-channel names, and the combined text
// carries both boys, overdue first.
want := map[string]bool{
"harry_email": true, "harry_whatsapp": true,
"finn_email": true, "finn_whatsapp": true,
"joanna_email": true, "joanna_whatsapp": true,
"sam_email": true, "sam_whatsapp": true,
}
calls := fake.calls()
if len(calls) != 8 {
t.Fatalf("calls = %#v, want 8", calls)
}
for _, c := range calls {
if !want[c] {
t.Errorf("unexpected tag %q", c)
}
}
if !strings.Contains(combined.Text, "OVERDUE") ||
!strings.Contains(combined.Text, "=== FINN ===") ||
!strings.Contains(combined.Text, "=== HARRY ===") ||
!strings.Contains(combined.Text, "Science: Lab report") ||
!strings.Contains(combined.Text, "English: Essay") {
t.Errorf("combined text = %q", combined.Text)
}
// Finn's block is first and the two children are never interleaved.
if i, j := strings.Index(combined.Text, "=== FINN ==="), strings.Index(combined.Text, "=== HARRY ==="); i < 0 || j < 0 || i > j {
t.Errorf("combined blocks are not Finn then Harry: %q", combined.Text)
}
rows, err := st.RecentNotifications(context.Background(), 20)
if err != nil {
t.Fatalf("rows: %v", err)
}
if len(rows) != 8 {
t.Fatalf("rows = %d, want 8", len(rows))
}
for _, n := range rows {
if !n.OK || n.SentAt == nil || n.Channel == "" || n.Tag == "" {
t.Errorf("row = %+v", n)
}
}
}
// TestDeliveryFailureIsRecordedAndLoud proves a failed channel is recorded with
// its error and no sent_at, and that the send is not reported as OK (FR-12).
func TestDeliveryFailureIsRecordedAndLoud(t *testing.T) {
fake := &fakeApprise{tagFail: map[string]int{
"harry_whatsapp": http.StatusFailedDependency,
"finn_whatsapp": http.StatusFailedDependency,
"joanna_whatsapp": http.StatusFailedDependency,
"sam_whatsapp": http.StatusFailedDependency,
}}
srv := fake.server(t)
svc, st := newService(t, srv.URL, seedItems)
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail, config.ChannelWhatsApp})
if err != nil {
t.Fatalf("morning: %v", err)
}
for _, s := range sends {
if s.SentOK() {
t.Errorf("%s reported OK despite the WhatsApp failures", s.Scope)
}
wantFailed := 1 // the boy's own WhatsApp tag
if s.Scope == ScopeCombined {
wantFailed = 2 // Jo and Sam
}
if s.Failed() != wantFailed {
t.Errorf("%s failed = %d, want %d (one per WhatsApp recipient)", s.Scope, s.Failed(), wantFailed)
}
if s.FirstError() == "" {
t.Errorf("%s has no recorded error", s.Scope)
}
}
status, err := st.NotificationSummary(context.Background())
if err != nil {
t.Fatalf("summary: %v", err)
}
if status.Sent != 4 || status.Failed != 4 {
t.Errorf("summary = %+v, want 4 sent and 4 failed", status)
}
rows, _ := st.RecentNotifications(context.Background(), 20)
for _, n := range rows {
if n.Channel == config.ChannelWhatsApp {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("whatsapp row = %+v, want a loud failure", n)
}
}
}
}
// TestFailedScanIsNeverNothingDue proves FR-12: a child whose last scan failed
// gets an explicit warning instead of a clean, empty brief.
func TestFailedScanIsNeverNothingDue(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, _ := newService(t, srv.URL, func(st *store.Store) {
seedItems(st)
harry, _ := st.ChildByName(context.Background(), "Harry")
if _, err := st.RecordScan(context.Background(), &harry.ID, 1, 0, false, "cloudflare challenge"); err != nil {
t.Fatal(err)
}
})
sends, err := svc.Morning(context.Background(), []string{config.ChannelEmail})
if err != nil {
t.Fatalf("morning: %v", err)
}
if len(sends[1].ScanFailed) != 1 || sends[1].ScanFailed[0] != "Harry" {
t.Errorf("Harry scan failure not reported: %+v", sends[1].ScanFailed)
}
if !strings.Contains(sends[1].Text, "SCAN FAILED") {
t.Errorf("brief does not say the scan failed: %q", sends[1].Text)
}
if !strings.Contains(sends[1].Text, "UNKNOWN") {
t.Errorf("brief does not say due work is unknown: %q", sends[1].Text)
}
if !strings.Contains(sends[2].Text, "SCAN FAILED") {
t.Errorf("combined brief does not carry the failure: %q", sends[2].Text)
}
if sends[1].OK() {
t.Error("a scan failure must make the run not OK")
}
}
// TestNoScanYetIsLoud proves a child that has never been scanned is never shown
// as "nothing due".
func TestNoScanYetIsLoud(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, st := newService(t, srv.URL, nil)
harry, _ := st.ChildByName(context.Background(), "Harry")
text, ok, err := svc.ChildrenText(context.Background(), *harry)
if err != nil {
t.Fatalf("brief: %v", err)
}
if ok {
t.Error("a child with no scan must not be reported as OK")
}
if !strings.Contains(text, "no scan has run yet") {
t.Errorf("brief = %q", text)
}
}
// TestUnconfiguredRecipientFails proves a name that is not configured fails
// loudly instead of sending somewhere unexpected.
func TestUnconfiguredRecipientFails(t *testing.T) {
fake := &fakeApprise{}
srv := fake.server(t)
svc, _ := newService(t, srv.URL, seedItems)
if _, err := svc.RecipientsFor([]string{"Nana"}); err == nil {
t.Fatal("want an error for an unknown recipient")
}
if _, err := svc.Deliver(context.Background(), "Harry", "t", "b", nil, []string{config.ChannelEmail}); err == nil {
t.Fatal("want an error for no recipients")
}
if _, err := svc.Deliver(context.Background(), "Harry", "t", "b", []config.Recipient{{Name: "Harry", TagBase: "harry"}}, nil); err == nil {
t.Fatal("want an error for no channels")
}
}