Files
compass_teams_nhs/internal/store/notify_test.go
Sam Rolfe 09639c5125 Phase 7: notify and send via Apprise (Email + WhatsApp)
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>
2026-10-15 19:28:05 +11:00

128 lines
3.8 KiB
Go

package store
import (
"context"
"strings"
"testing"
"time"
)
// TestNotificationDeliveryLog covers the Phase 7 delivery row: a delivered
// brief records the channel, the tag and sent_at, and a failure records the
// error without a sent_at, so a failure can never read as delivered (FR-12).
func TestNotificationDeliveryLog(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
sent := time.Date(2026, 10, 16, 6, 0, 0, 0, time.UTC)
okID, err := st.RecordNotification(ctx, Notification{
Scope: "Harry", Recipient: "Harry", Channel: "email", Tag: "harry_email",
Text: "Harry — 1 overdue.", OK: true, SentAt: &sent,
})
if err != nil {
t.Fatalf("record ok: %v", err)
}
if _, err := st.RecordNotification(ctx, Notification{
Scope: "Harry", Recipient: "Harry", Channel: "whatsapp", Tag: "harry_whatsapp",
Text: "Harry — 1 overdue.", OK: false, Error: "apprise returned status 424",
}); err != nil {
t.Fatalf("record fail: %v", err)
}
got, err := st.NotificationByID(ctx, okID)
if err != nil || got == nil {
t.Fatalf("by id: %v %v", got, err)
}
if !got.OK || got.Channel != "email" || got.Tag != "harry_email" || got.SentAt == nil {
t.Errorf("row = %+v", got)
}
rows, err := st.RecentNotifications(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("recent = %d, %v", len(rows), err)
}
var failed *Notification
for i := range rows {
if !rows[i].OK {
failed = &rows[i]
}
}
if failed == nil {
t.Fatal("the failed delivery row is missing")
}
if failed.SentAt != nil {
t.Error("a failed delivery must not carry sent_at")
}
if failed.Error == "" {
t.Error("a failed delivery must record its error")
}
st2, err := st.NotificationSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if st2.Total != 2 || st2.Sent != 1 || st2.Failed != 1 || st2.Drafts != 0 {
t.Errorf("summary = %+v", st2)
}
if st2.Last == nil || st2.Last.Tag != "harry_whatsapp" {
t.Errorf("last = %+v", st2.Last)
}
}
// TestDraftAndMarkSent covers the Send action on a stored brief (FR-10): a
// draft starts unsent and is stamped only when a channel delivered.
func TestDraftAndMarkSent(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
id, err := st.CreateDraft(ctx, "combined", "Jo+Sam", "School brief — Harry and Finn")
if err != nil {
t.Fatalf("draft: %v", err)
}
draft, err := st.NotificationByID(ctx, id)
if err != nil || draft == nil {
t.Fatalf("by id: %v %v", draft, err)
}
if draft.Scope != "combined" || draft.Channel != "" || draft.OK || draft.SentAt != nil {
t.Errorf("draft = %+v", draft)
}
at := time.Date(2026, 10, 16, 6, 1, 0, 0, time.UTC)
if err := st.MarkSummarySent(ctx, id, "email", "joanna_email", true, "", at); err != nil {
t.Fatalf("mark sent: %v", err)
}
got, _ := st.NotificationByID(ctx, id)
if got == nil || !got.OK || got.SentAt == nil || got.Channel != "email" {
t.Errorf("after send = %+v", got)
}
st2, _ := st.NotificationSummary(ctx)
if st2.Drafts != 0 || st2.Sent != 1 {
t.Errorf("summary = %+v", st2)
}
}
// TestNotificationSummaryCountsFailures proves a failed send is visible in the
// summary the dashboard shows (FR-12).
func TestNotificationSummaryCountsFailures(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
if _, err := st.RecordNotification(ctx, Notification{
Scope: "combined", Recipient: "Jo", Channel: "whatsapp", Tag: "joanna_whatsapp",
Text: "brief", Error: "apprise returned status 424: <redacted>",
}); err != nil {
t.Fatalf("record: %v", err)
}
status, err := st.NotificationSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if status.Failed != 1 || status.Sent != 0 || !status.EverRun() {
t.Errorf("status = %+v", status)
}
if !strings.Contains(status.Last.Error, "424") {
t.Errorf("last error = %q", status.Last.Error)
}
}