packages/requirements/tests/Unit/Model/SourceTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Model;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Small;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use Requirements\Input\Fields;
13use Requirements\Input\InvalidInputException;
14use Requirements\Model\Source;
15
16#[CoversClass(Source::class)]
17#[UsesClass(Fields::class)]
18#[Small]
19final class SourceTest extends TestCase
20{
21    public function testFromReadsTheRequiredFieldsWithDefaults(): void
22    {
23        $source = Source::from(['id' => 'manual', 'uri' => 'https://example.org/manual.html', 'format' => 'html', 'selector' => 'main p']);
24        self::assertSame('manual', $source->id);
25        self::assertSame('https://example.org/manual.html', $source->uri);
26        self::assertSame('html', $source->format);
27        self::assertSame('main p', $source->selector);
28        self::assertNull($source->snapshot);
29        self::assertNull($source->sha256);
30        self::assertSame([], $source->options);
31    }
32
33    public function testFromReadsSnapshotDigestAndOptions(): void
34    {
35        $hash = str_repeat('0a', 32);
36        $source = Source::from(['id' => 'notes', 'uri' => 'notes.txt', 'format' => 'text', 'selector' => 'lines:1-3', 'snapshot' => 'cache/notes.txt', 'sha256' => $hash, 'options' => ['encoding' => 'utf-8']]);
37        self::assertSame('cache/notes.txt', $source->snapshot);
38        self::assertSame($hash, $source->sha256);
39        self::assertSame(['encoding' => 'utf-8'], $source->options);
40    }
41
42    public function testFromAcceptsADigestWithoutSnapshot(): void
43    {
44        $hash = str_repeat('f', 64);
45        $source = Source::from(['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'p', 'sha256' => $hash]);
46        self::assertNull($source->snapshot);
47        self::assertSame($hash, $source->sha256);
48    }
49
50    public function testFromTreatsNullSnapshotAndDigestAsAbsent(): void
51    {
52        $source = Source::from(['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'p', 'snapshot' => null, 'sha256' => null]);
53        self::assertNull($source->snapshot);
54        self::assertNull($source->sha256);
55    }
56
57    /**
58     * @param array<string, mixed> $extra
59     */
60    #[DataProvider('providerFromRejectsSnapshotsWithoutAValidDigest')]
61    public function testFromRejectsSnapshotsWithoutAValidDigest(array $extra): void
62    {
63        $this->expectException(InvalidInputException::class);
64        $this->expectExceptionMessage('A snapshot requires a lowercase SHA-256 digest.');
65        Source::from(['id' => 'manual', 'uri' => 'https://example.org/', 'format' => 'html', 'selector' => 'p', ...$extra]);
66    }
67
68    /**
69     * @return array<string, array{array<string, mixed>}>
70     */
71    public static function providerFromRejectsSnapshotsWithoutAValidDigest(): array
72    {
73        return [
74            'snapshot without digest' => [['snapshot' => 'cache/manual.html']],
75            'uppercase digest' => [['snapshot' => 'cache/manual.html', 'sha256' => str_repeat('A', 64)]],
76            'short digest' => [['sha256' => str_repeat('a', 63)]],
77            'long digest' => [['sha256' => str_repeat('a', 65)]],
78            'digest with newline' => [['sha256' => str_repeat('a', 64) . "\n"]],
79            'digest with prefix' => [['sha256' => 'x' . str_repeat('a', 64)]],
80            'non-hex digest' => [['sha256' => str_repeat('g', 64)]],
81        ];
82    }
83
84    #[DataProvider('providerFromRejectsInvalidMappings')]
85    public function testFromRejectsInvalidMappings(mixed $value, string $message): void
86    {
87        $this->expectException(InvalidInputException::class);
88        $this->expectExceptionMessage($message);
89        Source::from($value);
90    }
91
92    /**
93     * @return array<string, array{mixed, string}>
94     */
95    public static function providerFromRejectsInvalidMappings(): array
96    {
97        $source = ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'p'];
98        return [
99            'not a mapping' => ['source.html', 'source must be a mapping.'],
100            'unknown field' => [[...$source, 'scope' => 'p'], "source: unknown field 'scope'."],
101            'missing id' => [['uri' => 'source.html', 'format' => 'html', 'selector' => 'p'], 'id must be a nonempty string.'],
102            'missing uri' => [['id' => 'manual', 'format' => 'html', 'selector' => 'p'], 'uri must be a nonempty string.'],
103            'missing format' => [['id' => 'manual', 'uri' => 'source.html', 'selector' => 'p'], 'format must be a nonempty string.'],
104            'missing selector' => [['id' => 'manual', 'uri' => 'source.html', 'format' => 'html'], 'selector must be a nonempty string.'],
105            'blank snapshot' => [[...$source, 'snapshot' => ' '], 'snapshot must be a nonempty string.'],
106            'blank digest' => [[...$source, 'sha256' => ''], 'sha256 must be a nonempty string.'],
107            'options not a mapping' => [[...$source, 'options' => ['utf-8']], 'source.options must have string keys.'],
108            'options scalar' => [[...$source, 'options' => 'utf-8'], 'source.options must be a mapping.'],
109        ];
110    }
111}
112