Files
compass_teams_nhs/internal/store/dashboard_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

133 lines
4.1 KiB
Go

package store
import (
"context"
"testing"
"time"
)
// TestItemsForChildAndDelete covers the Phase 5 reads and the Delete half of
// FR-10: one child's items are read alone, one item is fetched by id, and
// deleting it removes exactly one row.
func TestItemsForChildAndDelete(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
due := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
{Source: "compass", Type: "task", Subject: "English", Title: "Essay", ExternalRef: "h1", DueAt: &due, Body: "Status: Overdue"},
{Source: "compass", Type: "message", Title: "Notice", ExternalRef: "h2"},
}); err != nil {
t.Fatalf("upsert harry: %v", err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
{Source: "compass", Type: "task", Title: "Lab report", ExternalRef: "f1"},
}); err != nil {
t.Fatalf("upsert finn: %v", err)
}
rows, err := st.ItemsForChild(ctx, harry.ID)
if err != nil {
t.Fatalf("items for child: %v", err)
}
if len(rows) != 2 {
t.Fatalf("harry rows = %d, want 2", len(rows))
}
var essay *Item
for i := range rows {
if rows[i].ChildID != harry.ID {
t.Errorf("row belongs to child %d", rows[i].ChildID)
}
if rows[i].ExternalRef == "h1" {
essay = &rows[i]
}
}
if essay == nil || essay.DueAt == nil || !essay.DueAt.Equal(due) {
t.Fatalf("essay due not stored: %+v", essay)
}
got, err := st.ItemByID(ctx, essay.ID)
if err != nil || got == nil {
t.Fatalf("item by id: %v %v", got, err)
}
if got.Title != "Essay" || got.Child != "Harry" {
t.Errorf("item by id = %+v", got)
}
n, err := st.DeleteItemByID(ctx, essay.ID)
if err != nil || n != 1 {
t.Fatalf("delete = %d, %v", n, err)
}
if got, _ := st.ItemByID(ctx, essay.ID); got != nil {
t.Error("item survived delete")
}
if rows, _ := st.ItemsForChild(ctx, finn.ID); len(rows) != 1 {
t.Errorf("finn rows = %d, want 1 (delete must not cross children)", len(rows))
}
// Deleting a missing id is a quiet no-op, not an error.
if n, err := st.DeleteItemByID(ctx, essay.ID); err != nil || n != 0 {
t.Errorf("second delete = %d, %v", n, err)
}
}
// TestSummariesRoundTrip covers the stored brief: create a draft, list it,
// fetch it and delete it (FR-10 summary half).
func TestSummariesRoundTrip(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
id, err := st.CreateSummary(ctx, "Harry", "Harry — 1 overdue.\n\nOVERDUE\n- Essay\n")
if err != nil || id == 0 {
t.Fatalf("create: %v id=%d", err, id)
}
list, err := st.RecentNotifications(ctx, 10)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if list[0].Recipient != "Harry" || list[0].SentAt != nil || list[0].Channel != "" {
t.Errorf("summary = %+v, want draft for Harry", list[0])
}
got, err := st.NotificationByID(ctx, id)
if err != nil || got == nil || got.Text != list[0].Text {
t.Fatalf("by id: %v %+v", err, got)
}
n, err := st.DeleteNotification(ctx, id)
if err != nil || n != 1 {
t.Fatalf("delete = %d, %v", n, err)
}
if got, _ := st.NotificationByID(ctx, id); got != nil {
t.Error("summary survived delete")
}
}
// TestScanSummaryForChild proves a failed scan for one child is visible for
// that child even when the other child has not been scanned (FR-12).
func TestScanSummaryForChild(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
if _, err := st.RecordScan(ctx, &finn.ID, 0, 0, false, "login challenge"); err != nil {
t.Fatalf("record: %v", err)
}
if _, err := st.RecordScan(ctx, &harry.ID, 3, 12, true, ""); err != nil {
t.Fatalf("record: %v", err)
}
hs, err := st.ScanSummaryForChild(ctx, harry.ID)
if err != nil || !hs.EverRun || hs.Last == nil || !hs.Last.OK {
t.Fatalf("harry scan summary = %+v, %v", hs, err)
}
fs, err := st.ScanSummaryForChild(ctx, finn.ID)
if err != nil || !fs.EverRun || fs.Last == nil || fs.Last.OK {
t.Fatalf("finn scan summary = %+v, %v", fs, err)
}
if fs.Failed != 1 {
t.Errorf("finn failed = %d, want 1", fs.Failed)
}
}