Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Assisted-by: GLM 5.3 Flash
373 lines
14 KiB
Go
373 lines
14 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build linux || freebsd
|
|
|
|
package contactform
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestValidateContact_Happy(t *testing.T) {
|
|
r := &Request{
|
|
Name: "Jane Doe",
|
|
Email: "jane@example.com",
|
|
Service: "architecture",
|
|
Message: "Hello, I would like to discuss a project.",
|
|
}
|
|
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
|
|
t.Fatalf("expected no errors, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateContact_DefaultType(t *testing.T) {
|
|
// Unknown / empty type falls back to contact validation.
|
|
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
|
|
if errs := NormalizeAndValidate(r, ""); len(errs) > 0 {
|
|
t.Fatalf("expected no errors with empty type, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
|
|
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
|
|
errs := NormalizeAndValidate(r, "contact")
|
|
if len(errs) == 0 {
|
|
t.Fatal("expected error for invalid email")
|
|
}
|
|
if errs[0].Field != "email" {
|
|
t.Errorf("expected field=email, got %q", errs[0].Field)
|
|
}
|
|
}
|
|
|
|
func TestValidateContact_RejectsBadService(t *testing.T) {
|
|
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
|
|
errs := NormalizeAndValidate(r, "contact")
|
|
if len(errs) == 0 || errs[0].Field != "service" {
|
|
t.Fatalf("expected service error, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateFeedback_OmitsService(t *testing.T) {
|
|
// Feedback validates like generic: name, email, message; no service field.
|
|
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
|
|
if errs := NormalizeAndValidate(r, "feedback"); len(errs) > 0 {
|
|
t.Fatalf("expected no errors, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
|
|
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
|
|
errs := NormalizeAndValidate(r, "generic")
|
|
if len(errs) == 0 || errs[0].Field != "message" {
|
|
t.Fatalf("expected message error, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateNewsletter_Happy(t *testing.T) {
|
|
r := &Request{Email: "jane@example.com"}
|
|
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
|
|
t.Fatalf("expected no errors, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
|
|
r := &Request{Email: "garbage"}
|
|
errs := NormalizeAndValidate(r, "newsletter")
|
|
if len(errs) == 0 {
|
|
t.Fatal("expected error for missing/invalid email")
|
|
}
|
|
}
|
|
|
|
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
|
|
// Newsletter type only cares about the email field.
|
|
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
|
|
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
|
|
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
|
|
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
|
|
// validation, not to a hard error. The hard error is enforced at the
|
|
// config layer, not in Validate itself.
|
|
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
|
|
if errs := NormalizeAndValidate(r, "foo"); len(errs) > 0 {
|
|
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateTrimsWhitespace(t *testing.T) {
|
|
r := &Request{
|
|
Name: " Jane ",
|
|
Email: " jane@example.com ",
|
|
Service: " architecture ",
|
|
Message: " hello there ",
|
|
}
|
|
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
|
|
t.Fatalf("expected no errors, got %v", errs)
|
|
}
|
|
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
|
|
t.Errorf("expected whitespace to be trimmed, got %+v", r)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Policy-driven validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The presets must reproduce the exact rules the fixed validators enforced
|
|
// before the policy layer existed.
|
|
func TestPresets(t *testing.T) {
|
|
contact := Preset("contact")
|
|
if !contact.RequireName || !contact.RequireMessage {
|
|
t.Error("contact preset must require name and message")
|
|
}
|
|
if contact.MinNameRunes != MinNameRunes || contact.MaxNameRunes != MaxNameRunes {
|
|
t.Errorf("contact name limits = %d/%d, want %d/%d",
|
|
contact.MinNameRunes, contact.MaxNameRunes, MinNameRunes, MaxNameRunes)
|
|
}
|
|
if contact.MinMessageRunes != MinMessageRunes || contact.MaxMessageRunes != MaxMessageRunes {
|
|
t.Errorf("contact message limits = %d/%d, want %d/%d",
|
|
contact.MinMessageRunes, contact.MaxMessageRunes, MinMessageRunes, MaxMessageRunes)
|
|
}
|
|
if !slices.Contains(contact.Services, "architecture") || !slices.Contains(contact.Services, "other") {
|
|
t.Errorf("contact preset services = %v, want the built-in list", contact.Services)
|
|
}
|
|
|
|
for _, typ := range []string{"feedback", "generic"} {
|
|
p := Preset(typ)
|
|
if !p.RequireName || !p.RequireMessage {
|
|
t.Errorf("%s preset must require name and message", typ)
|
|
}
|
|
if p.Services != nil {
|
|
t.Errorf("%s preset must not validate service, got %v", typ, p.Services)
|
|
}
|
|
}
|
|
|
|
news := Preset("newsletter")
|
|
if news.RequireName || news.RequireMessage || news.Services != nil {
|
|
t.Errorf("newsletter preset = %+v, want email-only", news)
|
|
}
|
|
|
|
// Unknown and empty types fall back to contact.
|
|
fallback := Preset("nonsense")
|
|
if !fallback.RequireName || fallback.Services == nil {
|
|
t.Errorf("unknown type preset = %+v, want the contact preset", fallback)
|
|
}
|
|
}
|
|
|
|
// A payload without a service field is legitimate under the contact
|
|
// preset: the field is optional, never a mandatory category.
|
|
func TestValidateContactWithoutServiceField(t *testing.T) {
|
|
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "A plain hello for you."}
|
|
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
|
|
t.Fatalf("payload without service must pass, got %v", errs)
|
|
}
|
|
}
|
|
|
|
// A nil Services list means the field is not validated at all, so any
|
|
// value rides through untouched.
|
|
func TestPolicyNilServicesSkipsValidation(t *testing.T) {
|
|
p := Preset("feedback")
|
|
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "anything-goes", Message: "A message long enough."}
|
|
if errs := Validate(r, p); len(errs) > 0 {
|
|
t.Fatalf("nil services list must skip the field, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestPolicyServices(t *testing.T) {
|
|
base := func(services []string, service string) []FieldError {
|
|
p := Policy{
|
|
RequireName: true,
|
|
RequireMessage: true,
|
|
MinNameRunes: 2, MaxNameRunes: 100,
|
|
MinMessageRunes: 10, MaxMessageRunes: 5000,
|
|
Services: services,
|
|
}
|
|
r := &Request{Name: "Jane", Email: "jane@example.com", Service: service, Message: "A message long enough."}
|
|
return Validate(r, p)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
services []string
|
|
service string
|
|
wantErr bool
|
|
}{
|
|
{"empty service always passes", []string{"consulting"}, "", false},
|
|
{"listed value passes", []string{"consulting", "support"}, "support", false},
|
|
{"unlisted value fails", []string{"consulting"}, "hairstyling", true},
|
|
{"explicit empty list rejects any value", []string{}, "consulting", true},
|
|
{"explicit empty list keeps empty value", []string{}, "", false},
|
|
{"wildcard accepts anything", []string{ServiceAny}, "hairstyling", false},
|
|
{"wildcard beside entries still accepts anything", []string{"consulting", ServiceAny}, "anything", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
errs := base(tt.services, tt.service)
|
|
if gotErr := len(errs) > 0; gotErr != tt.wantErr {
|
|
t.Fatalf("Validate(service=%q, list=%v) errors = %v, wantErr %v",
|
|
tt.service, tt.services, errs, tt.wantErr)
|
|
}
|
|
if tt.wantErr && errs[0].Field != "service" {
|
|
t.Errorf("error field = %q, want service", errs[0].Field)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Custom limits are honoured and the error messages carry the configured
|
|
// numbers, not the built-in defaults.
|
|
func TestPolicyCustomLimits(t *testing.T) {
|
|
p := Policy{
|
|
RequireName: true,
|
|
RequireMessage: true,
|
|
MinNameRunes: 5,
|
|
MaxNameRunes: 10,
|
|
MinMessageRunes: 20,
|
|
MaxMessageRunes: 40,
|
|
}
|
|
r := &Request{Name: "Ja", Email: "jane@example.com", Message: "too short"}
|
|
errs := Validate(r, p)
|
|
if len(errs) != 2 {
|
|
t.Fatalf("errors = %v, want two length failures", errs)
|
|
}
|
|
if errs[0].Field != "name" || !strings.Contains(errs[0].Message, "at least 5 characters") {
|
|
t.Errorf("name error = %+v, want the configured minimum", errs[0])
|
|
}
|
|
if errs[1].Field != "message" || !strings.Contains(errs[1].Message, "at least 20 characters") {
|
|
t.Errorf("message error = %+v, want the configured minimum", errs[1])
|
|
}
|
|
|
|
r2 := &Request{Name: "A very long name over the limit", Email: "jane@example.com",
|
|
Message: strings.Repeat("x", 41)}
|
|
errs = Validate(r2, p)
|
|
if len(errs) != 2 {
|
|
t.Fatalf("errors = %v, want two maximum failures", errs)
|
|
}
|
|
if !strings.Contains(errs[0].Message, "at most 10 characters") ||
|
|
!strings.Contains(errs[1].Message, "at most 40 characters") {
|
|
t.Errorf("errors = %v, want the configured maxima", errs)
|
|
}
|
|
}
|
|
|
|
// A false Require flag takes the whole field out of the checks; the field
|
|
// is still trimmed so the email body sees clean text.
|
|
func TestPolicyOptionalFields(t *testing.T) {
|
|
p := Policy{RequireName: false, RequireMessage: false, Services: []string{ServiceAny}}
|
|
r := &Request{Name: "", Email: "jane@example.com", Message: "", Service: "x"}
|
|
if errs := Validate(r, p); len(errs) > 0 {
|
|
t.Fatalf("optional fields must pass empty, got %v", errs)
|
|
}
|
|
if r.Name != "" || r.Message != "" || r.Service != "x" {
|
|
t.Errorf("fields should stay trimmed, got %+v", r)
|
|
}
|
|
}
|
|
|
|
// The email address is never optional: it is the reply-to of the mail the
|
|
// form produces.
|
|
func TestPolicyEmailAlwaysRequired(t *testing.T) {
|
|
p := Policy{}
|
|
r := &Request{Name: "Jane", Email: "not-an-email", Message: "hello"}
|
|
errs := Validate(r, p)
|
|
if len(errs) != 1 || errs[0].Field != "email" {
|
|
t.Fatalf("errors = %v, want exactly the email failure", errs)
|
|
}
|
|
}
|
|
|
|
// Validate must produce the same verdicts as the type dispatcher did
|
|
// before the policy layer: every case the old tests covered still holds
|
|
// through the wrapper.
|
|
func TestNormalizeAndValidateBackwardCompatible(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
formType string
|
|
req Request
|
|
wantErr bool
|
|
}{
|
|
{"contact happy", "contact", Request{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss a project."}, false},
|
|
{"contact bad service", "contact", Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}, true},
|
|
{"feedback happy", "feedback", Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}, false},
|
|
{"generic short message", "generic", Request{Name: "Jane", Email: "jane@example.com", Message: "short"}, true},
|
|
{"newsletter happy", "newsletter", Request{Email: "jane@example.com"}, false},
|
|
{"newsletter bad email", "newsletter", Request{Email: "garbage"}, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := tc.req
|
|
errs := NormalizeAndValidate(&req, tc.formType)
|
|
if got := len(errs) > 0; got != tc.wantErr {
|
|
t.Fatalf("NormalizeAndValidate(%q) errors = %v, wantErr %v", tc.formType, errs, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
|
|
// Sanity: the minLength error message in the en.json UI mirror should
|
|
// remain a simple string. Here we just check that error messages are
|
|
// human-readable, not empty.
|
|
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
|
|
errs := NormalizeAndValidate(r, "contact")
|
|
if len(errs) < 2 {
|
|
t.Fatalf("expected at least 2 errors, got %v", errs)
|
|
}
|
|
for _, e := range errs {
|
|
if strings.TrimSpace(e.Message) == "" {
|
|
t.Errorf("error message is empty for field %q", e.Field)
|
|
}
|
|
}
|
|
}
|
|
|
|
// FuzzValidate explores the validator with arbitrary payloads and
|
|
// policies. The invariants: it never panics, every reported error names
|
|
// one of the four known fields with a non-empty message, and validation
|
|
// is deterministic in the trimmed request.
|
|
func FuzzValidate(f *testing.F) {
|
|
seeds := []Request{
|
|
{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss an engagement."},
|
|
{Name: "", Email: "", Service: "", Message: ""},
|
|
{Name: " J ", Email: " j@example.com ", Service: " unix ", Message: "x"},
|
|
{Name: "<script>", Email: "a@b", Service: "unknown", Message: strings.Repeat("m", 6000)},
|
|
{Name: "Elf", Email: "u@exämple.com", Service: "*", Message: "emoji 🚀 body"},
|
|
}
|
|
for _, s := range seeds {
|
|
f.Add(s.Name, s.Email, s.Service, s.Message, true, true, 2, 100, 10, 5000, 0)
|
|
}
|
|
f.Add("A", "a@b.c", "", "long enough", false, false, 0, 1, 0, 1, 3)
|
|
|
|
knownFields := []string{"name", "email", "service", "message"}
|
|
serviceLists := [][]string{nil, {}, {"architecture", "unix"}, {"*"}}
|
|
f.Fuzz(func(t *testing.T, name, email, service, message string,
|
|
requireName, requireMessage bool,
|
|
minName, maxName, minMessage, maxMessage, serviceChoice int) {
|
|
policy := Policy{
|
|
RequireName: requireName,
|
|
RequireMessage: requireMessage,
|
|
MinNameRunes: minName,
|
|
MaxNameRunes: maxName,
|
|
MinMessageRunes: minMessage,
|
|
MaxMessageRunes: maxMessage,
|
|
Services: serviceLists[((serviceChoice%len(serviceLists))+len(serviceLists))%len(serviceLists)],
|
|
}
|
|
req := Request{Name: name, Email: email, Service: service, Message: message}
|
|
|
|
errs := Validate(&req, policy)
|
|
for _, e := range errs {
|
|
if !slices.Contains(knownFields, e.Field) {
|
|
t.Fatalf("unknown field %q in error %q", e.Field, e.Message)
|
|
}
|
|
if strings.TrimSpace(e.Message) == "" {
|
|
t.Fatalf("empty message on field %q", e.Field)
|
|
}
|
|
}
|
|
again := Validate(&req, policy)
|
|
if !slices.Equal(errs, again) {
|
|
t.Fatalf("validation is not deterministic: %v then %v", errs, again)
|
|
}
|
|
})
|
|
}
|