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 ", "Security alert", "Action", "high") seedHubMessage(t, srv.store, "a2", "Store ", "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 ", "Security alert", "Action", "high") seedHubMessage(t, srv.store, "w2", "Store ", "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 ", "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 ", "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 }