Files
email_agent/internal/server/rules_test.go
Sam Rolfe 00bb0e05c4 Phase 6: rules editor + unsubscribe detection (AUD-72)
- FR-11 rules editor: plain-text area at /rules, saved and versioned
  (SaveRules appends a version; restore re-saves an old version), parse
  errors surfaced, one-click re-classify. The reply model now reads the
  rules document too (FR-11).
- FR-7 unsubscribe: parse and store List-Unsubscribe / List-Unsubscribe-Post
  (migration v6), detect email and web links (internal/unsubscribe), a
  domain-level ignore list, and an explicit record of what was marked. No
  code path contacts an unsubscribe link; acting on one needs Sam's approval.
- Migration v6 is additive; unsubscribe gains url/updated_at.
- Tests: detector, parser, store migration/records/candidates, rules editor,
  unsubscribe handlers, message-detail rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:27:08 +11:00

153 lines
4.7 KiB
Go

package server
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestRulesPageRenders(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
rec := getWithSession(t, srv, "/rules", session)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Rules editor", "Save new version", "Versions", "Back to inbox", "Unsubscribe"} {
if !strings.Contains(body, want) {
t.Errorf("rules page missing %q", want)
}
}
}
func TestRulesSaveCreatesVersion(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
rule := `from contains "heritagefruittrees.com.au" => category=Action, importance=high, action=file`
rec := postForm(t, srv, "/rules/save", session, csrfCookie, url.Values{
"csrf": {csrf}, "text": {rule},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
text, version, err := srv.store.CurrentRules(context.Background())
if err != nil {
t.Fatalf("CurrentRules() error = %v", err)
}
if text != rule {
t.Errorf("CurrentRules() text = %q, want the saved rule", text)
}
if version != 2 {
t.Errorf("CurrentRules() version = %d, want 2", version)
}
// The editor shows the new version and active rule count.
body := getWithSession(t, srv, "/rules", session).Body.String()
if !strings.Contains(body, "version 2") || !strings.Contains(body, "1 active rule") {
t.Errorf("rules page did not reflect the saved version; body:\n%s", body)
}
}
func TestRulesSaveRequiresCSRF(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
rec := postForm(t, srv, "/rules/save", session, nil, url.Values{"text": {"anything"}})
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
}
func TestRulesPageShowsParseErrors(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/rules/save", session, csrfCookie, url.Values{
"csrf": {csrf}, "text": {"this line has no arrow"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", rec.Code)
}
body := getWithSession(t, srv, "/rules", session).Body.String()
if !strings.Contains(body, "could not be parsed") || !strings.Contains(body, "missing =&gt;") {
t.Errorf("rules page did not surface the parse error; body:\n%s", body)
}
}
func TestRulesRestoreCreatesNewVersion(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
ctx := context.Background()
first := `from contains "a" => category=Info`
second := `from contains "b" => category=Action`
for _, text := range []string{first, second} {
rec := postForm(t, srv, "/rules/save", session, csrfCookie, url.Values{"csrf": {csrf}, "text": {text}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("save %q status = %d, want 303", text, rec.Code)
}
}
// Restore version 2 (the first saved rule) as a new version.
rec := postForm(t, srv, "/rules/restore", session, csrfCookie, url.Values{
"csrf": {csrf}, "version": {"2"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("restore status = %d, want 303", rec.Code)
}
text, version, err := srv.store.CurrentRules(ctx)
if err != nil {
t.Fatalf("CurrentRules() error = %v", err)
}
if text != first {
t.Errorf("restored text = %q, want %q", text, first)
}
if version != 4 {
t.Errorf("restored version = %d, want 4 (append-only)", version)
}
}
func TestDashboardLinksRulesAndUnsubscribe(t *testing.T) {
srv := newTestServer(t)
session, _, _ := login(t, srv)
body := getWithSession(t, srv, "/", session).Body.String()
for _, want := range []string{`href="/rules"`, `href="/unsubscribe"`} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
}
func TestRulesCountsLines(t *testing.T) {
text := "# comment\n\nfrom contains \"a\" => category=Info\nfrom contains \"b\" => category=Action"
if got := countRuleLines(text); got != 2 {
t.Errorf("countRuleLines() = %d, want 2", got)
}
if got := firstRuleLine(text); got != `from contains "a" => category=Info` {
t.Errorf("firstRuleLine() = %q", got)
}
if got := firstRuleLine("# only a comment"); got != "(comments only)" {
t.Errorf("firstRuleLine(comment only) = %q", got)
}
}
func TestRulesReclassifyWithoutJEVRedirects(t *testing.T) {
srv := newTestServer(t)
session, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/rules/reclassify", session, csrfCookie, url.Values{"csrf": {csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "JEV_API_KEY") {
t.Errorf("Location = %q, want a JEV-not-configured note", loc)
}
}