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>
128 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|