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