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