Phase 4: triage actions (delete, move, mark; bulk select) (AUD-70)
Bulk select in the triage list, with delete/move/mark and a recoverable delete (FR-6, FR-9). - migration v4: messages.folder + messages.mailbox; folder/category views - internal/mailops: app folder -> Gmail label moves over IMAP (IPv4), locate by X-GM-MSGID, create labels lazily, move/save/reply/restore, flag marks - mailfetch: CREATE, UID MOVE (COPY+EXPUNGE fallback), STORE flags, X-GM-MSGID search - server: POST /actions/bulk with CSRF, UI delete confirmation, second confirmation for messages classified Spam (never auto-deleted), per-message and summary action-log rows (FR-13) - inbox: select-all toolbar, checkboxes, folder nav, Restore on non-inbox rows Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
303
internal/server/actions_test.go
Normal file
303
internal/server/actions_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
type moveCall struct {
|
||||
id int64
|
||||
dest string
|
||||
}
|
||||
|
||||
type markCall struct {
|
||||
id int64
|
||||
mark string
|
||||
}
|
||||
|
||||
type fakeActions struct {
|
||||
mu sync.Mutex
|
||||
moves []moveCall
|
||||
marks []markCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeActions) Move(_ context.Context, msg store.Message, dest string) (string, error) {
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.moves = append(f.moves, moveCall{id: msg.ID, dest: dest})
|
||||
return mailops.MailboxForFolder(dest, msg.SourceAddress), nil
|
||||
}
|
||||
|
||||
func (f *fakeActions) Mark(_ context.Context, msg store.Message, mark string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.marks = append(f.marks, markCall{id: msg.ID, mark: mark})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeActions) moveCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.moves)
|
||||
}
|
||||
|
||||
func (f *fakeActions) markCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.marks)
|
||||
}
|
||||
|
||||
func newActionsServer(t *testing.T) (*Server, *fakeActions) {
|
||||
t.Helper()
|
||||
srv := newTestServer(t)
|
||||
fa := &fakeActions{}
|
||||
srv.actions = fa
|
||||
return srv, fa
|
||||
}
|
||||
|
||||
func seedClassified(t *testing.T, srv *Server, gmailID, subject, category string) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
id, err := srv.store.UpsertMessage(ctx, &store.Message{
|
||||
GmailID: gmailID,
|
||||
FromAddr: "sender@example.test",
|
||||
ToAddr: "hello@where-woof.com",
|
||||
Subject: subject,
|
||||
SourceAddress: "hello@where-woof.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
if category != "" {
|
||||
if _, err := srv.store.SaveClassification(ctx, store.Classification{
|
||||
MessageID: id, Category: category, Importance: "high", Action: "delete", Confidence: 0.9,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed classification: %v", err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func bulkForm(csrf, action string, ids []int64, extra url.Values) url.Values {
|
||||
form := url.Values{"csrf": {csrf}, "action": {action}}
|
||||
for _, id := range ids {
|
||||
form.Add("ids", strconv.FormatInt(id, 10))
|
||||
}
|
||||
for k, v := range extra {
|
||||
form[k] = v
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
func TestBulkDeleteRequiresCSRF(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
seedClassified(t, srv, "1", "spammy", "Spam")
|
||||
|
||||
// No CSRF cookie and no csrf form value.
|
||||
form := url.Values{"action": {"delete"}, "ids": {"1"}, "confirm": {"yes"}, "spam_confirm": {"yes"}}
|
||||
rec := postForm(t, srv, "/actions/bulk", session, nil, form)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Errorf("moves = %d, want 0 on a rejected request", fa.moveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteRequiresConfirmation(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
id := seedClassified(t, srv, "1", "newsletter", "Newsletter")
|
||||
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Fatalf("moves = %d, want 0 without confirmation", fa.moveCount())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "needs confirmation") {
|
||||
t.Errorf("body did not explain the confirmation requirement")
|
||||
}
|
||||
|
||||
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
|
||||
if len(msgs) != 1 || msgs[0].Folder != store.FolderInbox {
|
||||
t.Errorf("message moved despite missing confirmation: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteSpamRequiresSpamConfirmation(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
id := seedClassified(t, srv, "1", "lottery winner", "Spam")
|
||||
|
||||
extra := url.Values{"confirm": {"yes"}} // no spam_confirm
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{id}, extra))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Fatalf("moves = %d, want 0 without the spam confirmation", fa.moveCount())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "classified Spam") {
|
||||
t.Errorf("body did not explain the spam confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteThenRestoreIsRecoverable(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
spamID := seedClassified(t, srv, "1", "lottery winner", "Spam")
|
||||
infoID := seedClassified(t, srv, "2", "receipt", "Info")
|
||||
|
||||
extra := url.Values{"confirm": {"yes"}, "spam_confirm": {"yes"}}
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", []int64{spamID, infoID}, extra))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 2 {
|
||||
t.Fatalf("moves = %d, want 2", fa.moveCount())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Delete folder") {
|
||||
t.Errorf("note did not mention the Delete folder")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
deleted, err := srv.store.ListMessagesFiltered(ctx, store.MessageFilter{Folder: store.FolderDelete, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list delete folder: %v", err)
|
||||
}
|
||||
if len(deleted) != 2 {
|
||||
t.Fatalf("delete folder has %d messages, want 2", len(deleted))
|
||||
}
|
||||
for _, m := range deleted {
|
||||
if m.Mailbox != "Delete" {
|
||||
t.Errorf("message %d mailbox = %q, want Delete", m.ID, m.Mailbox)
|
||||
}
|
||||
}
|
||||
|
||||
// The Delete folder view shows the recoverable rows with a Restore button.
|
||||
view := getWithSession(t, srv, "/partials/inbox?folder=delete", session)
|
||||
if view.Code != http.StatusOK {
|
||||
t.Fatalf("delete folder view status = %d", view.Code)
|
||||
}
|
||||
if !strings.Contains(view.Body.String(), "Restore") {
|
||||
t.Errorf("delete folder view has no Restore button")
|
||||
}
|
||||
|
||||
// Restore one message.
|
||||
rec = postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "restore", []int64{spamID}, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore status = %d, want 200", rec.Code)
|
||||
}
|
||||
restored, err := srv.store.MessagesByIDs(ctx, []int64{spamID})
|
||||
if err != nil {
|
||||
t.Fatalf("load restored message: %v", err)
|
||||
}
|
||||
if len(restored) != 1 || restored[0].Folder != store.FolderInbox || restored[0].Mailbox != "INBOX" {
|
||||
t.Errorf("restored message = %+v, want inbox/INBOX", restored)
|
||||
}
|
||||
|
||||
// FR-13: every action has a log row.
|
||||
entries, err := srv.store.RecentActions(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentActions() error = %v", err)
|
||||
}
|
||||
var moves, bulk int
|
||||
for _, e := range entries {
|
||||
if e.Action == "move" {
|
||||
moves++
|
||||
}
|
||||
if strings.HasPrefix(e.Action, "bulk_") {
|
||||
bulk++
|
||||
}
|
||||
}
|
||||
if moves != 3 {
|
||||
t.Errorf("move log rows = %d, want 3 (2 deletes + 1 restore)", moves)
|
||||
}
|
||||
if bulk != 2 {
|
||||
t.Errorf("bulk summary rows = %d, want 2", bulk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxPartialHasSelectCheckboxes(t *testing.T) {
|
||||
srv, _ := newActionsServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
seedClassified(t, srv, "1", "one", "Info")
|
||||
seedClassified(t, srv, "2", "two", "Info")
|
||||
|
||||
rec := getWithSession(t, srv, "/partials/inbox", session)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `id="select-all"`) {
|
||||
t.Errorf("partial missing select-all checkbox")
|
||||
}
|
||||
if strings.Count(body, `name="ids"`) != 2 {
|
||||
t.Errorf("checkbox count = %d, want 2", strings.Count(body, `name="ids"`))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkMoveAndMark(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
id := seedClassified(t, srv, "1", "receipt", "Info")
|
||||
|
||||
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "save", []int64{id}, nil)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("save status = %d", rec.Code)
|
||||
}
|
||||
if rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "mark_read", []int64{id}, nil)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("mark_read status = %d", rec.Code)
|
||||
}
|
||||
if fa.moveCount() != 1 || fa.markCount() != 1 {
|
||||
t.Fatalf("calls: moves=%d marks=%d, want 1 and 1", fa.moveCount(), fa.markCount())
|
||||
}
|
||||
msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id})
|
||||
if len(msgs) != 1 || msgs[0].Folder != store.FolderSaved {
|
||||
t.Errorf("message folder = %+v, want saved", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkNoSelection(t *testing.T) {
|
||||
srv, fa := newActionsServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
|
||||
rec := postForm(t, srv, "/actions/bulk", session, csrfCookie, bulkForm(csrf, "delete", nil, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Select at least one") {
|
||||
t.Errorf("body did not explain the empty selection")
|
||||
}
|
||||
if fa.moveCount() != 0 {
|
||||
t.Errorf("moves = %d, want 0", fa.moveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIDList(t *testing.T) {
|
||||
got := parseIDList([]string{"1,2", "2", "x", "-3", "4"})
|
||||
want := []int64{1, 2, 4}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseIDList() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("parseIDList()[%d] = %d, want %d", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user