package server import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/reply" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) type fakeGenerator struct { body string templateID string err error calls int lastReq reply.Request } func (f *fakeGenerator) Draft(_ context.Context, req reply.Request) (string, string, error) { f.calls++ f.lastReq = req if f.err != nil { return "", "", f.err } return f.body, f.templateID, nil } func (f *fakeGenerator) Configured() bool { return true } func (f *fakeGenerator) Model() string { return "fake-model" } type fakeSender struct { sent []mailops.OutgoingMessage err error threadUpdated bool verifyErr error verifyCalls int } func (f *fakeSender) Send(_ context.Context, msg mailops.OutgoingMessage) error { if f.err != nil { return f.err } f.sent = append(f.sent, msg) return nil } func (f *fakeSender) VerifyThreadUpdated(_ context.Context, _ string) (bool, error) { f.verifyCalls++ return f.threadUpdated, f.verifyErr } func newReplyServer(t *testing.T) (*Server, *fakeActions, *fakeGenerator, *fakeSender) { t.Helper() srv, fa := newActionsServer(t) fg := &fakeGenerator{body: "Thanks, Saturday works.\n\nSam", templateID: "confirm"} fs := &fakeSender{threadUpdated: true} srv.replies = fg srv.sender = fs return srv, fa, fg, fs } func seedReplyable(t *testing.T, srv *Server, gmailID, source, messageID string) int64 { t.Helper() ctx := context.Background() id, err := srv.store.UpsertMessage(ctx, &store.Message{ GmailID: gmailID, ThreadID: "1234567890", MessageIDHeader: messageID, FromAddr: "Customer Name ", ToAddr: source, Subject: "Booking for Saturday", BodyText: "Can we book Saturday at 10?", SourceAddress: source, }) if err != nil { t.Fatalf("seed message: %v", err) } if _, err := srv.store.SaveClassification(ctx, store.Classification{ MessageID: id, Category: "Action", Importance: "high", Action: "reply", Confidence: 0.8, }); err != nil { t.Fatalf("seed classification: %v", err) } return id } func TestMessageDetailRendersReplyIdentity(t *testing.T) { srv, _, _, _ := newReplyServer(t) session, _, _ := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") rec := getWithSession(t, srv, "/partials/message?id="+itoa(id), session) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{ "hello@where-woof.com", "Sam Rolfe", "cust@example.test", "Re: Booking for Saturday", "Can we book Saturday at 10?", "Draft reply", } { if !strings.Contains(body, want) { t.Errorf("detail pane missing %q", want) } } } func TestMessageDetailRequiresAuth(t *testing.T) { srv, _, _, _ := newReplyServer(t) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") req := httptest.NewRequest(http.MethodGet, "/partials/message?id="+itoa(id), nil) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther) } } func TestDraftRequiresCSRF(t *testing.T) { srv, _, fg, _ := newReplyServer(t) session, _, _ := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") form := url.Values{"id": {itoa(id)}, "template": {"quick-ack"}} rec := postForm(t, srv, "/actions/draft", session, nil, form) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } if fg.calls != 0 { t.Errorf("generator called %d times, want 0", fg.calls) } } func TestDraftGeneratesAndSaves(t *testing.T) { srv, _, fg, _ := newReplyServer(t) session, csrfCookie, csrf := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") form := url.Values{"csrf": {csrf}, "id": {itoa(id)}, "template": {"confirm"}, "instruction": {"10am works"}} rec := postForm(t, srv, "/actions/draft", session, csrfCookie, form) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) } if fg.calls != 1 { t.Fatalf("generator calls = %d, want 1", fg.calls) } if fg.lastReq.Instruction != "10am works" || fg.lastReq.TemplateID != "confirm" { t.Errorf("generator request = %+v", fg.lastReq) } if !strings.Contains(rec.Body.String(), "Thanks, Saturday works.") { t.Errorf("response does not show the generated draft") } d, ok, err := srv.store.LatestDraft(context.Background(), id) if err != nil || !ok { t.Fatalf("LatestDraft() ok=%v err=%v", ok, err) } if d.TemplateID != "confirm" || d.Status != store.DraftStatusDraft { t.Errorf("stored draft = %+v", d) } } func TestSaveDraftEditsBody(t *testing.T) { srv, _, _, _ := newReplyServer(t) session, csrfCookie, csrf := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") d, err := srv.store.SaveDraft(context.Background(), id, "original", "quick-ack") if err != nil { t.Fatalf("SaveDraft() error = %v", err) } form := url.Values{"csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)}, "body": {"edited by Sam"}} rec := postForm(t, srv, "/actions/draft/save", session, csrfCookie, form) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } got, _, err := srv.store.LatestDraft(context.Background(), id) if err != nil { t.Fatalf("LatestDraft() error = %v", err) } if got.Body != "edited by Sam" { t.Errorf("body = %q, want the edit", got.Body) } } func TestSendRequiresConfirmation(t *testing.T) { srv, _, _, fs := newReplyServer(t) session, csrfCookie, csrf := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") d, _ := srv.store.SaveDraft(context.Background(), id, "body", "quick-ack") form := url.Values{"csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)}, "body": {"body"}} rec := postForm(t, srv, "/actions/send", session, csrfCookie, form) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if len(fs.sent) != 0 { t.Fatalf("sent %d messages without confirmation", len(fs.sent)) } if !strings.Contains(rec.Body.String(), "needs confirmation") { t.Errorf("body did not explain the confirmation requirement") } } func TestSendReplySendsFilesAndVerifiesThread(t *testing.T) { srv, fa, _, fs := newReplyServer(t) session, csrfCookie, csrf := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") d, _ := srv.store.SaveDraft(context.Background(), id, "Saturday works.\n\nSam", "confirm") form := url.Values{ "csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)}, "body": {"Saturday works.\n\nSam"}, "confirm": {"yes"}, } rec := postForm(t, srv, "/actions/send", session, csrfCookie, form) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) } if len(fs.sent) != 1 { t.Fatalf("sent = %d, want 1", len(fs.sent)) } out := fs.sent[0] if out.To != "cust@example.test" { t.Errorf("To = %q", out.To) } if out.From != `"Sam Rolfe" ` { t.Errorf("From = %q", out.From) } if out.Subject != "Re: Booking for Saturday" { t.Errorf("Subject = %q", out.Subject) } if out.InReplyTo != "" || out.References != "" { t.Errorf("threading headers = %q / %q", out.InReplyTo, out.References) } if fs.verifyCalls != 1 { t.Errorf("verifyCalls = %d, want 1", fs.verifyCalls) } if !strings.Contains(rec.Body.String(), "Reply sent") { t.Errorf("response does not confirm the send") } // The draft is marked sent and the message is filed under Replied-to. got, _, err := srv.store.LatestDraft(context.Background(), id) if err != nil { t.Fatalf("LatestDraft() error = %v", err) } if got.Status != store.DraftStatusSent { t.Errorf("draft status = %q, want sent", got.Status) } if fa.moveCount() != 1 { t.Errorf("moves = %d, want 1 (file the original)", fa.moveCount()) } msgs, _ := srv.store.MessagesByIDs(context.Background(), []int64{id}) if len(msgs) != 1 || msgs[0].Folder != store.FolderReplied { t.Errorf("message = %+v, want folder replied", msgs) } } func TestSendReplyAlreadySentRefused(t *testing.T) { srv, _, _, fs := newReplyServer(t) session, csrfCookie, csrf := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") d, _ := srv.store.SaveDraft(context.Background(), id, "body", "quick-ack") if err := srv.store.MarkDraftSent(context.Background(), d.ID); err != nil { t.Fatalf("MarkDraftSent() error = %v", err) } form := url.Values{ "csrf": {csrf}, "id": {itoa(id)}, "draft_id": {itoa(d.ID)}, "body": {"body"}, "confirm": {"yes"}, } rec := postForm(t, srv, "/actions/send", session, csrfCookie, form) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if len(fs.sent) != 0 { t.Fatalf("sent = %d, want 0 for an already-sent draft", len(fs.sent)) } if !strings.Contains(rec.Body.String(), "already been sent") { t.Errorf("body did not explain the duplicate guard") } } func TestSendReplyNotesMissingThreadHeader(t *testing.T) { srv, _, _, _ := newReplyServer(t) session, _, _ := login(t, srv) id := seedReplyable(t, srv, "1", "hello@where-woof.com", "") rec := getWithSession(t, srv, "/partials/message?id="+itoa(id), session) if !strings.Contains(rec.Body.String(), "cannot set threading headers") { t.Errorf("detail pane did not warn about the missing Message-ID") } }