// Copyright (c) 2026 Petr Balvín (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: "