packages/requirements/tests/Unit/Source/JsonPathTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Source;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\TestCase;
12use Requirements\Source\JsonPath;
13use RuntimeException;
14
15#[CoversClass(JsonPath::class)]
16#[Small]
17final class JsonPathTest extends TestCase
18{
19 /**
20 * @throws JsonException
21 */
22 public function testSelectRootIsTheWholeDocument(): void
23 {
24 $document = json_decode('{"a":1}', false, 512, JSON_THROW_ON_ERROR);
25
26 self::assertSame([['path' => '', 'value' => $document]], (new JsonPath())->select($document, '$'));
27 }
28
29 /**
30 * @param list<array{path: string, value: mixed}> $expected
31 * @throws JsonException
32 */
33 #[DataProvider('providerSelect')]
34 public function testSelect(string $path, array $expected): void
35 {
36 $document = json_decode('{"rules":[{"text":"First."},{"text":"Second.","note":{"text":"Nested."}}],"a/b~c":"Escaped.","0":"Zero.","other":"Outside."}', true, 512, JSON_THROW_ON_ERROR);
37
38 self::assertSame($expected, (new JsonPath())->select($document, $path));
39 }
40
41 /**
42 * @return array<string, array{string, list<array{path: string, value: mixed}>}>
43 */
44 public static function providerSelect(): array
45 {
46 return [
47 'child name' => ['$.other', [['path' => '/other', 'value' => 'Outside.']]],
48 'index' => ['$.rules[1].text', [['path' => '/rules/1/text', 'value' => 'Second.']]],
49 'single-quoted key' => ["$['rules'][0]['text']", [['path' => '/rules/0/text', 'value' => 'First.']]],
50 'double-quoted key' => ['$["other"]', [['path' => '/other', 'value' => 'Outside.']]],
51 'escaped pointer' => ["$['a/b~c']", [['path' => '/a~1b~0c', 'value' => 'Escaped.']]],
52 'numeric key' => ['$[0]', [['path' => '/0', 'value' => 'Zero.']]],
53 'bracket wildcard' => ['$.rules[*].text', [['path' => '/rules/0/text', 'value' => 'First.'], ['path' => '/rules/1/text', 'value' => 'Second.']]],
54 'dot wildcard' => ['$.rules[1].*', [['path' => '/rules/1/text', 'value' => 'Second.'], ['path' => '/rules/1/note', 'value' => ['text' => 'Nested.']]]],
55 'recursive name' => ['$..text', [['path' => '/rules/0/text', 'value' => 'First.'], ['path' => '/rules/1/text', 'value' => 'Second.'], ['path' => '/rules/1/note/text', 'value' => 'Nested.']]],
56 'recursive after child' => ['$.rules[1]..text', [['path' => '/rules/1/text', 'value' => 'Second.'], ['path' => '/rules/1/note/text', 'value' => 'Nested.']]],
57 'name with digits and dashes' => ['$.rules_2-x', []],
58 'missing name' => ['$.missing', []],
59 'missing index' => ['$.rules[5]', []],
60 'below a scalar' => ['$.other.text', []],
61 ];
62 }
63
64 /**
65 * @throws JsonException
66 */
67 public function testSelectReadsDecodedObjects(): void
68 {
69 $document = json_decode('{"rules":[{"text":"First."}]}', false, 512, JSON_THROW_ON_ERROR);
70
71 self::assertSame([['path' => '/rules/0/text', 'value' => 'First.']], (new JsonPath())->select($document, '$.rules[0].text'));
72 }
73
74 public function testSelectRequiresTheRoot(): void
75 {
76 $this->expectException(RuntimeException::class);
77 $this->expectExceptionMessage('JSONPath must start with $.');
78 (new JsonPath())->select([], 'rules[0]');
79 }
80
81 #[DataProvider('providerSelectUnsupported')]
82 public function testSelectRejectsUnsupportedSyntax(string $path): void
83 {
84 $this->expectException(RuntimeException::class);
85 $this->expectExceptionMessage('Supported JSONPath: child names, quoted keys, array indices, wildcards and recursive names.');
86 (new JsonPath())->select(['rules' => [['text' => 'First.']]], $path);
87 }
88
89 /**
90 * @return array<string, array{string}>
91 */
92 public static function providerSelectUnsupported(): array
93 {
94 return [
95 'filter' => ['$.rules[?(@.text)]'],
96 'negative index' => ['$.rules[-1]'],
97 'slice' => ['$.rules[0:1]'],
98 'name starting with a digit' => ['$.1rules'],
99 'empty quoted key' => ["$['']"],
100 'unterminated bracket' => ['$.rules[0'],
101 'trailing dot' => ['$.rules.'],
102 'triple dot' => ['$...text'],
103 'second root' => ['$$'],
104 'unsupported step after a match' => ['$.rules text'],
105 ];
106 }
107
108 /**
109 * @param list<array{path: string, value: mixed}> $expected
110 */
111 #[DataProvider('providerChildren')]
112 public function testChildren(mixed $value, string $key, bool $recursive, array $expected): void
113 {
114 self::assertSame($expected, (new JsonPath())->children($value, '/root', $key, $recursive));
115 }
116
117 /**
118 * @return array<string, array{mixed, string, bool, list<array{path: string, value: mixed}>}>
119 */
120 public static function providerChildren(): array
121 {
122 return [
123 'scalar' => ['text', '*', true, []],
124 'null' => [null, 'a', false, []],
125 'named child' => [['a' => 1, 'b' => 2], 'b', false, [['path' => '/root/b', 'value' => 2]]],
126 'every child' => [['a' => 1, 'b' => 2], '*', false, [['path' => '/root/a', 'value' => 1], ['path' => '/root/b', 'value' => 2]]],
127 'index' => [['x', 'y'], '1', false, [['path' => '/root/1', 'value' => 'y']]],
128 'not recursive' => [['a' => ['a' => 1]], 'a', false, [['path' => '/root/a', 'value' => ['a' => 1]]]],
129 'recursive' => [['a' => ['a' => 1]], 'a', true, [['path' => '/root/a', 'value' => ['a' => 1]], ['path' => '/root/a/a', 'value' => 1]]],
130 'recursive below a non-match' => [['b' => ['a' => 1]], 'a', true, [['path' => '/root/b/a', 'value' => 1]]],
131 'escaped name' => [['~/' => 1], '~/', false, [['path' => '/root/~0~1', 'value' => 1]]],
132 ];
133 }
134
135 /**
136 * @throws JsonException
137 */
138 public function testChildrenOfAnObject(): void
139 {
140 $value = json_decode('{"a":{"b":1}}', false, 512, JSON_THROW_ON_ERROR);
141
142 self::assertSame([['path' => '/root/a/b', 'value' => 1]], (new JsonPath())->children($value, '/root', 'b', true));
143 }
144}
145