Files
multica/server/internal/handler/contact_sales_test.go
Bohan Jiang 7984606eed feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493) (#2988)
* feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493)

Adds a public `/contact-sales` marketing page with a needs-discovery form
modelled on the design reference attached to MUL-2493 — first/last name,
business email (with free-provider rejection), company name + size,
country/region, intended use case, and a free-text goals field, plus the
two consent checkboxes from the reference.

Submissions hit a new public `POST /api/contact-sales` endpoint with
per-IP rate limiting (Redis-backed via the existing RateLimit middleware,
configurable through `RATE_LIMIT_CONTACT_SALES`) and a per-email hourly
cap so a single business address can't be used as a flood channel after
one valid pass. The inquiry is stored in a new `contact_sales_inquiry`
table; analytics fires a `contact_sales_submitted` PostHog event with
only the closed-enum dimensions (size, country, use case) — the free-text
goals stay in the DB and are never broadcast.

The page is linked from the landing header (md+) and the footer's Company
column, in both English and Simplified Chinese. The reserved-slug list is
updated so a workspace named `contact-sales` can't shadow the route.

Co-authored-by: multica-agent <github@multica.ai>

* fix(landing): canonicalize business email and tighten contact-sales form (MUL-2493)

- Parse the submitted email with net/mail and run the free-email
  block-list against the canonical addr.Address, so a display-name
  form like `Ada <ada@gmail.com>` can no longer slip past the gate
  (the raw string had domain `gmail.com>`, which wasn't blocked).
  Adds regression tests covering the display-name bypass and the
  canonicalization helper.
- Drop noValidate from the contact-sales form so the browser's
  native required / email / select checks fire before submit;
  the JS-side free-email warning still runs as a UX guard.
- Update success copy ("respond within three business days") in
  EN and ZH plus the page metadata.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:22:36 +08:00

237 lines
7.1 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func newContactSalesRequest(body CreateContactSalesRequest) *http.Request {
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/api/contact-sales", &buf)
req.Header.Set("Content-Type", "application/json")
return req
}
func validContactSalesRequest() CreateContactSalesRequest {
return CreateContactSalesRequest{
FirstName: "Ada",
LastName: "Lovelace",
BusinessEmail: "ada@analytical-engine.example",
CompanyName: "Analytical Engine Co.",
CompanySize: "11-50",
CountryRegion: "United Kingdom",
UseCase: "evaluate",
Goals: "We want to compound agent productivity across the team.",
ConsentOutreach: true,
ConsentUpdates: false,
}
}
func clearContactSalesForEmail(t *testing.T, email string) {
t.Helper()
if _, err := testPool.Exec(context.Background(),
`DELETE FROM contact_sales_inquiry WHERE business_email = $1`, email); err != nil {
t.Fatalf("clear contact_sales_inquiry: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM contact_sales_inquiry WHERE business_email = $1`, email)
})
}
func TestCreateContactSalesHappyPath(t *testing.T) {
body := validContactSalesRequest()
clearContactSalesForEmail(t, body.BusinessEmail)
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp ContactSalesResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ID == "" {
t.Fatal("expected inquiry id in response")
}
}
func TestCreateContactSalesRejectsFreeEmail(t *testing.T) {
body := validContactSalesRequest()
body.BusinessEmail = "ada@gmail.com"
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateContactSalesRejectsInvalidEmail(t *testing.T) {
body := validContactSalesRequest()
body.BusinessEmail = "not-an-email"
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateContactSalesRejectsUnknownCompanySize(t *testing.T) {
body := validContactSalesRequest()
body.CompanySize = "ten-ish"
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateContactSalesRejectsUnknownUseCase(t *testing.T) {
body := validContactSalesRequest()
body.UseCase = "world-domination"
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateContactSalesMissingFirstName(t *testing.T) {
body := validContactSalesRequest()
body.FirstName = " "
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateContactSalesPerEmailRateLimit(t *testing.T) {
body := validContactSalesRequest()
body.BusinessEmail = "ratelimit-ada@analytical-engine.example"
clearContactSalesForEmail(t, body.BusinessEmail)
for i := 0; i < contactSalesHourlyEmailCap; i++ {
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusCreated {
t.Fatalf("iteration %d: expected 201, got %d: %s", i, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
}
}
func TestIsBusinessEmailDomain(t *testing.T) {
cases := []struct {
email string
want bool
}{
{"ada@multica.ai", true},
{"ada@example.com", true},
{"ada@gmail.com", false},
{"ada@Gmail.COM", false},
{"ada@yahoo.co.uk", false},
{"ada@qq.com", false},
{"weird-no-at", false},
{"ada@", false},
}
for _, c := range cases {
got := isBusinessEmailDomain(c.email)
if got != c.want {
t.Errorf("isBusinessEmailDomain(%q) = %v, want %v", c.email, got, c.want)
}
}
}
func TestCanonicalBusinessEmail(t *testing.T) {
cases := []struct {
name string
input string
want string
wantOk bool
}{
{"plain", "ada@multica.ai", "ada@multica.ai", true},
{"uppercase normalized", "Ada@Multica.AI", "ada@multica.ai", true},
{"trim whitespace", " ada@multica.ai ", "ada@multica.ai", true},
{"display name stripped", "Ada Lovelace <ada@multica.ai>", "ada@multica.ai", true},
{"angle-bracketed", "<ada@multica.ai>", "ada@multica.ai", true},
{"empty", "", "", false},
{"only whitespace", " ", "", false},
{"missing at", "no-at-sign", "", false},
{"missing local", "@multica.ai", "", false},
{"missing domain", "ada@", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := canonicalBusinessEmail(c.input)
if ok != c.wantOk || got != c.want {
t.Errorf("canonicalBusinessEmail(%q) = (%q, %v), want (%q, %v)",
c.input, got, ok, c.want, c.wantOk)
}
})
}
}
// Regression: net/mail.ParseAddress accepts display-name forms like
// `Ada <ada@gmail.com>`. Earlier versions of the handler ran the
// free-email check against the raw user input, so the domain it saw was
// `gmail.com>` (with the trailing angle bracket) — which is not in the
// block list, allowing a personal address to bypass the gate. The handler
// must canonicalize through the parsed addr.Address before the check.
func TestCreateContactSalesRejectsFreeEmailWithDisplayName(t *testing.T) {
body := validContactSalesRequest()
body.BusinessEmail = "Ada Lovelace <ada@gmail.com>"
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateContactSalesNormalizesDisplayNameEmail(t *testing.T) {
body := validContactSalesRequest()
body.BusinessEmail = "Ada Lovelace <ada@display-name.example>"
clearContactSalesForEmail(t, "ada@display-name.example")
w := httptest.NewRecorder()
testHandler.CreateContactSales(w, newContactSalesRequest(body))
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var stored string
if err := testPool.QueryRow(context.Background(),
`SELECT business_email FROM contact_sales_inquiry WHERE business_email = $1`,
"ada@display-name.example").Scan(&stored); err != nil {
t.Fatalf("expected row with canonical email persisted: %v", err)
}
if stored != "ada@display-name.example" {
t.Fatalf("expected canonical email persisted, got %q", stored)
}
}