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