Files
nuntius/internal/contactform/validate_test.go
T
petrbalvin 3a38f00dc0
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
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

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