diff --git a/app/helpers/MetaFormats/MetaFormat.php b/app/helpers/MetaFormats/MetaFormat.php index 48f4dcf9..8138eff9 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; } /** diff --git a/tests/Mocks/MockHelper.php b/tests/Mocks/MockHelper.php new file mode 100644 index 00000000..dc3872b7 --- /dev/null +++ b/tests/Mocks/MockHelper.php @@ -0,0 +1,64 @@ +application = $application; + + $factory = new MockTemplateFactory(); + + $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/Mocks/MockTemplate.php b/tests/Mocks/MockTemplate.php new file mode 100644 index 00000000..d8cafea8 --- /dev/null +++ b/tests/Mocks/MockTemplate.php @@ -0,0 +1,20 @@ +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"); + } + + #[Format(PresenterTestFormat::class)] + public function actionTestFormat() + { + $this->sendSuccessResponse("OK"); + } + + #[Format(PresenterTestFormat::class)] + #[Post("loose", new VInt())] + public function actionTestCombined() + { + $this->sendSuccessResponse("OK"); + } +} + +/** + * 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 TestBasePresenter extends Tester\TestCase +{ + /** @var Nette\DI\Container */ + protected $container; + + public function __construct() + { + global $container; + $this->container = $container; + } + + /** + * 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) + { + MockHelper::injectFormat($format); + Assert::notNull(FormatCache::getFieldDefinitions($format), "Tests whether a format was injected successfully."); + } + + public function testLooseValid() + { + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // create a request object + $request = new Request( + "name", + method: "POST", + params: ["action" => "testLoose", "path" => "1", "query" => "1"], + post: ["post" => 1] + ); + + $response = $presenter->run($request); + Assert::equal("OK", $response->getPayload()["payload"]); + } + + public function testLooseInvalid() + { + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // 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); + }, + InvalidApiArgumentException::class + ); + } + + 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::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // create a valid request object + $request = new Request( + "name", + method: "POST", + 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, 2); + Assert::equal($format->query, 1); + Assert::equal($format->post, 3); + } + + public function testFormatInvalidParameter() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // 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); + }, + InvalidApiArgumentException::class + ); + } + + 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::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // 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); + }, + BadRequestException::class + ); + } + + public function testCombinedValid() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // create a valid request object + $request = new Request( + "name", + method: "POST", + params: ["action" => "testCombined", "path" => "2", "query" => "1"], + post: ["post" => 3, "loose" => 4] + ); + $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, 2); + Assert::equal($format->query, 1); + Assert::equal($format->post, 3); + } + + public function testCombinedInvalidFormatParameters() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // 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); + }, + InvalidApiArgumentException::class + ); + } + + public function testCombinedInvalidStructure() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // 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); + }, + BadRequestException::class + ); + } + + public function testCombinedInvalidLooseParam() + { + self::injectFormatChecked(PresenterTestFormat::class); + $presenter = new TestPresenter(); + MockHelper::initPresenter($presenter); + + // 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); + }, + 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 new file mode 100644 index 00000000..b72f8226 --- /dev/null +++ b/tests/Validation/Formats.phpt @@ -0,0 +1,369 @@ +query == 1; + } +} + +/** + * Format used to test nested Formats. + */ +#[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 used to test nested Formats. + */ +#[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 + */ +class TestFormats extends Tester\TestCase +{ + /** @var Nette\DI\Container */ + protected $container; + + public function __construct() + { + global $container; + $this->container = $container; + } + + /** + * 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) + { + 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::injectFormatChecked(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 + ); + } + + /** + * Tests that assigning null to a non-nullable property throws. + * @return void + */ + public function testRequiredNotNullable() + { + self::injectFormatChecked(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); + } + + /** + * Tests that assigning null to not-required or nullable properties does not throw. + * @return void + */ + public function testNullAssign() + { + self::injectFormatChecked(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); + } + } + + /** + * Test that QUERY and PATH properties use permissive validation (strings castable to ints). + */ + public function testIndividualParamValidationPermissive() + { + self::injectFormatChecked(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); + + // 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::injectFormatChecked(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + $format->checkedAssign("post", 1); + + // post parameters have strict validation, assigning a string will throw + Assert::throws( + function () use ($format) { + try { + $format->checkedAssign("post", "1"); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + InvalidApiArgumentException::class + ); + } + + /** + * Test that assigning null to a non-nullable field throws. + */ + public function testIndividualParamValidationNullable() + { + self::injectFormatChecked(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + // 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 + ); + } + + /** + * Test that the validate function throws with an invalid parameter or failed structural constraint. + */ + public function testAggregateParamValidation() + { + self::injectFormatChecked(ValidationTestFormat::class); + $format = new ValidationTestFormat(); + + // assign valid values and validate + $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->validate(); + + $format->checkedAssign("query", 2); + Assert::false($format->validateStructure()); + Assert::throws( + function () use ($format) { + try { + $format->validate(); + } catch (Exception $e) { + Assert::true(strlen($e->getMessage()) > 0); + throw $e; + } + }, + BadRequestException::class + ); + } + + /** + * This test checks that errors in nested Formats propagate to the parent. + */ + public function testNestedFormat() + { + self::injectFormatChecked(NestedFormat::class); + self::injectFormatChecked(ParentFormat::class); + $nested = new NestedFormat(); + $parent = new ParentFormat(); + + // assign valid values that do not pass structural validation + // (the parent field needs to be 1, the nested field 2) + $nested->checkedAssign("field", 0); + $parent->checkedAssign("field", 0); + $parent->checkedAssign("nested", $nested); + + Assert::false($nested->validateStructure()); + Assert::false($parent->validateStructure()); + + // invalid structure should throw during validation + Assert::throws( + function () use ($nested) { + $nested->validate(); + }, + BadRequestException::class + ); + // the nested structure should also throw + 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 + ); + + // fixing the nested structure should make both the nested and parent Format valid + $nested->checkedAssign("field", 2); + $nested->validate(); + $parent->validate(); + } +} + +(new TestFormats())->run(); diff --git a/tests/Validation/Validators.phpt b/tests/Validation/Validators.phpt new file mode 100644 index 00000000..5d56ac2a --- /dev/null +++ b/tests/Validation/Validators.phpt @@ -0,0 +1,258 @@ +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, + 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 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();