Files
email_agent/internal/server/hub_test.go
Sam Rolfe a349b42b1f 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>
2026-10-11 22:38:49 +11:00

263 lines
8.4 KiB
Go

package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
const testHubToken = "test-hub-token"
// newServerWithConfig builds a server from an explicit config so a test can set
// the Hub token and the Apprise URL.
func newServerWithConfig(t *testing.T, cfg *config.Config) *Server {
t.Helper()
st, err := store.Open(context.Background(), cfg.DBPath)
if err != nil {
t.Fatalf("store.Open() error = %v", err)
}
t.Cleanup(func() { _ = st.Close() })
srv, err := New(cfg, st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New() error = %v", err)
}
return srv
}
func hubTestConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Env: "test",
Addr: ":0",
DBPath: filepath.Join(t.TempDir(), "email_agent.db"),
LogLevel: "error",
SessionSecret: "test-session-secret",
Password: testPassword,
HubToken: testHubToken,
HubBaseURL: "http://127.0.0.1:0",
AppriseURL: "http://127.0.0.1:0",
AppriseKey: "test-key",
SummaryTo: "sam@example.com",
}
}
func TestCapabilities(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
// Without a token the endpoint refuses.
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/capabilities", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status = %d, want 401", rec.Code)
}
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+testHubToken)
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var body struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Project != "email_agent" {
t.Errorf("project = %q", body.Project)
}
for _, topic := range []string{"email", "inbox", "mail"} {
if !containsString(body.Topics, topic) {
t.Errorf("topics missing %q: %v", topic, body.Topics)
}
}
}
func TestAskRequiresToken(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ask", strings.NewReader(`{"question":"anything important?","asker":"sam"}`))
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestAskAnswersImportant(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
seedHubMessage(t, srv.store, "a1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
seedHubMessage(t, srv.store, "a2", "Store <shop@example.com>", "Sale", "Newsletter", "low")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ask",
strings.NewReader(`{"question":"anything important in my inbox?","asker":"sam"}`))
req.Header.Set("Authorization", "Bearer "+testHubToken)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var resp struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Answer == nil || !strings.Contains(*resp.Answer, "needs your attention") {
t.Fatalf("answer = %v reason = %q", resp.Answer, resp.Reason)
}
}
// TestAskWritesNothing proves the frozen read-only property of /ask: repeated
// calls leave the message, classification, and action-log row counts unchanged.
func TestAskWritesNothing(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
seedHubMessage(t, srv.store, "w1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
seedHubMessage(t, srv.store, "w2", "Store <shop@example.com>", "Sale", "Newsletter", "low")
before := tableCounts(t, srv)
questions := []string{
`{"question":"anything important in my inbox?","asker":"sam"}`,
`{"question":"how many emails?","asker":"sam"}`,
`{"question":"any spam?","asker":"sam"}`,
`{"question":"what time is it?","asker":"sam"}`,
}
for _, body := range questions {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ask", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testHubToken)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d for %s", rec.Code, body)
}
}
after := tableCounts(t, srv)
for _, table := range []string{"messages", "classifications", "actions_log", "summaries"} {
if before[table] != after[table] {
t.Errorf("%s row count changed: %d -> %d (an /ask call wrote state)", table, before[table], after[table])
}
}
}
func TestReportsGenerateStoresWithoutSending(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
seedHubMessage(t, srv.store, "r1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
sessionCookie, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/reports/generate", sessionCookie, csrfCookie, url.Values{"csrf": {csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
summaries, err := srv.store.ListSummaries(context.Background(), 10)
if err != nil {
t.Fatalf("ListSummaries() error = %v", err)
}
if len(summaries) != 1 {
t.Fatalf("summaries = %d, want 1", len(summaries))
}
if summaries[0].DeliveredTo != "" {
t.Errorf("delivered_to = %q, want empty (generate must not send)", summaries[0].DeliveredTo)
}
}
func TestReportsSendDeliversThroughApprise(t *testing.T) {
var (
appriseHits int
apprisePath string
)
appriseSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appriseHits++
apprisePath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer appriseSrv.Close()
cfg := hubTestConfig(t)
cfg.AppriseURL = appriseSrv.URL
cfg.AppriseKey = "summary"
srv := newServerWithConfig(t, cfg)
seedHubMessage(t, srv.store, "s1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
sessionCookie, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/reports/send", sessionCookie, csrfCookie, url.Values{"csrf": {csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
if appriseHits != 1 {
t.Fatalf("apprise hits = %d, want 1", appriseHits)
}
if apprisePath != "/notify/summary" {
t.Errorf("apprise path = %q, want /notify/summary", apprisePath)
}
summaries, _ := srv.store.ListSummaries(context.Background(), 10)
if len(summaries) != 1 || summaries[0].DeliveredTo != "sam@example.com" {
t.Errorf("summaries = %+v, want one delivered to sam@example.com", summaries)
}
}
func TestReportsRequireLogin(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/reports", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want a redirect to login", rec.Code)
}
}
func seedHubMessage(t *testing.T, st *store.Store, gmailID, from, subject, category, importance string) {
t.Helper()
id, err := st.UpsertMessage(context.Background(), &store.Message{
GmailID: gmailID,
FromAddr: from,
Subject: subject,
DateSent: time.Now().UTC(),
})
if err != nil {
t.Fatalf("seed message: %v", err)
}
if _, err := st.SaveClassification(context.Background(), store.Classification{
MessageID: id, Category: category, Importance: importance,
}); err != nil {
t.Fatalf("classify: %v", err)
}
}
// tableCounts reads row counts for the tables /ask must never touch.
func tableCounts(t *testing.T, srv *Server) map[string]int {
t.Helper()
out := map[string]int{}
for _, table := range []string{"messages", "classifications", "actions_log", "summaries"} {
var n int
if err := srv.store.DB().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM "+table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
out[table] = n
}
return out
}
func containsString(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}