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") } } // TestCombinedTextKeepsBriefOrder proves FR-9 holds whatever order the caller // passes: the combined brief is always Finn's block then Harry's, so the CLI // preview, the dashboard and the Hub match the email that is sent. func TestCombinedTextKeepsBriefOrder(t *testing.T) { fake := &fakeApprise{} srv := fake.server(t) svc, st := newService(t, srv.URL, seedItems) children, err := st.ListChildren(context.Background()) if err != nil { t.Fatalf("children: %v", err) } if len(children) != 2 { t.Fatalf("children = %d, want Harry and Finn", len(children)) } // Reverse the input on purpose: the email order must not depend on it. children[0], children[1] = children[1], children[0] text, failed, err := svc.CombinedText(context.Background(), children) if err != nil { t.Fatalf("combined text: %v", err) } if len(failed) != 0 { t.Fatalf("failed = %#v, want none", failed) } i, j := strings.Index(text, "=== FINN ==="), strings.Index(text, "=== HARRY ===") if i < 0 || j < 0 || i > j { t.Errorf("combined blocks are not Finn then Harry: %q", text) } }