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:
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the read/write helpers the Phase 5 dashboard needs:
|
||||
@@ -131,93 +130,43 @@ func (s *Store) ScanSummaryForChild(ctx context.Context, childID int64) (ScanSta
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Summary is one stored brief (AGENTS.md §7 notifications): either a draft
|
||||
// (SentAt nil) or a delivered brief. It is the summary half of FR-10.
|
||||
type Summary struct {
|
||||
ID int64
|
||||
Recipient string
|
||||
Text string
|
||||
SentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
// Draft briefs (FR-10). A brief is a row in `notifications`; the delivery log
|
||||
// and the per-channel rows live in notify.go. CreateDraft stores a brief that
|
||||
// has not been sent yet, so the Send panel can preview it first.
|
||||
|
||||
// CreateSummary stores a brief for one recipient as a draft (sent_at NULL). It
|
||||
// is CreateDraft with the scope defaulted to the recipient.
|
||||
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
|
||||
return s.CreateDraft(ctx, recipient, recipient, text)
|
||||
}
|
||||
|
||||
// CreateSummary stores a brief for one recipient. sent_at stays NULL for a
|
||||
// draft; Phase 7 sets it when the brief is sent.
|
||||
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
|
||||
// CreateDraft stores a brief that has not been sent yet. scope is the child
|
||||
// name, or "combined" for the brief to Jo and Sam (FR-9, FR-14).
|
||||
func (s *Store) CreateDraft(ctx context.Context, scope, recipient, text string) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`,
|
||||
recipient, text)
|
||||
`INSERT INTO notifications (scope, recipient, channel, summary, sent_at)
|
||||
VALUES (?, ?, '', ?, NULL)`,
|
||||
scope, recipient, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
|
||||
return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
|
||||
return 0, fmt.Errorf("create draft for %q: %w", recipient, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListSummaries returns recent briefs, newest first.
|
||||
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, recipient, summary, sent_at, created_at
|
||||
FROM notifications ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list summaries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Summary
|
||||
for rows.Next() {
|
||||
var sm Summary
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := rows.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
|
||||
return nil, fmt.Errorf("scan summary: %w", err)
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
sm.SentAt = &t
|
||||
}
|
||||
sm.CreatedAt = parseTime(created)
|
||||
out = append(out, sm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SummaryByID returns one brief, or nil when it does not exist.
|
||||
func (s *Store) SummaryByID(ctx context.Context, id int64) (*Summary, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, recipient, summary, sent_at, created_at FROM notifications WHERE id = ?`, id)
|
||||
var sm Summary
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := row.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("summary %d: %w", id, err)
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
sm.SentAt = &t
|
||||
}
|
||||
sm.CreatedAt = parseTime(created)
|
||||
return &sm, nil
|
||||
}
|
||||
|
||||
// DeleteSummary removes one stored brief. It returns the number of rows removed.
|
||||
func (s *Store) DeleteSummary(ctx context.Context, id int64) (int64, error) {
|
||||
// DeleteNotification removes one brief or delivery row. It returns the number
|
||||
// of rows removed.
|
||||
func (s *Store) DeleteNotification(ctx context.Context, id int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete summary %d: %w", id, err)
|
||||
return 0, fmt.Errorf("delete notification %d: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete summary %d: %w", id, err)
|
||||
return 0, fmt.Errorf("delete notification %d: %w", id, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -83,22 +83,22 @@ func TestSummariesRoundTrip(t *testing.T) {
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("create: %v id=%d", err, id)
|
||||
}
|
||||
list, err := st.ListSummaries(ctx, 10)
|
||||
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 {
|
||||
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.SummaryByID(ctx, id)
|
||||
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.DeleteSummary(ctx, id)
|
||||
n, err := st.DeleteNotification(ctx, id)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("delete = %d, %v", n, err)
|
||||
}
|
||||
if got, _ := st.SummaryByID(ctx, id); got != nil {
|
||||
if got, _ := st.NotificationByID(ctx, id); got != nil {
|
||||
t.Error("summary survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
20
internal/store/migrations/004_notifications_send.sql
Normal file
20
internal/store/migrations/004_notifications_send.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Migration 004 — Phase 7 notify and send (AGENTS.md §4 FR-9/FR-13/FR-14,
|
||||
-- §11 Phase 7).
|
||||
--
|
||||
-- `notifications` becomes the delivery log: one row per brief per recipient per
|
||||
-- channel. The brief text is the `summary` column (it was already there), and
|
||||
-- the new columns record where the row belongs (scope), which channel and
|
||||
-- Apprise tag were used, whether the delivery succeeded, and the redacted error
|
||||
-- when it did not. A failed send is therefore visible and loud instead of being
|
||||
-- lost (FR-12).
|
||||
--
|
||||
-- A draft (built but not sent) is a row with an empty `channel`, ok = 0 and
|
||||
-- sent_at NULL, which is what the Phase 5 dashboard already showed.
|
||||
|
||||
ALTER TABLE notifications ADD COLUMN scope TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE notifications ADD COLUMN channel TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE notifications ADD COLUMN tag TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE notifications ADD COLUMN ok INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE notifications ADD COLUMN error TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_scope ON notifications(scope, created_at);
|
||||
169
internal/store/notify.go
Normal file
169
internal/store/notify.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the Phase 7 delivery helpers (AGENTS.md §4 FR-9, FR-13,
|
||||
// FR-14): one row in `notifications` per brief per recipient per channel, the
|
||||
// draft that the Send panel previews, and the summary the dashboard uses to
|
||||
// show a failed send loudly (FR-12).
|
||||
|
||||
// Notification is one brief row: either a draft, or one delivery attempt on one
|
||||
// channel. It is both the stored summary (FR-10) and the send log (FR-13).
|
||||
type Notification struct {
|
||||
ID int64
|
||||
// Scope is the child name, or "combined" for the brief to Jo and Sam.
|
||||
Scope string
|
||||
// Recipient is the person the brief is for, for example "Harry" or "Jo+Sam".
|
||||
Recipient string
|
||||
// Channel is "email", "whatsapp", or "" for a draft.
|
||||
Channel string
|
||||
// Tag is the Apprise tag the delivery used, for example "harry_email".
|
||||
Tag string
|
||||
// Text is the brief itself (the `summary` column).
|
||||
Text string
|
||||
// OK reports whether the delivery succeeded.
|
||||
OK bool
|
||||
// Error is the redacted failure text, empty on success.
|
||||
Error string
|
||||
// SentAt is when the brief was delivered, or nil for a draft or a failure.
|
||||
SentAt *time.Time
|
||||
// CreatedAt is when the row was written.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// notificationSelect is the shared column list for a notification row.
|
||||
const notificationSelect = `SELECT id, COALESCE(scope, ''), recipient, COALESCE(channel, ''), COALESCE(tag, ''),
|
||||
summary, ok, COALESCE(error, ''), sent_at, created_at FROM notifications`
|
||||
|
||||
func scanNotification(r rowScanner) (Notification, error) {
|
||||
var n Notification
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := r.Scan(&n.ID, &n.Scope, &n.Recipient, &n.Channel, &n.Tag,
|
||||
&n.Text, &n.OK, &n.Error, &sent, &created); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
n.SentAt = &t
|
||||
}
|
||||
n.CreatedAt = parseTime(created)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RecordNotification writes one brief or one delivery attempt. sent_at is NULL
|
||||
// while the delivery failed, so a failure can never be read as "delivered".
|
||||
func (s *Store) RecordNotification(ctx context.Context, n Notification) (int64, error) {
|
||||
var sentAt any
|
||||
if n.SentAt != nil {
|
||||
sentAt = n.SentAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO notifications (scope, recipient, channel, tag, summary, ok, error, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.Scope, n.Recipient, n.Channel, n.Tag, n.Text, n.OK, n.Error, sentAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("record notification for %q: %w", n.Recipient, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("record notification for %q: %w", n.Recipient, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// MarkSummarySent stamps a stored draft as sent and records which channel and
|
||||
// tag delivered it.
|
||||
func (s *Store) MarkSummarySent(ctx context.Context, id int64, channel, tag string, ok bool, errText string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE notifications SET sent_at = ?, channel = ?, tag = ?, ok = ?, error = ? WHERE id = ?`,
|
||||
at.UTC().Format(time.RFC3339), channel, tag, ok, errText, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark summary %d sent: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecentNotifications returns the newest brief rows, newest first. limit <= 0
|
||||
// means twenty.
|
||||
func (s *Store) RecentNotifications(ctx context.Context, limit int) ([]Notification, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
notificationSelect+` ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Notification
|
||||
for rows.Next() {
|
||||
n, err := scanNotification(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NotificationStatus counts the delivery attempts so the dashboard can show a
|
||||
// failed send loudly (FR-12).
|
||||
type NotificationStatus struct {
|
||||
// Total is every delivery attempt on record.
|
||||
Total int
|
||||
// Failed is the attempts that did not deliver.
|
||||
Failed int
|
||||
// Sent is the attempts that delivered.
|
||||
Sent int
|
||||
// Drafts is the stored briefs that were never sent.
|
||||
Drafts int
|
||||
// Last is the newest attempt, or nil.
|
||||
Last *Notification
|
||||
}
|
||||
|
||||
// EverRun reports whether any delivery has been attempted.
|
||||
func (st NotificationStatus) EverRun() bool { return st.Total > 0 }
|
||||
|
||||
// NotificationSummary reads the delivery log summary.
|
||||
func (s *Store) NotificationSummary(ctx context.Context) (NotificationStatus, error) {
|
||||
var st NotificationStatus
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN ok = 0 AND channel <> '' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN channel = '' THEN 1 ELSE 0 END), 0)
|
||||
FROM notifications`).
|
||||
Scan(&st.Total, &st.Failed, &st.Sent, &st.Drafts); err != nil {
|
||||
return st, fmt.Errorf("notification summary: %w", err)
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, notificationSelect+` ORDER BY id DESC LIMIT 1`)
|
||||
n, err := scanNotification(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("last notification: %w", err)
|
||||
}
|
||||
st.Last = &n
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// NotificationByID returns one brief row, or nil when it does not exist.
|
||||
func (s *Store) NotificationByID(ctx context.Context, id int64) (*Notification, error) {
|
||||
row := s.db.QueryRowContext(ctx, notificationSelect+` WHERE id = ?`, id)
|
||||
n, err := scanNotification(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notification %d: %w", id, err)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
127
internal/store/notify_test.go
Normal file
127
internal/store/notify_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user