diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e37b490b..e41a15d5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -856,8 +856,65 @@ public function boot(IBootContext $context): void { } $initialState->provideInitialState('voorzieningen_register', $provisioned); + + // The SECOND register this app owns. lib/Settings/softwarecatalogus_register.json + // declares two: `voorzieningen` (15 schemas) and `vng-gemma` / AMEF + // (element, model, property-definition, relation, view). A schema is only + // fetchable through /api/objects/{register}/{schema} from a register that + // ATTACHES it — OpenRegister's ObjectService::setSchema() resolves a slug + // register-scoped and, since its 2026-08-16 change, THROWS on a scoped miss + // instead of falling back to a global lookup. So the manifest pages whose + // schema lives in the AMEF register (Standaarden / StandaardDetail, both + // `schema: element`) need their OWN sentinel; pointing them at + // `voorzieningen_register` yields `404 {"message":"Schema not found: + // 'element'"}` on every fetch. + // + // Its canonical home is the `amef_config` JSON blob written by + // SettingsService::configureAmef(), exactly as `voorzieningen_config` is for + // the register above. configureAmef() detects the register by the PRESENCE of + // the AMEF core schemas rather than by slug, which is the property this + // sentinel needs — whatever it selects is by construction a register that + // carries `element`. + $amefRegisterId = $this->resolveAmefRegisterId(appConfig: $appConfig); + $amefProvisioned = null; + if ($amefRegisterId !== '') { + $amefProvisioned = $amefRegisterId; + } + + $initialState->provideInitialState('amef_register', $amefProvisioned); }//end boot() + /** + * Resolve the numeric AMEF (vng-gemma) register id from the canonical config. + * + * Mirrors resolveVoorzieningenRegisterId() below, over the AMEF key family. + * Resolution order: + * 1. `amef_config` JSON blob's `register` field — the canonical home written + * by SettingsService::setAmefConfig() from configureAmef(). + * 2. The same blob's `register_id` field — the legacy shape + * SettingsService::getAmefConfig() assembles when the blob is absent. + * 3. The flat `amef_register_id` scalar key — legacy fallback. + * + * @param IAppConfig $appConfig The app config service. + * + * @return string The numeric register id, or '' when none is configured. + */ + private function resolveAmefRegisterId(IAppConfig $appConfig): string { + $configJson = $appConfig->getValueString(self::APP_ID, 'amef_config', ''); + if ($configJson !== '') { + $decoded = json_decode($configJson, true); + if (is_array($decoded) === true) { + foreach (['register', 'register_id'] as $key) { + if (isset($decoded[$key]) === true && $decoded[$key] !== '') { + return (string)$decoded[$key]; + } + } + } + } + + return $appConfig->getValueString(self::APP_ID, 'amef_register_id', ''); + }//end resolveAmefRegisterId() + /** * Resolve the numeric voorzieningen register id from the canonical config. * diff --git a/src/manifest.json b/src/manifest.json index f36b2708..b190a8d0 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -489,7 +489,7 @@ "type": "index", "title": "Standards", "config": { - "register": "@resolve:voorzieningen_register", + "register": "@resolve:amef_register", "schema": "element", "filter": { "gemmaType": "standaard" }, "columns": [ @@ -504,7 +504,7 @@ }, "documentationUrl": "https://softwarecatalog.conduction.nl" }, - "_note": "The register has NO dedicated 'standard' schema — GEMMA standards are `element` objects (the generic ArchiMate/GEMMA model-element schema, shared with referentiecomponenten, applications, capabilities, …) discriminated by `gemmaType`. Fixed a pre-existing bug: this page previously pointed at a non-existent `schema: \"standaard\"`, which would 404 on every fetch. Scoped with `filter.gemmaType: \"standaard\"` (see lib/Service/ArchiMateImportService.php's `extractGemmaType` comparisons and its `?gemmaType=referentiecomponent` query comment) so only standard-typed elements list here." + "_note": "The register has NO dedicated 'standard' schema — GEMMA standards are `element` objects (the generic ArchiMate/GEMMA model-element schema, shared with referentiecomponenten, applications, capabilities, …) discriminated by `gemmaType`. Fixed a pre-existing bug: this page previously pointed at a non-existent `schema: \"standaard\"`, which would 404 on every fetch. Scoped with `filter.gemmaType: \"standaard\"` (see lib/Service/ArchiMateImportService.php's `extractGemmaType` comparisons and its `?gemmaType=referentiecomponent` query comment) so only standard-typed elements list here. REGISTER FIX (measured): `element` is declared under `components.registers.vng-gemma.schemas`, NOT under `voorzieningen`, so `@resolve:voorzieningen_register` produced `GET /api/objects//element -> 404 {\"message\":\"Schema not found: 'element'\"}` on every load (CI trace, run 31981873526). Attaching `element` to `voorzieningen` was rejected deliberately: the request would then succeed and return an EMPTY list, because objects live per register and the AMEF elements are written to the AMEF one — a visible error traded for an invisible pass. Repointed at `@resolve:amef_register`, the second sentinel, provisioned in lib/AppInfo/Application.php::boot() from the `amef_config` blob exactly as `voorzieningen_register` is from `voorzieningen_config`." }, { "id": "StandaardDetail", @@ -512,9 +512,9 @@ "type": "detail", "title": "Standard", "config": { - "register": "@resolve:voorzieningen_register", + "register": "@resolve:amef_register", "schema": "element", - "_note": "Catalog archetype (a normative GEMMA standard). What matters about a standard is (a) its definition, (b) the specification documents, and (c) the ecosystem around it: which modules implement it and what compliance records claim it. Body: standard data 8-wide top-left (name, gemmaThema, gemmaStatus, documentation, url, versieaanduiding, beschikbaarheid — the `element` schema's GEMMA-standard fields); a Specification documents (files) panel 4-wide top-right for the published spec/PDFs. Below, a Related panel surfaces the element's own FK web (standaardVersies, aanbevolenVoorReferentiecomponent, verplichteVoorReferentiecomponent, gekoppeldeStandaardVersies — all self-references into other `element` objects), then a compliancy object-list shows every compliance claim naming this standard (compliancy.standaardGemma matched to the standard's own `name`) so a reader sees adoption at a glance. FIX: the register has no dedicated 'standard' schema — this page previously pointed at a non-existent `schema: \"standaard\"` (guaranteed fetch failure); corrected to `element` (the shared GEMMA/ArchiMate element schema, discriminated by `gemmaType`). A standard does not communicate, so per the comms hard-rule NO Emails/Meetings/Talk widgets appear. Audit trail stays a sidebar tab.", + "_note": "Catalog archetype (a normative GEMMA standard). What matters about a standard is (a) its definition, (b) the specification documents, and (c) the ecosystem around it: which modules implement it and what compliance records claim it. Body: standard data 8-wide top-left (name, gemmaThema, gemmaStatus, documentation, url, versieaanduiding, beschikbaarheid — the `element` schema's GEMMA-standard fields); a Specification documents (files) panel 4-wide top-right for the published spec/PDFs. Below, a Related panel surfaces the element's own FK web (standaardVersies, aanbevolenVoorReferentiecomponent, verplichteVoorReferentiecomponent, gekoppeldeStandaardVersies — all self-references into other `element` objects), then a compliancy object-list shows every compliance claim naming this standard (compliancy.standaardGemma matched to the standard's own `name`) so a reader sees adoption at a glance. FIX: the register has no dedicated 'standard' schema — this page previously pointed at a non-existent `schema: \"standaard\"` (guaranteed fetch failure); corrected to `element` (the shared GEMMA/ArchiMate element schema, discriminated by `gemmaType`). A standard does not communicate, so per the comms hard-rule NO Emails/Meetings/Talk widgets appear. Audit trail stays a sidebar tab. REGISTER FIX: the page-level register moved from `@resolve:voorzieningen_register` to `@resolve:amef_register` for the same reason as the Standaarden index — `element` is attached to the AMEF register, not to voorzieningen, and an unattached slug now throws rather than resolving globally. The nested `st-compliance` object-list deliberately KEEPS `@resolve:voorzieningen_register`: `compliancy` really does live in voorzieningen, and a detail page's widgets carry their own register.", "widgets": [ { "id": "st-data", "type": "data", "title": "Standard", "icon": "ShieldCheckOutline", "content": { "columns": 2 } }, { "id": "st-files", "type": "integration", "integrationId": "files", "title": "Specification documents", "icon": "FolderOutline" }, diff --git a/tests/Unit/AppInfo/ManifestRegisterSentinelTest.php b/tests/Unit/AppInfo/ManifestRegisterSentinelTest.php new file mode 100644 index 00000000..f52fb2ea --- /dev/null +++ b/tests/Unit/AppInfo/ManifestRegisterSentinelTest.php @@ -0,0 +1,297 @@ +`. + * + * 2. **A sentinel pointing at a register that does not carry the schema.** + * This one shipped. The Standards pages read `schema: "element"` while + * naming `@resolve:voorzieningen_register`, and + * `lib/Settings/softwarecatalogus_register.json` attaches `element` to the + * SECOND register in the same file (`vng-gemma` / AMEF), not to + * `voorzieningen`. Declaring a schema is not attaching it: only an + * attached schema is fetchable through `/api/objects/{register}/{schema}`, + * and since OpenRegister's 2026-08-16 change to + * `ObjectService::setSchema()` an unattached slug THROWS rather than + * falling back to a global lookup. Every load of `/standaarden` answered + * `404 {"message":"Schema not found: 'element'"}`. + * + * These tests close both holes statically, from the repository's own files. + * + * ⚠️ The sentinel → register-slug mapping below is DECLARED HERE, on purpose. + * Nothing in the app declares it: the register ids are discovered at runtime by + * `SettingsService::configureVoorzieningen()` / `configureAmef()`, the latter by + * detecting which register carries the AMEF core schemas. So this map is the + * written-down intent, and an unmapped sentinel FAILS rather than being skipped + * — a new sentinel must be a decision, not a silent gap. + * + * @category Test + * @package OCA\SoftwareCatalog\Tests\Unit\AppInfo + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\AppInfo; + +use PHPUnit\Framework\TestCase; + +/** + * Every manifest register sentinel must be provisioned, and must name a + * register that actually attaches the schema the page asks for. + */ +class ManifestRegisterSentinelTest extends TestCase { + + /** + * Sentinel key => the register slug it is expected to resolve to in + * `lib/Settings/softwarecatalogus_register.json`. + * + * @var array + */ + private const SENTINEL_REGISTERS = [ + 'voorzieningen_register' => 'voorzieningen', + 'amef_register' => 'vng-gemma', + ]; + + /** + * Repository root. + * + * @var string + */ + private string $root; + + /** + * Decoded `src/manifest.json`. + * + * @var array + */ + private array $manifest; + + /** + * Decoded `lib/Settings/softwarecatalogus_register.json`. + * + * @var array + */ + private array $registerConfig; + + /** + * Source of `lib/AppInfo/Application.php`. + * + * @var string + */ + private string $applicationPhp; + + /** + * Load the three artefacts under test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->root = dirname(__DIR__, 3); + + $manifest = json_decode( + (string)file_get_contents($this->root . '/src/manifest.json'), + true + ); + $this->assertIsArray($manifest, 'src/manifest.json did not decode'); + $this->manifest = $manifest; + + $registerConfig = json_decode( + (string)file_get_contents( + $this->root . '/lib/Settings/softwarecatalogus_register.json' + ), + true + ); + $this->assertIsArray($registerConfig, 'the register JSON did not decode'); + $this->registerConfig = $registerConfig; + + $this->applicationPhp = (string)file_get_contents( + $this->root . '/lib/AppInfo/Application.php' + ); + }//end setUp() + + /** + * Collect every `(sentinel key, schema slug)` pair the manifest declares. + * + * Walks the whole tree rather than just `pages[].config`, because a detail + * page's widgets carry their own `content.{register,schema}` and those hit + * the same endpoint. + * + * @param mixed $node Current node. + * @param array> $out Accumulator, by reference. + * + * @return void + */ + private function collectPairs($node, array &$out): void { + if (is_array($node) === false) { + return; + } + + $register = ($node['register'] ?? null); + $schema = ($node['schema'] ?? null); + if (is_string($register) === true + && is_string($schema) === true + && str_starts_with($register, '@resolve:') === true + && $schema !== '' + ) { + $out[] = [substr($register, strlen('@resolve:')), $schema]; + } + + foreach ($node as $child) { + $this->collectPairs($child, $out); + } + }//end collectPairs() + + /** + * Every sentinel the manifest uses is provisioned by `Application::boot()`. + * + * @return void + */ + public function testEverySentinelIsProvisioned(): void { + $pairs = []; + $this->collectPairs($this->manifest, $pairs); + + $keys = array_values(array_unique(array_column($pairs, 0))); + sort($keys); + + // A zero-pair run would pass every assertion below without checking + // anything, so state the subject count first. + $this->assertGreaterThan( + 0, + count($keys), + 'no @resolve: register sentinels found in src/manifest.json — the ' + . 'collector is broken, not the manifest' + ); + + foreach ($keys as $key) { + $this->assertStringContainsString( + "provideInitialState('" . $key . "'", + $this->applicationPhp, + sprintf( + 'src/manifest.json uses "@resolve:%s" but ' + . 'lib/AppInfo/Application.php::boot() never provisions it, so ' + . 'it resolves to null and the page fetches ' + . '/api/objects/null/.', + $key + ) + ); + } + }//end testEverySentinelIsProvisioned() + + /** + * Every `(sentinel, schema)` pair names a register that ATTACHES that + * schema in the register configuration. + * + * @return void + */ + public function testEveryPairNamesARegisterThatAttachesTheSchema(): void { + $registers = ($this->registerConfig['components']['registers'] ?? []); + $this->assertNotEmpty( + $registers, + 'the register JSON declares no registers — the reader is broken' + ); + + $pairs = []; + $this->collectPairs($this->manifest, $pairs); + $this->assertGreaterThan(0, count($pairs), 'no (register, schema) pairs collected'); + + foreach ($pairs as [$key, $schema]) { + $this->assertArrayHasKey( + $key, + self::SENTINEL_REGISTERS, + sprintf( + '"@resolve:%s" is not in this test\'s sentinel map. Add it ' + . 'together with the register slug it resolves to — an ' + . 'unmapped sentinel is unchecked, and the defect this test ' + . 'exists for is exactly a sentinel pointing at the wrong ' + . 'register.', + $key + ) + ); + + $slug = self::SENTINEL_REGISTERS[$key]; + $this->assertArrayHasKey( + $slug, + $registers, + sprintf('register "%s" is not declared in the register JSON', $slug) + ); + + $attached = ($registers[$slug]['schemas'] ?? []); + $this->assertContains( + $schema, + $attached, + sprintf( + 'src/manifest.json reads schema "%s" from "@resolve:%s" ' + . '(register "%s"), but that register attaches only [%s]. ' + . 'GET /api/objects/{%s}/%s answers 404 "Schema not found". ' + . 'Point the page at the register that carries the schema — ' + . 'attaching the schema to this register instead would make ' + . 'the request succeed and return NOTHING, because objects ' + . 'live per register.', + $schema, + $key, + $slug, + implode(', ', $attached), + $slug, + $schema + ) + ); + } + }//end testEveryPairNamesARegisterThatAttachesTheSchema() + + /** + * Positive control for the check above: the attachment assertion really + * does reject a schema the register does not carry. + * + * Without this, `testEveryPairNamesARegisterThatAttachesTheSchema` passing + * is equally consistent with the register JSON listing every schema under + * every register, or with the reader silently yielding an empty list. + * + * @return void + */ + public function testTheAttachmentCheckCanFail(): void { + $registers = ($this->registerConfig['components']['registers'] ?? []); + $schemas = array_keys(($this->registerConfig['components']['schemas'] ?? [])); + + $this->assertContains( + 'element', + $schemas, + '"element" is not even declared as a schema — the fixture moved' + ); + $this->assertNotContains( + 'element', + ($registers['voorzieningen']['schemas'] ?? []), + '"element" is now attached to voorzieningen. If that was deliberate, ' + . 'note that it makes the Standards fetch SUCCEED and return an ' + . 'empty list, because AMEF elements are written to the AMEF ' + . 'register — a visible error traded for an invisible pass.' + ); + $this->assertContains( + 'element', + ($registers['vng-gemma']['schemas'] ?? []), + '"element" is no longer attached to the AMEF register, so the ' + . 'Standards pages have nowhere to read from' + ); + }//end testTheAttachmentCheckCanFail() +}//end class diff --git a/tests/e2e/ci-seed.sh b/tests/e2e/ci-seed.sh index 39617f01..63bc63b7 100755 --- a/tests/e2e/ci-seed.sh +++ b/tests/e2e/ci-seed.sh @@ -113,7 +113,12 @@ verify() { import json, sys path, kind = sys.argv[1], sys.argv[2] required = { - 'registers': ['voorzieningen'], + # `vng-gemma` (title "AMEF") is the SECOND register this app declares. It + # carries element / model / property-definition / relation / view, and the + # Standaarden + StandaardDetail manifest pages read `element` from it via the + # `@resolve:amef_register` sentinel. It was previously unchecked here, so an + # import that produced only `voorzieningen` reported a clean seed. + 'registers': ['voorzieningen', 'vng-gemma'], # The schemas the e2e fixtures create/read through, per _fixtures.ts # (organization, contactPerson, module, contract, moduleVersion) plus the # ones the spec-coverage index pages render. @@ -192,6 +197,177 @@ if missing: print('[ci-seed] app-level voorzieningen mapping OK.') PY +# ── 3b. The AMEF register: resolve it, PROBE IT, and give it rows ──────────── +# The Standaarden / StandaardDetail manifest pages read `schema: element`, which +# lib/Settings/softwarecatalogus_register.json attaches to the `vng-gemma` (AMEF) +# register and NOT to `voorzieningen`. They resolve it through the +# `@resolve:amef_register` sentinel that lib/AppInfo/Application.php::boot() +# provisions from the `amef_config` blob. +# +# ⚠️ THE CHECK ABOVE CANNOT CATCH THE FAILURE THIS ONE EXISTS FOR. Verifying that +# a schema slug is PRESENT in /api/schemas is a different question from whether it +# is ATTACHED to the register you are about to address, and only the second one +# decides whether /api/objects/{register}/{schema} answers. OpenRegister used to +# fall back to a global slug lookup on a scoped miss; since 2026-08-16 it throws, +# so an unattached slug returns `404 {"message":"Schema not found: 'element'"}`. +# The only honest probe is the request the page itself makes — so we make it. +# +# And a 200 is still not enough. An empty list renders as a legitimate "No items +# found", i.e. a page that is broken and quiet looks exactly like a page that is +# healthy and unpopulated. GEMMA elements normally arrive via the ArchiMate import +# of a multi-megabyte GEMMA_release.xml, which no CI job runs, so we seed two +# `element` fixtures here — one `gemmaType: standaard` (which the page MUST list) +# and one `gemmaType: referentiecomponent` (which it must NOT). The pair makes the +# page's own `filter.gemmaType` falsifiable in both directions. +AMEF_BODY="$(mktemp)" +curl -sS -u "${USER_NAME}:${USER_PASS}" -H 'OCS-APIRequest: true' \ + "${BASE}/index.php/apps/softwarecatalog/api/amef/config" -o "$AMEF_BODY" + +AMEF_REGISTER="$( + python3 - "$AMEF_BODY" <<'PY' +import json, sys +with open(sys.argv[1]) as fh: + raw = fh.read() +try: + body = json.loads(raw) +except json.JSONDecodeError: + print('::error::amef/config did not return JSON. First 500 bytes:', file=sys.stderr) + print(raw[:500], file=sys.stderr) + sys.exit(1) +config = (body or {}).get('config') or {} +print(f'[ci-seed] amef config: {json.dumps(config)[:400]}', file=sys.stderr) +register = str(config.get('register') or config.get('register_id') or '') +if not register: + print('::error::softwarecatalog has no AMEF register mapping — the ' + '@resolve:amef_register sentinel would resolve to null and the ' + 'Standards pages would fetch /api/objects/@resolve:amef_register/element.', + file=sys.stderr) + sys.exit(1) +if not config.get('element_schema'): + print('::error::the AMEF config names a register but no element schema.', file=sys.stderr) + sys.exit(1) +print(register) +PY +)" +echo "[ci-seed] amef register id: ${AMEF_REGISTER}" + +# The page's own request, verbatim. A non-200 here is the whole defect this +# section guards, so report the status AND the body — "Schema not found" and +# "Register not found" are different faults with different fixes. +ELEM_BODY="$(mktemp)" +ELEM_CODE="$( + curl -sS -o "$ELEM_BODY" -w '%{http_code}' \ + -u "${USER_NAME}:${USER_PASS}" -H 'OCS-APIRequest: true' \ + "${BASE}/index.php/apps/openregister/api/objects/${AMEF_REGISTER}/element?_limit=1" || echo 000 +)" +echo "[ci-seed] GET /api/objects/${AMEF_REGISTER}/element -> ${ELEM_CODE}" +if [ "$ELEM_CODE" != "200" ]; then + head -c 500 "$ELEM_BODY"; echo + echo "::error::The AMEF register does not carry the 'element' schema (HTTP ${ELEM_CODE})." + echo "::error::Declaring a schema does not attach it — check components.registers.vng-gemma.schemas in lib/Settings/softwarecatalogus_register.json." + exit 1 +fi + +# Seed the two fixtures, idempotently. `identifier` is a declared property, so it +# is addressable as a bare query filter (the HTTP list API takes bare property +# names; `filters[identifier]=…` matches nothing). Verified with a negative +# control: an unknown identifier returns total 0, so a hit really is a hit. +python3 - "$BASE" "$USER_NAME" "$USER_PASS" "$AMEF_REGISTER" <<'PY' +import base64, json, sys, urllib.error, urllib.parse, urllib.request + +base, user, password, register = sys.argv[1:5] +auth = base64.b64encode(f'{user}:{password}'.encode()).decode() +collection = f'{base}/index.php/apps/openregister/api/objects/{register}/element' + +# `identifier`, `type` and `properties` are the element schema's required fields +# and it runs with hardValidation on, so all three must be present or the create +# is refused. +FIXTURES = [ + { + 'identifier': 'e2e-gemma-standaard-digikoppeling', + 'type': 'Standard', + 'properties': [], + 'name': 'Digikoppeling', + 'gemmaType': 'standaard', + 'gemmaThema': 'Gegevensuitwisseling', + 'gemmaStatus': 'In gebruik', + 'url': 'https://gemmaonline.nl/index.php/Digikoppeling', + }, + { + # The discriminator control. Same register, same schema, different + # gemmaType — so it is listed by an unfiltered page and hidden by a + # correctly filtered one. Without it, `filter.gemmaType` could be dropped + # entirely and every assertion would still pass. + 'identifier': 'e2e-gemma-referentiecomponent-zaakregistratie', + 'type': 'ApplicationComponent', + 'properties': [], + 'name': 'Zaakregistratiecomponent', + 'gemmaType': 'referentiecomponent', + 'gemmaThema': 'Zaakgericht werken', + }, +] + + +def request(url, method='GET', payload=None): + data = None + headers = {'Authorization': f'Basic {auth}', 'OCS-APIRequest': 'true'} + if payload is not None: + data = json.dumps(payload).encode() + headers['Content-Type'] = 'application/json' + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode() or '{}') + + +def count(query): + url = f'{collection}?{urllib.parse.urlencode(query)}' + _, body = request(url) + return body.get('total', 0), body + + +for fixture in FIXTURES: + existing, _ = count({'_limit': 1, 'identifier': fixture['identifier']}) + if existing: + print(f"[ci-seed] element fixture already present: {fixture['identifier']}") + continue + try: + status, _ = request(collection, method='POST', payload=fixture) + except urllib.error.HTTPError as exc: + print(f"::error::Creating element fixture {fixture['identifier']} failed " + f'(HTTP {exc.code}): {exc.read()[:300]!r}') + sys.exit(1) + print(f"[ci-seed] created element fixture {fixture['identifier']} (HTTP {status})") + +# Verify with a FRESH read, never with the create response — OpenRegister echoes +# back properties it discarded, so a save response cannot tell you what was +# stored. Three assertions, of which the middle one is the point: the filtered +# query must be strictly smaller than the unfiltered one. +total_all, _ = count({'_limit': 50}) +total_std, listed = count({'_limit': 50, 'gemmaType': 'standaard'}) +total_none, _ = count({'_limit': 50, 'gemmaType': 'NO-SUCH-GEMMA-TYPE'}) + +names = sorted(str(r.get('name') or '') for r in listed.get('results', [])) +print(f'[ci-seed] element objects: {total_all} total, {total_std} with ' + f'gemmaType=standaard, {total_none} with a nonsense gemmaType') +print(f'[ci-seed] standards the index page will list: {names}') + +if total_std < 1: + print('::error::No element object carries gemmaType=standaard, so the ' + 'Standards index would render its empty state. A quiet empty page is ' + 'the failure this seed exists to prevent.') + sys.exit(1) +if total_none != 0: + print('::error::A nonsense gemmaType still matched rows — the bare-property ' + 'filter is not filtering, so "the page lists standards" would be ' + 'satisfied by any element at all.') + sys.exit(1) +if total_all <= total_std: + print('::error::Every element carries gemmaType=standaard, so the page\'s ' + 'filter has nothing to exclude and cannot be shown to work.') + sys.exit(1) +print('[ci-seed] AMEF element fixtures verified (positive + negative control).') +PY + echo "[ci-seed] SoftwareCatalog registers + schemas provisioned." # ── 4. Warm the SPA so the first spec doesn't pay the cold start ───────────── diff --git a/tests/e2e/spec-coverage/index-pages.spec.ts b/tests/e2e/spec-coverage/index-pages.spec.ts index 33a44c9f..cb560b8f 100644 --- a/tests/e2e/spec-coverage/index-pages.spec.ts +++ b/tests/e2e/spec-coverage/index-pages.spec.ts @@ -19,6 +19,13 @@ * Application::boot), so the list object-fetch hits a real register and no * longer 404s. collectAppErrors no longer filters that 404, so these suites * assert it is genuinely absent alongside any other app-origin error / 5xx. + * + * There are now TWO such sentinels. The Standards pages read `schema: element`, + * which lib/Settings/softwarecatalogus_register.json attaches to the `vng-gemma` + * (AMEF) register rather than to `voorzieningen`, and they resolve it through + * `@resolve:amef_register` — provisioned the same way, from the `amef_config` + * blob. See the block above the standards test for why repointing the page was + * the right fix and attaching the schema to `voorzieningen` was not. */ import { test, expect } from '@playwright/test' import { @@ -102,14 +109,18 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + // A skip whose reason has stopped being true reads exactly like a passing // test, so the reason is not repaired here — the test is put back to work. // -// 🔴 IT IS RED, AND THE CAUSE IS MEASURED — DO NOT RE-SKIP IT. +// ✅ THE RED IT EXPOSED IS NOW FIXED, AND THE FIX IS NOT THE OBVIOUS ONE. // Un-skipping it produced a real, previously invisible defect. The surface -// assertions all hold (chrome, "Add Element", list body), and the failure is -// `expectNoAppErrors`: +// assertions all held (chrome, "Add Element", list body); the failure was +// `expectNoAppErrors`, on: +// +// Error fetching 14-element collection: Proxy(Object) // -// Error fetching 14-element collection +// The console message names neither the status nor the cause. The Playwright +// trace does — `GET /api/objects/14/element?…` returned +// `404 {"message":"Schema not found: 'element'"}` (run 31981873526). // -// The page config is `register: "@resolve:voorzieningen_register"` + +// The page config was `register: "@resolve:voorzieningen_register"` + // `schema: "element"` — but `element` is NOT attached to the voorzieningen // register. `lib/Settings/softwarecatalogus_register.json` binds it to the // SECOND register in the same file: @@ -119,31 +130,67 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + // property-definition, // relation, view // -// So the page addresses schema `element` under a register that does not carry -// it. Same family as openconnector#1275's `synchronization_run`: declaring a -// schema does not attach it, and only an attached schema is fetchable through -// /api/objects/{register}/{schema}. +// Same family as openconnector#1275's `synchronization_run`: declaring a schema +// does not attach it, and only an attached schema is fetchable through +// /api/objects/{register}/{schema}. ⚠️ This only became a HARD failure on +// 2026-08-16: OpenRegister's `ObjectService::setSchema()` used to fall back to a +// global slug lookup after a register-scoped miss, and now THROWS instead. An +// instance running an older openregister still serves this page, so "it works +// here" is not evidence — check the version you are measuring against. // // ⚠️ THE OBVIOUS FIX IS THE WRONG ONE. Adding `element` to // `registers.voorzieningen.schemas` would make the request succeed and return -// NOTHING — objects live per register, and the GEMMA elements were imported -// under vng-gemma. That converts a visible error into an empty list, i.e. an -// invisible pass, which is worse than this red. +// NOTHING — objects live per register, and AMEF elements are written to the AMEF +// one. That converts a visible error into an empty list, i.e. an invisible pass, +// which is worse than the red. // -// The honest fix is to point the page at the register that holds the data, -// and that needs a second `@resolve:` sentinel: `voorzieningen_register` is -// currently the ONLY one (34 uses), it is provisioned in -// lib/AppInfo/Application.php::boot() from the `voorzieningen_config` blob, -// and NO app-config key holds a vng-gemma register id — nor does -// tests/e2e/ci-seed.sh provision that register at all. Choosing where that id -// lives is a config-ownership decision, not an E2E repair, so it is escalated -// on the fleet board rather than guessed at here. +// The fix taken instead points the page at the register that carries the schema, +// through a SECOND `@resolve:` sentinel — `@resolve:amef_register`, provisioned +// in lib/AppInfo/Application.php::boot() from the `amef_config` blob exactly as +// `voorzieningen_register` is from `voorzieningen_config`. +// +// 🔑 AND THE ASSERTIONS BELOW GO PAST "THE ERROR IS GONE". A repointed page with +// no rows is quiet, renders "No items found", and passes every surface check — +// the exact invisible pass the fix above was chosen to avoid. So the page must +// be shown to LIST something. tests/e2e/ci-seed.sh seeds two `element` objects +// into the AMEF register and verifies them through the page's own query with a +// positive and a negative control: `Digikoppeling` (gemmaType `standaard`) and +// `Zaakregistratiecomponent` (gemmaType `referentiecomponent`). The second one +// is the discriminator: it exists, in the same register and schema, and the page +// must NOT show it. Without it, `filter.gemmaType` could be deleted outright and +// every assertion here would still hold. test('index standards: nav entry reaches the CnIndexPage surface (toggle + add + list body)', async ({ page, }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Standards') await expectIndexSurface(page, 'Add Element') + + const main = page.locator(APP_MAIN).first() + + // A POPULATED list, not `emptyState.or(populated)`. CnIndexPage renders this + // header only once a non-empty collection has loaded, so it fails on both an + // empty register and a failed fetch. + await expect( + main.getByText(/Showing\s+\d+\s+of\s+\d+/i).first(), + 'the Standards index rendered no rows — the AMEF register resolved but carries no gemmaType=standaard element', + ).toBeVisible({ timeout: 30000 }) + + // The seeded standard itself, by name. + await expect( + main.getByText('Digikoppeling', { exact: false }).first(), + ).toBeVisible({ timeout: 30000 }) + + // …and the seeded NON-standard must be absent. This is an absence assertion + // whose subject the product really does emit: `Zaakregistratiecomponent` is a + // live row in the same register + schema, listed by an unfiltered page, and + // ci-seed.sh fails the job if it is missing. So a zero here means the filter + // worked, not that the string never existed. + await expect( + main.getByText('Zaakregistratiecomponent', { exact: false }), + 'a referentiecomponent is listed on the Standards index — config.filter.gemmaType is not being applied', + ).toHaveCount(0) + expectNoAppErrors(bag) })