From 36983bc87e54c9f8bcd1e85431ef2a6af16f2049 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 15:42:47 +0200 Subject: [PATCH] fix(12-05): match Laravel's in and not_in rules on array values A 162-case truth table recorded from WinterCMS's validator (the vendored winter/storm Factory, Laravel 9) found three divergences, all on arrays: - in compared array elements loosely; Laravel uses array_diff, an exact string comparison, so ["1.0"] is not in 1,2; - not_in failed when any element was listed; Laravel's validateNotIn is !validateIn, so an array passes unless every element is listed; - not_in failed an array without the array rule; Laravel passes it. validate_rules_test.go keeps the whole table (accepted, array keys, boolean, numeric, integer, in/not_in, sizes by type, regex, dates and comparisons, url, presence, nested and map wildcards, bail and order). --- modules/lagoon/validate_rules.go | 28 ++-- modules/lagoon/validate_rules_test.go | 211 ++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 modules/lagoon/validate_rules_test.go diff --git a/modules/lagoon/validate_rules.go b/modules/lagoon/validate_rules.go index 3d98b67..63e02ec 100644 --- a/modules/lagoon/validate_rules.go +++ b/modules/lagoon/validate_rules.go @@ -11,6 +11,7 @@ import ( "net/http" "reflect" "regexp" + "slices" "strconv" "strings" "time" @@ -700,6 +701,11 @@ func inList(s string, list []string) bool { return false } +// validateIn is Laravel's validateIn. An array value needs the array rule +// and no nested element, and then, like count(array_diff($value, +// $parameters)) === 0, every element's string form must equal a parameter +// exactly. A scalar compares like in_array((string) $value, $parameters), +// where two numeric strings compare as numbers. func (v *requestValidator) validateIn(attr string, value any, params []string) bool { if _, isArr := arrayLen(value); isArr { if !v.hasRule(attr, "array") { @@ -709,8 +715,10 @@ func (v *requestValidator) validateIn(attr string, value any, params []string) b if _, nested := arrayLen(ch.value); nested { return false } + } + for _, ch := range children(value) { s, ok := phpScalarString(ch.value) - if !ok || !inList(s, params) { + if !ok || !slices.Contains(params, s) { return false } } @@ -720,21 +728,11 @@ func (v *requestValidator) validateIn(attr string, value any, params []string) b return ok && inList(s, params) } +// validateNotIn is Laravel's validateNotIn: the negation of validateIn, so +// an array passes unless every element is listed, and an array without the +// array rule always passes. func (v *requestValidator) validateNotIn(attr string, value any, params []string) bool { - if _, isArr := arrayLen(value); isArr { - if !v.hasRule(attr, "array") { - return false - } - for _, ch := range children(value) { - s, ok := phpScalarString(ch.value) - if ok && inList(s, params) { - return false - } - } - return true - } - s, ok := phpScalarString(value) - return ok && !inList(s, params) + return !v.validateIn(attr, value, params) } // exists is Laravel's exists:table,column presence check. It counts rows diff --git a/modules/lagoon/validate_rules_test.go b/modules/lagoon/validate_rules_test.go new file mode 100644 index 0000000..6af7ac1 --- /dev/null +++ b/modules/lagoon/validate_rules_test.go @@ -0,0 +1,211 @@ +package lagoon + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// laravelRuleCases is a truth table for ValidateRequest. Each want is the +// English errors object that WinterCMS's validator (Laravel 9, the +// vendored winter/storm Factory) returned for the same JSON input and rule +// strings, so a case that fails here is a divergence from PHP. Inputs are +// decoded with UseNumber, as the application decodes request bodies. +var laravelRuleCases = []struct { + name string + data string + rules [][2]string + want string +}{ + {"accepted-yes", `{"t":"yes"}`, [][2]string{{"t", `accepted`}}, `{}`}, + {"accepted-on", `{"t":"on"}`, [][2]string{{"t", `accepted`}}, `{}`}, + {"accepted-one-string", `{"t":"1"}`, [][2]string{{"t", `accepted`}}, `{}`}, + {"accepted-true-string", `{"t":"true"}`, [][2]string{{"t", `accepted`}}, `{}`}, + {"accepted-true", `{"t":true}`, [][2]string{{"t", `accepted`}}, `{}`}, + {"accepted-one", `{"t":1}`, [][2]string{{"t", `accepted`}}, `{}`}, + {"accepted-no", `{"t":"no"}`, [][2]string{{"t", `accepted`}}, `{"t":["The t must be accepted."]}`}, + {"accepted-zero", `{"t":0}`, [][2]string{{"t", `accepted`}}, `{"t":["The t must be accepted."]}`}, + {"accepted-false-string", `{"t":"false"}`, [][2]string{{"t", `accepted`}}, `{"t":["The t must be accepted."]}`}, + {"accepted-two", `{"t":2}`, [][2]string{{"t", `accepted`}}, `{"t":["The t must be accepted."]}`}, + {"accepted-missing", `{}`, [][2]string{{"t", `accepted`}}, `{"t":["The t must be accepted."]}`}, + {"array-keys-ok", `{"a":{"x":1,"y":2}}`, [][2]string{{"a", `array:x,y`}}, `{}`}, + {"array-keys-extra", `{"a":{"x":1,"z":2}}`, [][2]string{{"a", `array:x,y`}}, `{"a":["The a must be an array."]}`}, + {"array-scalar", `{"a":"str"}`, [][2]string{{"a", `array`}}, `{"a":["The a must be an array."]}`}, + {"array-list-keys", `{"a":[1,2]}`, [][2]string{{"a", `array:0,1`}}, `{}`}, + {"array-list-keys-short", `{"a":[1,2,3]}`, [][2]string{{"a", `array:0,1`}}, `{"a":["The a must be an array."]}`}, + {"boolean-true", `{"b":true}`, [][2]string{{"b", `boolean`}}, `{}`}, + {"boolean-false", `{"b":false}`, [][2]string{{"b", `boolean`}}, `{}`}, + {"boolean-zero", `{"b":0}`, [][2]string{{"b", `boolean`}}, `{}`}, + {"boolean-one", `{"b":1}`, [][2]string{{"b", `boolean`}}, `{}`}, + {"boolean-zero-string", `{"b":"0"}`, [][2]string{{"b", `boolean`}}, `{}`}, + {"boolean-one-string", `{"b":"1"}`, [][2]string{{"b", `boolean`}}, `{}`}, + {"boolean-true-string", `{"b":"true"}`, [][2]string{{"b", `boolean`}}, `{"b":["The b field must be true or false."]}`}, + {"boolean-yes", `{"b":"yes"}`, [][2]string{{"b", `boolean`}}, `{"b":["The b field must be true or false."]}`}, + {"boolean-two", `{"b":2}`, [][2]string{{"b", `boolean`}}, `{"b":["The b field must be true or false."]}`}, + {"boolean-two-string", `{"b":"2"}`, [][2]string{{"b", `boolean`}}, `{"b":["The b field must be true or false."]}`}, + {"boolean-float-one", `{"b":1.0}`, [][2]string{{"b", `boolean`}}, `{"b":["The b field must be true or false."]}`}, + {"numeric-exp", `{"n":"1e3"}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-spaces", `{"n":" 12 "}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-trailing-letters", `{"n":"12abc"}`, [][2]string{{"n", `numeric`}}, `{"n":["The n must be a number."]}`}, + {"numeric-neg-frac", `{"n":"-.5"}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-frac-exp", `{"n":".5e1"}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-plus", `{"n":"+1"}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-bare-e", `{"n":"1e"}`, [][2]string{{"n", `numeric`}}, `{"n":["The n must be a number."]}`}, + {"numeric-hex", `{"n":"0x1A"}`, [][2]string{{"n", `numeric`}}, `{"n":["The n must be a number."]}`}, + {"numeric-float", `{"n":12.5}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-true", `{"n":true}`, [][2]string{{"n", `numeric`}}, `{"n":["The n must be a number."]}`}, + {"numeric-dot", `{"n":"."}`, [][2]string{{"n", `numeric`}}, `{"n":["The n must be a number."]}`}, + {"numeric-leading-space", `{"n":" 12"}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-trailing-space", `{"n":"12 "}`, [][2]string{{"n", `numeric`}}, `{}`}, + {"numeric-array", `{"n":[1]}`, [][2]string{{"n", `numeric`}}, `{"n":["The n must be a number."]}`}, + {"integer-leading-zero", `{"i":"007"}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-neg-zero", `{"i":"-0"}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-plus", `{"i":"+5"}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-spaces", `{"i":" 5 "}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-decimal-string", `{"i":"5.0"}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-float-whole", `{"i":5.0}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-float-exp", `{"i":1000.0}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-overflow", `{"i":"9223372036854775808"}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-max", `{"i":"9223372036854775807"}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-true", `{"i":true}`, [][2]string{{"i", `integer`}}, `{}`}, + {"integer-false", `{"i":false}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-array", `{"i":[]}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-exp-string", `{"i":"1e3"}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-minus", `{"i":"-"}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-plus-only", `{"i":"+"}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-float-frac", `{"i":5.5}`, [][2]string{{"i", `integer`}}, `{"i":["The i must be an integer."]}`}, + {"integer-zero", `{"i":"0"}`, [][2]string{{"i", `integer`}}, `{}`}, + {"in-ok", `{"f":"LP"}`, [][2]string{{"f", `in:LP,CD`}}, `{}`}, + {"in-case", `{"f":"lp"}`, [][2]string{{"f", `in:LP,CD`}}, `{"f":["The selected f is invalid."]}`}, + {"in-loose-numeric", `{"f":"1.0"}`, [][2]string{{"f", `in:1,2`}}, `{}`}, + {"in-int-value", `{"f":1}`, [][2]string{{"f", `in:1,2`}}, `{}`}, + {"in-bool-value", `{"f":true}`, [][2]string{{"f", `in:1,2`}}, `{}`}, + {"in-array-ok", `{"f":["LP","CD"]}`, [][2]string{{"f", `array|in:LP,CD`}}, `{}`}, + {"in-array-bad", `{"f":["LP","X"]}`, [][2]string{{"f", `array|in:LP,CD`}}, `{"f":["The selected f is invalid."]}`}, + {"in-array-strict-string", `{"f":["1.0"]}`, [][2]string{{"f", `array|in:1,2`}}, `{"f":["The selected f is invalid."]}`}, + {"in-array-ints", `{"f":[1,2]}`, [][2]string{{"f", `array|in:1,2`}}, `{}`}, + {"in-array-nested", `{"f":[["LP"]]}`, [][2]string{{"f", `array|in:LP`}}, `{"f":["The selected f is invalid."]}`}, + {"in-array-without-array-rule", `{"f":["LP"]}`, [][2]string{{"f", `in:LP`}}, `{"f":["The selected f is invalid."]}`}, + {"not-in-ok", `{"x":"a"}`, [][2]string{{"x", `not_in:b,c`}}, `{}`}, + {"not-in-fail", `{"x":"b"}`, [][2]string{{"x", `not_in:b,c`}}, `{"x":["The selected x is invalid."]}`}, + {"not-in-loose", `{"x":"2.0"}`, [][2]string{{"x", `not_in:2`}}, `{"x":["The selected x is invalid."]}`}, + {"not-in-array-some", `{"x":["a","b"]}`, [][2]string{{"x", `array|not_in:b`}}, `{}`}, + {"not-in-array-all", `{"x":["b"]}`, [][2]string{{"x", `array|not_in:b`}}, `{"x":["The selected x is invalid."]}`}, + {"not-in-array-none", `{"x":["a"]}`, [][2]string{{"x", `array|not_in:b`}}, `{}`}, + {"not-in-array-without-array-rule", `{"x":["b"]}`, [][2]string{{"x", `not_in:b`}}, `{}`}, + {"string-int", `{"s":123}`, [][2]string{{"s", `string`}}, `{"s":["The s must be a string."]}`}, + {"string-null", `{"s":null}`, [][2]string{{"s", `string`}}, `{"s":["The s must be a string."]}`}, + {"string-null-nullable", `{"s":null}`, [][2]string{{"s", `nullable|string`}}, `{}`}, + {"size-string", `{"s":"abc"}`, [][2]string{{"s", `size:4`}}, `{"s":["The s must be 4 characters."]}`}, + {"size-string-ok", `{"s":"abc"}`, [][2]string{{"s", `size:3`}}, `{}`}, + {"max-string-chars", `{"s":"ąęś"}`, [][2]string{{"s", `max:2`}}, `{"s":["The s may not be greater than 2 characters."]}`}, + {"min-array", `{"a":[1]}`, [][2]string{{"a", `array|min:2`}}, `{"a":["The a must have at least 2 items."]}`}, + {"max-array", `{"a":[1,2,3]}`, [][2]string{{"a", `array|max:2`}}, `{"a":["The a may not have more than 2 items."]}`}, + {"size-array", `{"a":[1,2]}`, [][2]string{{"a", `array|size:3`}}, `{"a":["The a must contain 3 items."]}`}, + {"between-array", `{"a":[]}`, [][2]string{{"a", `array|between:1,2`}}, `{"a":["The a must have between 1 and 2 items."]}`}, + {"between-numeric", `{"n":"5"}`, [][2]string{{"n", `numeric|between:1,4`}}, `{"n":["The n must be between 1 and 4."]}`}, + {"max-integer", `{"n":5}`, [][2]string{{"n", `integer|max:4`}}, `{"n":["The n may not be greater than 4."]}`}, + {"min-numeric-decimal", `{"n":"0.5"}`, [][2]string{{"n", `numeric|min:1`}}, `{"n":["The n must be at least 1."]}`}, + {"size-numeric", `{"n":"3"}`, [][2]string{{"n", `numeric|size:4`}}, `{"n":["The n must be 4."]}`}, + {"size-numeric-string-without-numeric-rule", `{"n":"12345"}`, [][2]string{{"n", `size:5`}}, `{}`}, + {"min-null", `{"n":null}`, [][2]string{{"n", `min:1`}}, `{"n":["The n must be at least 1 characters."]}`}, + {"between-string", `{"s":"a"}`, [][2]string{{"s", `string|between:2,4`}}, `{"s":["The s must be between 2 and 4 characters."]}`}, + {"max-bool", `{"s":true}`, [][2]string{{"s", `max:0`}}, `{"s":["The s may not be greater than 0 characters."]}`}, + {"regex-ok", `{"d":"3:45"}`, [][2]string{{"d", `regex:/^\d{1,3}:[0-5]\d$/`}}, `{}`}, + {"regex-bad", `{"d":"3:75"}`, [][2]string{{"d", `regex:/^\d{1,3}:[0-5]\d$/`}}, `{"d":["The d format is invalid."]}`}, + {"regex-number", `{"d":345}`, [][2]string{{"d", `regex:/^\d+$/`}}, `{}`}, + {"regex-array", `{"d":["x"]}`, [][2]string{{"d", `regex:/x/`}}, `{"d":["The d format is invalid."]}`}, + {"regex-bool", `{"d":true}`, [][2]string{{"d", `regex:/1/`}}, `{"d":["The d format is invalid."]}`}, + {"not-regex", `{"d":"abc"}`, [][2]string{{"d", `not_regex:/a/`}}, `{"d":["The d format is invalid."]}`}, + {"not-regex-ok", `{"d":"xyz"}`, [][2]string{{"d", `not_regex:/a/`}}, `{}`}, + {"regex-flags", `{"d":"ABC"}`, [][2]string{{"d", `regex:/^abc$/i`}}, `{}`}, + {"regex-hash-delimiter", `{"d":"a/b"}`, [][2]string{{"d", `regex:#^a/b$#`}}, `{}`}, + {"date-feb30", `{"d":"2020-02-30"}`, [][2]string{{"d", `date`}}, `{"d":["The d is not a valid date."]}`}, + {"date-leap", `{"d":"2020-02-29"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-garbage", `{"d":"garbage"}`, [][2]string{{"d", `date`}}, `{"d":["The d is not a valid date."]}`}, + {"date-int", `{"d":12345}`, [][2]string{{"d", `date`}}, `{"d":["The d is not a valid date."]}`}, + {"date-slash", `{"d":"2020/02/29"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-us", `{"d":"02/29/2020"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-dot", `{"d":"29.02.2020"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-dash-dmy", `{"d":"29-02-2020"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-iso-z", `{"d":"2020-02-29T10:00:00Z"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-iso-offset", `{"d":"2020-02-29T10:00:00+02:00"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-bad-hour", `{"d":"2020-02-29 25:00"}`, [][2]string{{"d", `date`}}, `{"d":["The d is not a valid date."]}`}, + {"date-with-time", `{"d":"2020-02-29 10:30"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-with-seconds", `{"d":"2020-02-29 10:30:15"}`, [][2]string{{"d", `date`}}, `{}`}, + {"date-month13", `{"d":"2020-13-01"}`, [][2]string{{"d", `date`}}, `{"d":["The d is not a valid date."]}`}, + {"date-array", `{"d":["2020-01-01"]}`, [][2]string{{"d", `date`}}, `{"d":["The d is not a valid date."]}`}, + {"after-same", `{"d":"2020-01-01"}`, [][2]string{{"d", `after:2020-01-01`}}, `{"d":["The d must be a date after 2020-01-01."]}`}, + {"after-or-equal-same", `{"d":"2020-01-01"}`, [][2]string{{"d", `after_or_equal:2020-01-01`}}, `{}`}, + {"before-ok", `{"d":"2019-12-31"}`, [][2]string{{"d", `before:2020-01-01`}}, `{}`}, + {"before-or-equal-future", `{"d":"2099-01-01"}`, [][2]string{{"d", `before_or_equal:tomorrow`}}, `{"d":["The d must be a date before or equal to tomorrow."]}`}, + {"before-or-equal-past", `{"d":"2001-01-01"}`, [][2]string{{"d", `before_or_equal:tomorrow`}}, `{}`}, + {"after-or-equal-1900", `{"d":"1899-12-31"}`, [][2]string{{"d", `after_or_equal:1900-01-01`}}, `{"d":["The d must be a date after or equal to 1900-01-01."]}`}, + {"after-field-bad", `{"start":"2020-01-01","end":"2019-01-01"}`, [][2]string{{"end", `after:start`}}, `{"end":["The end must be a date after start."]}`}, + {"after-field-ok", `{"start":"2020-01-01","end":"2021-01-01"}`, [][2]string{{"end", `after:start`}}, `{}`}, + {"after-unparsable", `{"d":"garbage"}`, [][2]string{{"d", `after:2020-01-01`}}, `{"d":["The d must be a date after 2020-01-01."]}`}, + {"after-missing-field", `{"d":"2020-01-01"}`, [][2]string{{"d", `after:nofield`}}, `{}`}, + {"before-int", `{"d":5}`, [][2]string{{"d", `before:2020-01-01`}}, `{}`}, + {"after-yesterday", `{"d":"2001-01-01"}`, [][2]string{{"d", `after:yesterday`}}, `{"d":["The d must be a date after yesterday."]}`}, + {"before-today", `{"d":"2001-01-01"}`, [][2]string{{"d", `before:today`}}, `{}`}, + {"after-now", `{"d":"2099-01-01 10:00"}`, [][2]string{{"d", `after:now`}}, `{}`}, + {"url-https", `{"u":"https://example.com/x?y=1"}`, [][2]string{{"u", `url`}}, `{}`}, + {"url-bare", `{"u":"notaurl"}`, [][2]string{{"u", `url`}}, `{"u":["The u format is invalid."]}`}, + {"url-localhost-port", `{"u":"http://localhost:8000"}`, [][2]string{{"u", `url`}}, `{}`}, + {"url-ftp", `{"u":"ftp://a.b"}`, [][2]string{{"u", `url`}}, `{}`}, + {"url-space", `{"u":"https://exa mple.com"}`, [][2]string{{"u", `url`}}, `{"u":["The u format is invalid."]}`}, + {"url-int", `{"u":5}`, [][2]string{{"u", `url`}}, `{"u":["The u format is invalid."]}`}, + {"filled-null", `{"x":null}`, [][2]string{{"x", `filled`}}, `{"x":["The x field must have a value."]}`}, + {"filled-missing", `{}`, [][2]string{{"x", `filled`}}, `{}`}, + {"filled-blank", `{"x":""}`, [][2]string{{"x", `filled`}}, `{"x":["The x field must have a value."]}`}, + {"nullable-blank", `{"x":""}`, [][2]string{{"x", `nullable|string|max:2`}}, `{}`}, + {"sometimes-missing", `{}`, [][2]string{{"x", `sometimes|required`}}, `{}`}, + {"sometimes-present-null", `{"x":null}`, [][2]string{{"x", `sometimes|required`}}, `{"x":["The x field is required."]}`}, + {"integer-blank", `{"x":""}`, [][2]string{{"x", `integer`}}, `{}`}, + {"present-missing", `{}`, [][2]string{{"x", `present`}}, `{"x":["The x field must be present."]}`}, + {"present-null", `{"x":null}`, [][2]string{{"x", `present`}}, `{}`}, + {"required-whitespace", `{"x":" "}`, [][2]string{{"x", `required`}}, `{"x":["The x field is required."]}`}, + {"required-empty-array", `{"x":[]}`, [][2]string{{"x", `required`}}, `{"x":["The x field is required."]}`}, + {"required-zero", `{"x":0}`, [][2]string{{"x", `required`}}, `{}`}, + {"required-false", `{"x":false}`, [][2]string{{"x", `required`}}, `{}`}, + {"nested-wildcard", `{"a":[{"b":[{"c":""}]},{"b":[{"c":"x"},{"c":null}]}]}`, [][2]string{{"a.*.b.*.c", `required`}}, `{"a.0.b.0.c":["The a.0.b.0.c field is required."],"a.1.b.1.c":["The a.1.b.1.c field is required."]}`}, + {"map-wildcard", `{"a":{"k2":{"c":"v"},"k1":{"c":""}}}`, [][2]string{{"a.*.c", `required`}}, `{"a.k1.c":["The a.k1.c field is required."]}`}, + {"wildcard-missing-mid", `{"a":[{"x":1}]}`, [][2]string{{"a.*.b.*.c", `required`}}, `{}`}, + {"wildcard-scalar-parent", `{"a":"str"}`, [][2]string{{"a.*", `required`}}, `{}`}, + {"wildcard-in", `{"f":["LP","X","CD","Y"]}`, [][2]string{{"f", `array`}, {"f.*", `in:LP,CD`}}, `{"f.1":["The selected f.1 is invalid."],"f.3":["The selected f.3 is invalid."]}`}, + {"wildcard-numeric-keys", `{"t":[{"title":"a","duration":"3:75"},{"title":"","duration":"1:00"}]}`, [][2]string{{"t.*.title", `required|string|max:255`}, {"t.*.duration", `nullable|string|regex:/^\d{1,3}:[0-5]\d$/`}}, `{"t.1.title":["The t.1.title field is required."],"t.0.duration":["The t.0.duration format is invalid."]}`}, + {"wildcard-trailing", `{"a":["x",""]}`, [][2]string{{"a.*", `required`}}, `{"a.1":["The a.1 field is required."]}`}, + {"bail", `{"x":"ab"}`, [][2]string{{"x", `bail|integer|min:5`}}, `{"x":["The x must be an integer."]}`}, + {"no-bail", `{"x":"ab"}`, [][2]string{{"x", `integer|min:5`}}, `{"x":["The x must be an integer.","The x must be at least 5."]}`}, + {"implicit-stop", `{}`, [][2]string{{"x", `required|integer|min:5`}}, `{"x":["The x field is required."]}`}, + {"order", `{"b":"","a":""}`, [][2]string{{"b", `required`}, {"a", `required`}}, `{"b":["The b field is required."],"a":["The a field is required."]}`}, +} + +func TestValidateRulesMatchLaravel(t *testing.T) { + for _, tc := range laravelRuleCases { + t.Run(tc.name, func(t *testing.T) { + dec := json.NewDecoder(strings.NewReader(tc.data)) + dec.UseNumber() + var input map[string]any + if err := dec.Decode(&input); err != nil { + t.Fatal(err) + } + var rules []RequestRule + for _, r := range tc.rules { + rules = append(rules, RequestRule{Field: r[0], Rules: ParseRules(r[1])}) + } + got := mustValidate(t, inLocale("en"), input, rules) + var want map[string][]string + if err := json.Unmarshal([]byte(tc.want), &want); err != nil { + t.Fatal(err) + } + if len(want) == 0 && len(got) == 0 { + return + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v\nwant %v (PHP)", got, want) + } + }) + } +}