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]) } } }