package lagoon import ( "slices" "strings" "testing" "time" ) func TestValidateBetweenYear(t *testing.T) { rules := map[string]string{"year": "nullable|integer|between:1889,2100"} errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": 1700}, nil) if err != nil { t.Fatal(err) } if len(errs["year"]) == 0 { t.Fatal("year=1700 must fail") } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"year": nil}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("nil year = %v", errs) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"year": 1991}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("1991 = %v", errs) } } func TestValidateIntegerPointerAndJSONNumber(t *testing.T) { rules := map[string]string{"year": "nullable|integer|between:1889,2100"} year := 1991 var nilYear *int pass := map[string]any{ "*int": &year, "nil *int": nilYear, "float64": float64(1991), "digit string": "1991", } for name, val := range pass { errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) if err != nil { t.Fatalf("%s: %v", name, err) } if len(errs) != 0 { t.Fatalf("%s must pass, got %v", name, errs) } } zero := 0 early := 1700 fail := map[string]any{ "fractional float": 1991.5, "out of range *int": &early, "bool": true, "non-digit string": "19x1", "int 0": 0, "float64 0": float64(0), "*int 0": &zero, "string 0": "0", } for name, val := range fail { errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) if err != nil { t.Fatalf("%s: %v", name, err) } if len(errs["year"]) == 0 { t.Fatalf("%s must fail", name) } } } func TestValidateOneofQuotedSpace(t *testing.T) { rules := map[string]string{"format": `nullable|in:LP,2LP,CD,2CD,MC,Box,EP 7"`} errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"format": `EP 7"`}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("EP 7\" rejected: %v", errs) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"format": "tape"}, nil) if err != nil { t.Fatal(err) } if len(errs["format"]) == 0 { t.Fatal("invalid format must fail") } } func TestValidateMoneyRange(t *testing.T) { rules := map[string]string{"market_price_stored": "nullable|numeric|min:0|max:999999.9999"} errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": "1000000.0000"}, nil) if err != nil { t.Fatal(err) } if len(errs["market_price_stored"]) == 0 { t.Fatal("1000000.0000 must fail max") } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": "25.0000"}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("25.0000 = %v", errs) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": 0}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("numeric 0 must pass min:0, got %v", errs) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": nil}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("nil money = %v", errs) } } func TestValidateBooleanNoop(t *testing.T) { rules := map[string]string{"search_use_typesense": "boolean"} for _, v := range []any{true, false} { errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"search_use_typesense": v}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("boolean %v = %v", v, errs) } } } func TestValidateEmailConfirmedDifferentMimes(t *testing.T) { errs, err := Validate(t.Context(), nil, nil, map[string]string{"email": "email"}, map[string]any{"email": "not-an-email"}, nil) if err != nil { t.Fatal(err) } if len(errs["email"]) == 0 { t.Fatal("not-an-email must fail") } errs, err = Validate(t.Context(), nil, nil, map[string]string{"email": "email"}, map[string]any{"email": "a@b.com"}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("a@b.com = %v", errs) } rules := map[string]string{"password": "confirmed"} errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{ "password": "secret", "password_confirmation": "secret", }, nil) if err != nil || len(errs) != 0 { t.Fatalf("confirmed match: %v %v", errs, err) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{ "password": "secret", "password_confirmation": "other", }, nil) if err != nil || len(errs["password"]) == 0 { t.Fatalf("confirmed mismatch: %v %v", errs, err) } rules = map[string]string{"password": "different:current_password"} errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{ "password": "same", "current_password": "same", }, nil) if err != nil || len(errs["password"]) == 0 { t.Fatalf("different equal: %v %v", errs, err) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{ "password": "new", "current_password": "old", }, nil) if err != nil || len(errs) != 0 { t.Fatalf("different distinct: %v %v", errs, err) } rules = map[string]string{"avatar": "mimes:jpeg,jpg,png,webp,gif"} errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"avatar": "PNG"}, nil) if err != nil || len(errs) != 0 { t.Fatalf("png: %v %v", errs, err) } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"avatar": "svg"}, nil) if err != nil || len(errs["avatar"]) == 0 { t.Fatalf("svg: %v %v", errs, err) } } func TestValidateUnrecognizedRule(t *testing.T) { _, err := Validate(t.Context(), nil, nil, map[string]string{"name": "required|nope"}, map[string]any{"name": "x"}, nil) if err == nil || !strings.Contains(err.Error(), "nope") { t.Fatalf("want unrecognized nope, got %v", err) } } func TestValidateUniqueRespectsDeletedAt(t *testing.T) { sqlDB, _ := dedicatedDB(t, "lagoon_validate_unique") gdb, err := Use(t.Context(), sqlDB) if err != nil { t.Fatal(err) } if err := gdb.Exec(`CREATE TABLE lagoon_unique_rows ( id SERIAL PRIMARY KEY, slug TEXT NOT NULL, deleted_at TIMESTAMPTZ )`).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`INSERT INTO lagoon_unique_rows (slug, deleted_at) VALUES ('live', NULL), ('gone', NOW())`).Error; err != nil { t.Fatal(err) } rules := map[string]string{"slug": "unique:lagoon_unique_rows"} errs, err := Validate(t.Context(), gdb, nil, rules, map[string]any{"slug": "live"}, nil) if err != nil { t.Fatal(err) } if len(errs["slug"]) == 0 { t.Fatal("live slug must fail unique") } errs, err = Validate(t.Context(), gdb, nil, rules, map[string]any{"slug": "gone"}, nil) if err != nil { t.Fatal(err) } if len(errs) != 0 { t.Fatalf("soft-deleted slug should pass unique: %v", errs) } } func TestValidateNumericRangeMessagePicksFailedBound(t *testing.T) { cases := []struct { rule string val any want string }{ {"integer|min:0", -1, "The views must be at least 0."}, {"integer|max:10", 11, "The views may not be greater than 10."}, {"numeric|min:0|max:10", "-0.5", "The views must be at least 0."}, {"numeric|min:0|max:10", "10.5", "The views may not be greater than 10."}, {"integer|between:0,10", 11, "The views must be between 0 and 10."}, {"integer|between:0,10", -1, "The views must be between 0 and 10."}, {"integer|between:0,10|min:2", 1, "The views must be at least 2."}, } for _, c := range cases { errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": c.rule}, map[string]any{"views": c.val}, nil) if err != nil { t.Fatal(err) } if got := errs["views"]; len(got) != 1 || got[0] != c.want { t.Fatalf("%s with %v = %v, want %q", c.rule, c.val, got, c.want) } } errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": "integer|between:0,10"}, map[string]any{"views": 5}, nil) if err != nil || errs != nil { t.Fatalf("in range: %v %v", errs, err) } } func TestValidateNumericRangeMessageTranslated(t *testing.T) { tr := requestTranslator(t) errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{"year": "integer|between:1889,2100"}, map[string]any{"year": 1700}, tr) if err != nil { t.Fatal(err) } if got := errs["year"]; len(got) != 1 || got[0] != "year musi zawierać się w granicach 1889 - 2100." { t.Fatalf("pl between = %v", got) } } // TestValidateRequiredZeroDates: required rejects a zero time.Time, Date or // TimeOfDay and a non-nil pointer to one, accepts set values (midnight // included), and keeps the old emptiness of every other type: an int 0, a // false bool and a non-date struct are present. func TestValidateRequiredZeroDates(t *testing.T) { zeroTime, zeroDate, zeroClock := time.Time{}, Date{}, TimeOfDay{} setTime, setDate, midnight := time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC), NewDate(2026, 10, 2), NewTimeOfDay(0, 0, 0) type other struct{ A int } cases := []struct { name string value any empty bool }{ {"zero time", zeroTime, true}, {"pointer to zero time", &zeroTime, true}, {"nil time pointer", (*time.Time)(nil), true}, {"zero date", zeroDate, true}, {"pointer to zero date", &zeroDate, true}, {"nil date pointer", (*Date)(nil), true}, {"zero clock", zeroClock, true}, {"pointer to zero clock", &zeroClock, true}, {"nil clock pointer", (*TimeOfDay)(nil), true}, {"set time", setTime, false}, {"pointer to set time", &setTime, false}, {"set date", setDate, false}, {"pointer to set date", &setDate, false}, {"midnight", midnight, false}, {"pointer to midnight", &midnight, false}, {"int zero", 0, false}, {"false", false, false}, {"zero struct", other{}, false}, {"empty string", "", true}, {"nil", nil, true}, } for _, tc := range cases { errs, err := Validate(t.Context(), nil, &fillDates{}, map[string]string{"v": "required"}, map[string]any{"v": tc.value}, nil) if err != nil { t.Fatalf("%s: %v", tc.name, err) } if got := len(errs["v"]) > 0; got != tc.empty { t.Fatalf("%s: required failed=%v, want %v (%v)", tc.name, got, tc.empty, errs) } } } // ruleCase is one value under one rule string with the exact messages // lagoon.Validate must report for the field (nil when it passes). type ruleCase struct { name string rule string value any want []string } func runRuleCases(t *testing.T, field string, cases []ruleCase) { t.Helper() for _, tc := range cases { errs, err := Validate(t.Context(), nil, nil, map[string]string{field: tc.rule}, map[string]any{field: tc.value}, nil) if err != nil { t.Fatalf("%s: %v", tc.name, err) } if got := errs[field]; !slices.Equal(got, tc.want) { t.Fatalf("%s: %q on %#v = %q, want %q", tc.name, tc.rule, tc.value, got, tc.want) } } } // TestValidateStringRule: string accepts a string, a pointer to one and a // named string type, refuses any other value with Laravel's message, and // skips an empty or blank value, which is required's job. func TestValidateStringRule(t *testing.T) { type label string empty, x := "", "x" const rule = "nullable|string|max:255" notString := []string{"The title must be a string."} runRuleCases(t, "title", []ruleCase{ {"plain", rule, "hello", nil}, {"nil", rule, nil, nil}, {"empty", rule, "", nil}, {"blank", rule, " ", nil}, {"nil pointer", rule, (*string)(nil), nil}, {"pointer to empty", rule, &empty, nil}, {"pointer to value", rule, &x, nil}, {"named type", rule, label("news"), nil}, {"int", rule, 5, notString}, {"bool", rule, true, notString}, {"slice", rule, []string{"a"}, notString}, {"no nullable empty", "string", "", nil}, {"no nullable nil", "string", nil, nil}, {"no nullable int", "string", 5, notString}, {"required empty", "required|string", "", []string{"The title field is required."}}, }) } // TestValidateURLRule: url accepts what Laravel's pattern accepts (the one // lagoon.ValidateRequest uses) and skips an empty or blank value. func TestValidateURLRule(t *testing.T) { empty, nope := "", "nope" const rule = "nullable|url|max:500" invalid := []string{"The website format is invalid."} runRuleCases(t, "website", []ruleCase{ {"https", rule, "https://example.com/feed.png", nil}, {"localhost port query fragment", rule, "http://localhost:8080/x?y=1#z", nil}, {"unicode host and path", rule, "https://przykład.pl/ścieżka", nil}, {"nil", rule, nil, nil}, {"empty", rule, "", nil}, {"blank", rule, " ", nil}, {"pointer to empty", rule, &empty, nil}, {"words", rule, "not a url", invalid}, {"no scheme", rule, "example.com", invalid}, {"javascript", rule, "javascript:alert(1)", invalid}, {"leading space", rule, " https://example.com", invalid}, {"trailing space", rule, "https://example.com ", invalid}, {"pointer to non-url", rule, &nope, invalid}, {"int", rule, 42, invalid}, }) } // TestValidateStringURLTranslated: with a translator the string and url // failures are the lagoon::validate lines of the request locale. func TestValidateStringURLTranslated(t *testing.T) { tr := requestTranslator(t) cases := []struct { field, rule string value any want string }{ {"title", "nullable|string|max:255", 5, "title musi być ciągiem znaków."}, {"website", "nullable|url|max:500", "not a url", "Format website jest nieprawidłowy."}, {"title", "nullable|string|max:255", strings.Repeat("a", 256), "title nie może być dłuższy niż 255 znaków."}, {"title", "string|min:3", "ab", "title musi mieć przynajmniej 3 znaków."}, } for _, tc := range cases { errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{tc.field: tc.rule}, map[string]any{tc.field: tc.value}, tr) if err != nil { t.Fatal(err) } if got := errs[tc.field]; len(got) != 1 || got[0] != tc.want { t.Fatalf("%s %q = %q, want %q", tc.field, tc.rule, got, tc.want) } } } // TestValidateStringLength: on a string or url field min and max compare // the length in characters (runes, Laravel's mb_strlen) with the string // size messages; rule strings without string or url keep their old results. func TestValidateStringLength(t *testing.T) { const longURL = "https://example.com/a-very-long-path/feed.png" const longWords = "this is not a url and it is far too long" if len(longWords) != 40 { t.Fatalf("fixture length %d", len(longWords)) } runRuleCases(t, "title", []ruleCase{ {"255 ascii", "nullable|string|max:255", strings.Repeat("a", 255), nil}, {"256 ascii", "nullable|string|max:255", strings.Repeat("a", 256), []string{"The title may not be greater than 255 characters."}}, {"255 polish letters", "nullable|string|max:255", strings.Repeat("ż", 255), nil}, {"256 polish letters", "nullable|string|max:255", strings.Repeat("ż", 256), []string{"The title may not be greater than 255 characters."}}, {"below min", "string|min:3", "ab", []string{"The title must be at least 3 characters."}}, {"at min", "string|min:3", "abc", nil}, {"min empty", "string|min:3", "", nil}, {"min nil", "string|min:3", nil, nil}, {"required min empty", "required|string|min:3", "", []string{"The title field is required."}}, {"non-string with max", "string|max:3", 12345, []string{"The title must be a string."}}, // Pinned: without string or url, min and max are not checked on a // non-numeric field (unchanged behaviour, see the follow-up). {"max without string", "max:5", "0123456789", nil}, {"nullable max without string", "nullable|max:5", "0123456789", nil}, {"string between keeps its check", "string|between:1,5", "0123456789", []string{"The title must be between 1 and 5 characters."}}, {"string between passes", "string|between:1,5", "abc", nil}, }) runRuleCases(t, "website", []ruleCase{ {"short url", "nullable|url|max:30", "https://example.com/feed.png", nil}, {"long url", "nullable|url|max:30", longURL, []string{"The website may not be greater than 30 characters."}}, {"long non-url", "nullable|url|max:30", longWords, []string{"The website format is invalid.", "The website may not be greater than 30 characters."}}, }) for _, v := range []any{"x", nil} { _, err := Validate(t.Context(), nil, nil, map[string]string{"title": "string|max:abc"}, map[string]any{"title": v}, nil) if err == nil || !strings.Contains(err.Error(), "max:abc") { t.Fatalf("malformed max on %#v: err = %v", v, err) } } _, err := Validate(t.Context(), nil, nil, map[string]string{"title": "nullable|string|min:x"}, map[string]any{"title": "abc"}, nil) if err == nil || !strings.Contains(err.Error(), "min:x") { t.Fatalf("malformed min: err = %v", err) } }