|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * Manifest register-sentinel wiring tests. |
| 5 | + * |
| 6 | + * `src/manifest.json` addresses OpenRegister collections as a |
| 7 | + * `(register, schema)` pair, where the register is written as a runtime |
| 8 | + * sentinel — `"register": "@resolve:voorzieningen_register"`. The sentinel is |
| 9 | + * substituted in the browser by @conduction/nextcloud-vue's |
| 10 | + * `resolveManifestSentinels()`, which reads the value provisioned by |
| 11 | + * `Application::boot()` through `@nextcloud/initial-state`. |
| 12 | + * |
| 13 | + * Two things can go wrong with that arrangement, and NEITHER is visible to any |
| 14 | + * check this repo ran before these tests existed: |
| 15 | + * |
| 16 | + * 1. **A sentinel nobody provisions.** The manifest validator only checks the |
| 17 | + * value's SHAPE, and the gate that cross-references the manifest against |
| 18 | + * the register JSON deliberately skips sentinels — `isLiteralSlug()` in |
| 19 | + * the gate package excludes any value containing `@`. So an unprovisioned |
| 20 | + * key substitutes `null` and the page fetches |
| 21 | + * `/api/objects/null/<schema>`. |
| 22 | + * |
| 23 | + * 2. **A sentinel pointing at a register that does not carry the schema.** |
| 24 | + * This one shipped. The Standards pages read `schema: "element"` while |
| 25 | + * naming `@resolve:voorzieningen_register`, and |
| 26 | + * `lib/Settings/softwarecatalogus_register.json` attaches `element` to the |
| 27 | + * SECOND register in the same file (`vng-gemma` / AMEF), not to |
| 28 | + * `voorzieningen`. Declaring a schema is not attaching it: only an |
| 29 | + * attached schema is fetchable through `/api/objects/{register}/{schema}`, |
| 30 | + * and since OpenRegister's 2026-08-16 change to |
| 31 | + * `ObjectService::setSchema()` an unattached slug THROWS rather than |
| 32 | + * falling back to a global lookup. Every load of `/standaarden` answered |
| 33 | + * `404 {"message":"Schema not found: 'element'"}`. |
| 34 | + * |
| 35 | + * These tests close both holes statically, from the repository's own files. |
| 36 | + * |
| 37 | + * ⚠️ The sentinel → register-slug mapping below is DECLARED HERE, on purpose. |
| 38 | + * Nothing in the app declares it: the register ids are discovered at runtime by |
| 39 | + * `SettingsService::configureVoorzieningen()` / `configureAmef()`, the latter by |
| 40 | + * detecting which register carries the AMEF core schemas. So this map is the |
| 41 | + * written-down intent, and an unmapped sentinel FAILS rather than being skipped |
| 42 | + * — a new sentinel must be a decision, not a silent gap. |
| 43 | + * |
| 44 | + * @category Test |
| 45 | + * @package OCA\SoftwareCatalog\Tests\Unit\AppInfo |
| 46 | + * @author Conduction b.v. <info@conduction.nl> |
| 47 | + * @copyright 2026 Conduction B.V. |
| 48 | + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 |
| 49 | + * @link https://codeberg.org/Conduction/SoftwareCatalog |
| 50 | + */ |
| 51 | + |
| 52 | +declare(strict_types=1); |
| 53 | + |
| 54 | +namespace OCA\SoftwareCatalog\Tests\Unit\AppInfo; |
| 55 | + |
| 56 | +use PHPUnit\Framework\TestCase; |
| 57 | + |
| 58 | +/** |
| 59 | + * Every manifest register sentinel must be provisioned, and must name a |
| 60 | + * register that actually attaches the schema the page asks for. |
| 61 | + */ |
| 62 | +class ManifestRegisterSentinelTest extends TestCase { |
| 63 | + |
| 64 | + /** |
| 65 | + * Sentinel key => the register slug it is expected to resolve to in |
| 66 | + * `lib/Settings/softwarecatalogus_register.json`. |
| 67 | + * |
| 68 | + * @var array<string, string> |
| 69 | + */ |
| 70 | + private const SENTINEL_REGISTERS = [ |
| 71 | + 'voorzieningen_register' => 'voorzieningen', |
| 72 | + 'amef_register' => 'vng-gemma', |
| 73 | + ]; |
| 74 | + |
| 75 | + /** |
| 76 | + * Repository root. |
| 77 | + * |
| 78 | + * @var string |
| 79 | + */ |
| 80 | + private string $root; |
| 81 | + |
| 82 | + /** |
| 83 | + * Decoded `src/manifest.json`. |
| 84 | + * |
| 85 | + * @var array<string, mixed> |
| 86 | + */ |
| 87 | + private array $manifest; |
| 88 | + |
| 89 | + /** |
| 90 | + * Decoded `lib/Settings/softwarecatalogus_register.json`. |
| 91 | + * |
| 92 | + * @var array<string, mixed> |
| 93 | + */ |
| 94 | + private array $registerConfig; |
| 95 | + |
| 96 | + /** |
| 97 | + * Source of `lib/AppInfo/Application.php`. |
| 98 | + * |
| 99 | + * @var string |
| 100 | + */ |
| 101 | + private string $applicationPhp; |
| 102 | + |
| 103 | + /** |
| 104 | + * Load the three artefacts under test. |
| 105 | + * |
| 106 | + * @return void |
| 107 | + */ |
| 108 | + protected function setUp(): void { |
| 109 | + parent::setUp(); |
| 110 | + |
| 111 | + $this->root = dirname(__DIR__, 3); |
| 112 | + |
| 113 | + $manifest = json_decode( |
| 114 | + (string)file_get_contents($this->root . '/src/manifest.json'), |
| 115 | + true |
| 116 | + ); |
| 117 | + $this->assertIsArray($manifest, 'src/manifest.json did not decode'); |
| 118 | + $this->manifest = $manifest; |
| 119 | + |
| 120 | + $registerConfig = json_decode( |
| 121 | + (string)file_get_contents( |
| 122 | + $this->root . '/lib/Settings/softwarecatalogus_register.json' |
| 123 | + ), |
| 124 | + true |
| 125 | + ); |
| 126 | + $this->assertIsArray($registerConfig, 'the register JSON did not decode'); |
| 127 | + $this->registerConfig = $registerConfig; |
| 128 | + |
| 129 | + $this->applicationPhp = (string)file_get_contents( |
| 130 | + $this->root . '/lib/AppInfo/Application.php' |
| 131 | + ); |
| 132 | + }//end setUp() |
| 133 | + |
| 134 | + /** |
| 135 | + * Collect every `(sentinel key, schema slug)` pair the manifest declares. |
| 136 | + * |
| 137 | + * Walks the whole tree rather than just `pages[].config`, because a detail |
| 138 | + * page's widgets carry their own `content.{register,schema}` and those hit |
| 139 | + * the same endpoint. |
| 140 | + * |
| 141 | + * @param mixed $node Current node. |
| 142 | + * @param array<int, array<string>> $out Accumulator, by reference. |
| 143 | + * |
| 144 | + * @return void |
| 145 | + */ |
| 146 | + private function collectPairs($node, array &$out): void { |
| 147 | + if (is_array($node) === false) { |
| 148 | + return; |
| 149 | + } |
| 150 | + |
| 151 | + $register = ($node['register'] ?? null); |
| 152 | + $schema = ($node['schema'] ?? null); |
| 153 | + if (is_string($register) === true |
| 154 | + && is_string($schema) === true |
| 155 | + && str_starts_with($register, '@resolve:') === true |
| 156 | + && $schema !== '' |
| 157 | + ) { |
| 158 | + $out[] = [substr($register, strlen('@resolve:')), $schema]; |
| 159 | + } |
| 160 | + |
| 161 | + foreach ($node as $child) { |
| 162 | + $this->collectPairs($child, $out); |
| 163 | + } |
| 164 | + }//end collectPairs() |
| 165 | + |
| 166 | + /** |
| 167 | + * Every sentinel the manifest uses is provisioned by `Application::boot()`. |
| 168 | + * |
| 169 | + * @return void |
| 170 | + */ |
| 171 | + public function testEverySentinelIsProvisioned(): void { |
| 172 | + $pairs = []; |
| 173 | + $this->collectPairs($this->manifest, $pairs); |
| 174 | + |
| 175 | + $keys = array_values(array_unique(array_column($pairs, 0))); |
| 176 | + sort($keys); |
| 177 | + |
| 178 | + // A zero-pair run would pass every assertion below without checking |
| 179 | + // anything, so state the subject count first. |
| 180 | + $this->assertGreaterThan( |
| 181 | + 0, |
| 182 | + count($keys), |
| 183 | + 'no @resolve: register sentinels found in src/manifest.json — the ' |
| 184 | + . 'collector is broken, not the manifest' |
| 185 | + ); |
| 186 | + |
| 187 | + foreach ($keys as $key) { |
| 188 | + $this->assertStringContainsString( |
| 189 | + "provideInitialState('" . $key . "'", |
| 190 | + $this->applicationPhp, |
| 191 | + sprintf( |
| 192 | + 'src/manifest.json uses "@resolve:%s" but ' |
| 193 | + . 'lib/AppInfo/Application.php::boot() never provisions it, so ' |
| 194 | + . 'it resolves to null and the page fetches ' |
| 195 | + . '/api/objects/null/<schema>.', |
| 196 | + $key |
| 197 | + ) |
| 198 | + ); |
| 199 | + } |
| 200 | + }//end testEverySentinelIsProvisioned() |
| 201 | + |
| 202 | + /** |
| 203 | + * Every `(sentinel, schema)` pair names a register that ATTACHES that |
| 204 | + * schema in the register configuration. |
| 205 | + * |
| 206 | + * @return void |
| 207 | + */ |
| 208 | + public function testEveryPairNamesARegisterThatAttachesTheSchema(): void { |
| 209 | + $registers = ($this->registerConfig['components']['registers'] ?? []); |
| 210 | + $this->assertNotEmpty( |
| 211 | + $registers, |
| 212 | + 'the register JSON declares no registers — the reader is broken' |
| 213 | + ); |
| 214 | + |
| 215 | + $pairs = []; |
| 216 | + $this->collectPairs($this->manifest, $pairs); |
| 217 | + $this->assertGreaterThan(0, count($pairs), 'no (register, schema) pairs collected'); |
| 218 | + |
| 219 | + foreach ($pairs as [$key, $schema]) { |
| 220 | + $this->assertArrayHasKey( |
| 221 | + $key, |
| 222 | + self::SENTINEL_REGISTERS, |
| 223 | + sprintf( |
| 224 | + '"@resolve:%s" is not in this test\'s sentinel map. Add it ' |
| 225 | + . 'together with the register slug it resolves to — an ' |
| 226 | + . 'unmapped sentinel is unchecked, and the defect this test ' |
| 227 | + . 'exists for is exactly a sentinel pointing at the wrong ' |
| 228 | + . 'register.', |
| 229 | + $key |
| 230 | + ) |
| 231 | + ); |
| 232 | + |
| 233 | + $slug = self::SENTINEL_REGISTERS[$key]; |
| 234 | + $this->assertArrayHasKey( |
| 235 | + $slug, |
| 236 | + $registers, |
| 237 | + sprintf('register "%s" is not declared in the register JSON', $slug) |
| 238 | + ); |
| 239 | + |
| 240 | + $attached = ($registers[$slug]['schemas'] ?? []); |
| 241 | + $this->assertContains( |
| 242 | + $schema, |
| 243 | + $attached, |
| 244 | + sprintf( |
| 245 | + 'src/manifest.json reads schema "%s" from "@resolve:%s" ' |
| 246 | + . '(register "%s"), but that register attaches only [%s]. ' |
| 247 | + . 'GET /api/objects/{%s}/%s answers 404 "Schema not found". ' |
| 248 | + . 'Point the page at the register that carries the schema — ' |
| 249 | + . 'attaching the schema to this register instead would make ' |
| 250 | + . 'the request succeed and return NOTHING, because objects ' |
| 251 | + . 'live per register.', |
| 252 | + $schema, |
| 253 | + $key, |
| 254 | + $slug, |
| 255 | + implode(', ', $attached), |
| 256 | + $slug, |
| 257 | + $schema |
| 258 | + ) |
| 259 | + ); |
| 260 | + } |
| 261 | + }//end testEveryPairNamesARegisterThatAttachesTheSchema() |
| 262 | + |
| 263 | + /** |
| 264 | + * Positive control for the check above: the attachment assertion really |
| 265 | + * does reject a schema the register does not carry. |
| 266 | + * |
| 267 | + * Without this, `testEveryPairNamesARegisterThatAttachesTheSchema` passing |
| 268 | + * is equally consistent with the register JSON listing every schema under |
| 269 | + * every register, or with the reader silently yielding an empty list. |
| 270 | + * |
| 271 | + * @return void |
| 272 | + */ |
| 273 | + public function testTheAttachmentCheckCanFail(): void { |
| 274 | + $registers = ($this->registerConfig['components']['registers'] ?? []); |
| 275 | + $schemas = array_keys(($this->registerConfig['components']['schemas'] ?? [])); |
| 276 | + |
| 277 | + $this->assertContains( |
| 278 | + 'element', |
| 279 | + $schemas, |
| 280 | + '"element" is not even declared as a schema — the fixture moved' |
| 281 | + ); |
| 282 | + $this->assertNotContains( |
| 283 | + 'element', |
| 284 | + ($registers['voorzieningen']['schemas'] ?? []), |
| 285 | + '"element" is now attached to voorzieningen. If that was deliberate, ' |
| 286 | + . 'note that it makes the Standards fetch SUCCEED and return an ' |
| 287 | + . 'empty list, because AMEF elements are written to the AMEF ' |
| 288 | + . 'register — a visible error traded for an invisible pass.' |
| 289 | + ); |
| 290 | + $this->assertContains( |
| 291 | + 'element', |
| 292 | + ($registers['vng-gemma']['schemas'] ?? []), |
| 293 | + '"element" is no longer attached to the AMEF register, so the ' |
| 294 | + . 'Standards pages have nowhere to read from' |
| 295 | + ); |
| 296 | + }//end testTheAttachmentCheckCanFail() |
| 297 | +}//end class |
0 commit comments