Phase 7: daily summary and read-only Hub API (AUD-73)

- internal/hub: project side of the frozen ask-contract. GET /capabilities,
  read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify
  client.
- internal/apprise: daily-summary delivery through the house Apprise API.
- internal/summary: deterministic plain-text daily summary builder.
- internal/store: CountByCategory, MessagesByCategory, and summary CRUD.
- internal/server: /capabilities and /ask (bearer-token protected), plus the
  Reports screen (/reports, build, send, delete).
- main.go: -summary, -summary-send, and -notify CLI entry points for n8n.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 22:38:49 +11:00
parent c4213ebed1
commit a349b42b1f
24 changed files with 2454 additions and 1 deletions

View File

@@ -0,0 +1,106 @@
package store
import (
"context"
"testing"
)
func TestCountByCategory(t *testing.T) {
s, ctx := testStore(t)
a := seedMessage(t, s, ctx, "m1", "Security alert", "hello@where-woof.com")
b := seedMessage(t, s, ctx, "m2", "News", "hello@where-woof.com")
if _, err := s.SaveClassification(ctx, Classification{MessageID: a, Category: "Action", Importance: "high"}); err != nil {
t.Fatalf("classify a: %v", err)
}
if _, err := s.SaveClassification(ctx, Classification{MessageID: b, Category: "Newsletter"}); err != nil {
t.Fatalf("classify b: %v", err)
}
counts, err := s.CountByCategory(ctx, FolderInbox)
if err != nil {
t.Fatalf("CountByCategory() error = %v", err)
}
got := map[string]int{}
for _, c := range counts {
got[c.Category] = c.Count
}
if got["Action"] != 1 || got["Newsletter"] != 1 {
t.Errorf("counts = %v, want Action=1 Newsletter=1", got)
}
}
func TestMessagesByCategoryOrder(t *testing.T) {
s, ctx := testStore(t)
low := seedMessage(t, s, ctx, "low", "Routine", "hello@where-woof.com")
high := seedMessage(t, s, ctx, "high", "Urgent", "hello@where-woof.com")
info := seedMessage(t, s, ctx, "info", "FYI", "hello@where-woof.com")
mustClassify(t, s, ctx, low, "Action", "low")
mustClassify(t, s, ctx, high, "Action", "high")
mustClassify(t, s, ctx, info, "Info", "low")
items, err := s.MessagesByCategory(ctx, FolderInbox, []string{"Action", "Important"}, 10)
if err != nil {
t.Fatalf("MessagesByCategory() error = %v", err)
}
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
if items[0].ID != high {
t.Errorf("first item id = %d, want the high-importance message %d", items[0].ID, high)
}
}
func mustClassify(t *testing.T, s *Store, ctx context.Context, msgID int64, category, importance string) {
t.Helper()
if _, err := s.SaveClassification(ctx, Classification{MessageID: msgID, Category: category, Importance: importance}); err != nil {
t.Fatalf("classify %d: %v", msgID, err)
}
}
func TestSummaryStore(t *testing.T) {
s, ctx := testStore(t)
id, err := s.SaveSummary(ctx, "2026-10-11", "first text", "")
if err != nil {
t.Fatalf("SaveSummary() error = %v", err)
}
// Saving the same date replaces rather than duplicates.
id2, err := s.SaveSummary(ctx, "2026-10-11", "second text", "sam@example.com")
if err != nil {
t.Fatalf("SaveSummary() second error = %v", err)
}
if id2 != id {
t.Errorf("id = %d, want the existing row %d", id2, id)
}
sum, ok, err := s.SummaryByDate(ctx, "2026-10-11")
if err != nil || !ok {
t.Fatalf("SummaryByDate() ok=%v err=%v", ok, err)
}
if sum.Text != "second text" || sum.DeliveredTo != "sam@example.com" {
t.Errorf("summary = %+v", sum)
}
list, err := s.ListSummaries(ctx, 10)
if err != nil {
t.Fatalf("ListSummaries() error = %v", err)
}
if len(list) != 1 {
t.Fatalf("list = %d, want 1", len(list))
}
if err := s.MarkSummaryDelivered(ctx, id, "other@example.com"); err != nil {
t.Fatalf("MarkSummaryDelivered() error = %v", err)
}
sum, _, _ = s.SummaryByDate(ctx, "2026-10-11")
if sum.DeliveredTo != "other@example.com" {
t.Errorf("delivered_to = %q", sum.DeliveredTo)
}
if err := s.DeleteSummary(ctx, id); err != nil {
t.Fatalf("DeleteSummary() error = %v", err)
}
if _, ok, _ := s.SummaryByDate(ctx, "2026-10-11"); ok {
t.Error("summary still present after delete")
}
}