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>
335 lines
10 KiB
Go
335 lines
10 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 Harry, Finn and combined", len(sends))
|
|
}
|
|
for _, s := range sends {
|
|
if !s.OK() {
|
|
t.Errorf("send %s not OK: %+v", s.Scope, s)
|
|
}
|
|
}
|
|
|
|
// Harry: 1 recipient x 2 channels. Combined: 2 recipients x 2 channels.
|
|
if got := len(sends[0].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, "Harry: English: Essay") ||
|
|
!strings.Contains(combined.Text, "Finn: Science: Lab report") {
|
|
t.Errorf("combined text = %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[0].ScanFailed) != 1 || sends[0].ScanFailed[0] != "Harry" {
|
|
t.Errorf("Harry scan failure not reported: %+v", sends[0].ScanFailed)
|
|
}
|
|
if !strings.Contains(sends[0].Text, "SCAN FAILED") {
|
|
t.Errorf("brief does not say the scan failed: %q", sends[0].Text)
|
|
}
|
|
if !strings.Contains(sends[0].Text, "UNKNOWN") {
|
|
t.Errorf("brief does not say due work is unknown: %q", sends[0].Text)
|
|
}
|
|
if !strings.Contains(sends[2].Text, "SCAN FAILED") {
|
|
t.Errorf("combined brief does not carry the failure: %q", sends[2].Text)
|
|
}
|
|
if sends[0].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")
|
|
}
|
|
}
|