1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
|
<?php
declare(strict_types=1);
namespace JsonSchema\Tests;
use CallbackFilterIterator;
use JsonSchema\Constraints\Constraint;
use JsonSchema\Constraints\Factory;
use JsonSchema\SchemaStorage;
use JsonSchema\SchemaStorageInterface;
use JsonSchema\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class JsonSchemaTestSuiteTest extends TestCase
{
/**
* @dataProvider casesDataProvider
*
* @param \stdClass|bool $schema
* @param mixed $data
*/
#[DataProvider('casesDataProvider')]
public function testTestCaseValidatesCorrectly(
string $testCaseDescription,
string $testDescription,
$schema,
$data,
int $checkMode,
bool $expectedValidationResult,
bool $optional
): void {
$schemaStorage = new SchemaStorage();
$id = is_object($schema) && property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI;
$schemaStorage->addSchema($id, $schema);
$this->loadRemotesIntoStorage($schemaStorage);
$validator = new Validator(new Factory($schemaStorage));
try {
$validator->validate($data, $schema, $checkMode);
} catch (\Exception $e) {
if ($optional) {
$this->markTestSkipped('Optional test case throws exception during validate() invocation: "' . $e->getMessage() . '"');
}
throw $e;
}
if ($optional && $expectedValidationResult !== (count($validator->getErrors()) === 0)) {
$this->markTestSkipped('Optional test case would fail');
}
self::assertEquals(
$expectedValidationResult,
count($validator->getErrors()) === 0,
$expectedValidationResult ? print_r($validator->getErrors(), true) : 'Validator returned valid but the testcase indicates it is invalid'
);
}
public function casesDataProvider(): \Generator
{
$testDir = __DIR__ . '/../vendor/json-schema/json-schema-test-suite/tests';
$drafts = array_filter(glob($testDir . '/*'), static function (string $filename) {
return is_dir($filename);
});
$skippedDrafts = ['draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest'];
foreach ($drafts as $draft) {
if (in_array(basename($draft), $skippedDrafts, true)) {
continue;
}
$files = new CallbackFilterIterator(
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($draft)
),
function ($file) {
return $file->isFile() && strtolower($file->getExtension()) === 'json';
}
);
/** @var \SplFileInfo $file */
foreach ($files as $file) {
$contents = json_decode(file_get_contents($file->getPathname()), false);
foreach ($contents as $testCase) {
foreach ($testCase->tests as $test) {
$name = sprintf(
'[%s/%s%s]: %s: %s is expected to be %s',
basename($draft),
str_contains($file->getPathname(), '/optional/') ? 'optional/' : '',
$file->getBasename(),
$testCase->description,
$test->description,
$test->valid ? 'valid' : 'invalid'
);
if ($this->shouldNotYieldTest($name)) {
continue;
}
yield $name => [
'testCaseDescription' => $testCase->description,
'testDescription' => $test->description,
'schema' => $testCase->schema,
'data' => $test->data,
'checkMode' => $this->getCheckModeForDraft($baseDraftName),
'expectedValidationResult' => $test->valid,
'optional' => str_contains($file->getPathname(), '/optional/')
];
}
}
}
}
}
private function loadRemotesIntoStorage(SchemaStorageInterface $storage): void
{
$remotesDir = __DIR__ . '/../vendor/json-schema/json-schema-test-suite/remotes';
$directory = new \RecursiveDirectoryIterator($remotesDir);
$iterator = new \RecursiveIteratorIterator($directory);
foreach ($iterator as $info) {
if (!$info->isFile()) {
continue;
}
$id = str_replace($remotesDir, 'http://localhost:1234', $info->getPathname());
$storage->addSchema($id, json_decode(file_get_contents($info->getPathname()), false));
}
}
private function shouldNotYieldTest(string $name): bool
{
$skip = [
'[draft4/ref.json]: refs with quote: object with numbers is valid is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: refs with quote: object with strings is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: Location-independent identifier: match is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: Location-independent identifier: mismatch is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: Location-independent identifier with base URI change in subschema: match is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: Location-independent identifier with base URI change in subschema: mismatch is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: id must be resolved against nearest parent, not just immediate parent: number is valid is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: id must be resolved against nearest parent, not just immediate parent: non-number is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: empty tokens in $ref json-pointer: number is valid is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/ref.json]: empty tokens in $ref json-pointer: non-number is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft4/refRemote.json]: base URI change - change folder: number is valid is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft4/refRemote.json]: Location-independent identifier in remote ref: integer is valid is expected to be valid', // Test case was added after v1.2.0, skip test for now.
'[draft4/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft6/ref.json]: Location-independent identifier with base URI change in subschema: mismatch is expected to be invalid', // Test case was added after v1.2.0, skip test for now.
'[draft6/ref.json]: Location-independent identifier: mismatch is expected to be invalid', // Same test case is skipped for draft4, skip for now as well.
'[draft6/ref.json]: refs with quote: object with strings is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well.
'[draft6/ref.json]: empty tokens in $ref json-pointer: non-number is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well.
'[draft6/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well.
'[draft6/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well.
// Skipping complex edge cases for now
'[draft6/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches second anyOf, which has a real schema in it is expected to be valid',
'[draft6/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches non-schema in third anyOf is expected to be invalid',
'[draft6/refRemote.json]: $ref to $ref finds location-independent $id: non-number is invalid is expected to be invalid',
'[draft6/ref.json]: ref overrides any sibling keywords: ref valid, maxItems ignored is expected to be valid',
'[draft6/ref.json]: Reference an anchor with a non-relative URI: mismatch is expected to be invalid',
'[draft6/ref.json]: refs with relative uris and defs: invalid on inner field is expected to be invalid',
'[draft6/ref.json]: refs with relative uris and defs: invalid on outer field is expected to be invalid',
'[draft6/ref.json]: relative refs with absolute uris and defs: invalid on inner field is expected to be invalid',
'[draft6/ref.json]: relative refs with absolute uris and defs: invalid on outer field is expected to be invalid',
'[draft6/ref.json]: simple URN base URI with JSON pointer: a non-string is invalid is expected to be invalid',
'[draft6/ref.json]: URN base URI with NSS: a non-string is invalid is expected to be invalid',
'[draft6/ref.json]: URN base URI with r-component: a non-string is invalid is expected to be invalid',
'[draft6/ref.json]: URN base URI with q-component: a non-string is invalid is expected to be invalid',
'[draft6/ref.json]: URN base URI with URN and anchor ref: a non-string is invalid is expected to be invalid',
];
if ($this->is32Bit()) {
$skip[] = '[draft4/multipleOf.json]: small multiple of large integer: any integer is a multiple of 1e-8 is expected to be valid'; // Test case contains a number which doesn't fit in 32 bits
}
return in_array($name, $skip, true);
}
private function is32Bit(): bool
{
return PHP_INT_SIZE === 4;
}
/**
* @phpstan-return int-mask-of<Validator::ERROR_*>
*/
private function getCheckModeForDraft(string $draft): int
{
switch ($draft) {
case 'draft6':
return Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT;
default:
return Constraint::CHECK_MODE_NORMAL;
}
}
}
|