From 54a98c678cf887f6a4fd004d144e11e2e164554c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=A3=E8=A8=80=E5=B0=B1=E6=98=AFSiam?= <59419979@qq.com> Date: Tue, 29 Aug 2023 21:09:06 +0800 Subject: [PATCH] Added some validation rules such as `ExcludeIf` and `ProhibitedIf`. (#6094) --- CHANGELOG-3.0.md | 1 + src/validation/composer.json | 2 + .../src/Concerns/ValidatesAttributes.php | 16 +- src/validation/src/Contract/DataAwareRule.php | 22 ++ .../src/Contract/ValidatorAwareRule.php | 24 ++ src/validation/src/Rules/ExcludeIf.php | 49 +++ src/validation/src/Rules/File.php | 334 ++++++++++++++++ src/validation/src/Rules/ImageFile.php | 33 ++ src/validation/src/Rules/ProhibitedIf.php | 51 +++ src/validation/src/Validator.php | 10 + .../tests/Cases/ValidationExcludeIfTest.php | 73 ++++ .../tests/Cases/ValidationFileRuleTest.php | 361 ++++++++++++++++++ .../Cases/ValidationImageFileRuleTest.php | 131 +++++++ .../Cases/ValidationProhibitedIfTest.php | 73 ++++ src/validation/tests/Cases/fixtures/txt.txt | 1 + src/validation/tests/File/File.php | 125 ++++++ src/validation/tests/File/FileFactory.php | 98 +++++ src/validation/tests/File/MimeType.php | 60 +++ src/validation/tests/FileTest.php | 62 +++ 19 files changed, 1524 insertions(+), 2 deletions(-) create mode 100644 src/validation/src/Contract/DataAwareRule.php create mode 100644 src/validation/src/Contract/ValidatorAwareRule.php create mode 100644 src/validation/src/Rules/ExcludeIf.php create mode 100644 src/validation/src/Rules/File.php create mode 100644 src/validation/src/Rules/ImageFile.php create mode 100644 src/validation/src/Rules/ProhibitedIf.php create mode 100644 src/validation/tests/Cases/ValidationExcludeIfTest.php create mode 100644 src/validation/tests/Cases/ValidationFileRuleTest.php create mode 100644 src/validation/tests/Cases/ValidationImageFileRuleTest.php create mode 100644 src/validation/tests/Cases/ValidationProhibitedIfTest.php create mode 100644 src/validation/tests/Cases/fixtures/txt.txt create mode 100644 src/validation/tests/File/File.php create mode 100644 src/validation/tests/File/FileFactory.php create mode 100644 src/validation/tests/File/MimeType.php create mode 100644 src/validation/tests/FileTest.php diff --git a/CHANGELOG-3.0.md b/CHANGELOG-3.0.md index 98a51f97a..e518f888b 100644 --- a/CHANGELOG-3.0.md +++ b/CHANGELOG-3.0.md @@ -8,6 +8,7 @@ ## Added - [#6096](https://github.com/hyperf/hyperf/pull/6096) Added `getThrowable` method to request events and crontab event. +- [#6094](https://github.com/hyperf/hyperf/pull/6094) Added some validation rules such as `ExcludeIf` `File` `ImageFile` and `ProhibitedIf`. ## Optimized diff --git a/src/validation/composer.json b/src/validation/composer.json index ee1b93b32..d72bad79a 100755 --- a/src/validation/composer.json +++ b/src/validation/composer.json @@ -21,6 +21,7 @@ "php": ">=8.0", "egulias/email-validator": "^3.0", "hyperf/collection": "~3.0.0", + "hyperf/conditionable": "~3.0.0", "hyperf/contract": "~3.0.0", "hyperf/database": "~3.0.0", "hyperf/di": "~3.0.0", @@ -29,6 +30,7 @@ "hyperf/macroable": "~3.0.0", "hyperf/tappable": "~3.0.0", "hyperf/translation": "~3.0.0", + "hyperf/stringable": "~3.0.0", "hyperf/support": "~3.0.0", "hyperf/utils": "~3.0.0", "nesbot/carbon": "^2.21", diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index edc22259e..bf313d22b 100755 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -342,13 +342,25 @@ trait ValidatesAttributes */ public function validateDimensions(string $attribute, $value, array $parameters): bool { - if (! $this->isValidFileInstance($value) || ! $sizeDetails = @getimagesize($value->getRealPath())) { + if ($this->isValidFileInstance($value) && in_array($value->getMimeType(), ['image/svg+xml', 'image/svg'])) { + return true; + } + + if (! $this->isValidFileInstance($value)) { + return false; + } + + $dimensions = method_exists($value, 'dimensions') + ? $value->dimensions() + : @getimagesize($value->getRealPath()); + + if (! $dimensions) { return false; } $this->requireParameterCount(1, $parameters, 'dimensions'); - [$width, $height] = $sizeDetails; + [$width, $height] = $dimensions; $parameters = $this->parseNamedParameters($parameters); diff --git a/src/validation/src/Contract/DataAwareRule.php b/src/validation/src/Contract/DataAwareRule.php new file mode 100644 index 000000000..8ca79b190 --- /dev/null +++ b/src/validation/src/Contract/DataAwareRule.php @@ -0,0 +1,22 @@ +condition = $condition; + } else { + throw new InvalidArgumentException('The provided condition must be a callable or boolean.'); + } + } + + /** + * Convert the rule to a validation string. + * + * @return string + */ + public function __toString() + { + if (is_callable($this->condition)) { + return call_user_func($this->condition) ? 'exclude' : ''; + } + + return $this->condition ? 'exclude' : ''; + } +} diff --git a/src/validation/src/Rules/File.php b/src/validation/src/Rules/File.php new file mode 100644 index 000000000..f9732bc81 --- /dev/null +++ b/src/validation/src/Rules/File.php @@ -0,0 +1,334 @@ +|string $mimetypes + */ + public static function types(array|string $mimetypes): static + { + return \Hyperf\Tappable\tap(new static(), fn ($file) => $file->allowedMimetypes = (array) $mimetypes); + } + + /** + * Indicate that the uploaded file should be exactly a certain size in kilobytes. + * + * @return $this + */ + public function size(int|string $size): static + { + $this->minimumFileSize = $this->toKilobytes($size); + $this->maximumFileSize = $this->minimumFileSize; + + return $this; + } + + /** + * Indicate that the uploaded file should be between a minimum and maximum size in kilobytes. + * + * @return $this + */ + public function between(int|string $minSize, int|string $maxSize): static + { + $this->minimumFileSize = $this->toKilobytes($minSize); + $this->maximumFileSize = $this->toKilobytes($maxSize); + + return $this; + } + + /** + * Indicate that the uploaded file should be no less than the given number of kilobytes. + * + * @return $this + */ + public function min(int|string $size): static + { + $this->minimumFileSize = (int) $this->toKilobytes($size); + + return $this; + } + + /** + * Indicate that the uploaded file should be no more than the given number of kilobytes. + * + * @return $this + */ + public function max(int|string $size): static + { + $this->maximumFileSize = (int) $this->toKilobytes($size); + + return $this; + } + + /** + * Specify additional validation rules that should be merged with the default rules during validation. + * + * @param mixed $rules + * @return $this + */ + public function rules($rules): static + { + $this->customRules = array_merge($this->customRules, Arr::wrap($rules)); + + return $this; + } + + /** + * Determine if the validation rule passes. + */ + public function passes(string $attribute, mixed $value): bool + { + $this->messages = []; + + $test = $this->buildValidationRules(); + + $validator = ApplicationContext::getContainer()->get(ValidatorFactory::class)->make( + $this->data, + [$attribute => $test], + $this->validator->customMessages, + $this->validator->customAttributes + ); + + if ($validator->fails()) { + return $this->fail($validator->messages()->all()); + } + + return true; + } + + /** + * Get the validation error message. + */ + public function message(): array|string + { + return $this->messages; + } + + /** + * Set the current validator. + * + * @return $this + */ + public function setValidator(Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + /** + * Set the current data under validation. + * + * @return $this + */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + /** + * Convert a potentially human-friendly file size to kilobytes. + * + * @param int|string $size + * @return mixed + */ + protected function toKilobytes($size) + { + if (! is_string($size)) { + return $size; + } + + $value = floatval($size); + + return round(match (true) { + Str::endsWith($size, 'kb') => $value * 1, + Str::endsWith($size, 'mb') => $value * 1000, + Str::endsWith($size, 'gb') => $value * 1000000, + Str::endsWith($size, 'tb') => $value * 1000000000, + default => throw new InvalidArgumentException('Invalid file size suffix.'), + }); + } + + /** + * Build the array of underlying validation rules based on the current state. + * + * @return array + */ + protected function buildValidationRules() + { + $rules = ['file']; + + $rules = array_merge($rules, $this->buildMimetypes()); + + $rules[] = match (true) { + is_null($this->minimumFileSize) && is_null($this->maximumFileSize) => null, + is_null($this->maximumFileSize) => "min:{$this->minimumFileSize}", + is_null($this->minimumFileSize) => "max:{$this->maximumFileSize}", + $this->minimumFileSize !== $this->maximumFileSize => "between:{$this->minimumFileSize},{$this->maximumFileSize}", + default => "size:{$this->minimumFileSize}", + }; + + return array_merge(array_filter($rules), $this->customRules); + } + + /** + * Separate the given mimetypes from extensions and return an array of correct rules to validate against. + * + * @return array + */ + protected function buildMimetypes() + { + if (count($this->allowedMimetypes) === 0) { + return []; + } + + $rules = []; + + $mimetypes = array_filter( + $this->allowedMimetypes, + fn ($type) => str_contains($type, '/') + ); + + $mimes = array_diff($this->allowedMimetypes, $mimetypes); + + if (count($mimetypes) > 0) { + $rules[] = 'mimetypes:' . implode(',', $mimetypes); + } + + if (count($mimes) > 0) { + $rules[] = 'mimes:' . implode(',', $mimes); + } + + return $rules; + } + + /** + * Adds the given failures, and return false. + * + * @param array|string $messages + * @return bool + */ + protected function fail($messages) + { + $messages = collect(Arr::wrap($messages))->map(function ($message) { + return $this->validator->getTranslator()->get($message); + })->all(); + + $this->messages = array_merge($this->messages, $messages); + + return false; + } +} diff --git a/src/validation/src/Rules/ImageFile.php b/src/validation/src/Rules/ImageFile.php new file mode 100644 index 000000000..d347ae948 --- /dev/null +++ b/src/validation/src/Rules/ImageFile.php @@ -0,0 +1,33 @@ +rules('image'); + } + + /** + * The dimension constraints for the uploaded file. + */ + public function dimensions(Dimensions $dimensions): static + { + $this->rules($dimensions); + + return $this; + } +} diff --git a/src/validation/src/Rules/ProhibitedIf.php b/src/validation/src/Rules/ProhibitedIf.php new file mode 100644 index 000000000..cc060c7e5 --- /dev/null +++ b/src/validation/src/Rules/ProhibitedIf.php @@ -0,0 +1,51 @@ +condition = $condition; + } else { + throw new InvalidArgumentException('The provided condition must be a callable or boolean.'); + } + } + + /** + * Convert the rule to a validation string. + * + * @return string + */ + public function __toString() + { + if (is_callable($this->condition)) { + return call_user_func($this->condition) ? 'prohibited' : ''; + } + + return $this->condition ? 'prohibited' : ''; + } +} diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 73b8b27a7..f70a81ea4 100755 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -21,9 +21,11 @@ use Hyperf\HttpMessage\Upload\UploadedFile; use Hyperf\Stringable\Str; use Hyperf\Support\Fluent; use Hyperf\Support\MessageBag; +use Hyperf\Validation\Contract\DataAwareRule; use Hyperf\Validation\Contract\ImplicitRule; use Hyperf\Validation\Contract\PresenceVerifierInterface; use Hyperf\Validation\Contract\Rule as RuleContract; +use Hyperf\Validation\Contract\ValidatorAwareRule; use Psr\Container\ContainerInterface; use RuntimeException; use Stringable; @@ -888,6 +890,14 @@ class Validator implements ValidatorContract */ protected function validateUsingCustomRule(string $attribute, $value, RuleContract $rule) { + if ($rule instanceof ValidatorAwareRule) { + $rule->setValidator($this); + } + + if ($rule instanceof DataAwareRule) { + $rule->setData($this->data); + } + if (! $rule->passes($attribute, $value)) { $this->failedRules[$attribute][$rule::class] = []; diff --git a/src/validation/tests/Cases/ValidationExcludeIfTest.php b/src/validation/tests/Cases/ValidationExcludeIfTest.php new file mode 100644 index 000000000..70efbc332 --- /dev/null +++ b/src/validation/tests/Cases/ValidationExcludeIfTest.php @@ -0,0 +1,73 @@ +assertSame('exclude', (string) $rule); + + $rule = new ExcludeIf(function () { + return false; + }); + + $this->assertSame('', (string) $rule); + + $rule = new ExcludeIf(true); + + $this->assertSame('exclude', (string) $rule); + + $rule = new ExcludeIf(false); + + $this->assertSame('', (string) $rule); + } + + public function testItValidatesCallableAndBooleanAreAcceptableArguments() + { + $this->assertInstanceOf(ExcludeIf::class, new ExcludeIf(false)); + $this->assertInstanceOf(ExcludeIf::class, new ExcludeIf(true)); + $this->assertInstanceOf(ExcludeIf::class, new ExcludeIf(fn () => true)); + + foreach ([1, 1.1, 'phpinfo', new stdClass()] as $condition) { + try { + $this->assertInstanceOf(ExcludeIf::class, new ExcludeIf($condition)); + $this->fail('The ExcludeIf constructor must not accept ' . gettype($condition)); + } catch (InvalidArgumentException $exception) { + $this->assertEquals('The provided condition must be a callable or boolean.', $exception->getMessage()); + } + } + } + + public function testItThrowsExceptionIfRuleIsNotSerializable() + { + $this->expectException(Exception::class); + + serialize(new ExcludeIf(function () { + return true; + })); + } +} diff --git a/src/validation/tests/Cases/ValidationFileRuleTest.php b/src/validation/tests/Cases/ValidationFileRuleTest.php new file mode 100644 index 000000000..4e01a9846 --- /dev/null +++ b/src/validation/tests/Cases/ValidationFileRuleTest.php @@ -0,0 +1,361 @@ +shouldReceive('get')->with(ValidatorFactory::class)->andReturn( + new ValidatorFactory($this->getIlluminateArrayTranslator()) + ); + } + + protected function tearDown(): void + { + } + + public function testBasic() + { + $this->fails( + File::default(), + 'foo', + ['validation.file'], + ); + + $this->passes( + File::default(), + (new FileFactory())->create('foo.bar'), + ); + + $this->passes(File::default(), null); + } + + public function testSingleMimetype() + { + $this->fails( + File::types('text/plain'), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ['validation.mimetypes'] + ); + + $this->passes( + File::types('image/png'), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ); + } + + public function testMultipleMimeTypes() + { + $this->fails( + File::types(['text/plain', 'image/jpeg']), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ['validation.mimetypes'] + ); + + $this->passes( + File::types(['text/plain', 'image/png']), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ); + } + + public function testSingleMime() + { + $this->fails( + File::types('txt'), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ['validation.mimes'] + ); + + $this->passes( + File::types('png'), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ); + } + + public function testMultipleMimes() + { + $this->fails( + File::types(['png', 'jpg', 'jpeg', 'svg']), + (new FileFactory())->createWithContent('foo.txt', 'Hello World!'), + ['validation.mimes'] + ); + + $this->passes( + File::types(['png', 'jpg', 'jpeg', 'svg']), + [ + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + (new FileFactory())->createWithContent('foo.svg', file_get_contents(__DIR__ . '/fixtures/image.svg')), + ] + ); + } + + public function testMixOfMimetypesAndMimes() + { + $this->fails( + File::types(['png', 'image/png']), + (new FileFactory())->createWithContent('foo.txt', 'Hello World!'), + ['validation.mimetypes', 'validation.mimes'] + ); + + $this->passes( + File::types(['png', 'image/png']), + (new FileFactory())->createWithContent('foo.png', file_get_contents(__DIR__ . '/fixtures/image.png')), + ); + } + + public function testImage() + { + $this->fails( + File::image(), + (new FileFactory())->createWithContent('foo.txt', 'Hello World!'), + ['validation.image'] + ); + + $this->passes( + File::image(), + (new FileFactory())->image('foo.png'), + ); + } + + public function testSize() + { + $this->fails( + File::default()->size(1024), + [ + (new FileFactory())->create('foo.txt', 1025), + (new FileFactory())->create('foo.txt', 1023), + ], + ['validation.size.file'] + ); + + $this->passes( + File::default()->size(1024), + (new FileFactory())->create('foo.txt', 1024), + ); + } + + public function testBetween() + { + $this->fails( + File::default()->between(1024, 2048), + [ + (new FileFactory())->create('foo.txt', 1023), + (new FileFactory())->create('foo.txt', 2049), + ], + ['validation.between.file'] + ); + + $this->passes( + File::default()->between(1024, 2048), + [ + (new FileFactory())->create('foo.txt', 1024), + (new FileFactory())->create('foo.txt', 2048), + (new FileFactory())->create('foo.txt', 1025), + (new FileFactory())->create('foo.txt', 2047), + ] + ); + } + + public function testMin() + { + $this->fails( + File::default()->min(1024), + (new FileFactory())->create('foo.txt', 1023), + ['validation.min.file'] + ); + + $this->passes( + File::default()->min(1024), + [ + (new FileFactory())->create('foo.txt', 1024), + (new FileFactory())->create('foo.txt', 1025), + (new FileFactory())->create('foo.txt', 2048), + ] + ); + } + + public function testMinWithHumanReadableSize() + { + $this->fails( + File::default()->min('1024kb'), + (new FileFactory())->create('foo.txt', 1023), + ['validation.min.file'] + ); + + $this->passes( + File::default()->min('1024kb'), + [ + (new FileFactory())->create('foo.txt', 1024), + (new FileFactory())->create('foo.txt', 1025), + (new FileFactory())->create('foo.txt', 2048), + ] + ); + } + + public function testMax() + { + $this->fails( + File::default()->max(1024), + (new FileFactory())->create('foo.txt', 1025), + ['validation.max.file'] + ); + + $this->passes( + File::default()->max(1024), + [ + (new FileFactory())->create('foo.txt', 1024), + (new FileFactory())->create('foo.txt', 1023), + (new FileFactory())->create('foo.txt', 512), + ] + ); + } + + public function testMaxWithHumanReadableSize() + { + $this->fails( + File::default()->max('1024kb'), + (new FileFactory())->create('foo.txt', 1025), + ['validation.max.file'] + ); + + $this->passes( + File::default()->max('1024kb'), + [ + (new FileFactory())->create('foo.txt', 1024), + (new FileFactory())->create('foo.txt', 1023), + (new FileFactory())->create('foo.txt', 512), + ] + ); + } + + public function testMaxWithHumanReadableSizeAndMultipleValue() + { + $this->fails( + File::default()->max('1mb'), + (new FileFactory())->create('foo.txt', 1025), + ['validation.max.file'] + ); + + $this->passes( + File::default()->max('1mb'), + [ + (new FileFactory())->create('foo.txt', 1000), + (new FileFactory())->create('foo.txt', 999), + (new FileFactory())->create('foo.txt', 512), + ] + ); + } + + public function testMacro() + { + File::macro('toDocument', function () { + return static::default()->rules('mimes:txt,csv'); + }); + + $this->fails( + File::toDocument(), + (new FileFactory())->create('foo.png'), + ['validation.mimes'] + ); + + $this->passes( + File::toDocument(), + [ + (new FileFactory())->create('foo.txt'), + (new FileFactory())->create('foo.csv'), + ] + ); + } + + public function testItCanSetDefaultUsing() + { + $this->assertInstanceOf(File::class, File::default()); + + File::defaults(function () { + return File::types('txt')->max(12 * 1024); + }); + + $this->fails( + File::default(), + (new FileFactory())->create('foo.png', 13 * 1024), + [ + 'validation.mimes', + 'validation.max.file', + ] + ); + + File::defaults(File::image()->between(1024, 2048)); + + $this->passes( + File::default(), + (new FileFactory())->create('foo.png', (int) (1.5 * 1024)), + ); + } + + public function getIlluminateArrayTranslator(): Translator + { + return new Translator( + new ArrayLoader(), + 'en' + ); + } + + protected function fails($rule, $values, $messages) + { + $this->assertValidationRules($rule, $values, false, $messages); + } + + protected function assertValidationRules($rule, $values, $result, $messages) + { + $values = Arr::wrap($values); + + foreach ($values as $value) { + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['my_file' => $value], + ['my_file' => is_object($rule) ? clone $rule : $rule] + ); + + $this->assertSame($result, $v->passes()); + + $this->assertSame( + $result ? [] : ['my_file' => $messages], + $v->messages()->toArray() + ); + } + } + + protected function passes($rule, $values) + { + $this->assertValidationRules($rule, $values, true, []); + } +} diff --git a/src/validation/tests/Cases/ValidationImageFileRuleTest.php b/src/validation/tests/Cases/ValidationImageFileRuleTest.php new file mode 100644 index 000000000..b2db959a0 --- /dev/null +++ b/src/validation/tests/Cases/ValidationImageFileRuleTest.php @@ -0,0 +1,131 @@ +shouldReceive('get')->with(ValidatorFactory::class)->andReturn( + new ValidatorFactory($this->getIlluminateArrayTranslator()) + ); + } + + protected function tearDown(): void + { + } + + public function testDimensions() + { + $this->fails( + ( new ImageFile())->dimensions(Rule::dimensions()->width(100)->height(100)), + (new FileFactory())->image('foo.png', 101, 101), + ['validation.dimensions'], + ); + + $this->passes( + ( new ImageFile())->dimensions(Rule::dimensions()->width(100)->height(100)), + (new FileFactory())->image('foo.png', 100, 100), + ); + } + + public function testDimensionsWithCustomImageSizeMethod() + { + $this->fails( + (new ImageFile())->dimensions(Rule::dimensions()->width(100)->height(100)), + new UploadedFileWithCustomImageSizeMethod(stream_get_meta_data($tmpFile = tmpfile())['uri'], 0, 0, 'foo.png'), + ['validation.dimensions'], + ); + + $this->passes( + (new ImageFile())->dimensions(Rule::dimensions()->width(200)->height(200)), + new UploadedFileWithCustomImageSizeMethod(stream_get_meta_data($tmpFile = tmpfile())['uri'], 0, 0, 'foo.png'), + ); + } + + public function getIlluminateArrayTranslator(): Translator + { + return new Translator( + new ArrayLoader(), + 'en' + ); + } + + protected function fails($rule, $values, $messages): void + { + $this->assertValidationRules($rule, $values, false, $messages); + } + + protected function assertValidationRules($rule, $values, $result, $messages): void + { + $values = Arr::wrap($values); + + foreach ($values as $value) { + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['my_file' => $value], + ['my_file' => is_object($rule) ? clone $rule : $rule] + ); + + $this->assertSame($result, $v->passes()); + + $this->assertSame( + $result ? [] : ['my_file' => $messages], + $v->messages()->toArray() + ); + } + } + + protected function passes($rule, $values): void + { + $this->assertValidationRules($rule, $values, true, []); + } +} + +class UploadedFileWithCustomImageSizeMethod extends UploadedFile +{ + public function isValid(): bool + { + return true; + } + + public function guessExtension(): string + { + return 'png'; + } + + public function dimensions(): array + { + return [200, 200]; + } +} diff --git a/src/validation/tests/Cases/ValidationProhibitedIfTest.php b/src/validation/tests/Cases/ValidationProhibitedIfTest.php new file mode 100644 index 000000000..67027d68c --- /dev/null +++ b/src/validation/tests/Cases/ValidationProhibitedIfTest.php @@ -0,0 +1,73 @@ +assertSame('prohibited', (string) $rule); + + $rule = new ProhibitedIf(function () { + return false; + }); + + $this->assertSame('', (string) $rule); + + $rule = new ProhibitedIf(true); + + $this->assertSame('prohibited', (string) $rule); + + $rule = new ProhibitedIf(false); + + $this->assertSame('', (string) $rule); + } + + public function testItValidatesCallableAndBooleanAreAcceptableArguments() + { + $this->assertInstanceOf(ProhibitedIf::class, new ProhibitedIf(false)); + $this->assertInstanceOf(ProhibitedIf::class, new ProhibitedIf(true)); + $this->assertInstanceOf(ProhibitedIf::class, new ProhibitedIf(fn () => true)); + + foreach ([1, 1.1, 'phpinfo', new stdClass()] as $condition) { + try { + $this->assertInstanceOf(ProhibitedIf::class, new ProhibitedIf($condition)); + $this->fail('The ProhibitedIf constructor must not accept ' . gettype($condition)); + } catch (InvalidArgumentException $exception) { + $this->assertEquals('The provided condition must be a callable or boolean.', $exception->getMessage()); + } + } + } + + public function testItThrowsExceptionIfRuleIsNotSerializable() + { + $this->expectException(Exception::class); + + serialize(new ProhibitedIf(function () { + return true; + })); + } +} diff --git a/src/validation/tests/Cases/fixtures/txt.txt b/src/validation/tests/Cases/fixtures/txt.txt new file mode 100644 index 000000000..c57eff55e --- /dev/null +++ b/src/validation/tests/Cases/fixtures/txt.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file diff --git a/src/validation/tests/File/File.php b/src/validation/tests/File/File.php new file mode 100644 index 000000000..bb1364ad3 --- /dev/null +++ b/src/validation/tests/File/File.php @@ -0,0 +1,125 @@ +tempFile = $tempFile; + + parent::__construct( + $this->tempFilePath(), + $this->sizeToReport, + $this->error, + $this->name, + $this->mimeType + ); + } + + /** + * Create a new fake file. + * @param null|mixed $clientFilename + * @param null|mixed $clientMediaType + */ + public static function create(string $name, int|string $kilobytes = 0, int $error = 0, $clientFilename = null, $clientMediaType = null): File + { + return (new FileFactory())->create($name, $kilobytes, $error, $clientFilename, $clientMediaType); + } + + /** + * Create a new fake file with content. + */ + public static function createWithContent(string $name, string $content): File + { + return (new FileFactory())->createWithContent($name, $content); + } + + /** + * Create a new fake image. + */ + public static function image(string $name, int $width = 10, int $height = 10): File + { + return (new FileFactory())->image($name, $width, $height); + } + + /** + * Set the "size" of the file in kilobytes. + * + * @return $this + */ + public function size(int $kilobytes): static + { + $this->sizeToReport = $kilobytes * 1024; + + return $this; + } + + /** + * Get the size of the file. + */ + public function getSize(): int + { + return $this->sizeToReport ?: parent::getSize(); + } + + /** + * Set the "MIME type" for the file. + * + * @return $this + */ + public function mimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + /** + * Get the MIME type of the file. + */ + public function getMimeType(): string + { + return $this->mimeType ?: MimeType::from($this->name); + } + + /** + * Get the path to the temporary file. + */ + protected function tempFilePath(): string + { + return stream_get_meta_data($this->tempFile)['uri']; + } +} diff --git a/src/validation/tests/File/FileFactory.php b/src/validation/tests/File/FileFactory.php new file mode 100644 index 000000000..fcf51d36b --- /dev/null +++ b/src/validation/tests/File/FileFactory.php @@ -0,0 +1,98 @@ +createWithContent($name, $kilobytes); + } + + return tap(new File($name, tmpfile(), $error, $clientMediaType), function ($file) use ($kilobytes, $clientMediaType) { + $file->sizeToReport = $kilobytes * 1024; + $file->mimeTypeToReport = $clientMediaType; + }); + } + + /** + * Create a new fake file with content. + */ + public function createWithContent(string $name, string $content): File + { + $tmpFile = tmpfile(); + + fwrite($tmpFile, $content); + + return tap(new File($name, $tmpFile), function ($file) use ($tmpFile) { + $file->sizeToReport = fstat($tmpFile)['size']; + }); + } + + /** + * Create a new fake image. + * + * @throws LogicException + */ + public function image(string $name, int $width = 10, int $height = 10): File + { + return new File($name, $this->generateImage( + $width, + $height, + pathinfo($name, PATHINFO_EXTENSION) + )); + } + + /** + * Generate a dummy image of the given width and height. + * + * @return resource + * + * @throws LogicException + */ + protected function generateImage(int $width, int $height, string $extension) + { + if (! function_exists('imagecreatetruecolor')) { + throw new LogicException('GD extension is not installed.'); + } + + return tap(tmpfile(), function ($temp) use ($width, $height, $extension) { + ob_start(); + + $extension = in_array($extension, ['jpeg', 'png', 'gif', 'webp', 'wbmp', 'bmp']) + ? strtolower($extension) + : 'jpeg'; + + $image = imagecreatetruecolor($width, $height); + + if (! function_exists($functionName = "image{$extension}")) { + ob_get_clean(); + + throw new LogicException("{$functionName} function is not defined and image cannot be generated."); + } + + call_user_func($functionName, $image); + + fwrite($temp, ob_get_clean()); + }); + } +} diff --git a/src/validation/tests/File/MimeType.php b/src/validation/tests/File/MimeType.php new file mode 100644 index 000000000..f45885d06 --- /dev/null +++ b/src/validation/tests/File/MimeType.php @@ -0,0 +1,60 @@ +guessMimeType($extension) ?? 'application/octet-stream'; + } + + /** + * Search for the extension of a given MIME type. + */ + public static function search(string $mimeType): ?string + { + return self::getMimeTypes()->guessExtension($mimeType); + } +} diff --git a/src/validation/tests/FileTest.php b/src/validation/tests/FileTest.php new file mode 100644 index 000000000..f929236e3 --- /dev/null +++ b/src/validation/tests/FileTest.php @@ -0,0 +1,62 @@ +assertSame('text/plain', $file->getMimeType()); + $this->assertSame(1024 * 1024, $file->getSize()); + $this->assertSame(0, $file->getError()); + + $file = \HyperfTest\Validation\File\File::createWithContent('foo.txt', 'bar'); + $this->assertSame('text/plain', $file->getMimeType()); + $this->assertSame(3, $file->getSize()); + $this->assertSame(0, $file->getError()); + } + + public function testImage() + { + $file = \HyperfTest\Validation\File\File::image('foo.png', 1024, 1024); + $this->assertSame('image/png', $file->getMimeType()); + // 读取图片尺寸 + $imageSize = getimagesize($file->getPathname()); + $this->assertSame([1024, 1024], [$imageSize[0], $imageSize[1]]); + $this->assertSame(0, $file->getError()); + $this->assertSame('png', $file->getExtension()); + + $file = \HyperfTest\Validation\File\File::image('foo.jpg', 1024, 1024); + $this->assertSame('image/jpeg', $file->getMimeType()); + // 读取图片尺寸 + $imageSize = getimagesize($file->getPathname()); + $this->assertSame([1024, 1024], [$imageSize[0], $imageSize[1]]); + $this->assertSame(0, $file->getError()); + $this->assertSame('jpg', $file->getExtension()); + + $file = \HyperfTest\Validation\File\File::image('foo.gif', 1024, 1024); + $this->assertSame('image/gif', $file->getMimeType()); + // 读取图片尺寸 + $imageSize = getimagesize($file->getPathname()); + $this->assertSame([1024, 1024], [$imageSize[0], $imageSize[1]]); + $this->assertSame(0, $file->getError()); + $this->assertSame('gif', $file->getExtension()); + } +}