diff --git a/src/Phaseolies/Http/Support/ValidationRules.php b/src/Phaseolies/Http/Support/ValidationRules.php index 9a57f837..3860688e 100644 --- a/src/Phaseolies/Http/Support/ValidationRules.php +++ b/src/Phaseolies/Http/Support/ValidationRules.php @@ -63,6 +63,36 @@ protected function getDefaultErrorMessage(string $rule): string $defaultMessages = [ 'required' => 'The :attribute field is required.', 'exists_in' => 'The selected :attribute is invalid.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes, and underscores.', + 'numeric' => 'The :attribute must be a number.', + 'url' => 'The :attribute must be a valid URL.', + 'ip' => 'The :attribute must be a valid IP address.', + 'ipv4' => 'The :attribute must be a valid IPv4 address.', + 'ipv6' => 'The :attribute must be a valid IPv6 address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'boolean' => 'The :attribute field must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'different' => 'The :attribute and :other must be different.', + 'in' => 'The selected :attribute is invalid. Allowed: :values.', + 'not_in' => 'The selected :attribute is invalid.', + 'regex' => 'The :attribute format is invalid.', + 'phone' => 'The :attribute must be a valid phone number.', + 'digits' => 'The :attribute must be exactly :digits digits.', + 'min_digits' => 'The :attribute must have at least :min digits.', + 'max_digits' => 'The :attribute must not have more than :max digits.', + 'size' => 'The :attribute must be exactly :size characters.', + 'starts_with' => 'The :attribute must start with :value.', + 'ends_with' => 'The :attribute must end with :value.', + 'uuid' => 'The :attribute must be a valid UUID.', + 'date_format' => 'The :attribute does not match the format :format.', + 'uppercase' => 'The :attribute must be uppercase.', + 'lowercase' => 'The :attribute must be lowercase.', + 'slug' => 'The :attribute must be a valid slug (lowercase letters, numbers, and hyphens).', + 'string' => 'The :attribute must be a string.', + 'array' => 'The :attribute must be an array.', + 'timezone' => 'The :attribute must be a valid timezone.', ]; return $defaultMessages[$rule] ?? 'Validation failed.'; @@ -237,6 +267,216 @@ protected function sanitizeUserRequest( return $this->getErrorMessage('exists_in', $fieldName); } break; + + case 'alpha': + if (!$this->isAlpha($input, $fieldName)) { + return $this->getErrorMessage('alpha', $fieldName); + } + break; + + case 'alpha_num': + if (!$this->isAlphaNum($input, $fieldName)) { + return $this->getErrorMessage('alpha_num', $fieldName); + } + break; + + case 'alpha_dash': + if (!$this->isAlphaDash($input, $fieldName)) { + return $this->getErrorMessage('alpha_dash', $fieldName); + } + break; + + case 'numeric': + if (!$this->isNumeric($input, $fieldName)) { + return $this->getErrorMessage('numeric', $fieldName); + } + break; + + case 'url': + if (!$this->isUrl($input, $fieldName)) { + return $this->getErrorMessage('url', $fieldName); + } + break; + + case 'ip': + if (!$this->isIp($input, $fieldName)) { + return $this->getErrorMessage('ip', $fieldName); + } + break; + + case 'ipv4': + if (!$this->isIpv4($input, $fieldName)) { + return $this->getErrorMessage('ipv4', $fieldName); + } + break; + + case 'ipv6': + if (!$this->isIpv6($input, $fieldName)) { + return $this->getErrorMessage('ipv6', $fieldName); + } + break; + + case 'json': + if (!$this->isJsonable($input, $fieldName)) { + return $this->getErrorMessage('json', $fieldName); + } + break; + + case 'boolean': + if (!$this->isBoolean($input, $fieldName)) { + return $this->getErrorMessage('boolean', $fieldName); + } + break; + + case 'confirmed': + if (!$this->isConfirmed($input, $fieldName)) { + return $this->getErrorMessage('confirmed', $fieldName); + } + break; + + case 'different': + if (!$this->isDifferent($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('different', $fieldName, [ + ':other' => $this->getAttributeName($ruleValue), + 'other' => $this->getAttributeName($ruleValue), + ]); + } + break; + + case 'in': + if (!$this->isInList($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('in', $fieldName, [ + ':values' => $ruleValue, + 'values' => $ruleValue, + ]); + } + break; + + case 'not_in': + if (!$this->isNotInList($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('not_in', $fieldName, [ + ':values' => $ruleValue, + 'values' => $ruleValue, + ]); + } + break; + + case 'regex': + if (!$this->isRegexMatch($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('regex', $fieldName); + } + break; + + case 'phone': + if (!$this->isPhone($input, $fieldName)) { + return $this->getErrorMessage('phone', $fieldName); + } + break; + + case 'digits': + if (!$this->isExactDigits($input, $fieldName, (int) $ruleValue)) { + return $this->getErrorMessage('digits', $fieldName, [ + ':digits' => $ruleValue, + 'digits' => $ruleValue, + ]); + } + break; + + case 'min_digits': + if (!$this->isMinDigits($input, $fieldName, (int) $ruleValue)) { + return $this->getErrorMessage('min_digits', $fieldName, [ + ':min' => $ruleValue, + 'min' => $ruleValue, + ]); + } + break; + + case 'max_digits': + if (!$this->isMaxDigits($input, $fieldName, (int) $ruleValue)) { + return $this->getErrorMessage('max_digits', $fieldName, [ + ':max' => $ruleValue, + 'max' => $ruleValue, + ]); + } + break; + + case 'size': + if (!$this->isExactSize($input, $fieldName, (int) $ruleValue)) { + return $this->getErrorMessage('size', $fieldName, [ + ':size' => $ruleValue, + 'size' => $ruleValue, + ]); + } + break; + + case 'starts_with': + if (!$this->isStartsWith($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('starts_with', $fieldName, [ + ':value' => $ruleValue, + 'value' => $ruleValue, + ]); + } + break; + + case 'ends_with': + if (!$this->isEndsWith($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('ends_with', $fieldName, [ + ':value' => $ruleValue, + 'value' => $ruleValue, + ]); + } + break; + + case 'uuid': + if (!$this->isUuid($input, $fieldName)) { + return $this->getErrorMessage('uuid', $fieldName); + } + break; + + case 'date_format': + if (!$this->isDateFormat($input, $fieldName, $ruleValue)) { + return $this->getErrorMessage('date_format', $fieldName, [ + ':format' => $ruleValue, + 'format' => $ruleValue, + ]); + } + break; + + case 'uppercase': + if (!$this->isUppercase($input, $fieldName)) { + return $this->getErrorMessage('uppercase', $fieldName); + } + break; + + case 'lowercase': + if (!$this->isLowercase($input, $fieldName)) { + return $this->getErrorMessage('lowercase', $fieldName); + } + break; + + case 'slug': + if (!$this->isSlug($input, $fieldName)) { + return $this->getErrorMessage('slug', $fieldName); + } + break; + + case 'string': + if (!$this->isString($input, $fieldName)) { + return $this->getErrorMessage('string', $fieldName); + } + break; + + case 'array': + if (!$this->isArray($input, $fieldName)) { + return $this->getErrorMessage('array', $fieldName); + } + break; + + case 'timezone': + if (!$this->isTimezone($input, $fieldName)) { + return $this->getErrorMessage('timezone', $fieldName); + } + break; } } @@ -582,7 +822,17 @@ protected function formatBytes(int $bytes): string */ protected function isEmptyFieldRequired(array $input, string $fieldName): bool { - return !isset($input[$fieldName]) || $input[$fieldName] === ''; + if (!isset($input[$fieldName])) { + return true; + } + + $value = $input[$fieldName]; + + if (is_array($value)) { + return empty($value); + } + + return $value === '' || $value === null; } /** @@ -691,6 +941,425 @@ protected function _removeUnderscore(string $string): string return str_replace("_", " ", $string); } + /** + * Check if the field value contains only alphabetic characters. + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isAlpha(array $input, string $fieldName): bool + { + return (bool) preg_match('/^[a-zA-Z]+$/', $input[$fieldName] ?? ''); + } + + /** + * Check if the field value contains only alphanumeric characters. + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isAlphaNum(array $input, string $fieldName): bool + { + return (bool) preg_match('/^[a-zA-Z0-9]+$/', $input[$fieldName] ?? ''); + } + + /** + * Check if the field value contains only letters, numbers, dashes, and underscores + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isAlphaDash(array $input, string $fieldName): bool + { + return (bool) preg_match('/^[a-zA-Z0-9_\-]+$/', $input[$fieldName] ?? ''); + } + + /** + * Check if the field value is numeric + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isNumeric(array $input, string $fieldName): bool + { + return is_numeric($input[$fieldName] ?? ''); + } + + /** + * Check if the field value is a valid URL + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isUrl(array $input, string $fieldName): bool + { + return filter_var($input[$fieldName] ?? '', FILTER_VALIDATE_URL) !== false; + } + + /** + * Check if the field value is a valid IP address (v4 or v6) + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isIp(array $input, string $fieldName): bool + { + return filter_var($input[$fieldName] ?? '', FILTER_VALIDATE_IP) !== false; + } + + /** + * Check if the field value is a valid IPv4 address + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isIpv4(array $input, string $fieldName): bool + { + return filter_var($input[$fieldName] ?? '', FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + } + + /** + * Check if the field value is a valid IPv6 address + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isIpv6(array $input, string $fieldName): bool + { + return filter_var($input[$fieldName] ?? '', FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + + /** + * Check if the field value is a valid JSON string + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isJsonable(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + if (!is_string($value) || $value === '') { + return false; + } + + json_decode($value); + + return json_last_error() === JSON_ERROR_NONE; + } + + /** + * Check if the field value is a boolean-like value (true, false, 0, 1, "0", "1", "true", "false") + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isBoolean(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? null; + + return in_array($value, [true, false, 0, 1, '0', '1', 'true', 'false'], true); + } + + /** + * Check if the field value matches the {field}_confirmation counterpart + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isConfirmed(array $input, string $fieldName): bool + { + return ($input[$fieldName] ?? '') === ($input[$fieldName . '_confirmation'] ?? ''); + } + + /** + * Check if the field value is different from another field's value + * + * @param array $input + * @param string $fieldName + * @param string $otherField + * @return bool + */ + protected function isDifferent(array $input, string $fieldName, string $otherField): bool + { + return ($input[$fieldName] ?? '') !== ($input[$otherField] ?? ''); + } + + /** + * Check if the field value is one of the allowed values + * + * @param array $input + * @param string $fieldName + * @param string $ruleValue + * @return bool + */ + protected function isInList(array $input, string $fieldName, string $ruleValue): bool + { + $list = array_map('trim', explode(',', $ruleValue)); + + return in_array($input[$fieldName] ?? '', $list, true); + } + + /** + * Check if the field value is not in the given list + * + * @param array $input + * @param string $fieldName + * @param string $ruleValue + * @return bool + */ + protected function isNotInList(array $input, string $fieldName, string $ruleValue): bool + { + $list = array_map('trim', explode(',', $ruleValue)); + + return !in_array($input[$fieldName] ?? '', $list, true); + } + + /** + * Check if the field value matches a regular expression pattern + * + * @param array $input + * @param string $fieldName + * @param string $pattern + * @return bool + */ + protected function isRegexMatch(array $input, string $fieldName, string $pattern): bool + { + $value = $input[$fieldName] ?? ''; + + return (bool) @preg_match($pattern, $value); + } + + /** + * Check if the field value is a valid phone number + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isPhone(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + return (bool) preg_match('/^\+?[0-9\s\-\(\)]{7,20}$/', $value); + } + + /** + * Check if the field value is a numeric string with exactly N digits + * + * @param array $input + * @param string $fieldName + * @param int $digits + * @return bool + */ + protected function isExactDigits(array $input, string $fieldName, int $digits): bool + { + $value = (string)($input[$fieldName] ?? ''); + + return (bool) preg_match('/^\d{' . $digits . '}$/', $value); + } + + /** + * Check if the field value is a numeric string with at least N digits + * + * @param array $input + * @param string $fieldName + * @param int $min + * @return bool + */ + protected function isMinDigits(array $input, string $fieldName, int $min): bool + { + $value = (string)($input[$fieldName] ?? ''); + + return (bool) preg_match('/^\d+$/', $value) && strlen($value) >= $min; + } + + /** + * Check if the field value is a numeric string with at most N digits + * + * @param array $input + * @param string $fieldName + * @param int $max + * @return bool + */ + protected function isMaxDigits(array $input, string $fieldName, int $max): bool + { + $value = (string)($input[$fieldName] ?? ''); + + return (bool) preg_match('/^\d+$/', $value) && strlen($value) <= $max; + } + + /** + * Check if the field value has exactly N characters (strings) or N elements (arrays). + * + * @param array $input + * @param string $fieldName + * @param int $size + * + * @return bool + */ + protected function isExactSize(array $input, string $fieldName, int $size): bool + { + $value = $input[$fieldName] ?? ''; + + if (is_array($value)) { + return count($value) === $size; + } + + return strlen((string)$value) === $size; + } + + /** + * Check if the field value starts with a given string + * + * @param array $input + * @param string $fieldName + * @param string $prefix + * @return bool + */ + protected function isStartsWith(array $input, string $fieldName, string $prefix): bool + { + return str_starts_with($input[$fieldName] ?? '', $prefix); + } + + /** + * Check if the field value ends with a given string + * + * @param array $input + * @param string $fieldName + * @param string $suffix + * @return bool + */ + protected function isEndsWith(array $input, string $fieldName, string $suffix): bool + { + return str_ends_with($input[$fieldName] ?? '', $suffix); + } + + /** + * Check if the field value is a valid UUID (v1–v5) + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isUuid(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + return (bool) preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $value + ); + } + + /** + * Check if the field value matches a specific date format + * + * @param array $input + * @param string $fieldName + * @param string $format + * @return bool + */ + protected function isDateFormat(array $input, string $fieldName, string $format): bool + { + $value = $input[$fieldName] ?? ''; + + $date = \DateTime::createFromFormat($format, $value); + + return $date !== false && $date->format($format) === $value; + } + + /** + * Check if the field value is entirely uppercase + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isUppercase(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + return $value !== '' && $value === strtoupper($value) && preg_match('/[A-Z]/', $value); + } + + /** + * Check if the field value is entirely lowercase + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isLowercase(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + return $value !== '' && $value === strtolower($value) && preg_match('/[a-z]/', $value); + } + + /** + * Check if the field value is a valid URL slug + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isSlug(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value); + } + + /** + * Check if the field value is a string type + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isString(array $input, string $fieldName): bool + { + return is_string($input[$fieldName] ?? null); + } + + /** + * Check if the field value is an array type + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isArray(array $input, string $fieldName): bool + { + return is_array($input[$fieldName] ?? null); + } + + /** + * Check if the field value is a valid IANA timezone identifier + * + * @param array $input + * @param string $fieldName + * @return bool + */ + protected function isTimezone(array $input, string $fieldName): bool + { + $value = $input[$fieldName] ?? ''; + + return in_array($value, \DateTimeZone::listIdentifiers(), true); + } + /** * Remove the suffix from a rule string. * @@ -710,7 +1379,7 @@ protected function _removeRuleSuffix(string $string): string */ protected function _getRuleSuffix(string $string): ?string { - $arr = explode(":", $string); + $arr = explode(":", $string, 2); return $arr[1] ?? null; } diff --git a/src/Phaseolies/Support/Validation/Sanitizer.php b/src/Phaseolies/Support/Validation/Sanitizer.php index ad44e0e1..0c534cfb 100644 --- a/src/Phaseolies/Support/Validation/Sanitizer.php +++ b/src/Phaseolies/Support/Validation/Sanitizer.php @@ -135,9 +135,10 @@ protected function applyRule(string $field, string $rule) { $value = $this->data[$field] ?? null; - // Handle rules with parameters (e.g., min:2, max:100) + // Handle rules with parameters (e.g., min:2, date_format:Y-m-d H:i:s) + // Limit to 2 parts so values containing ':' (e.g., datetime formats) are preserved. if (str_contains($rule, ':')) { - [$rule, $param] = explode(':', $rule); + [$rule, $param] = explode(':', $rule, 2); } $errorMessage = $this->sanitizeUserRequest($this->data, $field, $rule, $param ?? null); diff --git a/tests/Validation/ValidationRulesExtendedTest.php b/tests/Validation/ValidationRulesExtendedTest.php new file mode 100644 index 00000000..e3541bad --- /dev/null +++ b/tests/Validation/ValidationRulesExtendedTest.php @@ -0,0 +1,778 @@ +bind('translator', function () { + $loader = $this->createMock(FileLoader::class); + return new Translator($loader, 'en'); + }); + } + + private function passes(array $data, array $rules): bool + { + return (new Sanitizer($data, $rules))->validate(); + } + + private function fails(array $data, array $rules): bool + { + return (new Sanitizer($data, $rules))->fails(); + } + + // ------------------------------------------------------------------------- + // alpha + // ------------------------------------------------------------------------- + + public function testAlphaPassesForLettersOnly(): void + { + $this->assertTrue($this->passes(['name' => 'John'], ['name' => 'alpha'])); + $this->assertTrue($this->passes(['name' => 'UPPERCASE'], ['name' => 'alpha'])); + $this->assertTrue($this->passes(['name' => 'mixedCase'], ['name' => 'alpha'])); + } + + public function testAlphaFailsWhenContainsNumbers(): void + { + $this->assertTrue($this->fails(['name' => 'John123'], ['name' => 'alpha'])); + } + + public function testAlphaFailsWhenContainsSpecialChars(): void + { + $this->assertTrue($this->fails(['name' => 'John_Doe'], ['name' => 'alpha'])); + $this->assertTrue($this->fails(['name' => 'John Doe'], ['name' => 'alpha'])); + $this->assertTrue($this->fails(['name' => 'John-Doe'], ['name' => 'alpha'])); + } + + // ------------------------------------------------------------------------- + // alpha_num + // ------------------------------------------------------------------------- + + public function testAlphaNumPassesForLettersAndNumbers(): void + { + $this->assertTrue($this->passes(['username' => 'John99'], ['username' => 'alpha_num'])); + $this->assertTrue($this->passes(['username' => 'abc123'], ['username' => 'alpha_num'])); + $this->assertTrue($this->passes(['username' => 'ABC'], ['username' => 'alpha_num'])); + $this->assertTrue($this->passes(['username' => '12345'], ['username' => 'alpha_num'])); + } + + public function testAlphaNumFailsWhenContainsSpecialChars(): void + { + $this->assertTrue($this->fails(['username' => 'john_99'], ['username' => 'alpha_num'])); + $this->assertTrue($this->fails(['username' => 'john-99'], ['username' => 'alpha_num'])); + $this->assertTrue($this->fails(['username' => 'john 99'], ['username' => 'alpha_num'])); + $this->assertTrue($this->fails(['username' => 'john@99'], ['username' => 'alpha_num'])); + } + + // ------------------------------------------------------------------------- + // alpha_dash + // ------------------------------------------------------------------------- + + public function testAlphaDashPassesForLettersNumbersDashesUnderscores(): void + { + $this->assertTrue($this->passes(['handle' => 'john_doe-99'], ['handle' => 'alpha_dash'])); + $this->assertTrue($this->passes(['handle' => 'john-doe'], ['handle' => 'alpha_dash'])); + $this->assertTrue($this->passes(['handle' => 'john_doe'], ['handle' => 'alpha_dash'])); + $this->assertTrue($this->passes(['handle' => 'JohnDoe123'], ['handle' => 'alpha_dash'])); + } + + public function testAlphaDashFailsWhenContainsSpacesOrSpecialChars(): void + { + $this->assertTrue($this->fails(['handle' => 'john doe'], ['handle' => 'alpha_dash'])); + $this->assertTrue($this->fails(['handle' => 'john@doe'], ['handle' => 'alpha_dash'])); + $this->assertTrue($this->fails(['handle' => 'john.doe'], ['handle' => 'alpha_dash'])); + } + + // ------------------------------------------------------------------------- + // numeric + // ------------------------------------------------------------------------- + + public function testNumericPassesForIntegerAndFloat(): void + { + $this->assertTrue($this->passes(['price' => '100'], ['price' => 'numeric'])); + $this->assertTrue($this->passes(['price' => '19.99'], ['price' => 'numeric'])); + $this->assertTrue($this->passes(['price' => '-5'], ['price' => 'numeric'])); + $this->assertTrue($this->passes(['price' => '0'], ['price' => 'numeric'])); + } + + public function testNumericFailsForNonNumericValues(): void + { + $this->assertTrue($this->fails(['price' => 'abc'], ['price' => 'numeric'])); + $this->assertTrue($this->fails(['price' => '19.99abc'], ['price' => 'numeric'])); + // empty string is nullable (skipped by itself); require + numeric catches empty inputs + $this->assertTrue($this->fails(['price' => ''], ['price' => 'required|numeric'])); + } + + // ------------------------------------------------------------------------- + // url + // ------------------------------------------------------------------------- + + public function testUrlPassesForValidUrls(): void + { + $this->assertTrue($this->passes(['site' => 'https://example.com'], ['site' => 'url'])); + $this->assertTrue($this->passes(['site' => 'http://example.com/path?q=1'], ['site' => 'url'])); + $this->assertTrue($this->passes(['site' => 'ftp://files.example.com'], ['site' => 'url'])); + } + + public function testUrlFailsForInvalidUrls(): void + { + $this->assertTrue($this->fails(['site' => 'example.com'], ['site' => 'url'])); + $this->assertTrue($this->fails(['site' => 'not a url'], ['site' => 'url'])); + $this->assertTrue($this->fails(['site' => 'htp:/bad'], ['site' => 'url'])); + } + + // ------------------------------------------------------------------------- + // ip + // ------------------------------------------------------------------------- + + public function testIpPassesForValidIpAddresses(): void + { + $this->assertTrue($this->passes(['addr' => '192.168.1.1'], ['addr' => 'ip'])); + $this->assertTrue($this->passes(['addr' => '::1'], ['addr' => 'ip'])); + $this->assertTrue($this->passes(['addr' => '2001:db8::1'], ['addr' => 'ip'])); + } + + public function testIpFailsForInvalidIpAddresses(): void + { + $this->assertTrue($this->fails(['addr' => '999.0.0.1'], ['addr' => 'ip'])); + $this->assertTrue($this->fails(['addr' => 'not.an.ip'], ['addr' => 'ip'])); + $this->assertTrue($this->fails(['addr' => '256.256.256.256'], ['addr' => 'ip'])); + } + + // ------------------------------------------------------------------------- + // ipv4 + // ------------------------------------------------------------------------- + + public function testIpv4PassesForValidIpv4(): void + { + $this->assertTrue($this->passes(['addr' => '192.168.1.1'], ['addr' => 'ipv4'])); + $this->assertTrue($this->passes(['addr' => '127.0.0.1'], ['addr' => 'ipv4'])); + $this->assertTrue($this->passes(['addr' => '0.0.0.0'], ['addr' => 'ipv4'])); + } + + public function testIpv4FailsForIpv6AndInvalid(): void + { + $this->assertTrue($this->fails(['addr' => '::1'], ['addr' => 'ipv4'])); + $this->assertTrue($this->fails(['addr' => '2001:db8::1'], ['addr' => 'ipv4'])); + $this->assertTrue($this->fails(['addr' => '999.0.0.1'], ['addr' => 'ipv4'])); + } + + // ------------------------------------------------------------------------- + // ipv6 + // ------------------------------------------------------------------------- + + public function testIpv6PassesForValidIpv6(): void + { + $this->assertTrue($this->passes(['addr' => '::1'], ['addr' => 'ipv6'])); + $this->assertTrue($this->passes(['addr' => '2001:db8::1'], ['addr' => 'ipv6'])); + $this->assertTrue($this->passes(['addr' => 'fe80::1'], ['addr' => 'ipv6'])); + } + + public function testIpv6FailsForIpv4AndInvalid(): void + { + $this->assertTrue($this->fails(['addr' => '192.168.1.1'], ['addr' => 'ipv6'])); + $this->assertTrue($this->fails(['addr' => 'not-an-ip'], ['addr' => 'ipv6'])); + } + + // ------------------------------------------------------------------------- + // json + // ------------------------------------------------------------------------- + + public function testJsonPassesForValidJsonString(): void + { + $this->assertTrue($this->passes(['meta' => '{"key":"value"}'], ['meta' => 'json'])); + $this->assertTrue($this->passes(['meta' => '[1,2,3]'], ['meta' => 'json'])); + $this->assertTrue($this->passes(['meta' => '"simple string"'], ['meta' => 'json'])); + $this->assertTrue($this->passes(['meta' => 'true'], ['meta' => 'json'])); + $this->assertTrue($this->passes(['meta' => '42'], ['meta' => 'json'])); + } + + public function testJsonFailsForInvalidJsonString(): void + { + $this->assertTrue($this->fails(['meta' => '{key:value}'], ['meta' => 'json'])); + $this->assertTrue($this->fails(['meta' => 'not json'], ['meta' => 'json'])); + $this->assertTrue($this->fails(['meta' => '{unclosed'], ['meta' => 'json'])); + } + + // ------------------------------------------------------------------------- + // boolean + // ------------------------------------------------------------------------- + + public function testBooleanPassesForBooleanLikeValues(): void + { + $this->assertTrue($this->passes(['flag' => true], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => false], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => 1], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => 0], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => '1'], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => '0'], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => 'true'], ['flag' => 'boolean'])); + $this->assertTrue($this->passes(['flag' => 'false'], ['flag' => 'boolean'])); + } + + public function testBooleanFailsForNonBooleanValues(): void + { + $this->assertTrue($this->fails(['flag' => 'yes'], ['flag' => 'boolean'])); + $this->assertTrue($this->fails(['flag' => 'no'], ['flag' => 'boolean'])); + $this->assertTrue($this->fails(['flag' => '2'], ['flag' => 'boolean'])); + $this->assertTrue($this->fails(['flag' => 'on'], ['flag' => 'boolean'])); + } + + // ------------------------------------------------------------------------- + // confirmed + // ------------------------------------------------------------------------- + + public function testConfirmedPassesWhenConfirmationMatches(): void + { + $this->assertTrue($this->passes( + ['password' => 'secret123', 'password_confirmation' => 'secret123'], + ['password' => 'confirmed'] + )); + } + + public function testConfirmedFailsWhenConfirmationMissing(): void + { + $this->assertTrue($this->fails( + ['password' => 'secret123'], + ['password' => 'confirmed'] + )); + } + + public function testConfirmedFailsWhenConfirmationDiffers(): void + { + $this->assertTrue($this->fails( + ['password' => 'secret123', 'password_confirmation' => 'different'], + ['password' => 'confirmed'] + )); + } + + // ------------------------------------------------------------------------- + // different + // ------------------------------------------------------------------------- + + public function testDifferentPassesWhenValuesAreDifferent(): void + { + $this->assertTrue($this->passes( + ['new_password' => 'newpass', 'old_password' => 'oldpass'], + ['new_password' => 'different:old_password'] + )); + } + + public function testDifferentFailsWhenValuesAreTheSame(): void + { + $this->assertTrue($this->fails( + ['new_password' => 'samepass', 'old_password' => 'samepass'], + ['new_password' => 'different:old_password'] + )); + } + + // ------------------------------------------------------------------------- + // in + // ------------------------------------------------------------------------- + + public function testInPassesWhenValueIsInList(): void + { + $this->assertTrue($this->passes(['status' => 'active'], ['status' => 'in:active,inactive,pending'])); + $this->assertTrue($this->passes(['status' => 'inactive'], ['status' => 'in:active,inactive,pending'])); + $this->assertTrue($this->passes(['role' => 'admin'], ['role' => 'in:admin,user,moderator'])); + } + + public function testInFailsWhenValueIsNotInList(): void + { + $this->assertTrue($this->fails(['status' => 'deleted'], ['status' => 'in:active,inactive,pending'])); + $this->assertTrue($this->fails(['status' => 'ACTIVE'], ['status' => 'in:active,inactive,pending'])); + $this->assertTrue($this->fails(['role' => 'superuser'], ['role' => 'in:admin,user,moderator'])); + } + + // ------------------------------------------------------------------------- + // not_in + // ------------------------------------------------------------------------- + + public function testNotInPassesWhenValueIsNotInList(): void + { + $this->assertTrue($this->passes(['username' => 'johndoe'], ['username' => 'not_in:admin,root,superuser'])); + $this->assertTrue($this->passes(['username' => 'alice'], ['username' => 'not_in:admin,root,superuser'])); + } + + public function testNotInFailsWhenValueIsInList(): void + { + $this->assertTrue($this->fails(['username' => 'admin'], ['username' => 'not_in:admin,root,superuser'])); + $this->assertTrue($this->fails(['username' => 'root'], ['username' => 'not_in:admin,root,superuser'])); + $this->assertTrue($this->fails(['username' => 'superuser'], ['username' => 'not_in:admin,root,superuser'])); + } + + // ------------------------------------------------------------------------- + // regex + // ------------------------------------------------------------------------- + + public function testRegexPassesWhenValueMatchesPattern(): void + { + $this->assertTrue($this->passes(['code' => '1234'], ['code' => 'regex:/^\d{4,6}$/'])); + $this->assertTrue($this->passes(['color' => '#FF5733'], ['color' => 'regex:/^#[0-9a-fA-F]{6}$/'])); + } + + public function testRegexFailsWhenValueDoesNotMatchPattern(): void + { + $this->assertTrue($this->fails(['code' => 'abc'], ['code' => 'regex:/^\d{4,6}$/'])); + $this->assertTrue($this->fails(['color' => '#GGG'], ['color' => 'regex:/^#[0-9a-fA-F]{6}$/'])); + $this->assertTrue($this->fails(['code' => '12'], ['code' => 'regex:/^\d{4,6}$/'])); + } + + // ------------------------------------------------------------------------- + // phone + // ------------------------------------------------------------------------- + + public function testPhonePassesForValidPhoneNumbers(): void + { + $this->assertTrue($this->passes(['phone' => '+8801712345678'], ['phone' => 'phone'])); + $this->assertTrue($this->passes(['phone' => '01712345678'], ['phone' => 'phone'])); + $this->assertTrue($this->passes(['phone' => '+1 800 555 1234'], ['phone' => 'phone'])); + $this->assertTrue($this->passes(['phone' => '(555) 867-5309'], ['phone' => 'phone'])); + } + + public function testPhoneFailsForInvalidPhoneNumbers(): void + { + $this->assertTrue($this->fails(['phone' => 'abc-def'], ['phone' => 'phone'])); + $this->assertTrue($this->fails(['phone' => '123'], ['phone' => 'phone'])); + $this->assertTrue($this->fails(['phone' => 'not-phone'], ['phone' => 'phone'])); + } + + // ------------------------------------------------------------------------- + // digits + // ------------------------------------------------------------------------- + + public function testDigitsPassesForExactDigitCount(): void + { + $this->assertTrue($this->passes(['pin' => '1234'], ['pin' => 'digits:4'])); + $this->assertTrue($this->passes(['otp' => '123456'], ['otp' => 'digits:6'])); + $this->assertTrue($this->passes(['code' => '00000'], ['code' => 'digits:5'])); + } + + public function testDigitsFailsForWrongDigitCount(): void + { + $this->assertTrue($this->fails(['pin' => '123'], ['pin' => 'digits:4'])); + $this->assertTrue($this->fails(['pin' => '12345'], ['pin' => 'digits:4'])); + $this->assertTrue($this->fails(['pin' => 'abcd'], ['pin' => 'digits:4'])); + $this->assertTrue($this->fails(['pin' => '12.34'], ['pin' => 'digits:4'])); + } + + // ------------------------------------------------------------------------- + // min_digits + // ------------------------------------------------------------------------- + + public function testMinDigitsPassesWhenDigitCountMeetsMinimum(): void + { + $this->assertTrue($this->passes(['number' => '1234567890'], ['number' => 'min_digits:10'])); + $this->assertTrue($this->passes(['number' => '12345678901'], ['number' => 'min_digits:10'])); + $this->assertTrue($this->passes(['number' => '12345'], ['number' => 'min_digits:5'])); + } + + public function testMinDigitsFailsWhenDigitCountBelowMinimum(): void + { + $this->assertTrue($this->fails(['number' => '123456789'], ['number' => 'min_digits:10'])); + $this->assertTrue($this->fails(['number' => '1234'], ['number' => 'min_digits:5'])); + $this->assertTrue($this->fails(['number' => 'abc123'], ['number' => 'min_digits:3'])); + } + + // ------------------------------------------------------------------------- + // max_digits + // ------------------------------------------------------------------------- + + public function testMaxDigitsPassesWhenDigitCountWithinMaximum(): void + { + $this->assertTrue($this->passes(['code' => '1234'], ['code' => 'max_digits:6'])); + $this->assertTrue($this->passes(['code' => '123456'], ['code' => 'max_digits:6'])); + $this->assertTrue($this->passes(['code' => '1'], ['code' => 'max_digits:6'])); + } + + public function testMaxDigitsFailsWhenDigitCountExceedsMaximum(): void + { + $this->assertTrue($this->fails(['code' => '1234567'], ['code' => 'max_digits:6'])); + $this->assertTrue($this->fails(['code' => '12345678'], ['code' => 'max_digits:6'])); + $this->assertTrue($this->fails(['code' => '1234abc'], ['code' => 'max_digits:4'])); + } + + // ------------------------------------------------------------------------- + // size + // ------------------------------------------------------------------------- + + public function testSizePassesForExactStringLength(): void + { + $this->assertTrue($this->passes(['otp' => '123456'], ['otp' => 'size:6'])); + $this->assertTrue($this->passes(['code' => 'AB'], ['code' => 'size:2'])); + } + + public function testSizePassesForExactNumericValue(): void + { + // size always checks character count, so '100' has 3 chars → size:3 + $this->assertTrue($this->passes(['amount' => '100'], ['amount' => 'size:3'])); + $this->assertTrue($this->passes(['amount' => '1'], ['amount' => 'size:1'])); + } + + public function testSizeFailsForWrongLength(): void + { + $this->assertTrue($this->fails(['otp' => '12345'], ['otp' => 'size:6'])); + $this->assertTrue($this->fails(['otp' => '1234567'], ['otp' => 'size:6'])); + } + + // ------------------------------------------------------------------------- + // starts_with + // ------------------------------------------------------------------------- + + public function testStartsWithPassesWhenValueStartsWithPrefix(): void + { + $this->assertTrue($this->passes(['code' => 'INV-2024'], ['code' => 'starts_with:INV-'])); + $this->assertTrue($this->passes(['route' => '/api/users'], ['route' => 'starts_with:/api'])); + } + + public function testStartsWithFailsWhenValueDoesNotStartWithPrefix(): void + { + $this->assertTrue($this->fails(['code' => '2024-INV'], ['code' => 'starts_with:INV-'])); + $this->assertTrue($this->fails(['code' => 'inv-2024'], ['code' => 'starts_with:INV-'])); + $this->assertTrue($this->fails(['code' => 'SOMETHING'], ['code' => 'starts_with:INV-'])); + } + + // ------------------------------------------------------------------------- + // ends_with + // ------------------------------------------------------------------------- + + public function testEndsWithPassesWhenValueEndsWithSuffix(): void + { + $this->assertTrue($this->passes(['file' => 'report.pdf'], ['file' => 'ends_with:.pdf'])); + $this->assertTrue($this->passes(['file' => 'image.jpg'], ['file' => 'ends_with:.jpg'])); + $this->assertTrue($this->passes(['email' => 'user@company.com'], ['email' => 'ends_with:.com'])); + } + + public function testEndsWithFailsWhenValueDoesNotEndWithSuffix(): void + { + $this->assertTrue($this->fails(['file' => 'report.docx'], ['file' => 'ends_with:.pdf'])); + $this->assertTrue($this->fails(['file' => 'pdf.report'], ['file' => 'ends_with:.pdf'])); + } + + // ------------------------------------------------------------------------- + // uuid + // ------------------------------------------------------------------------- + + public function testUuidPassesForValidUuids(): void + { + $this->assertTrue($this->passes(['id' => '550e8400-e29b-41d4-a716-446655440000'], ['id' => 'uuid'])); + $this->assertTrue($this->passes(['id' => '6ba7b810-9dad-11d1-80b4-00c04fd430c8'], ['id' => 'uuid'])); + $this->assertTrue($this->passes(['id' => '6ba7b811-9dad-11d1-80b4-00c04fd430c8'], ['id' => 'uuid'])); + } + + public function testUuidFailsForInvalidUuids(): void + { + $this->assertTrue($this->fails(['id' => 'abc-123'], ['id' => 'uuid'])); + $this->assertTrue($this->fails(['id' => '550e8400-e29b-41d4-a716'], ['id' => 'uuid'])); + $this->assertTrue($this->fails(['id' => 'not-a-uuid-at-all'], ['id' => 'uuid'])); + $this->assertTrue($this->fails(['id' => '550e8400e29b41d4a716446655440000'], ['id' => 'uuid'])); + } + + // ------------------------------------------------------------------------- + // date_format + // ------------------------------------------------------------------------- + + public function testDateFormatPassesForCorrectFormat(): void + { + $this->assertTrue($this->passes(['dob' => '1995-06-15'], ['dob' => 'date_format:Y-m-d'])); + $this->assertTrue($this->passes(['dob' => '15/06/1995'], ['dob' => 'date_format:d/m/Y'])); + $this->assertTrue($this->passes(['at' => '2024-01-31 14:30:00'], ['at' => 'date_format:Y-m-d H:i:s'])); + } + + public function testDateFormatFailsForWrongFormat(): void + { + $this->assertTrue($this->fails(['dob' => '15-06-1995'], ['dob' => 'date_format:Y-m-d'])); + $this->assertTrue($this->fails(['dob' => '1995/06/15'], ['dob' => 'date_format:Y-m-d'])); + $this->assertTrue($this->fails(['dob' => 'not-a-date'], ['dob' => 'date_format:Y-m-d'])); + } + + // ------------------------------------------------------------------------- + // uppercase + // ------------------------------------------------------------------------- + + public function testUppercasePassesForAllUppercaseValues(): void + { + $this->assertTrue($this->passes(['code' => 'USA'], ['code' => 'uppercase'])); + $this->assertTrue($this->passes(['code' => 'BD'], ['code' => 'uppercase'])); + $this->assertTrue($this->passes(['code' => 'HELLO'], ['code' => 'uppercase'])); + $this->assertTrue($this->passes(['code' => 'PHP8'], ['code' => 'uppercase'])); + } + + public function testUppercaseFailsForLowercaseOrMixedValues(): void + { + $this->assertTrue($this->fails(['code' => 'usa'], ['code' => 'uppercase'])); + $this->assertTrue($this->fails(['code' => 'Usa'], ['code' => 'uppercase'])); + $this->assertTrue($this->fails(['code' => 'uSA'], ['code' => 'uppercase'])); + } + + // ------------------------------------------------------------------------- + // lowercase + // ------------------------------------------------------------------------- + + public function testLowercasePassesForAllLowercaseValues(): void + { + $this->assertTrue($this->passes(['tag' => 'php'], ['tag' => 'lowercase'])); + $this->assertTrue($this->passes(['tag' => 'laravel'], ['tag' => 'lowercase'])); + $this->assertTrue($this->passes(['tag' => 'hello123'], ['tag' => 'lowercase'])); + } + + public function testLowercaseFailsForUppercaseOrMixedValues(): void + { + $this->assertTrue($this->fails(['tag' => 'PHP'], ['tag' => 'lowercase'])); + $this->assertTrue($this->fails(['tag' => 'Laravel'], ['tag' => 'lowercase'])); + $this->assertTrue($this->fails(['tag' => 'heLLo'], ['tag' => 'lowercase'])); + } + + // ------------------------------------------------------------------------- + // slug + // ------------------------------------------------------------------------- + + public function testSlugPassesForValidSlugs(): void + { + $this->assertTrue($this->passes(['slug' => 'my-blog-post'], ['slug' => 'slug'])); + $this->assertTrue($this->passes(['slug' => 'hello-world'], ['slug' => 'slug'])); + $this->assertTrue($this->passes(['slug' => 'php8'], ['slug' => 'slug'])); + $this->assertTrue($this->passes(['slug' => 'post-123'], ['slug' => 'slug'])); + $this->assertTrue($this->passes(['slug' => 'a'], ['slug' => 'slug'])); + } + + public function testSlugFailsForInvalidSlugs(): void + { + $this->assertTrue($this->fails(['slug' => 'My Blog Post'], ['slug' => 'slug'])); + $this->assertTrue($this->fails(['slug' => 'my_blog'], ['slug' => 'slug'])); + $this->assertTrue($this->fails(['slug' => 'my--blog'], ['slug' => 'slug'])); + $this->assertTrue($this->fails(['slug' => '-my-blog'], ['slug' => 'slug'])); + $this->assertTrue($this->fails(['slug' => 'my-blog-'], ['slug' => 'slug'])); + $this->assertTrue($this->fails(['slug' => 'My-Blog'], ['slug' => 'slug'])); + } + + // ------------------------------------------------------------------------- + // string + // ------------------------------------------------------------------------- + + public function testStringPassesForStringValues(): void + { + $this->assertTrue($this->passes(['title' => 'Hello World'], ['title' => 'string'])); + $this->assertTrue($this->passes(['title' => ''], ['title' => 'string'])); + $this->assertTrue($this->passes(['title' => '123'], ['title' => 'string'])); + } + + public function testStringFailsForNonStringValues(): void + { + $this->assertTrue($this->fails(['title' => ['array']], ['title' => 'string'])); + $this->assertTrue($this->fails(['title' => [1, 2, 3]], ['title' => 'string'])); + } + + // ------------------------------------------------------------------------- + // array + // ------------------------------------------------------------------------- + + public function testArrayPassesForArrayValues(): void + { + $this->assertTrue($this->passes(['tags' => [1, 2, 3]], ['tags' => 'array'])); + $this->assertTrue($this->passes(['tags' => ['php', 'js']], ['tags' => 'array'])); + $this->assertTrue($this->passes(['tags' => ['a' => 'b']], ['tags' => 'array'])); + } + + public function testArrayFailsForNonArrayValues(): void + { + $this->assertTrue($this->fails(['tags' => '[1,2,3]'], ['tags' => 'array'])); + $this->assertTrue($this->fails(['tags' => 'php,js'], ['tags' => 'array'])); + $this->assertTrue($this->fails(['tags' => 'string'], ['tags' => 'array'])); + $this->assertTrue($this->fails(['tags' => '123'], ['tags' => 'array'])); + } + + // ------------------------------------------------------------------------- + // timezone + // ------------------------------------------------------------------------- + + public function testTimezonePassesForValidTimezones(): void + { + $this->assertTrue($this->passes(['tz' => 'Asia/Dhaka'], ['tz' => 'timezone'])); + $this->assertTrue($this->passes(['tz' => 'UTC'], ['tz' => 'timezone'])); + $this->assertTrue($this->passes(['tz' => 'America/New_York'], ['tz' => 'timezone'])); + $this->assertTrue($this->passes(['tz' => 'Europe/London'], ['tz' => 'timezone'])); + } + + public function testTimezoneFailsForInvalidTimezones(): void + { + $this->assertTrue($this->fails(['tz' => 'GMT+6'], ['tz' => 'timezone'])); + $this->assertTrue($this->fails(['tz' => 'Asia/xyz'], ['tz' => 'timezone'])); + $this->assertTrue($this->fails(['tz' => '+06:00'], ['tz' => 'timezone'])); + $this->assertTrue($this->fails(['tz' => 'not/valid'], ['tz' => 'timezone'])); + } + + // ------------------------------------------------------------------------- + // required — array handling (fixed bug) + // ------------------------------------------------------------------------- + + public function testRequiredFailsForEmptyArray(): void + { + $this->assertTrue($this->fails(['tags' => []], ['tags' => 'required'])); + } + + public function testRequiredPassesForNonEmptyArray(): void + { + $this->assertTrue($this->passes(['tags' => [1, 2, 3]], ['tags' => 'required'])); + $this->assertTrue($this->passes(['tags' => ['php', 'js']], ['tags' => 'required'])); + } + + public function testRequiredFailsForMissingField(): void + { + $this->assertTrue($this->fails([], ['name' => 'required'])); + } + + public function testRequiredFailsForEmptyString(): void + { + $this->assertTrue($this->fails(['name' => ''], ['name' => 'required'])); + } + + public function testRequiredPassesForNonEmptyString(): void + { + $this->assertTrue($this->passes(['name' => 'John'], ['name' => 'required'])); + } + + // ------------------------------------------------------------------------- + // combined rules — realistic scenarios + // ------------------------------------------------------------------------- + + public function testRegistrationRulesPassForValidData(): void + { + $data = [ + 'first_name' => 'John', + 'username' => 'john_doe-99', + 'status' => 'active', + 'website' => 'https://john.com', + 'ip_address' => '192.168.1.1', + 'pin' => '1234', + 'timezone' => 'Asia/Dhaka', + 'slug' => 'my-post', + 'referral_code' => 'ABC12345', + ]; + + $rules = [ + 'first_name' => 'required|alpha', + 'username' => 'required|alpha_dash|min:3|max:30', + 'status' => 'required|in:active,inactive,pending', + 'website' => 'required|url', + 'ip_address' => 'required|ipv4', + 'pin' => 'required|digits:4', + 'timezone' => 'required|timezone', + 'slug' => 'required|slug', + 'referral_code' => 'required|alpha_num|size:8', + ]; + + $this->assertTrue($this->passes($data, $rules)); + } + + public function testRegistrationRulesFailForInvalidData(): void + { + $data = [ + 'first_name' => 'John123', // alpha fails + 'username' => 'jd', // min:3 fails + 'status' => 'deleted', // in fails + 'website' => 'not-a-url', // url fails + 'ip_address' => '::1', // ipv4 fails (ipv6) + 'pin' => '12', // digits:4 fails + 'timezone' => 'GMT+6', // timezone fails + 'slug' => 'My Bad Slug', // slug fails + 'referral_code' => 'AB', // size:8 fails + ]; + + $rules = [ + 'first_name' => 'required|alpha', + 'username' => 'required|alpha_dash|min:3|max:30', + 'status' => 'required|in:active,inactive,pending', + 'website' => 'required|url', + 'ip_address' => 'required|ipv4', + 'pin' => 'required|digits:4', + 'timezone' => 'required|timezone', + 'slug' => 'required|slug', + 'referral_code' => 'required|alpha_num|size:8', + ]; + + $sanitizer = new Sanitizer($data, $rules); + $this->assertTrue($sanitizer->fails()); + $errors = $sanitizer->errors()['errors']; + $this->assertCount(9, $errors); + } + + public function testPasswordConfirmedWithMinLength(): void + { + // Pass: password matches confirmation and meets min length + $this->assertTrue($this->passes( + ['password' => 'secret12', 'password_confirmation' => 'secret12'], + ['password' => 'required|min:8|confirmed'] + )); + + // Fail: password too short + $this->assertTrue($this->fails( + ['password' => 'short', 'password_confirmation' => 'short'], + ['password' => 'required|min:8|confirmed'] + )); + + // Fail: confirmation mismatch + $this->assertTrue($this->fails( + ['password' => 'secret12', 'password_confirmation' => 'different'], + ['password' => 'required|min:8|confirmed'] + )); + } + + public function testNewPasswordMustDifferFromOld(): void + { + $this->assertTrue($this->passes( + ['new_password' => 'newSecret99', 'old_password' => 'oldSecret11'], + ['new_password' => 'required|min:8|different:old_password'] + )); + + $this->assertTrue($this->fails( + ['new_password' => 'samePassword', 'old_password' => 'samePassword'], + ['new_password' => 'required|min:8|different:old_password'] + )); + } + + public function testJsonFieldWithRequiredRule(): void + { + $this->assertTrue($this->passes( + ['settings' => '{"theme":"dark","lang":"en"}'], + ['settings' => 'required|json'] + )); + + $this->assertTrue($this->fails( + ['settings' => '{bad json}'], + ['settings' => 'required|json'] + )); + } + + public function testTagsArrayRequiredAndNotEmpty(): void + { + // Non-empty array passes + $this->assertTrue($this->passes( + ['tags' => ['php', 'laravel']], + ['tags' => 'required|array'] + )); + + // Empty array fails required + $this->assertTrue($this->fails( + ['tags' => []], + ['tags' => 'required|array'] + )); + + // String representation of array fails array rule + $this->assertTrue($this->fails( + ['tags' => '[1,2,3]'], + ['tags' => 'required|array'] + )); + } +}