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:
262
internal/server/hub_test.go
Normal file
262
internal/server/hub_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user