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>
This commit is contained in:
401
internal/brief/brief.go
Normal file
401
internal/brief/brief.go
Normal file
@@ -0,0 +1,401 @@
|
||||
// Package brief builds and sends the morning briefs through the house Apprise
|
||||
// API (AGENTS.md §4 FR-9, FR-10, FR-12, FR-14, §11 Phase 7).
|
||||
//
|
||||
// One brief goes to each boy — what is on today, overdue work, pending work and
|
||||
// the school notices — and one combined brief goes to Jo and Sam. Every
|
||||
// delivery attempt is recorded in `notifications`, one row per recipient per
|
||||
// channel, so a failed send is visible rather than silent (FR-12, FR-13).
|
||||
//
|
||||
// The service never holds a destination: it names an Apprise tag, for example
|
||||
// "harry_email", and the Apprise configuration on `.35` holds the address
|
||||
// (AGENTS.md §10 rule 3).
|
||||
package brief
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"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/dashboard"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// ScopeCombined is the scope of the single brief to Jo and Sam.
|
||||
const ScopeCombined = "combined"
|
||||
|
||||
// Service builds and delivers briefs. Every field is required except Log, which
|
||||
// defaults to the standard logger.
|
||||
type Service struct {
|
||||
Store *store.Store
|
||||
Client *apprise.Client
|
||||
Cfg config.AppriseConfig
|
||||
Loc *time.Location
|
||||
Log *slog.Logger
|
||||
// Now is injectable so tests can pin the clock.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New builds a Service.
|
||||
func New(st *store.Store, client *apprise.Client, cfg config.AppriseConfig, loc *time.Location, log *slog.Logger) *Service {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{Store: st, Client: client, Cfg: cfg, Loc: loc, Log: log, Now: time.Now}
|
||||
}
|
||||
|
||||
// Delivery is one send attempt: one recipient, one channel, one Apprise tag.
|
||||
type Delivery struct {
|
||||
Recipient string
|
||||
Channel string
|
||||
Tag string
|
||||
OK bool
|
||||
Status int
|
||||
Error string
|
||||
// ID is the `notifications` row for this attempt.
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Send is one brief and everything that happened to it.
|
||||
type Send struct {
|
||||
// Scope is the child name, or ScopeCombined.
|
||||
Scope string
|
||||
// Title is the message subject.
|
||||
Title string
|
||||
// Text is the brief itself.
|
||||
Text string
|
||||
// Recipients are the names the brief went to, in order.
|
||||
Recipients []string
|
||||
// Deliveries is one entry per recipient per channel.
|
||||
Deliveries []Delivery
|
||||
// ScanFailed lists the children whose last scan did not complete, so the
|
||||
// caller can exit loudly (FR-12).
|
||||
ScanFailed []string
|
||||
}
|
||||
|
||||
// Failed is the number of delivery attempts that did not deliver.
|
||||
func (s Send) Failed() int {
|
||||
n := 0
|
||||
for _, d := range s.Deliveries {
|
||||
if !d.OK {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Delivered is the number of delivery attempts that delivered.
|
||||
func (s Send) Delivered() int { return len(s.Deliveries) - s.Failed() }
|
||||
|
||||
// OK reports whether every attempt delivered and no scan was broken.
|
||||
func (s Send) OK() bool { return s.Failed() == 0 && len(s.ScanFailed) == 0 }
|
||||
|
||||
// SentOK reports whether every delivery attempt delivered, ignoring the scan
|
||||
// state (which is reported separately).
|
||||
func (s Send) SentOK() bool { return s.Failed() == 0 }
|
||||
|
||||
// FirstError is the first redacted delivery error, or "" when nothing failed.
|
||||
func (s Send) FirstError() string {
|
||||
for _, d := range s.Deliveries {
|
||||
if !d.OK {
|
||||
return d.Error
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChildrenText returns one child's brief and whether that child's last scan
|
||||
// completed. A failed or missing scan never reads as "nothing due": the brief
|
||||
// opens with an explicit warning (FR-12).
|
||||
func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string, bool, error) {
|
||||
items, err := s.Store.ItemsForChild(ctx, child.ID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("items for %s: %w", child.Name, err)
|
||||
}
|
||||
buckets := dashboard.Classify(items, s.Now(), s.Loc)
|
||||
text := dashboard.RenderWithLimits(child.Name, buckets, s.Loc, dashboard.MorningLimits)
|
||||
|
||||
sc, err := s.Store.ScanSummaryForChild(ctx, child.ID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("scan summary for %s: %w", child.Name, err)
|
||||
}
|
||||
notice := s.scanNotice(child.Name, sc)
|
||||
if notice == "" {
|
||||
return text, true, nil
|
||||
}
|
||||
return notice + "\n" + text, false, nil
|
||||
}
|
||||
|
||||
// CombinedText returns the single brief for Jo and Sam — both boys in one
|
||||
// message — and the children whose scan did not complete.
|
||||
func (s *Service) CombinedText(ctx context.Context, children []store.Child) (string, []string, error) {
|
||||
parts := make([]dashboard.Combined, 0, len(children))
|
||||
var failed []string
|
||||
var notices []string
|
||||
for _, c := range children {
|
||||
items, err := s.Store.ItemsForChild(ctx, c.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("items for %s: %w", c.Name, err)
|
||||
}
|
||||
parts = append(parts, dashboard.Combined{
|
||||
Child: c.Name,
|
||||
Buckets: dashboard.Classify(items, s.Now(), s.Loc),
|
||||
})
|
||||
sc, err := s.Store.ScanSummaryForChild(ctx, c.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("scan summary for %s: %w", c.Name, err)
|
||||
}
|
||||
if notice := s.scanNotice(c.Name, sc); notice != "" {
|
||||
notices = append(notices, notice)
|
||||
failed = append(failed, c.Name)
|
||||
}
|
||||
}
|
||||
text := dashboard.RenderCombinedWithLimits(parts, s.Loc, dashboard.MorningLimits)
|
||||
if len(notices) > 0 {
|
||||
text = strings.Join(notices, "\n") + "\n\n" + text
|
||||
}
|
||||
return text, failed, nil
|
||||
}
|
||||
|
||||
// scanNotice is the FR-12 warning for one child, or "" when the last scan
|
||||
// completed.
|
||||
func (s *Service) scanNotice(child string, sc store.ScanStatus) string {
|
||||
switch {
|
||||
case !sc.EverRun:
|
||||
return fmt.Sprintf("%s — BRIEF UNAVAILABLE: no scan has run yet. Due work is UNKNOWN, not “nothing due”.", child)
|
||||
case sc.Last != nil && !sc.Last.OK:
|
||||
detail := strings.TrimSpace(sc.Last.Error)
|
||||
when := sc.Last.StartedAt.In(s.Loc).Format("Mon 02 Jan 15:04")
|
||||
msg := fmt.Sprintf("%s — SCAN FAILED at %s", child, when)
|
||||
if detail != "" {
|
||||
msg += " (" + detail + ")"
|
||||
}
|
||||
return msg + ". Due work is UNKNOWN, not “nothing due”."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// RecipientsFor resolves names to configured Apprise recipients, and fails
|
||||
// loudly when a name is not configured.
|
||||
func (s *Service) RecipientsFor(names []string) ([]config.Recipient, error) {
|
||||
out := make([]config.Recipient, 0, len(names))
|
||||
for _, name := range names {
|
||||
r, ok := s.Cfg.RecipientByName(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipient %q is not configured (SCHOOL_APPRISE_TAGS)", name)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChildRecipients returns the configured recipients that are children, matched
|
||||
// by name against the children table.
|
||||
func (s *Service) ChildRecipients(child store.Child) ([]config.Recipient, error) {
|
||||
return s.RecipientsFor([]string{child.Name})
|
||||
}
|
||||
|
||||
// ParentRecipients returns the recipients of the combined brief (FR-14).
|
||||
func (s *Service) ParentRecipients() ([]config.Recipient, string, error) {
|
||||
parents := s.Cfg.ParentRecipients()
|
||||
if len(parents) == 0 {
|
||||
return nil, "", fmt.Errorf("no parents configured for the combined brief (SCHOOL_APPRISE_PARENTS)")
|
||||
}
|
||||
names := make([]string, 0, len(parents))
|
||||
for _, p := range parents {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
return parents, strings.Join(names, "+"), nil
|
||||
}
|
||||
|
||||
// Deliver sends one brief to the given recipients on the given channels and
|
||||
// records every attempt. It never returns an error for a failed delivery: the
|
||||
// failure is in the returned Send, recorded in the database, and the caller is
|
||||
// responsible for going loud (FR-12).
|
||||
func (s *Service) Deliver(ctx context.Context, scope, title, text string, recipients []config.Recipient, channels []string) (Send, error) {
|
||||
out := Send{Scope: scope, Title: title, Text: text}
|
||||
if len(recipients) == 0 {
|
||||
return out, fmt.Errorf("no recipients for scope %q", scope)
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return out, fmt.Errorf("no channels requested for scope %q", scope)
|
||||
}
|
||||
if s.Client == nil || !s.Client.Configured() {
|
||||
return out, fmt.Errorf("apprise is not configured: set %s",
|
||||
strings.Join(s.Cfg.Missing(), ", "))
|
||||
}
|
||||
|
||||
now := s.Now()
|
||||
for _, r := range recipients {
|
||||
out.Recipients = append(out.Recipients, r.Name)
|
||||
for _, ch := range channels {
|
||||
tag := s.Cfg.ChannelTag(r, ch)
|
||||
d := Delivery{Recipient: r.Name, Channel: ch, Tag: tag}
|
||||
|
||||
res, err := s.Client.SendTags(ctx, []string{tag}, title, text, apprise.TypeInfo)
|
||||
d.Status = res.Status
|
||||
d.OK = err == nil
|
||||
if err != nil {
|
||||
// apprise.Redact has already run on the response text, so no
|
||||
// address or phone number reaches the log or the row.
|
||||
d.Error = apprise.Truncate(err.Error(), 300)
|
||||
}
|
||||
sentAt := &now
|
||||
if !d.OK {
|
||||
sentAt = nil
|
||||
}
|
||||
id, recErr := s.Store.RecordNotification(ctx, store.Notification{
|
||||
Scope: scope,
|
||||
Recipient: r.Name,
|
||||
Channel: ch,
|
||||
Tag: tag,
|
||||
Text: text,
|
||||
OK: d.OK,
|
||||
Error: d.Error,
|
||||
SentAt: sentAt,
|
||||
})
|
||||
if recErr != nil {
|
||||
return out, recErr
|
||||
}
|
||||
d.ID = id
|
||||
if d.OK {
|
||||
s.Log.Info("brief delivered", "scope", scope, "recipient", r.Name, "channel", ch, "tag", tag, "status", d.Status)
|
||||
} else {
|
||||
s.Log.Error("brief NOT delivered", "scope", scope, "recipient", r.Name, "channel", ch, "tag", tag, "status", d.Status, "err", d.Error)
|
||||
}
|
||||
out.Deliveries = append(out.Deliveries, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// planned is one brief that is ready to go: the send, plus the resolved
|
||||
// Apprise recipients it would go to.
|
||||
type planned struct {
|
||||
Send
|
||||
recipients []config.Recipient
|
||||
}
|
||||
|
||||
// Morning builds and sends the whole morning run: one brief per boy, then one
|
||||
// combined brief to Jo and Sam (FR-9, FR-14). The briefs always go out, even
|
||||
// when a scan failed, because the failure notice is the message that matters
|
||||
// most; ScanFailed tells the caller to exit loudly.
|
||||
func (s *Service) Morning(ctx context.Context, channels []string) ([]Send, error) {
|
||||
plans, err := s.plan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sends := make([]Send, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
send, err := s.Deliver(ctx, p.Scope, p.Title, p.Text, p.recipients, channels)
|
||||
if err != nil {
|
||||
return sends, err
|
||||
}
|
||||
send.ScanFailed = p.ScanFailed
|
||||
sends = append(sends, send)
|
||||
}
|
||||
return sends, nil
|
||||
}
|
||||
|
||||
// Plan builds every morning brief without sending anything. It is the build
|
||||
// path Morning uses, exposed for the machine endpoint's dry run so the n8n
|
||||
// wiring can be checked without messaging the family.
|
||||
func (s *Service) Plan(ctx context.Context) ([]Send, error) {
|
||||
plans, err := s.plan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Send, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
out = append(out, p.Send)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plan builds the per-boy briefs and the combined brief, without sending.
|
||||
func (s *Service) plan(ctx context.Context) ([]planned, error) {
|
||||
children, err := s.Store.ListChildren(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list children: %w", err)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil, fmt.Errorf("no children on file: nothing to brief")
|
||||
}
|
||||
|
||||
var out []planned
|
||||
for _, c := range children {
|
||||
text, scanOK, err := s.ChildrenText(ctx, c)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
recipients, err := s.ChildRecipients(c)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
p := planned{
|
||||
Send: Send{
|
||||
Scope: c.Name,
|
||||
Title: fmt.Sprintf("%s — school brief %s", c.Name, s.Now().In(s.Loc).Format("Mon 02 Jan")),
|
||||
Text: text,
|
||||
Recipients: []string{c.Name},
|
||||
},
|
||||
recipients: recipients,
|
||||
}
|
||||
if !scanOK {
|
||||
p.ScanFailed = []string{c.Name}
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
parents, _, err := s.ParentRecipients()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
text, failed, err := s.CombinedText(ctx, children)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
names := make([]string, 0, len(parents))
|
||||
for _, r := range parents {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
out = append(out, planned{
|
||||
Send: Send{
|
||||
Scope: ScopeCombined,
|
||||
Title: fmt.Sprintf("School brief — %s %s", combinedNames(children), s.Now().In(s.Loc).Format("Mon 02 Jan")),
|
||||
Text: text,
|
||||
Recipients: names,
|
||||
ScanFailed: failed,
|
||||
},
|
||||
recipients: parents,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func combinedNames(children []store.Child) string {
|
||||
names := make([]string, 0, len(children))
|
||||
for _, c := range children {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
switch len(names) {
|
||||
case 0:
|
||||
return "school"
|
||||
case 1:
|
||||
return names[0]
|
||||
case 2:
|
||||
return names[0] + " and " + names[1]
|
||||
default:
|
||||
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// CombinedNames is the exported form of the display label for the combined
|
||||
// brief, used by the Send panel.
|
||||
func CombinedNames(children []store.Child) string { return combinedNames(children) }
|
||||
334
internal/brief/brief_test.go
Normal file
334
internal/brief/brief_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user