packages/requirements/tests/Unit/Config/SchemaValidatorTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Config\DefinitionReader;
14use Requirements\Config\DocumentReader;
15use Requirements\Config\JsonSchemaFile;
16use Requirements\Config\Loader;
17use Requirements\Config\MarkdownDocument;
18use Requirements\Config\SchemaValidator;
19use Requirements\Input\Fields;
20use Requirements\Input\InvalidInputException;
21use stdClass;
22use Tests\Fake\ProjectDirectory;
23
24#[CoversClass(SchemaValidator::class)]
25#[UsesClass(DefinitionReader::class)]
26#[UsesClass(DocumentReader::class)]
27#[UsesClass(Fields::class)]
28#[UsesClass(JsonSchemaFile::class)]
29#[UsesClass(Loader::class)]
30#[UsesClass(MarkdownDocument::class)]
31#[Small]
32final class SchemaValidatorTest extends TestCase
33{
34 /**
35 * @throws JsonException
36 */
37 public function testValidateAddsLocalSchemaConstraintsToBundledSchema(): void
38 {
39 $project = new ProjectDirectory();
40 file_put_contents($project->directory . '/team.schema.json', '{"type":"object","properties":{"items":{"items":{"required":["category"]}}}}');
41 $data = (new Loader())->document($project->directory . '/definition.yaml');
42 $data['$schema'] = './team.schema.json';
43 $project->write('definition.yaml', $data);
44 $this->expectException(InvalidInputException::class);
45 $this->expectExceptionMessage('category');
46 (new Loader())->load($project->directory . '/requirements.yaml');
47 }
48
49 /**
50 * @throws JsonException
51 */
52 #[DataProvider('providerInvalidDocuments')]
53 public function testValidateRejectsWrongTypesUnknownPropertiesAndDeclarations(string $yaml, string $message): void
54 {
55 $project = new ProjectDirectory();
56 file_put_contents($project->directory . '/definition.yaml', $yaml);
57 $this->expectException(InvalidInputException::class);
58 $this->expectExceptionMessage($message);
59 (new Loader())->load($project->directory . '/requirements.yaml');
60 }
61
62 /**
63 * @return list<array{string, string}>
64 */
65 public static function providerInvalidDocuments(): array
66 {
67 return [
68 ["version: 1\nsource: null\nitems: {}\n", '/items'],
69 ["version: 1\nsource: []\nitems: []\n", '/source'],
70 ["version: 1\nsource: null\nitems: []\nlables: []\n", 'lables'],
71 ["\$schema: missing.json\nversion: 1\nsource: null\nitems: []\n", 'cannot read schema'],
72 ["\$schema: https://example.org/unknown.json\nversion: 1\nsource: null\nitems: []\n", 'unknown $schema'],
73 ["version: '1'\nsource: null\nitems: []\n", '/version'],
74 ];
75 }
76
77 /**
78 * @throws JsonException
79 */
80 #[DataProvider('providerBundledDeclarations')]
81 public function testValidateAcceptsBundledSchemaUri(string $kind, string $file, stdClass $data): void
82 {
83 $project = new ProjectDirectory();
84 (new SchemaValidator())->validate($data, $kind, $project->path($file));
85 $this->addToAssertionCount(1);
86 }
87
88 /**
89 * @return array<string, array{string, string, stdClass}>
90 */
91 public static function providerBundledDeclarations(): array
92 {
93 return [
94 'definition schema' => ['definition', 'definition.yaml', (object) ['$schema' => SchemaValidator::BASE . 'definition.schema.json', 'version' => 1, 'source' => null, 'items' => []]],
95 'definition schema in Markdown' => ['definition', 'definition.md', (object) ['$schema' => SchemaValidator::BASE . 'definition.schema.json', 'version' => 1, 'source' => null, 'items' => []]],
96 'document profile in Markdown' => ['definition', 'definition.md', (object) ['$schema' => SchemaValidator::BASE . 'definition.document.yaml', 'version' => 1, 'source' => null, 'items' => []]],
97 'document profile in upper case Markdown' => ['definition', 'definition.MARKDOWN', (object) ['$schema' => SchemaValidator::BASE . 'definition.document.yaml', 'version' => 1, 'source' => null, 'items' => []]],
98 'configuration schema' => ['config', 'requirements.yaml', (object) ['$schema' => SchemaValidator::BASE . 'config.schema.json', 'version' => 1, 'definitions' => ['definition.yaml']]],
99 ];
100 }
101
102 /**
103 * @throws JsonException
104 */
105 #[DataProvider('providerUnknownDeclarations')]
106 public function testValidateRejectsUnknownRemoteSchema(string $kind, string $file, stdClass $data, string $declared): void
107 {
108 $project = new ProjectDirectory();
109 $this->expectException(InvalidInputException::class);
110 $this->expectExceptionMessage($project->path($file) . ": unknown \$schema '$declared'; use the bundled schema URI or a local JSON Schema path.");
111 (new SchemaValidator())->validate($data, $kind, $project->path($file));
112 }
113
114 /**
115 * @return array<string, array{string, string, stdClass, string}>
116 */
117 public static function providerUnknownDeclarations(): array
118 {
119 return [
120 'other host' => ['definition', 'definition.yaml', (object) ['$schema' => 'https://example.org/unknown.json', 'version' => 1, 'source' => null, 'items' => []], 'https://example.org/unknown.json'],
121 'other scheme' => ['definition', 'definition.yaml', (object) ['$schema' => 'ftp://example.org/definition.schema.json', 'version' => 1, 'source' => null, 'items' => []], 'ftp://example.org/definition.schema.json'],
122 'schema of the other kind' => ['definition', 'definition.yaml', (object) ['$schema' => SchemaValidator::BASE . 'config.schema.json', 'version' => 1, 'source' => null, 'items' => []], SchemaValidator::BASE . 'config.schema.json'],
123 'configuration declaring the definition schema' => ['config', 'requirements.yaml', (object) ['$schema' => SchemaValidator::BASE . 'definition.schema.json', 'version' => 1, 'definitions' => ['definition.yaml']], SchemaValidator::BASE . 'definition.schema.json'],
124 'document profile in YAML' => ['definition', 'definition.yaml', (object) ['$schema' => SchemaValidator::BASE . 'definition.document.yaml', 'version' => 1, 'source' => null, 'items' => []], SchemaValidator::BASE . 'definition.document.yaml'],
125 'document profile for configuration' => ['config', 'requirements.md', (object) ['$schema' => SchemaValidator::BASE . 'definition.document.yaml', 'version' => 1, 'definitions' => ['definition.yaml']], SchemaValidator::BASE . 'definition.document.yaml'],
126 'bundled schema with a prefix' => ['definition', 'definition.yaml', (object) ['$schema' => 'x' . SchemaValidator::BASE . 'definition.schema.json', 'version' => 1, 'source' => null, 'items' => []], 'x' . SchemaValidator::BASE . 'definition.schema.json'],
127 ];
128 }
129
130 /**
131 * @throws JsonException
132 */
133 public function testValidateAcceptsDocumentWithoutDeclaration(): void
134 {
135 $project = new ProjectDirectory();
136 (new SchemaValidator())->validate((object) ['version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('definition.yaml'));
137 $this->addToAssertionCount(1);
138 }
139
140 /**
141 * @throws JsonException
142 */
143 public function testValidateAcceptsRelativeLocalSchema(): void
144 {
145 $project = new ProjectDirectory();
146 $project->put('schemas/team.schema.json', '{"type":"object","required":["items"]}');
147 (new SchemaValidator())->validate((object) ['$schema' => 'schemas/team.schema.json', 'version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('definition.yaml'));
148 $this->addToAssertionCount(1);
149 }
150
151 /**
152 * @throws JsonException
153 */
154 public function testValidateResolvesRelativeSchemaAgainstDocumentDirectory(): void
155 {
156 $project = new ProjectDirectory();
157 $project->put('team.schema.json', '{"type":"object","required":["category"]}');
158 $project->put('nested/team.schema.json', '{"type":"object","required":["team"]}');
159 $this->expectException(InvalidInputException::class);
160 $this->expectExceptionMessage($project->path('nested/definition.yaml') . ': schema validation failed: {"/":["The required properties (team) are missing"]}');
161 (new SchemaValidator())->validate((object) ['$schema' => 'team.schema.json', 'version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('nested/definition.yaml'));
162 }
163
164 /**
165 * @throws JsonException
166 */
167 public function testValidateReadsAbsoluteLocalSchema(): void
168 {
169 $project = new ProjectDirectory();
170 $schema = $project->put('team.schema.json', '{"type":"object","required":["category"]}');
171 $this->expectException(InvalidInputException::class);
172 $this->expectExceptionMessage($project->path('nested/definition.yaml') . ': schema validation failed: {"/":["The required properties (category) are missing"]}');
173 (new SchemaValidator())->validate((object) ['$schema' => $schema, 'version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('nested/definition.yaml'));
174 }
175
176 /**
177 * @throws JsonException
178 */
179 public function testValidateRejectsUnreadableLocalSchema(): void
180 {
181 $project = new ProjectDirectory();
182 $this->expectException(InvalidInputException::class);
183 $this->expectExceptionMessage($project->path('definition.yaml') . ': cannot read schema ' . $project->path('urn:team'));
184 (new SchemaValidator())->validate((object) ['$schema' => 'urn:team', 'version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('definition.yaml'));
185 }
186
187 /**
188 * @throws JsonException
189 */
190 public function testValidateRejectsLocalSchemaThatIsNotJson(): void
191 {
192 $project = new ProjectDirectory();
193 $project->put('team.schema.json', 'type: object');
194 $this->expectException(JsonException::class);
195 (new SchemaValidator())->validate((object) ['$schema' => 'team.schema.json', 'version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('definition.yaml'));
196 }
197
198 /**
199 * @throws JsonException
200 */
201 public function testValidateAppliesBundledSchemaBeforeDeclaredOne(): void
202 {
203 $project = new ProjectDirectory();
204 $this->expectException(InvalidInputException::class);
205 $this->expectExceptionMessage($project->path('definition.yaml') . ': schema validation failed: {"/%24schema":["The data (integer) must match the type: string"]}');
206 (new SchemaValidator())->validate((object) ['$schema' => 1, 'version' => 1, 'source' => null, 'items' => []], 'definition', $project->path('definition.yaml'));
207 }
208
209 /**
210 * @throws JsonException
211 */
212 public function testValidateRejectsDocumentBreakingBundledSchema(): void
213 {
214 $this->expectException(InvalidInputException::class);
215 $this->expectExceptionMessage('requirements.yaml: schema validation failed: {"/":["The required properties (definitions) are missing"]}');
216 (new SchemaValidator())->validate((object) ['version' => 1], 'config', 'requirements.yaml');
217 }
218
219 #[DataProvider('providerBundledSchemas')]
220 public function testPathLocatesBundledSchema(string $name): void
221 {
222 self::assertSame(dirname(__DIR__, 3) . '/schemas/' . $name, SchemaValidator::path($name));
223 self::assertFileExists(SchemaValidator::path($name));
224 }
225
226 /**
227 * @return array<string, array{string}>
228 */
229 public static function providerBundledSchemas(): array
230 {
231 return [
232 'configuration' => ['config.schema.json'],
233 'definition' => ['definition.schema.json'],
234 'document profile' => ['definition.document.yaml'],
235 ];
236 }
237
238 public function testBaseIsPublishedSchemaLocation(): void
239 {
240 self::assertSame('https://raw.githubusercontent.com/k-kinzal/ztd-query-php/main/packages/requirements/schemas/', SchemaValidator::BASE);
241 }
242 /**
243 * @throws JsonException
244 */
245 #[DataProvider('providerInvalidRunPolicies')]
246 public function testRejectsInvalidTestRunPolicies(mixed $run): void
247 {
248 $workspace = new ProjectDirectory();
249 $data = (new Loader())->document($workspace->directory . '/definition.yaml');
250 $data['items'] = [[...ProjectDirectory::item(), 'tests' => [['runner' => 'unit', 'target' => 'Example::testOne', 'run' => $run]]]];
251 $workspace->write('definition.yaml', $data);
252 $this->expectException(InvalidInputException::class);
253 $this->expectExceptionMessage('/items/0/tests/0/run');
254 (new Loader())->load($workspace->directory . '/requirements.yaml');
255 }
256
257 /**
258 * @return list<array{mixed}>
259 */
260 public static function providerInvalidRunPolicies(): array
261 {
262 return [['never'], [''], [false], [null], [1]];
263 }
264}
265