- 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>
263 lines
8.4 KiB
Go
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
|
|
}
|