From fb573f7dc1df6c152b9b06b49e697349e44f69c6 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 14 May 2025 14:51:37 +0200 Subject: [PATCH 01/12] added validator tests --- tests/Validation/Validators.phpt | 247 +++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests/Validation/Validators.phpt diff --git a/tests/Validation/Validators.phpt b/tests/Validation/Validators.phpt new file mode 100644 index 000000000..832c3652f --- /dev/null +++ b/tests/Validation/Validators.phpt @@ -0,0 +1,247 @@ +container = $container; + } + + private static function getAssertionFailedMessage( + BaseValidator $validator, + mixed $value, + bool $expectedValid, + bool $strict + ): string { + $classTokens = explode("\\", get_class($validator)); + $class = $classTokens[array_key_last($classTokens)]; + $strictString = $strict ? "strict" : "permissive"; + $expectedString = $expectedValid ? "valid" : "invalid"; + $valueString = json_encode($value); + return "Asserts that the value <$valueString> using $strictString validator <$class> is $expectedString"; + } + + private static function assertAllValid(BaseValidator $validator, array $values, bool $strict) + { + foreach ($values as $value) { + $failMessage = self::getAssertionFailedMessage($validator, $value, true, $strict); + Assert::true($validator->validate($value), $failMessage); + } + } + + private static function assertAllInvalid(BaseValidator $validator, array $values, bool $strict) + { + foreach ($values as $value) { + $failMessage = self::getAssertionFailedMessage($validator, $value, false, $strict); + Assert::false($validator->validate($value), $failMessage); + } + } + + /** + * Test a validator against a set of input values. The strictness mode is set automatically by the method. + * @param App\Helpers\MetaFormats\Validators\BaseValidator $validator The validator to be tested. + * @param array $strictValid Valid values in the strict mode. + * @param array $strictInvalid Invalid values in the strict mode. + * @param array $permissiveValid Valid values in the permissive mode. + * @param array $permissiveInvalid Invalid values in the permissive mode. + */ + private static function validatorTester( + BaseValidator $validator, + array $strictValid, + array $strictInvalid, + array $permissiveValid, + array $permissiveInvalid + ): void { + // test strict + $validator->setStrict(true); + self::assertAllValid($validator, $strictValid, true); + self::assertAllInvalid($validator, $strictInvalid, true); + // all invalid values in the permissive mode have to be invalid in the strict mode + self::assertAllInvalid($validator, $permissiveInvalid, true); + + // test permissive + $validator->setStrict(false); + self::assertAllValid($validator, $permissiveValid, false); + self::assertAllInvalid($validator, $permissiveInvalid, false); + // all valid values in the strict mode have to be valid in the permissive mode + self::assertAllValid($validator, $strictValid, false); + } + + public function testVBool() + { + $validator = new VBool(); + $strictValid = [true, false]; + $strictInvalid = [0, 1, -1, [], "0", "1", "true", "false", "", "text"]; + $permissiveValid = [true, false, 0, 1, "0", "1", "true", "false"]; + $permissiveInvalid = [-1, [], "", "text"]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVInt() + { + $validator = new VInt(); + $strictValid = [0, 1, -1]; + $strictInvalid = [0.0, 2.5, "0", "1", "-1", "0.0", "", false, []]; + $permissiveValid = [0, 1, -1, 0.0, "0", "1", "-1", "0.0"]; + $permissiveInvalid = ["", 2.5, false, []]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVTimestamp() + { + // timestamps are just ints (unix timestamps, timestamps can be negative) + $validator = new VTimestamp(); + $strictValid = [0, 1, -1]; + $strictInvalid = [0.0, 2.5, "0", "1", "-1", "0.0", "", false, []]; + $permissiveValid = [0, 1, -1, 0.0, "0", "1", "-1", "0.0"]; + $permissiveInvalid = ["", 2.5, false, []]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVDouble() + { + $validator = new VDouble(); + $strictValid = [0, 1, -1, 0.0, 2.5]; + $strictInvalid = ["0", "1", "-1", "0.0", "2.5", "", false, []]; + $permissiveValid = [0, 1, -1, 0.0, 2.5, "0", "1", "-1", "0.0", "2.5"]; + $permissiveInvalid = ["", false, []]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVArrayShallow() + { + // no nested validators, strictness has no effect + $validator = new VArray(); + $valid = [[], [[]], [0], [[], 0]]; + $invalid = ["[]", 0, false, ""]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVArrayNested() + { + // nested array validator, strictness has no effect + $validator = new VArray(new VArray()); + $valid = [[[]], []]; // an array without any nested arrays is still valid (it just has 0 elements) + $invalid = [[0], [[], 0], "[]", 0, false, ""]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVArrayNestedInt() + { + // nested int validator, strictness affects int validation + $validator = new VArray(new VInt()); + $strictValid = [[], [0]]; + $strictInvalid = [["0"], [0.0], [[]], [[], 0], "[]", 0, false, ""]; + $permissiveValid = [[], [0], ["0"], [0.0]]; + $permissiveInvalid = [[[]], [[], 0], "[]", 0, false, ""]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVArrayDoublyNestedInt() + { + // doubly nested int validator, strictness affects int validation through the middle array validator + $validator = new VArray(new VArray(new VInt())); + $strictValid = [[], [[]], [[0]]]; + $strictInvalid = [[0], [["0"]], [[0.0]], [[], 0], "[]", 0, false, ""]; + $permissiveValid = [[], [[]], [[0]], [["0"]], [[0.0]]]; + $permissiveInvalid = [[0], [[], 0], "[]", 0, false, ""]; + self::validatorTester($validator, $strictValid, $strictInvalid, $permissiveValid, $permissiveInvalid); + } + + public function testVStringBasic() + { + // strictness does not affect strings + $validator = new VString(); + $valid = ["", "text"]; + $invalid = [0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVStringLength() + { + // strictness does not affect strings + $validator = new VString(minLength: 2); + $valid = ["ab", "text"]; + $invalid = ["", "a", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + + $validator = new VString(maxLength: 2); + $valid = ["", "a", "ab"]; + $invalid = ["abc", "text", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + + $validator = new VString(minLength: 2, maxLength: 3); + $valid = ["ab", "abc"]; + $invalid = ["", "a", "text", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVStringRegex() + { + // strictness does not affect strings + $validator = new VString(regex: "/^A[0-9a-f]{2}$/"); + $valid = ["A2c", "Add", "A00"]; + $invalid = ["2c", "a2c", "A2g", "A2cc", "", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVStringComplex() + { + // strictness does not affect strings + $validator = new VString(minLength: 1, maxLength: 2, regex: "/^[0-9a-f]*$/"); + $valid = ["a", "aa", "0a"]; + $invalid = ["", "g", "aaa", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVUuid() + { + // strictness does not affect strings + $validator = new VUuid(); + $valid = ["10000000-2000-4000-8000-160000000000"]; + $invalid = ["g0000000-2000-4000-8000-160000000000", "010000000-2000-4000-8000-160000000000", 0, false, []]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVMixed() + { + // accepts everything + $validator = new VMixed(); + $valid = [0, 1.2, -1, "", false, [], new VMixed()]; + $invalid = []; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } + + public function testVObject() + { + // accepts all formats (content is not validated, that is done with the checkedAssign method) + $validator = new VObject(UserFormat::class); + $valid = [new UserFormat()]; + $invalid = [0, 1.2, -1, "", false, [], new VMixed()]; + self::validatorTester($validator, $valid, $invalid, $valid, $invalid); + } +} + +(new TestValidators())->run(); From 0d31158e3bf36576e8bf8a5093125006e1c0277a Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 14 May 2025 22:55:14 +0200 Subject: [PATCH 02/12] MetaFormat::validate now throws instead of returning bool, fixed bad error annotations --- app/V1Module/presenters/base/BasePresenter.php | 5 ++++- app/helpers/MetaFormats/MetaFormat.php | 16 +++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 59242990f..8a5a600e1 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -12,6 +12,7 @@ use App\Exceptions\WrongHttpMethodException; use App\Exceptions\NotImplementedException; use App\Exceptions\InternalServerException; +use App\Exceptions\InvalidApiArgumentException; use App\Exceptions\FrontendErrorMappings; use App\Security\AccessManager; use App\Security\Authorizator; @@ -219,6 +220,7 @@ private function processParams(ReflectionMethod $reflection) /** * Processes loose parameters. Request parameters are validated, no new data is created. + * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. * @param array $paramData Parameter data to be validated. */ private function processParamsLoose(array $paramData) @@ -240,7 +242,8 @@ private function processParamsLoose(array $paramData) * from here instead of the request object. Format validation ignores parameter type (path, query or post). * A top-level format will be created if null. * @throws InternalServerException Thrown when the format definition is corrupted/absent. - * @throws BadRequestException Thrown when the request parameter values do not conform to the definition. + * @throws BadRequestException Thrown when the request parameter values do not meet the structural constraints. + * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. * @return MetaFormat Returns a format instance with values filled from the request object. */ private function processParamsFormat(string $format, ?array $valueDictionary): MetaFormat diff --git a/app/helpers/MetaFormats/MetaFormat.php b/app/helpers/MetaFormats/MetaFormat.php index 48f4dcf98..8138eff92 100644 --- a/app/helpers/MetaFormats/MetaFormat.php +++ b/app/helpers/MetaFormats/MetaFormat.php @@ -2,6 +2,7 @@ namespace App\Helpers\MetaFormats; +use App\Exceptions\BadRequestException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidApiArgumentException; @@ -42,29 +43,26 @@ public function checkedAssign(string $fieldName, mixed $value) /** * Validates the given format. - * @return bool Returns whether the format and all nested formats are valid. + * @throws InvalidApiArgumentException Thrown when a value is not assignable. + * @throws BadRequestException Thrown when the structural constraints were not met. */ public function validate() { // check whether all higher level contracts hold if (!$this->validateStructure()) { - return false; + throw new BadRequestException("The structural constraints of the format were not met."); } // go through all fields and check whether they were assigned properly $fieldFormats = FormatCache::getFieldDefinitions(get_class($this)); foreach ($fieldFormats as $fieldName => $fieldFormat) { - if (!$this->checkIfAssignable($fieldName, $this->$fieldName)) { - return false; - } + $this->checkIfAssignable($fieldName, $this->$fieldName); // check nested formats recursively - if ($this->$fieldName instanceof MetaFormat && !$this->$fieldName->validate()) { - return false; + if ($this->$fieldName instanceof MetaFormat) { + $this->$fieldName->validate(); } } - - return true; } /** From 9213a9e8249a74c54cead79d82770b73b0500c4b Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 14 May 2025 22:55:30 +0200 Subject: [PATCH 03/12] added format tests --- tests/Validation/Formats.phpt | 246 ++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/Validation/Formats.phpt diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt new file mode 100644 index 000000000..de0a8d20f --- /dev/null +++ b/tests/Validation/Formats.phpt @@ -0,0 +1,246 @@ +query == 1; + } +} + +/** + * @testCase + */ +class TestFormats extends Tester\TestCase +{ + /** @var Nette\DI\Container */ + protected $container; + + public function __construct() + { + global $container; + $this->container = $container; + } + + private function injectFormat(string $format) + { + // initialize the cache + FormatCache::getFormatToFieldDefinitionsMap(); + FormatCache::getFormatNamesHashSet(); + + // inject the format name + $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); + $hashSetReflector->setAccessible(true); + $formatNamesHashSet = $hashSetReflector->getValue(); + $formatNamesHashSet[$format] = true; + $hashSetReflector->setValue(null, $formatNamesHashSet); + + // inject the format definitions + $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); + $formatMapReflector->setAccessible(true); + $formatToFieldFormatsMap = $formatMapReflector->getValue(); + $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + $formatMapReflector->setValue(null, $formatToFieldFormatsMap); + + Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); + } + + public function testInvalidFieldName() + { + self::injectFormat(RequiredNullabilityTestFormat::class); + + Assert::throws( + function () { + try { + $format = new RequiredNullabilityTestFormat(); + $format->checkedAssign("invalidIdentifier", null); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InternalServerException::class + ); + } + + public function testRequiredNotNullable() + { + self::injectFormat(RequiredNullabilityTestFormat::class); + $fieldName = "requiredNotNullable"; + + // it is not nullable so this has to throw + Assert::throws( + function () use ($fieldName) { + try { + $format = new RequiredNullabilityTestFormat(); + $format->checkedAssign($fieldName, null); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + + // assign 1 + $format = new RequiredNullabilityTestFormat(); + $format->checkedAssign($fieldName, 1); + Assert::equal($format->$fieldName, 1); + } + + public function testNullAssign() + { + self::injectFormat(RequiredNullabilityTestFormat::class); + $format = new RequiredNullabilityTestFormat(); + + // not required and not nullable fields can contain null (not required overrides not nullable) + foreach (["requiredNullable", "notRequiredNullable", "notRequiredNotNullable"] as $fieldName) { + // assign 1 + $format->checkedAssign($fieldName, 1); + Assert::equal($format->$fieldName, 1); + + // assign null + $format->checkedAssign($fieldName, null); + Assert::equal($format->$fieldName, null); + } + } + + public function testIndividualParamValidation() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + // path and query parameters do not have strict validation + $format->checkedAssign("query", "1"); + $format->checkedAssign("query", 1); + $format->checkedAssign("path", "1"); + $format->checkedAssign("path", 1); + + // post parameters have strict validation, assigning a string will throw + $format->checkedAssign("post", 1); + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("post", "1"); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + + // null cannot be assigned unless the parameter is nullable or not required + $format->checkedAssign("queryOptional", null); + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("query", null); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + } + + public function testAggregateParamValidation() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + $format->checkedAssign("query", 1); + $format->checkedAssign("path", 1); + $format->checkedAssign("post", 1); + $format->checkedAssign("queryOptional", null); + $format->validate(); + + // invalidate a format field + Assert::throws( + function () use ($format) { + try { + // bypass the checkedAssign + $format->path = null; + $format->validate(); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + + // assign valid values to all fields, but fail the structural constraint of $query == 1 + $format->checkedAssign("path", 1); + $format->checkedAssign("query", 2); + Assert::throws( + function () use ($format) { + try { + $format->validate(); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + BadRequestException::class + ); + } + + ///TODO: nested formats, loose format +} + +(new TestFormats())->run(); From 0daa9d48350d83c59861ccf317f7b57ea2a74165 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 17:20:09 +0200 Subject: [PATCH 04/12] made many methods accept a request object instead of fetching it themselves --- .../presenters/base/BasePresenter.php | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index 8a5a600e1..daf7ad933 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -27,6 +27,7 @@ use App\Responses\StorageFileResponse; use App\Responses\ZipFilesResponse; use Nette\Application\Application; +use Nette\Application\Request; use Nette\Http\IResponse; use Tracy\ILogger; use ReflectionClass; @@ -129,7 +130,7 @@ public function startup() $this->tryCall($this->formatPermissionCheckMethod($this->getAction()), $this->params); Validators::init(); - $this->processParams($actionReflection); + $this->processParams($this->getRequest(), $actionReflection); } protected function isRequestJson(): bool @@ -205,29 +206,30 @@ public function getFormatInstance(): MetaFormat return $this->requestFormatInstance; } - private function processParams(ReflectionMethod $reflection) + private function processParams(Request $request, ReflectionMethod $reflection) { // use a method specialized for formats if there is a format available $format = MetaFormatHelper::extractFormatFromAttribute($reflection); if ($format !== null) { - $this->requestFormatInstance = $this->processParamsFormat($format, null); + $this->requestFormatInstance = $this->processParamsFormat($request, $format, null); } // handle loose parameters $paramData = MetaFormatHelper::extractRequestParamData($reflection); - $this->processParamsLoose($paramData); + $this->processParamsLoose($request, $paramData); } /** * Processes loose parameters. Request parameters are validated, no new data is created. * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. + * @param Request $request Request object holding the request data. * @param array $paramData Parameter data to be validated. */ - private function processParamsLoose(array $paramData) + private function processParamsLoose(Request $request, array $paramData) { // validate each param foreach ($paramData as $param) { - $paramValue = $this->getValueFromParamData($param); + $paramValue = $this->getValueFromParamData($request, $param); // this throws when it does not conform $param->conformsToDefinition($paramValue); @@ -237,6 +239,7 @@ private function processParamsLoose(array $paramData) /** * Processes parameters defined by a format. Request parameters are validated and a format instance with * parameter values created. + * @param Request $request Request object holding the request data. * @param string $format The format defining the parameters. * @param ?array $valueDictionary If not null, a nested format instance will be created. The values will be taken * from here instead of the request object. Format validation ignores parameter type (path, query or post). @@ -246,7 +249,7 @@ private function processParamsLoose(array $paramData) * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. * @return MetaFormat Returns a format instance with values filled from the request object. */ - private function processParamsFormat(string $format, ?array $valueDictionary): MetaFormat + private function processParamsFormat(Request $request, string $format, ?array $valueDictionary): MetaFormat { // get the parsed attribute data from the format fields $formatToFieldDefinitionsMap = FormatCache::getFormatToFieldDefinitionsMap(); @@ -262,7 +265,7 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M $value = null; // top-level format if ($valueDictionary === null) { - $value = $this->getValueFromParamData($requestParamData); + $value = $this->getValueFromParamData($request, $requestParamData); // nested format } else { // Instead of retrieving the values with the getRequest call, use the provided $valueDictionary. @@ -278,7 +281,7 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M // replace the value dictionary stored in $value with a format instance $nestedFormatName = $requestParamData->getFormatName(); if ($nestedFormatName !== null) { - $value = $this->processParamsFormat($nestedFormatName, $value); + $value = $this->processParamsFormat($request, $nestedFormatName, $value); } // this throws if the value is invalid @@ -295,37 +298,37 @@ private function processParamsFormat(string $format, ?array $valueDictionary): M /** * Calls either getPostField, getQueryField or getPathField based on the provided metadata. + * @param Request $request Request object holding the request data. * @param \App\Helpers\MetaFormats\RequestParamData $paramData Metadata of the request parameter. * @throws \App\Exceptions\InternalServerException Thrown when an unexpected parameter location was set. * @return mixed Returns the value from the request. */ - private function getValueFromParamData(RequestParamData $paramData): mixed + private function getValueFromParamData(Request $request, RequestParamData $paramData): mixed { switch ($paramData->type) { case Type::Post: - return $this->getPostField($paramData->name, required: $paramData->required); + return $this->getPostField($request, $paramData->name, required: $paramData->required); case Type::Query: - return $this->getQueryField($paramData->name, required: $paramData->required); + return $this->getQueryField($request, $paramData->name, required: $paramData->required); case Type::Path: - return $this->getPathField($paramData->name); + return $this->getPathField($request, $paramData->name); default: throw new InternalServerException("Unknown parameter type: {$paramData->type->name}"); } } - private function getPostField($param, $required = true) + private function getPostField(Request $request, $param, $required = true) { - $req = $this->getRequest(); - $post = $req->getPost(); + $post = $request->getPost(); - if ($req->isMethod("POST")) { + if ($request->isMethod("POST")) { // nothing to see here... } else { - if ($req->isMethod("PUT") || $req->isMethod("DELETE")) { + if ($request->isMethod("PUT") || $request->isMethod("DELETE")) { parse_str(file_get_contents('php://input'), $post); } else { throw new WrongHttpMethodException( - "Cannot get the post parameters in method '" . $req->getMethod() . "'." + "Cannot get the post parameters in method '" . $request->getMethod() . "'." ); } } @@ -341,18 +344,18 @@ private function getPostField($param, $required = true) } } - private function getQueryField($param, $required = true) + private function getQueryField(Request $request, $param, $required = true) { - $value = $this->getRequest()->getParameter($param); + $value = $request->getParameter($param); if ($value === null && $required) { throw new BadRequestException("Missing required query field $param"); } return $value; } - private function getPathField($param) + private function getPathField(Request $request, $param) { - $value = $this->getParameter($param); + $value = $request->getParameter($param); if ($value === null) { throw new BadRequestException("Missing required path field $param"); } From eaac5b85dafbbd1bfbb0e047be156778f5dc9188 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 19:36:42 +0200 Subject: [PATCH 05/12] added nested format tests --- tests/Validation/Formats.phpt | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index de0a8d20f..79aedae90 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -3,6 +3,7 @@ use App\Exceptions\BadRequestException; use App\Exceptions\InternalServerException; use App\Exceptions\InvalidApiArgumentException; +use App\Helpers\MetaFormats\Attributes\Format; use App\Helpers\MetaFormats\Attributes\FPath; use App\Helpers\MetaFormats\Attributes\FPost; use App\Helpers\MetaFormats\Attributes\FQuery; @@ -61,6 +62,33 @@ class ValidationTestFormat extends MetaFormat } } +#[Format(ParentFormat::class)] +class ParentFormat extends MetaFormat +{ + #[FQuery(new VInt(), required: true, nullable: false)] + public ?int $field; + + #[FPost(new VObject(NestedFormat::class), required: true, nullable: false)] + public NestedFormat $nested; + + public function validateStructure() + { + return $this->field == 1; + } +} + +#[Format(NestedFormat::class)] +class NestedFormat extends MetaFormat +{ + #[FQuery(new VInt(), required: true, nullable: false)] + public ?int $field; + + public function validateStructure() + { + return $this->field == 2; + } +} + /** * @testCase */ @@ -75,7 +103,7 @@ class TestFormats extends Tester\TestCase $this->container = $container; } - private function injectFormat(string $format) + private static function injectFormat(string $format) { // initialize the cache FormatCache::getFormatToFieldDefinitionsMap(); @@ -227,6 +255,7 @@ class TestFormats extends Tester\TestCase // assign valid values to all fields, but fail the structural constraint of $query == 1 $format->checkedAssign("path", 1); $format->checkedAssign("query", 2); + Assert::false($format->validateStructure()); Assert::throws( function () use ($format) { try { @@ -240,7 +269,47 @@ class TestFormats extends Tester\TestCase ); } - ///TODO: nested formats, loose format + public function testNestedFormat() + { + self::injectFormat(NestedFormat::class); + self::injectFormat(ParentFormat::class); + $nested = new NestedFormat(); + $parent = new ParentFormat(); + + // assign valid values that do not pass structural validation + // (both fields need to be 1 to pass) + $nested->checkedAssign("field", 0); + $parent->checkedAssign("field", 0); + $parent->checkedAssign("nested", $nested); + + Assert::false($nested->validateStructure()); + Assert::false($parent->validateStructure()); + + Assert::throws( + function () use ($nested) { + $nested->validate(); + }, + BadRequestException::class + ); + Assert::throws( + function () use ($parent) { + $parent->validate(); + }, + BadRequestException::class + ); + + // fix the structural constain in the parent + $parent->checkedAssign("field", 1); + Assert::true($parent->validateStructure()); + + // make sure that the structural error in the nested format propagates to the parent + Assert::throws( + function () use ($parent) { + $parent->validate(); + }, + BadRequestException::class + ); + } } (new TestFormats())->run(); From 81cc0c4caba8e05733d5cf0b1f3557323cf2ad23 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 19:37:03 +0200 Subject: [PATCH 06/12] added BasePresenter tests --- tests/Validation/BasePresenter.phpt | 309 ++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 tests/Validation/BasePresenter.phpt diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt new file mode 100644 index 000000000..b32aa0067 --- /dev/null +++ b/tests/Validation/BasePresenter.phpt @@ -0,0 +1,309 @@ +query == 1; + } +} + +class TestPresenter extends BasePresenter +{ + #[Post("post", new VInt())] + #[Query("query", new VInt())] + #[Path("path", new VInt())] + public function actionTestLoose() + { + } + + #[Format(PresenterTestFormat::class)] + public function actionTestFormat() + { + } + + #[Format(PresenterTestFormat::class)] + #[Post("loose", new VInt())] + public function actionTestCombined() + { + } +} + +/** + * @testCase + */ +class TestBasePresenter extends Tester\TestCase +{ + /** @var Nette\DI\Container */ + protected $container; + + public function __construct() + { + global $container; + $this->container = $container; + } + + private static function injectFormat(string $format) + { + // initialize the cache + FormatCache::getFormatToFieldDefinitionsMap(); + FormatCache::getFormatNamesHashSet(); + + // inject the format name + $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); + $hashSetReflector->setAccessible(true); + $formatNamesHashSet = $hashSetReflector->getValue(); + $formatNamesHashSet[$format] = true; + $hashSetReflector->setValue(null, $formatNamesHashSet); + + // inject the format definitions + $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); + $formatMapReflector->setAccessible(true); + $formatToFieldFormatsMap = $formatMapReflector->getValue(); + $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + $formatMapReflector->setValue(null, $formatToFieldFormatsMap); + + Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); + } + + private static function getMethod(BasePresenter $presenter, string $methodName): ReflectionMethod + { + $presenterReflection = new ReflectionObject($presenter); + $methodReflection = $presenterReflection->getMethod($methodName); + $methodReflection->setAccessible(true); + return $methodReflection; + } + + public function testLooseValid() + { + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestLoose"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object and invoke the actionTestLoose method + $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "1"], post: ["post" => 1]); + $processParams->invoke($presenter, $request, $action); + + // check that the previous row did not throw + Assert::true(true); + } + + public function testLooseInvalid() + { + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestLoose"); + $processParams = self::getMethod($presenter, "processParams"); + + // set an invalid parameter value and assert that the validation fails + $request = new Request( + "name", + method: "POST", + params: ["path" => "string", "query" => "1"], + post: ["post" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } + + public function testFormatValid() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestFormat"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a valid request object + $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "1"], post: ["post" => 1]); + $processParams->invoke($presenter, $request, $action); + + // the presenter should automatically create a valid format object + /** @var PresenterTestFormat */ + $format = $presenter->getFormatInstance(); + Assert::notNull($format); + $format->validate(); + + // check if the values match + Assert::equal($format->path, 1); + Assert::equal($format->query, 1); + Assert::equal($format->post, 1); + } + + public function testFormatInvalidField() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestFormat"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid fields + $request = new Request( + "name", + method: "POST", + params: ["path" => "string", "query" => "1"], + post: ["post" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } + + public function testFormatInvalidStructure() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestFormat"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid structure + $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "0"], post: ["post" => 1]); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + BadRequestException::class + ); + } + + public function testCombinedValid() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a valid request object + $request = new Request( + "name", + method: "POST", + params: ["path" => "1", "query" => "1"], + post: ["post" => 1, "loose" => 1] + ); + $processParams->invoke($presenter, $request, $action); + + // the presenter should automatically create a valid format object + /** @var PresenterTestFormat */ + $format = $presenter->getFormatInstance(); + Assert::notNull($format); + $format->validate(); + + // check if the values match + Assert::equal($format->path, 1); + Assert::equal($format->query, 1); + Assert::equal($format->post, 1); + } + + public function testCombinedInvalidFormatFields() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid fields + $request = new Request( + "name", + method: "POST", + params: ["path" => "string", "query" => "1"], + post: ["post" => 1, "loose" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } + + public function testCombinedInvalidStructure() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with invalid structure + $request = new Request( + "name", + method: "POST", + params: ["path" => "1", "query" => "0"], + post: ["post" => 1, "loose" => 1] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + BadRequestException::class + ); + } + + public function testCombinedInvalidLooseParam() + { + self::injectFormat(PresenterTestFormat::class); + $presenter = new TestPresenter(); + $action = self::getMethod($presenter, "actionTestCombined"); + $processParams = self::getMethod($presenter, "processParams"); + + // create a request object with an invalid loose parameter + $request = new Request( + "name", + method: "POST", + params: ["path" => "1", "query" => "1"], + post: ["post" => 1, "loose" => "string"] + ); + Assert::throws( + function () use ($processParams, $presenter, $request, $action) { + $processParams->invoke($presenter, $request, $action); + }, + InvalidApiArgumentException::class + ); + } +} + +(new TestBasePresenter())->run(); From b639c9e8e2f50bf8d10f466a35933454e3e41423 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 16 May 2025 19:49:00 +0200 Subject: [PATCH 07/12] improved format tests --- tests/Validation/Formats.phpt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index 79aedae90..b9e947943 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -186,7 +186,7 @@ class TestFormats extends Tester\TestCase } } - public function testIndividualParamValidation() + public function testIndividualParamValidationPermissive() { self::injectFormat(ValidationTestFormat::class); $format = new ValidationTestFormat(); @@ -197,6 +197,15 @@ class TestFormats extends Tester\TestCase $format->checkedAssign("path", "1"); $format->checkedAssign("path", 1); + // make sure that the above assignments did not throw + Assert::true(true); + } + + public function testIndividualParamValidationStrict() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + // post parameters have strict validation, assigning a string will throw $format->checkedAssign("post", 1); Assert::throws( @@ -210,6 +219,12 @@ class TestFormats extends Tester\TestCase }, InvalidApiArgumentException::class ); + } + + public function testIndividualParamValidationNullable() + { + self::injectFormat(ValidationTestFormat::class); + $format = new ValidationTestFormat(); // null cannot be assigned unless the parameter is nullable or not required $format->checkedAssign("queryOptional", null); @@ -231,6 +246,7 @@ class TestFormats extends Tester\TestCase self::injectFormat(ValidationTestFormat::class); $format = new ValidationTestFormat(); + // assign valid values and validate $format->checkedAssign("query", 1); $format->checkedAssign("path", 1); $format->checkedAssign("post", 1); @@ -285,6 +301,7 @@ class TestFormats extends Tester\TestCase Assert::false($nested->validateStructure()); Assert::false($parent->validateStructure()); + // invalid structure should throw during validation Assert::throws( function () use ($nested) { $nested->validate(); From 88c953f0655b0e1c7989513aa1eed8446e70532a Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Jun 2025 16:08:16 +0200 Subject: [PATCH 08/12] WIP adding mocks --- .../presenters/base/BasePresenter.php | 54 +++++++++---------- app/helpers/Mocks/MockHelper.php | 44 +++++++++++++++ app/helpers/Mocks/MockTemplate.php | 25 +++++++++ app/helpers/Mocks/MockTemplateFactory.php | 17 ++++++ app/helpers/Mocks/MockUserStorage.php | 26 +++++++++ tests/Validation/BasePresenter.phpt | 38 +++++++------ 6 files changed, 154 insertions(+), 50 deletions(-) create mode 100644 app/helpers/Mocks/MockHelper.php create mode 100644 app/helpers/Mocks/MockTemplate.php create mode 100644 app/helpers/Mocks/MockTemplateFactory.php create mode 100644 app/helpers/Mocks/MockUserStorage.php diff --git a/app/V1Module/presenters/base/BasePresenter.php b/app/V1Module/presenters/base/BasePresenter.php index daf7ad933..59242990f 100644 --- a/app/V1Module/presenters/base/BasePresenter.php +++ b/app/V1Module/presenters/base/BasePresenter.php @@ -12,7 +12,6 @@ use App\Exceptions\WrongHttpMethodException; use App\Exceptions\NotImplementedException; use App\Exceptions\InternalServerException; -use App\Exceptions\InvalidApiArgumentException; use App\Exceptions\FrontendErrorMappings; use App\Security\AccessManager; use App\Security\Authorizator; @@ -27,7 +26,6 @@ use App\Responses\StorageFileResponse; use App\Responses\ZipFilesResponse; use Nette\Application\Application; -use Nette\Application\Request; use Nette\Http\IResponse; use Tracy\ILogger; use ReflectionClass; @@ -130,7 +128,7 @@ public function startup() $this->tryCall($this->formatPermissionCheckMethod($this->getAction()), $this->params); Validators::init(); - $this->processParams($this->getRequest(), $actionReflection); + $this->processParams($actionReflection); } protected function isRequestJson(): bool @@ -206,30 +204,28 @@ public function getFormatInstance(): MetaFormat return $this->requestFormatInstance; } - private function processParams(Request $request, ReflectionMethod $reflection) + private function processParams(ReflectionMethod $reflection) { // use a method specialized for formats if there is a format available $format = MetaFormatHelper::extractFormatFromAttribute($reflection); if ($format !== null) { - $this->requestFormatInstance = $this->processParamsFormat($request, $format, null); + $this->requestFormatInstance = $this->processParamsFormat($format, null); } // handle loose parameters $paramData = MetaFormatHelper::extractRequestParamData($reflection); - $this->processParamsLoose($request, $paramData); + $this->processParamsLoose($paramData); } /** * Processes loose parameters. Request parameters are validated, no new data is created. - * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. - * @param Request $request Request object holding the request data. * @param array $paramData Parameter data to be validated. */ - private function processParamsLoose(Request $request, array $paramData) + private function processParamsLoose(array $paramData) { // validate each param foreach ($paramData as $param) { - $paramValue = $this->getValueFromParamData($request, $param); + $paramValue = $this->getValueFromParamData($param); // this throws when it does not conform $param->conformsToDefinition($paramValue); @@ -239,17 +235,15 @@ private function processParamsLoose(Request $request, array $paramData) /** * Processes parameters defined by a format. Request parameters are validated and a format instance with * parameter values created. - * @param Request $request Request object holding the request data. * @param string $format The format defining the parameters. * @param ?array $valueDictionary If not null, a nested format instance will be created. The values will be taken * from here instead of the request object. Format validation ignores parameter type (path, query or post). * A top-level format will be created if null. * @throws InternalServerException Thrown when the format definition is corrupted/absent. - * @throws BadRequestException Thrown when the request parameter values do not meet the structural constraints. - * @throws InvalidApiArgumentException Thrown when the request parameter values do not conform to the definition. + * @throws BadRequestException Thrown when the request parameter values do not conform to the definition. * @return MetaFormat Returns a format instance with values filled from the request object. */ - private function processParamsFormat(Request $request, string $format, ?array $valueDictionary): MetaFormat + private function processParamsFormat(string $format, ?array $valueDictionary): MetaFormat { // get the parsed attribute data from the format fields $formatToFieldDefinitionsMap = FormatCache::getFormatToFieldDefinitionsMap(); @@ -265,7 +259,7 @@ private function processParamsFormat(Request $request, string $format, ?array $v $value = null; // top-level format if ($valueDictionary === null) { - $value = $this->getValueFromParamData($request, $requestParamData); + $value = $this->getValueFromParamData($requestParamData); // nested format } else { // Instead of retrieving the values with the getRequest call, use the provided $valueDictionary. @@ -281,7 +275,7 @@ private function processParamsFormat(Request $request, string $format, ?array $v // replace the value dictionary stored in $value with a format instance $nestedFormatName = $requestParamData->getFormatName(); if ($nestedFormatName !== null) { - $value = $this->processParamsFormat($request, $nestedFormatName, $value); + $value = $this->processParamsFormat($nestedFormatName, $value); } // this throws if the value is invalid @@ -298,37 +292,37 @@ private function processParamsFormat(Request $request, string $format, ?array $v /** * Calls either getPostField, getQueryField or getPathField based on the provided metadata. - * @param Request $request Request object holding the request data. * @param \App\Helpers\MetaFormats\RequestParamData $paramData Metadata of the request parameter. * @throws \App\Exceptions\InternalServerException Thrown when an unexpected parameter location was set. * @return mixed Returns the value from the request. */ - private function getValueFromParamData(Request $request, RequestParamData $paramData): mixed + private function getValueFromParamData(RequestParamData $paramData): mixed { switch ($paramData->type) { case Type::Post: - return $this->getPostField($request, $paramData->name, required: $paramData->required); + return $this->getPostField($paramData->name, required: $paramData->required); case Type::Query: - return $this->getQueryField($request, $paramData->name, required: $paramData->required); + return $this->getQueryField($paramData->name, required: $paramData->required); case Type::Path: - return $this->getPathField($request, $paramData->name); + return $this->getPathField($paramData->name); default: throw new InternalServerException("Unknown parameter type: {$paramData->type->name}"); } } - private function getPostField(Request $request, $param, $required = true) + private function getPostField($param, $required = true) { - $post = $request->getPost(); + $req = $this->getRequest(); + $post = $req->getPost(); - if ($request->isMethod("POST")) { + if ($req->isMethod("POST")) { // nothing to see here... } else { - if ($request->isMethod("PUT") || $request->isMethod("DELETE")) { + if ($req->isMethod("PUT") || $req->isMethod("DELETE")) { parse_str(file_get_contents('php://input'), $post); } else { throw new WrongHttpMethodException( - "Cannot get the post parameters in method '" . $request->getMethod() . "'." + "Cannot get the post parameters in method '" . $req->getMethod() . "'." ); } } @@ -344,18 +338,18 @@ private function getPostField(Request $request, $param, $required = true) } } - private function getQueryField(Request $request, $param, $required = true) + private function getQueryField($param, $required = true) { - $value = $request->getParameter($param); + $value = $this->getRequest()->getParameter($param); if ($value === null && $required) { throw new BadRequestException("Missing required query field $param"); } return $value; } - private function getPathField(Request $request, $param) + private function getPathField($param) { - $value = $request->getParameter($param); + $value = $this->getParameter($param); if ($value === null) { throw new BadRequestException("Missing required path field $param"); } diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php new file mode 100644 index 000000000..89c9f46a1 --- /dev/null +++ b/app/helpers/Mocks/MockHelper.php @@ -0,0 +1,44 @@ +application = $application; + + $factory = new MockTemplateFactory(); + + $presenter->injectPrimary($httpRequest, $httpResponse, user: $user, templateFactory: $factory); + } +} diff --git a/app/helpers/Mocks/MockTemplate.php b/app/helpers/Mocks/MockTemplate.php new file mode 100644 index 000000000..ed5ca7c01 --- /dev/null +++ b/app/helpers/Mocks/MockTemplate.php @@ -0,0 +1,25 @@ +sendSuccessResponse("OK"); } #[Format(PresenterTestFormat::class)] public function actionTestFormat() { + $this->sendSuccessResponse("OK"); } #[Format(PresenterTestFormat::class)] #[Post("loose", new VInt())] public function actionTestCombined() { + $this->sendSuccessResponse("OK"); } } @@ -106,44 +110,38 @@ class TestBasePresenter extends Tester\TestCase Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); } - private static function getMethod(BasePresenter $presenter, string $methodName): ReflectionMethod - { - $presenterReflection = new ReflectionObject($presenter); - $methodReflection = $presenterReflection->getMethod($methodName); - $methodReflection->setAccessible(true); - return $methodReflection; - } - public function testLooseValid() { $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestLoose"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); - // create a request object and invoke the actionTestLoose method - $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "1"], post: ["post" => 1]); - $processParams->invoke($presenter, $request, $action); + // create a request object + $request = new Request( + "name", + method: "POST", + params: ["action" => "testLoose", "path" => "1", "query" => "1"], + post: ["post" => 1] + ); - // check that the previous row did not throw - Assert::true(true); + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); } public function testLooseInvalid() { $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestLoose"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // set an invalid parameter value and assert that the validation fails $request = new Request( "name", method: "POST", - params: ["path" => "string", "query" => "1"], + params: ["action" => "testLoose", "path" => "string", "query" => "1"], post: ["post" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); From 685be463ea883676a11964b6c4ba1f3afba13174 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Jun 2025 17:15:51 +0200 Subject: [PATCH 09/12] fixed tests with mocks --- tests/Validation/BasePresenter.phpt | 83 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt index 0dd1c09fc..a7162cfa1 100644 --- a/tests/Validation/BasePresenter.phpt +++ b/tests/Validation/BasePresenter.phpt @@ -1,7 +1,6 @@ "1", "query" => "1"], post: ["post" => 1]); - $processParams->invoke($presenter, $request, $action); + $request = new Request( + "name", + method: "POST", + params: ["action" => "testFormat", "path" => "1", "query" => "1"], + post: ["post" => 1] + ); + + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); // the presenter should automatically create a valid format object /** @var PresenterTestFormat */ @@ -174,19 +169,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestFormat"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid fields $request = new Request( "name", method: "POST", - params: ["path" => "string", "query" => "1"], + params: ["action" => "testFormat", "path" => "string", "query" => "1"], post: ["post" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); @@ -196,14 +190,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestFormat"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid structure - $request = new Request("name", method: "POST", params: ["path" => "1", "query" => "0"], post: ["post" => 1]); + $request = new Request( + "name", + method: "POST", + params: ["action" => "testFormat", "path" => "1", "query" => "0"], + post: ["post" => 1] + ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, BadRequestException::class ); @@ -213,17 +211,17 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a valid request object $request = new Request( "name", method: "POST", - params: ["path" => "1", "query" => "1"], + params: ["action" => "testCombined", "path" => "1", "query" => "1"], post: ["post" => 1, "loose" => 1] ); - $processParams->invoke($presenter, $request, $action); + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); // the presenter should automatically create a valid format object /** @var PresenterTestFormat */ @@ -241,19 +239,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid fields $request = new Request( "name", method: "POST", - params: ["path" => "string", "query" => "1"], + params: ["action" => "testCombined", "path" => "string", "query" => "1"], post: ["post" => 1, "loose" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); @@ -263,19 +260,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with invalid structure $request = new Request( "name", method: "POST", - params: ["path" => "1", "query" => "0"], + params: ["action" => "testCombined", "path" => "1", "query" => "0"], post: ["post" => 1, "loose" => 1] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, BadRequestException::class ); @@ -285,19 +281,18 @@ class TestBasePresenter extends Tester\TestCase { self::injectFormat(PresenterTestFormat::class); $presenter = new TestPresenter(); - $action = self::getMethod($presenter, "actionTestCombined"); - $processParams = self::getMethod($presenter, "processParams"); + MockHelper::initPresenter($presenter); // create a request object with an invalid loose parameter $request = new Request( "name", method: "POST", - params: ["path" => "1", "query" => "1"], + params: ["action" => "testCombined", "path" => "1", "query" => "1"], post: ["post" => 1, "loose" => "string"] ); Assert::throws( - function () use ($processParams, $presenter, $request, $action) { - $processParams->invoke($presenter, $request, $action); + function () use ($presenter, $request) { + $presenter->run($request); }, InvalidApiArgumentException::class ); From 804d6c75b737f470d86c740e971505adbdae5f43 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 12 Jun 2025 14:53:07 +0200 Subject: [PATCH 10/12] removed unused imports --- app/helpers/Mocks/MockHelper.php | 11 +---------- app/helpers/Mocks/MockTemplate.php | 3 --- app/helpers/Mocks/MockTemplateFactory.php | 2 -- tests/Validation/BasePresenter.phpt | 2 +- tests/Validation/Formats.phpt | 9 --------- 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php index 89c9f46a1..ce1b93ea4 100644 --- a/app/helpers/Mocks/MockHelper.php +++ b/app/helpers/Mocks/MockHelper.php @@ -3,29 +3,20 @@ namespace App\Helpers\Mocks; use App\Helpers\Mocks\MockUserStorage; -use App\Security\UserStorage; use App\V1Module\Presenters\BasePresenter; -use App\V1Module\Presenters\RegistrationPresenter; use Nette\Application\Application; use Nette\Application\PresenterFactory; -use Nette\Application\Request; -use Nette\Application\Responses\JsonResponse; use Nette\Application\Routers\RouteList; use Nette\Http\Response; use Nette\Http\UrlScript; use Nette\Security\User; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use App\Exceptions\InvalidAccessTokenException; -use App\Exceptions\InvalidArgumentException; use Nette; -use Nette\Security\IIdentity; class MockHelper { /** * Initializes a presenter object with empty http request, response, and user objects. + * This is intended to be called right after presenter instantiation and before calling the Presenter::run method. * @param BasePresenter $presenter The presenter to be initialized. */ public static function initPresenter(BasePresenter $presenter) diff --git a/app/helpers/Mocks/MockTemplate.php b/app/helpers/Mocks/MockTemplate.php index ed5ca7c01..1caf655fe 100644 --- a/app/helpers/Mocks/MockTemplate.php +++ b/app/helpers/Mocks/MockTemplate.php @@ -2,10 +2,7 @@ namespace App\Helpers\Mocks; -use Nette; use Nette\Application\UI\Template; -use Nette\Application\UI\TemplateFactory; -use Nette\Security\IIdentity; class MockTemplate implements Template { diff --git a/app/helpers/Mocks/MockTemplateFactory.php b/app/helpers/Mocks/MockTemplateFactory.php index 56f8af4b4..b5bc53243 100644 --- a/app/helpers/Mocks/MockTemplateFactory.php +++ b/app/helpers/Mocks/MockTemplateFactory.php @@ -2,11 +2,9 @@ namespace App\Helpers\Mocks; -use Nette; use Nette\Application\UI\Control; use Nette\Application\UI\Template; use Nette\Application\UI\TemplateFactory; -use Nette\Security\IIdentity; class MockTemplateFactory implements TemplateFactory { diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt index a7162cfa1..8795d35f2 100644 --- a/tests/Validation/BasePresenter.phpt +++ b/tests/Validation/BasePresenter.phpt @@ -65,7 +65,7 @@ class TestPresenter extends BasePresenter /** * @testCase */ -class TestBasePresenter extends Tester\TestCase +class TestPresenter extends Tester\TestCase { /** @var Nette\DI\Container */ protected $container; diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index b9e947943..3f0ddfb1b 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -8,19 +8,10 @@ use App\Helpers\MetaFormats\Attributes\FPath; use App\Helpers\MetaFormats\Attributes\FPost; use App\Helpers\MetaFormats\Attributes\FQuery; use App\Helpers\MetaFormats\FormatCache; -use App\Helpers\MetaFormats\FormatDefinitions\UserFormat; use App\Helpers\MetaFormats\MetaFormat; use App\Helpers\MetaFormats\MetaFormatHelper; -use App\Helpers\MetaFormats\Validators\BaseValidator; -use App\Helpers\MetaFormats\Validators\VArray; -use App\Helpers\MetaFormats\Validators\VBool; -use App\Helpers\MetaFormats\Validators\VDouble; use App\Helpers\MetaFormats\Validators\VInt; -use App\Helpers\MetaFormats\Validators\VMixed; use App\Helpers\MetaFormats\Validators\VObject; -use App\Helpers\MetaFormats\Validators\VString; -use App\Helpers\MetaFormats\Validators\VTimestamp; -use App\Helpers\MetaFormats\Validators\VUuid; use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; From ef0840eac735b1af56380f904610ab5bf1c61d95 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 12 Jun 2025 17:20:25 +0200 Subject: [PATCH 11/12] added more tests and comments --- app/helpers/Mocks/MockHelper.php | 29 +++++ tests/Validation/BasePresenter.phpt | 193 +++++++++++++++++++++------- tests/Validation/Formats.phpt | 110 +++++++++++----- tests/Validation/Validators.phpt | 13 +- 4 files changed, 268 insertions(+), 77 deletions(-) diff --git a/app/helpers/Mocks/MockHelper.php b/app/helpers/Mocks/MockHelper.php index ce1b93ea4..a0fd66b37 100644 --- a/app/helpers/Mocks/MockHelper.php +++ b/app/helpers/Mocks/MockHelper.php @@ -2,6 +2,8 @@ namespace App\Helpers\Mocks; +use App\Helpers\MetaFormats\FormatCache; +use App\Helpers\MetaFormats\MetaFormatHelper; use App\Helpers\Mocks\MockUserStorage; use App\V1Module\Presenters\BasePresenter; use Nette\Application\Application; @@ -11,6 +13,7 @@ use Nette\Http\UrlScript; use Nette\Security\User; use Nette; +use ReflectionProperty; class MockHelper { @@ -32,4 +35,30 @@ public static function initPresenter(BasePresenter $presenter) $presenter->injectPrimary($httpRequest, $httpResponse, user: $user, templateFactory: $factory); } + + /** + * Injects a Format class to the FormatCache. + * This method must not be used outside of testing, normal Format classes are discovered automatically. + * @param string $format The Format class name. + */ + public static function injectFormat(string $format) + { + // make sure the cache is initialized (it uses lazy loading) + FormatCache::getFormatToFieldDefinitionsMap(); + FormatCache::getFormatNamesHashSet(); + + // inject the format name + $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); + $hashSetReflector->setAccessible(true); + $formatNamesHashSet = $hashSetReflector->getValue(); + $formatNamesHashSet[$format] = true; + $hashSetReflector->setValue(null, $formatNamesHashSet); + + // inject the format definitions + $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); + $formatMapReflector->setAccessible(true); + $formatToFieldFormatsMap = $formatMapReflector->getValue(); + $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); + $formatMapReflector->setValue(null, $formatToFieldFormatsMap); + } } diff --git a/tests/Validation/BasePresenter.phpt b/tests/Validation/BasePresenter.phpt index 8795d35f2..a4fad886c 100644 --- a/tests/Validation/BasePresenter.phpt +++ b/tests/Validation/BasePresenter.phpt @@ -20,6 +20,9 @@ use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; +/** + * A Format class used to test FPost, FPath, FQuery and structure validation. + */ #[Format(ValidationTestFormat::class)] class PresenterTestFormat extends MetaFormat { @@ -32,17 +35,35 @@ class PresenterTestFormat extends MetaFormat #[FQuery(new VInt())] public ?int $query; + // The following properties will not be set in the tests, they are here to check that optional parameters + // can be omitted from the request. + #[FPost(new VInt(), required: false)] + public ?int $postOptional; + + #[FQuery(new VInt(), required: false)] + public ?int $queryOptional; + + /** + * This class requires the query property to be 1. + */ public function validateStructure() { return $this->query == 1; } } +/** + * A Presenter used to test loose attributes, Format attributes, and a combination of both. + */ class TestPresenter extends BasePresenter { #[Post("post", new VInt())] #[Query("query", new VInt())] #[Path("path", new VInt())] + // The following parameters will not be set in the tests, they are here to check that optional parameters + // can be omitted from the request. + #[Post("postOptional", new VInt(), required: false)] + #[Query("queryOptional", new VInt(), required: false)] public function actionTestLoose() { $this->sendSuccessResponse("OK"); @@ -63,9 +84,12 @@ class TestPresenter extends BasePresenter } /** + * This test suite simulates a BasePresenter receiving user requests. + * The tests start by creating a presenter object, defining request data, and running the request. + * The tests include scenarios with both valid and invalid request data. * @testCase */ -class TestPresenter extends Tester\TestCase +class TestBasePresenter extends Tester\TestCase { /** @var Nette\DI\Container */ protected $container; @@ -76,26 +100,13 @@ class TestPresenter extends Tester\TestCase $this->container = $container; } - private static function injectFormat(string $format) + /** + * Injects a Format class to the FormatCache and checks whether it was injected successfully. + * @param string $format The Format class name. + */ + private static function injectFormatChecked(string $format) { - // initialize the cache - FormatCache::getFormatToFieldDefinitionsMap(); - FormatCache::getFormatNamesHashSet(); - - // inject the format name - $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); - $hashSetReflector->setAccessible(true); - $formatNamesHashSet = $hashSetReflector->getValue(); - $formatNamesHashSet[$format] = true; - $hashSetReflector->setValue(null, $formatNamesHashSet); - - // inject the format definitions - $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); - $formatMapReflector->setAccessible(true); - $formatToFieldFormatsMap = $formatMapReflector->getValue(); - $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); - $formatMapReflector->setValue(null, $formatToFieldFormatsMap); - + MockHelper::injectFormat($format); Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); } @@ -121,13 +132,14 @@ class TestPresenter extends Tester\TestCase $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // set an invalid parameter value and assert that the validation fails + // set an invalid parameter value and assert that the validation fails ("path" should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testLoose", "path" => "string", "query" => "1"], post: ["post" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -136,9 +148,30 @@ class TestPresenter extends Tester\TestCase ); } + public function testLooseMissing() + { + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // create a request object + $request = new Request( + "name", + method: "POST", + params: ["action" => "testLoose", "path" => "1", "query" => "1"], + post: [] // missing path parameter + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } + public function testFormatValid() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); @@ -146,38 +179,41 @@ class TestPresenter extends Tester\TestCase $request = new Request( "name", method: "POST", - params: ["action" => "testFormat", "path" => "1", "query" => "1"], - post: ["post" => 1] + params: ["action" => "testFormat", "path" => "2", "query" => "1"], + post: ["post" => 3] ); $response = $presenter->run($request); Assert::equal("OK", $response->getPayload()["payload"]); - + // the presenter should automatically create a valid format object /** @var PresenterTestFormat */ $format = $presenter->getFormatInstance(); Assert::notNull($format); + + // throws when invalid $format->validate(); // check if the values match - Assert::equal($format->path, 1); + Assert::equal($format->path, 2); Assert::equal($format->query, 1); - Assert::equal($format->post, 1); + Assert::equal($format->post, 3); } - public function testFormatInvalidField() + public function testFormatInvalidParameter() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid fields + // create a request object with invalid parameters ("path" should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testFormat", "path" => "string", "query" => "1"], post: ["post" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -186,19 +222,41 @@ class TestPresenter extends Tester\TestCase ); } + public function testFormatMissingParameter() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + $request = new Request( + "name", + method: "POST", + params: ["action" => "testFormat", "query" => "1"], // missing path + post: ["post" => 3] + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } + public function testFormatInvalidStructure() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid structure + // create a request object with invalid structure ("query" has to be 1) $request = new Request( "name", method: "POST", params: ["action" => "testFormat", "path" => "1", "query" => "0"], post: ["post" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -209,7 +267,7 @@ class TestPresenter extends Tester\TestCase public function testCombinedValid() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); @@ -217,8 +275,8 @@ class TestPresenter extends Tester\TestCase $request = new Request( "name", method: "POST", - params: ["action" => "testCombined", "path" => "1", "query" => "1"], - post: ["post" => 1, "loose" => 1] + params: ["action" => "testCombined", "path" => "2", "query" => "1"], + post: ["post" => 3, "loose" => 4] ); $response = $presenter->run($request); Assert::equal("OK", $response->getPayload()["payload"]); @@ -227,27 +285,30 @@ class TestPresenter extends Tester\TestCase /** @var PresenterTestFormat */ $format = $presenter->getFormatInstance(); Assert::notNull($format); + + // throws when invalid $format->validate(); // check if the values match - Assert::equal($format->path, 1); + Assert::equal($format->path, 2); Assert::equal($format->query, 1); - Assert::equal($format->post, 1); + Assert::equal($format->post, 3); } - public function testCombinedInvalidFormatFields() + public function testCombinedInvalidFormatParameters() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid fields + // create a request object with invalid parameters ("path" should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testCombined", "path" => "string", "query" => "1"], post: ["post" => 1, "loose" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -258,17 +319,18 @@ class TestPresenter extends Tester\TestCase public function testCombinedInvalidStructure() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with invalid structure + // create a request object with invalid structure ("query" has to be 1) $request = new Request( "name", method: "POST", params: ["action" => "testCombined", "path" => "1", "query" => "0"], post: ["post" => 1, "loose" => 1] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -279,17 +341,18 @@ class TestPresenter extends Tester\TestCase public function testCombinedInvalidLooseParam() { - self::injectFormat(PresenterTestFormat::class); + self::injectFormatChecked(PresenterTestFormat::class); $presenter = new TestPresenter(); MockHelper::initPresenter($presenter); - // create a request object with an invalid loose parameter + // create a request object with an invalid loose parameter (it should be an int) $request = new Request( "name", method: "POST", params: ["action" => "testCombined", "path" => "1", "query" => "1"], post: ["post" => 1, "loose" => "string"] ); + Assert::throws( function () use ($presenter, $request) { $presenter->run($request); @@ -297,6 +360,48 @@ class TestPresenter extends Tester\TestCase InvalidApiArgumentException::class ); } + + public function testCombinedMissingLooseParam() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + $request = new Request( + "name", + method: "POST", + params: ["action" => "testCombined", "path" => "1", "query" => "1"], + post: ["post" => 1] // missing loose parameter + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } + + public function testCombinedMissingFormatParam() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + $request = new Request( + "name", + method: "POST", + params: ["action" => "testCombined", "path" => "1", "query" => "1"], + post: ["loose" => 1] // missing post parameter + ); + + Assert::throws( + function () use ($presenter, $request) { + $presenter->run($request); + }, + BadRequestException::class + ); + } } (new TestBasePresenter())->run(); diff --git a/tests/Validation/Formats.phpt b/tests/Validation/Formats.phpt index 3f0ddfb1b..8d13a433f 100644 --- a/tests/Validation/Formats.phpt +++ b/tests/Validation/Formats.phpt @@ -12,10 +12,14 @@ use App\Helpers\MetaFormats\MetaFormat; use App\Helpers\MetaFormats\MetaFormatHelper; use App\Helpers\MetaFormats\Validators\VInt; use App\Helpers\MetaFormats\Validators\VObject; +use App\Helpers\Mocks\MockHelper; use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; +/** + * Format used to test nullability and required flags. + */ #[Format(RequiredNullabilityTestFormat::class)] class RequiredNullabilityTestFormat extends MetaFormat { @@ -32,6 +36,9 @@ class RequiredNullabilityTestFormat extends MetaFormat public ?int $notRequiredNullable; } +/** + * Format used to test the Param attributes and structural validation. + */ #[Format(ValidationTestFormat::class)] class ValidationTestFormat extends MetaFormat { @@ -53,6 +60,9 @@ class ValidationTestFormat extends MetaFormat } } +/** + * Format used to test nested Formats. + */ #[Format(ParentFormat::class)] class ParentFormat extends MetaFormat { @@ -68,6 +78,9 @@ class ParentFormat extends MetaFormat } } +/** + * Format used to test nested Formats. + */ #[Format(NestedFormat::class)] class NestedFormat extends MetaFormat { @@ -94,32 +107,23 @@ class TestFormats extends Tester\TestCase $this->container = $container; } - private static function injectFormat(string $format) + /** + * Injects a Format class to the FormatCache and checks whether it was injected successfully. + * @param string $format The Format class name. + */ + private static function injectFormatChecked(string $format) { - // initialize the cache - FormatCache::getFormatToFieldDefinitionsMap(); - FormatCache::getFormatNamesHashSet(); - - // inject the format name - $hashSetReflector = new ReflectionProperty(FormatCache::class, "formatNamesHashSet"); - $hashSetReflector->setAccessible(true); - $formatNamesHashSet = $hashSetReflector->getValue(); - $formatNamesHashSet[$format] = true; - $hashSetReflector->setValue(null, $formatNamesHashSet); - - // inject the format definitions - $formatMapReflector = new ReflectionProperty(FormatCache::class, "formatToFieldFormatsMap"); - $formatMapReflector->setAccessible(true); - $formatToFieldFormatsMap = $formatMapReflector->getValue(); - $formatToFieldFormatsMap[$format] = MetaFormatHelper::createNameToFieldDefinitionsMap($format); - $formatMapReflector->setValue(null, $formatToFieldFormatsMap); - + MockHelper::injectFormat($format); Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); } + /** + * Tests that assigning an unknown Format property throws. + * @return void + */ public function testInvalidFieldName() { - self::injectFormat(RequiredNullabilityTestFormat::class); + self::injectFormatChecked(RequiredNullabilityTestFormat::class); Assert::throws( function () { @@ -135,9 +139,13 @@ class TestFormats extends Tester\TestCase ); } + /** + * Tests that assigning null to a non-nullable property throws. + * @return void + */ public function testRequiredNotNullable() { - self::injectFormat(RequiredNullabilityTestFormat::class); + self::injectFormatChecked(RequiredNullabilityTestFormat::class); $fieldName = "requiredNotNullable"; // it is not nullable so this has to throw @@ -160,9 +168,13 @@ class TestFormats extends Tester\TestCase Assert::equal($format->$fieldName, 1); } + /** + * Tests that assigning null to not-required or nullable properties does not throw. + * @return void + */ public function testNullAssign() { - self::injectFormat(RequiredNullabilityTestFormat::class); + self::injectFormatChecked(RequiredNullabilityTestFormat::class); $format = new RequiredNullabilityTestFormat(); // not required and not nullable fields can contain null (not required overrides not nullable) @@ -177,9 +189,12 @@ class TestFormats extends Tester\TestCase } } + /** + * Test that QUERY and PATH properties use permissive validation (strings castable to ints). + */ public function testIndividualParamValidationPermissive() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); // path and query parameters do not have strict validation @@ -188,17 +203,31 @@ class TestFormats extends Tester\TestCase $format->checkedAssign("path", "1"); $format->checkedAssign("path", 1); - // make sure that the above assignments did not throw - Assert::true(true); + // test that assigning an invalid type still throws (int expected) + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("query", "1.1"); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); } + /** + * Test that PATH parameters use strict validation (strings cannot be passed instead of target types). + */ public function testIndividualParamValidationStrict() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); - // post parameters have strict validation, assigning a string will throw $format->checkedAssign("post", 1); + + // post parameters have strict validation, assigning a string will throw Assert::throws( function () use ($format) { try { @@ -212,9 +241,12 @@ class TestFormats extends Tester\TestCase ); } + /** + * Test that assigning null to a non-nullable field throws. + */ public function testIndividualParamValidationNullable() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); // null cannot be assigned unless the parameter is nullable or not required @@ -232,9 +264,12 @@ class TestFormats extends Tester\TestCase ); } + /** + * Test that the validate function throws with an invalid parameter or failed structural constraint. + */ public function testAggregateParamValidation() { - self::injectFormat(ValidationTestFormat::class); + self::injectFormatChecked(ValidationTestFormat::class); $format = new ValidationTestFormat(); // assign valid values and validate @@ -261,6 +296,8 @@ class TestFormats extends Tester\TestCase // assign valid values to all fields, but fail the structural constraint of $query == 1 $format->checkedAssign("path", 1); + $format->validate(); + $format->checkedAssign("query", 2); Assert::false($format->validateStructure()); Assert::throws( @@ -276,15 +313,18 @@ class TestFormats extends Tester\TestCase ); } + /** + * This test checks that errors in nested Formats propagate to the parent. + */ public function testNestedFormat() { - self::injectFormat(NestedFormat::class); - self::injectFormat(ParentFormat::class); + self::injectFormatChecked(NestedFormat::class); + self::injectFormatChecked(ParentFormat::class); $nested = new NestedFormat(); $parent = new ParentFormat(); // assign valid values that do not pass structural validation - // (both fields need to be 1 to pass) + // (the parent field needs to be 1, the nested field 2) $nested->checkedAssign("field", 0); $parent->checkedAssign("field", 0); $parent->checkedAssign("nested", $nested); @@ -299,6 +339,7 @@ class TestFormats extends Tester\TestCase }, BadRequestException::class ); + // the nested structure should also throw Assert::throws( function () use ($parent) { $parent->validate(); @@ -317,6 +358,11 @@ class TestFormats extends Tester\TestCase }, BadRequestException::class ); + + // fixing the nested structure should make both the nested and parent Format valid + $nested->checkedAssign("field", 2); + $nested->validate(); + $parent->validate(); } } diff --git a/tests/Validation/Validators.phpt b/tests/Validation/Validators.phpt index 832c3652f..5d56ac2a1 100644 --- a/tests/Validation/Validators.phpt +++ b/tests/Validation/Validators.phpt @@ -16,6 +16,9 @@ use Tester\Assert; $container = require_once __DIR__ . "/../bootstrap.php"; /** + * This test suite tests Format validators. + * All tests contain lists of valid/invalid values for both levels of strictness (if applicable), that are tested + * against a specific validator. * @testCase */ class TestValidators extends Tester\TestCase @@ -29,6 +32,14 @@ class TestValidators extends Tester\TestCase $this->container = $container; } + /** + * Helper function that returns readable error messages on failed validations. + * @param BaseValidator $validator The validator to be tested. + * @param mixed $value The value that did not pass. + * @param bool $expectedValid The expected value. + * @param bool $strict The strictness mode. + * @return string Returns an error message. + */ private static function getAssertionFailedMessage( BaseValidator $validator, mixed $value, @@ -61,7 +72,7 @@ class TestValidators extends Tester\TestCase /** * Test a validator against a set of input values. The strictness mode is set automatically by the method. - * @param App\Helpers\MetaFormats\Validators\BaseValidator $validator The validator to be tested. + * @param BaseValidator $validator The validator to be tested. * @param array $strictValid Valid values in the strict mode. * @param array $strictInvalid Invalid values in the strict mode. * @param array $permissiveValid Valid values in the permissive mode. From 21954970e196986cea8b91cf5e3d61725accaac2 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 25 Jun 2025 14:18:56 +0200 Subject: [PATCH 12/12] moved mocks to tests folder --- {app/helpers => tests}/Mocks/MockHelper.php | 8 ++++---- {app/helpers => tests}/Mocks/MockTemplate.php | 2 -- {app/helpers => tests}/Mocks/MockTemplateFactory.php | 2 -- {app/helpers => tests}/Mocks/MockUserStorage.php | 3 --- tests/Validation/BasePresenter.phpt | 4 ++-- tests/Validation/Formats.phpt | 4 ++-- 6 files changed, 8 insertions(+), 15 deletions(-) rename {app/helpers => tests}/Mocks/MockHelper.php (92%) rename {app/helpers => tests}/Mocks/MockTemplate.php (90%) rename {app/helpers => tests}/Mocks/MockTemplateFactory.php (90%) rename {app/helpers => tests}/Mocks/MockUserStorage.php (91%) diff --git a/app/helpers/Mocks/MockHelper.php b/tests/Mocks/MockHelper.php similarity index 92% rename from app/helpers/Mocks/MockHelper.php rename to tests/Mocks/MockHelper.php index a0fd66b37..dc3872b74 100644 --- a/app/helpers/Mocks/MockHelper.php +++ b/tests/Mocks/MockHelper.php @@ -1,10 +1,12 @@