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>
This commit is contained in:
152
internal/server/rules_test.go
Normal file
152
internal/server/rules_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
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 =>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user