From deb3052170d3e35894d3ed918f09aacc1f0f9381 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Thu, 3 Sep 2026 21:35:38 +0200 Subject: [PATCH] fix(schemas): the keys these seeds write that no schema declares OpenRegister gives a declared property a magic-table column and an undeclared one nothing at all. MagicMapper::prepareObjectDataForTable() is a whitelist by omission: it walks the schema's declared properties and copies those out of the payload, so a key the schema does not declare is never read, and there is no JSON blob column to fall back on. The save answers 200 and the value is gone. MEASURED FIRST, on the live dossiq-proof rig. Its dossiq source declares case.properties and mode: array; the case schema in its database has neither, and carries the old child-record extends-form. Both sides read version 1.12.0. 7882afdc added the properties array and f7c9f87c switched the declaration to array mode, and neither bumped the version the importer gates on, so on that instance the create form still writes answers the old way against a schema that no longer matches the file. The version goes to 1.13.0, which is the one thing that makes the update unconditional rather than dependent on the content-diff fallback. THE THREE VTH CHECKLIST TEMPLATES never installed on any instance. The seeds carried a flat items[] and the schema requires sections[], so every save threw "The required property (sections) is missing", the step logged a warning, carried on, and reported success with zero rows. They are reshaped into sections, `type` becomes `responseType`, and photoRequired becomes the enum it was declared as rather than a boolean. caseTypeSlug is what a shipped file can carry, so the step now resolves it to the caseType uuid instead of writing a key nothing stores. genericRole IS DECLARED, NOT DROPPED. It is editable from the Roles tab, specified in case-types and admin-settings, and shipped by three seed files. It was discarded on every one of those writes. mandateGuard goes the other way: nothing reads it, and it is removed, following the dossiq#1782 precedent for `catalog`. FOUND BY THE SWEEP, not by the report. Sweeping every shipped seed payload against its schema turned up three more families: extensionDuration where the schema says extensionPeriod, `type`/`enum` on VTH property definitions where the schema says propertyType/enumValues, and three VTH templates shipping statusTypes, documentTypes and roleTypes as bare strings, which seedSubObjects() skips outright, so those templates seeded nothing at all while reporting success. The sweep ships as SeedPayloadSchemaConformanceTest. It reads the same merged configuration the installer imports, checks nested objects as well as top-level keys, and allows only the two keys OpenRegister genuinely consumes: `id`, which addresses the object, and `slug`, which lands on @self. `uuid` is deliberately not allowed, because nothing reads it. --- l10n/en.js | 2 + l10n/en.json | 2 + l10n/nl.js | 2 + l10n/nl.json | 2 + lib/Repair/VthSeedDataRepairStep.php | 87 ++- lib/Settings/case_flow_seed_data.json | 2 +- lib/Settings/dossiq_register.json | 19 +- .../templates/bvw-mandaatbesluit.json | 1 - .../templates/vth-handhavingszaak.json | 35 +- .../templates/vth-omgevingsvergunning.json | 36 +- lib/Settings/templates/vth-toezichtzaak.json | 18 +- lib/Settings/vth_seed_data.json | 62 ++- .../SeedPayloadSchemaConformanceTest.php | 509 ++++++++++++++++++ 13 files changed, 731 insertions(+), 46 deletions(-) create mode 100644 tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php diff --git a/l10n/en.js b/l10n/en.js index 297477a51..99b7656fa 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2,6 +2,8 @@ OC.L10N.register( "dossiq", { "Contacts": "Contacts", + "Generic Role": "Generic Role", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.", "— choose —": "— choose —", "'Valid from' date must be set": "'Valid from' date must be set", "'Valid until' must be after 'Valid from'": "'Valid until' must be after 'Valid from'", diff --git a/l10n/en.json b/l10n/en.json index 18eb0262f..341bd7eb8 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,6 +1,8 @@ { "translations": { "Contacts": "Contacts", + "Generic Role": "Generic Role", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.", "— choose —": "— choose —", "'Valid from' date must be set": "'Valid from' date must be set", "'Valid until' must be after 'Valid from'": "'Valid until' must be after 'Valid from'", diff --git a/l10n/nl.js b/l10n/nl.js index 0d0f74df8..e90c78508 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2,6 +2,8 @@ OC.L10N.register( "dossiq", { "Contacts": "Contacten", + "Generic Role": "Generieke rol", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "De generieke rol die deze zaaktyperol speelt, zodat routeringsregels en rapportages over \"de behandelaar\" kunnen spreken zonder de eigen benaming van elk zaaktype te kennen. Komt overeen met het ZGW-veld omschrijvingGeneriek op RolType. Wordt gezet op het tabblad Rollen in de zaaktype-instellingen, en meegeleverd door de bezwaar- en besluitvormingsseeds.", "— choose —": "— kies —", "'Valid from' date must be set": "Datum 'Geldig vanaf' moet worden ingevuld", "'Valid until' must be after 'Valid from'": "'Geldig tot' moet na 'Geldig vanaf' liggen", diff --git a/l10n/nl.json b/l10n/nl.json index a63d7ce7e..490e9c05c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,6 +1,8 @@ { "translations": { "Contacts": "Contacten", + "Generic Role": "Generieke rol", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "De generieke rol die deze zaaktyperol speelt, zodat routeringsregels en rapportages over \"de behandelaar\" kunnen spreken zonder de eigen benaming van elk zaaktype te kennen. Komt overeen met het ZGW-veld omschrijvingGeneriek op RolType. Wordt gezet op het tabblad Rollen in de zaaktype-instellingen, en meegeleverd door de bezwaar- en besluitvormingsseeds.", "— choose —": "— kies —", "'Valid from' date must be set": "Datum 'Geldig vanaf' moet worden ingevuld", "'Valid until' must be after 'Valid from'": "'Geldig tot' moet na 'Geldig vanaf' liggen", diff --git a/lib/Repair/VthSeedDataRepairStep.php b/lib/Repair/VthSeedDataRepairStep.php index f78000c46..3e37bdd40 100644 --- a/lib/Repair/VthSeedDataRepairStep.php +++ b/lib/Repair/VthSeedDataRepairStep.php @@ -83,6 +83,8 @@ public function __construct( * Get the repair-step display name. * * @return string + * + * @spec openspec/changes/vth-workflow-configuration-01-config-foundation/tasks.md */ public function getName(): string { return 'Seed VTH case types and inspection-checklist templates for Dossiq'; @@ -291,6 +293,11 @@ private function seedInspectionChecklists( schema: $schema ); + $caseTypeIds = $this->caseTypeIdsBySlug( + objectService: $objectService, + register: $register + ); + $seeded = 0; $skipped = 0; foreach ($checklists as $checklist) { @@ -312,7 +319,7 @@ private function seedInspectionChecklists( $objectService->saveObject( register: $register, schema: $schema, - object: $checklist + object: $this->bindCaseType(checklist: $checklist, caseTypeIds: $caseTypeIds) ); $seeded++; } catch (Throwable $e) { @@ -346,6 +353,84 @@ private function stripChildren(array $caseType): array { return $caseType; }//end stripChildren() + /** + * Bind a checklist template to its case type, by slug. + * + * The seed names its case type by slug because that is the only stable + * identifier a shipped file can carry: the uuid is minted at install. The + * schema declares `caseType` (a uuid `$ref`) and declares no `caseTypeSlug`, + * so shipping the slug straight through wrote a key OpenRegister answers 200 + * to and stores nowhere, and every checklist installed unbound. + * + * An unresolvable slug drops the binding rather than the template: a + * checklist with no case type is still usable, `caseType` is optional + * ("null means any case type"), and a `caseTypeSlug` left in the payload + * would only be discarded again. + * + * @param array $checklist The shipped checklist payload. + * @param array $caseTypeIds Case-type uuid keyed by slug. + * + * @return array The payload as OpenRegister should receive it. + */ + private function bindCaseType(array $checklist, array $caseTypeIds): array { + $slug = (string)($checklist['caseTypeSlug'] ?? ''); + unset($checklist['caseTypeSlug']); + + $caseTypeId = (string)($caseTypeIds[$slug] ?? ''); + if ($slug !== '' && $caseTypeId === '') { + $this->logger->warning( + 'Dossiq VTH checklist seed could not resolve its case type', + ['checklist' => ($checklist['slug'] ?? ''), 'caseTypeSlug' => $slug] + ); + return $checklist; + } + + if ($caseTypeId !== '') { + $checklist['caseType'] = $caseTypeId; + } + + return $checklist; + }//end bindCaseType() + + /** + * Map every seeded case type's slug to its OpenRegister uuid. + * + * The slug lives in `@self`, the same place `existingSlugs()` reads it from. + * + * @param object $objectService OpenRegister ObjectService. + * @param string $register Register slug. + * + * @return array Case-type uuid keyed by slug. + */ + private function caseTypeIdsBySlug(object $objectService, string $register): array { + $schema = (string)$this->settingsService->getConfigValue('case_type_schema'); + if ($schema === '') { + return []; + } + + try { + $rows = $this->searchObjectsAsArrays( + objectService: $objectService, + register: $register, + schema: $schema + ); + } catch (Throwable) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $self = ($row['@self'] ?? []); + $slug = (string)($self['slug'] ?? $row['slug'] ?? ''); + $id = (string)($self['id'] ?? $row['id'] ?? ''); + if ($slug !== '' && $id !== '') { + $ids[$slug] = $id; + } + } + + return $ids; + }//end caseTypeIdsBySlug() + /** * Read existing slugs for idempotency. * diff --git a/lib/Settings/case_flow_seed_data.json b/lib/Settings/case_flow_seed_data.json index 25ae7e682..1f902031e 100644 --- a/lib/Settings/case_flow_seed_data.json +++ b/lib/Settings/case_flow_seed_data.json @@ -9,7 +9,7 @@ "processingDeadline": "P56D", "suspensionAllowed": true, "extensionAllowed": true, - "extensionDuration": "P42D", + "extensionPeriod": "P42D", "confidentiality": "openbaar", "origin": "aanvragen", "statusTypes": [ diff --git a/lib/Settings/dossiq_register.json b/lib/Settings/dossiq_register.json index 6e22fd6cc..aad3b2b41 100644 --- a/lib/Settings/dossiq_register.json +++ b/lib/Settings/dossiq_register.json @@ -359,7 +359,7 @@ "roleType": { "slug": "roleType", "icon": "BadgeAccountOutline", - "version": "1.0.0", + "version": "1.1.0", "x-schema-org": "schema:Role", "x-zgw-equivalent": "RolType", "title": "Role Type", @@ -389,6 +389,21 @@ "description": "Reference to the parent case type", "title": "Case Type" }, + "genericRole": { + "type": "string", + "enum": [ + "initiator", + "co_initiator", + "handler", + "advisor", + "decision_maker", + "coordinator", + "stakeholder", + "contact" + ], + "description": "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.", + "title": "Generic Role" + }, "ncGroupId": { "type": "string", "nullable": true, @@ -692,7 +707,7 @@ } }, "icon": "FolderAccountOutline", - "version": "1.12.0", + "version": "1.13.0", "x-schema-org": "schema:Project", "x-zgw-equivalent": "Zaak", "x-openregister-archival": { diff --git a/lib/Settings/templates/bvw-mandaatbesluit.json b/lib/Settings/templates/bvw-mandaatbesluit.json index 6ac4136bf..9c49ac4c0 100644 --- a/lib/Settings/templates/bvw-mandaatbesluit.json +++ b/lib/Settings/templates/bvw-mandaatbesluit.json @@ -15,7 +15,6 @@ "internalOrExternal": "intern", "isDraft": false, "confidentiality": "intern", - "mandateGuard": true, "statusTypes": [ { "name": "Voorstel opstellen", "order": 1, "isFinal": false, "description": "Steller stelt het DT-advies op" }, { "name": "Ambtelijk advies", "order": 2, "isFinal": false, "description": "Inhoudelijk advies" }, diff --git a/lib/Settings/templates/vth-handhavingszaak.json b/lib/Settings/templates/vth-handhavingszaak.json index f497e3920..653b4e0c8 100644 --- a/lib/Settings/templates/vth-handhavingszaak.json +++ b/lib/Settings/templates/vth-handhavingszaak.json @@ -3,15 +3,32 @@ "title": "VTH Handhavingszaak", "version": "1.0.0", "description": "Template voor handhavingszaken (VTH module)", - "statusTypes": ["Constatering", "Vooraankondiging", "Zienswijze", "Handhavingsbesluit", "Begunstigingstermijn", "Hercontrole", "Afgehandeld"], - "documentTypes": ["Constateringsrapport", "Vooraankondigingsbrief", "Handhavingsbesluit", "Dwangsombeschikking"], + "statusTypes": [ + {"name": "Constatering", "order": 1, "isFinal": false}, + {"name": "Vooraankondiging", "order": 2, "isFinal": false}, + {"name": "Zienswijze", "order": 3, "isFinal": false}, + {"name": "Handhavingsbesluit", "order": 4, "isFinal": false}, + {"name": "Begunstigingstermijn", "order": 5, "isFinal": false}, + {"name": "Hercontrole", "order": 6, "isFinal": false}, + {"name": "Afgehandeld", "order": 7, "isFinal": true} + ], + "documentTypes": [ + {"name": "Constateringsrapport"}, + {"name": "Vooraankondigingsbrief"}, + {"name": "Handhavingsbesluit"}, + {"name": "Dwangsombeschikking"} + ], "propertyDefinitions": [ - {"name": "overtredingstype", "type": "string"}, - {"name": "ernst", "type": "string", "enum": ["gering", "aanzienlijk", "ernstig"]}, - {"name": "gedrag", "type": "string", "enum": ["goedwillend", "onverschillig", "calculerend", "crimineel"]}, - {"name": "intervention", "type": "string"}, - {"name": "dwangsombedrag", "type": "number"}, - {"name": "compliancePeriod", "type": "integer"} + {"name": "overtredingstype", "propertyType": "string"}, + {"name": "ernst", "propertyType": "enum", "enumValues": ["gering", "aanzienlijk", "ernstig"]}, + {"name": "gedrag", "propertyType": "enum", "enumValues": ["goedwillend", "onverschillig", "calculerend", "crimineel"]}, + {"name": "intervention", "propertyType": "string"}, + {"name": "dwangsombedrag", "propertyType": "number"}, + {"name": "compliancePeriod", "propertyType": "number"} ], - "roleTypes": ["handler", "overtreder", "toezichthouder"] + "roleTypes": [ + {"name": "handler"}, + {"name": "overtreder"}, + {"name": "toezichthouder"} + ] } diff --git a/lib/Settings/templates/vth-omgevingsvergunning.json b/lib/Settings/templates/vth-omgevingsvergunning.json index 2ab3de79a..d56973b03 100644 --- a/lib/Settings/templates/vth-omgevingsvergunning.json +++ b/lib/Settings/templates/vth-omgevingsvergunning.json @@ -3,16 +3,34 @@ "title": "VTH Omgevingsvergunning", "version": "1.0.0", "description": "Template voor omgevingsvergunning aanvragen (VTH module)", - "statusTypes": ["Ontvangen", "Ontvankelijkheidstoets", "In behandeling", "Advies", "Besluitvorming", "Afgehandeld"], - "documentTypes": ["Bouwtekening", "Constructieberekening", "Situatietekening", "Welstandsadvies", "Fotos bestaande situatie"], + "statusTypes": [ + {"name": "Ontvangen", "order": 1, "isFinal": false}, + {"name": "Ontvankelijkheidstoets", "order": 2, "isFinal": false}, + {"name": "In behandeling", "order": 3, "isFinal": false}, + {"name": "Advies", "order": 4, "isFinal": false}, + {"name": "Besluitvorming", "order": 5, "isFinal": false}, + {"name": "Afgehandeld", "order": 6, "isFinal": true} + ], + "documentTypes": [ + {"name": "Bouwtekening"}, + {"name": "Constructieberekening"}, + {"name": "Situatietekening"}, + {"name": "Welstandsadvies"}, + {"name": "Fotos bestaande situatie"} + ], "propertyDefinitions": [ - {"name": "bouwkosten", "type": "number"}, - {"name": "oppervlakte", "type": "number"}, - {"name": "aantalBouwlagen", "type": "integer"}, - {"name": "bagObject", "type": "string"}, - {"name": "procedureType", "type": "string", "enum": ["regulier", "uitgebreid"]}, - {"name": "activiteiten", "type": "string"} + {"name": "bouwkosten", "propertyType": "number"}, + {"name": "oppervlakte", "propertyType": "number"}, + {"name": "aantalBouwlagen", "propertyType": "number"}, + {"name": "bagObject", "propertyType": "string"}, + {"name": "procedureType", "propertyType": "enum", "enumValues": ["regulier", "uitgebreid"]}, + {"name": "activiteiten", "propertyType": "string"} + ], + "roleTypes": [ + {"name": "handler"}, + {"name": "applicant"}, + {"name": "gemachtigde"}, + {"name": "adviseur"} ], - "roleTypes": ["handler", "applicant", "gemachtigde", "adviseur"], "processingDeadline": {"regulier": "P56D", "uitgebreid": "P182D"} } diff --git a/lib/Settings/templates/vth-toezichtzaak.json b/lib/Settings/templates/vth-toezichtzaak.json index 24bab7b4d..ad030537d 100644 --- a/lib/Settings/templates/vth-toezichtzaak.json +++ b/lib/Settings/templates/vth-toezichtzaak.json @@ -3,15 +3,25 @@ "title": "VTH Toezichtzaak", "version": "1.0.0", "description": "Template voor toezichtzaken inclusief inspectiefasen (VTH module)", - "statusTypes": ["Gepland", "In uitvoering", "Rapport", "Opvolging", "Afgehandeld"], + "statusTypes": [ + {"name": "Gepland", "order": 1, "isFinal": false}, + {"name": "In uitvoering", "order": 2, "isFinal": false}, + {"name": "Rapport", "order": 3, "isFinal": false}, + {"name": "Opvolging", "order": 4, "isFinal": false}, + {"name": "Afgehandeld", "order": 5, "isFinal": true} + ], "inspectionPhases": [ {"name": "Fase 1 - Fundering", "order": 1}, {"name": "Fase 2 - Ruwbouw", "order": 2}, {"name": "Fase 3 - Oplevering", "order": 3} ], - "roleTypes": ["inspecteur", "contactpersoon", "opdrachtgever"], + "roleTypes": [ + {"name": "inspecteur"}, + {"name": "contactpersoon"}, + {"name": "opdrachtgever"} + ], "propertyDefinitions": [ - {"name": "inspectionPhase", "type": "string"}, - {"name": "location", "type": "string"} + {"name": "inspectionPhase", "propertyType": "string"}, + {"name": "location", "propertyType": "string"} ] } diff --git a/lib/Settings/vth_seed_data.json b/lib/Settings/vth_seed_data.json index 8da44182f..c7837e57a 100644 --- a/lib/Settings/vth_seed_data.json +++ b/lib/Settings/vth_seed_data.json @@ -9,7 +9,7 @@ "processingDeadline": "P56D", "suspensionAllowed": true, "extensionAllowed": true, - "extensionDuration": "P42D", + "extensionPeriod": "P42D", "confidentiality": "openbaar", "origin": "aanvragen", "statusTypes": [ @@ -195,44 +195,68 @@ "inspectionChecklists": [ { "slug": "bouwtoezicht-fase-1-fundering", + "seedKey": "bouwtoezicht-fase-1-fundering", "name": "Bouwtoezicht fase 1 - Fundering", "caseTypeSlug": "toezichtzaak-bouw", "version": 1, "status": "active", - "items": [ - {"order": 1, "label": "Fundering conform tekening", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer of de fundering overeenkomt met de goedgekeurde bouwtekening"}, - {"order": 2, "label": "Wapening aanwezig en correct", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer diameter, hart-op-hart afstand en dekking van de wapening"}, - {"order": 3, "label": "Waterkering conform bestek", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer waterkeringsmaatregelen en maak foto bij afwijking"}, - {"order": 4, "label": "Maatvoering gecontroleerd", "type": "getal", "required": true, "photoRequired": false, "helpText": "Meet de afwijking in mm ten opzichte van de tekening"} + "active": true, + "sections": [ + { + "order": 1, + "name": "Bouwtoezicht fase 1 - Fundering", + "items": [ + {"order": 1, "label": "Fundering conform tekening", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer of de fundering overeenkomt met de goedgekeurde bouwtekening"}, + {"order": 2, "label": "Wapening aanwezig en correct", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer diameter, hart-op-hart afstand en dekking van de wapening"}, + {"order": 3, "label": "Waterkering conform bestek", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer waterkeringsmaatregelen en maak foto bij afwijking"}, + {"order": 4, "label": "Maatvoering gecontroleerd", "responseType": "getal", "required": true, "photoRequired": "nooit", "helpText": "Meet de afwijking in mm ten opzichte van de tekening"} + ] + } ] }, { "slug": "bouwtoezicht-fase-2-ruwbouw", + "seedKey": "bouwtoezicht-fase-2-ruwbouw", "name": "Bouwtoezicht fase 2 - Ruwbouw", "caseTypeSlug": "toezichtzaak-bouw", "version": 1, "status": "active", - "items": [ - {"order": 1, "label": "Metselwerk conform tekening", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer metselwerk op conformiteit met de bouwtekening"}, - {"order": 2, "label": "Kozijnen correct geplaatst", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer plaatsing, waterdichtheid en isolatie van kozijnen"}, - {"order": 3, "label": "Dakconstructie conform bestek", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer dakconstructie, gordingen en spanten"}, - {"order": 4, "label": "Leidingen correct aangelegd", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer water-, gas- en elektriciteitsleidingen"}, - {"order": 5, "label": "Brandwering aangebracht", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer brandwerende doorvoeringen en scheidingen"} + "active": true, + "sections": [ + { + "order": 1, + "name": "Bouwtoezicht fase 2 - Ruwbouw", + "items": [ + {"order": 1, "label": "Metselwerk conform tekening", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer metselwerk op conformiteit met de bouwtekening"}, + {"order": 2, "label": "Kozijnen correct geplaatst", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer plaatsing, waterdichtheid en isolatie van kozijnen"}, + {"order": 3, "label": "Dakconstructie conform bestek", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer dakconstructie, gordingen en spanten"}, + {"order": 4, "label": "Leidingen correct aangelegd", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer water-, gas- en elektriciteitsleidingen"}, + {"order": 5, "label": "Brandwering aangebracht", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer brandwerende doorvoeringen en scheidingen"} + ] + } ] }, { "slug": "bouwtoezicht-fase-3-oplevering", + "seedKey": "bouwtoezicht-fase-3-oplevering", "name": "Bouwtoezicht fase 3 - Oplevering", "caseTypeSlug": "toezichtzaak-bouw", "version": 1, "status": "active", - "items": [ - {"order": 1, "label": "Afwerking conform bestek", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer afwerking vloeren, wanden en plafonds"}, - {"order": 2, "label": "Installaties werkend", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Test verwarming, ventilatie, elektra en sanitair"}, - {"order": 3, "label": "Brandveiligheid gewaarborgd", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer rookmelders, vluchtroutes, noodverlichting"}, - {"order": 4, "label": "Toegankelijkheid conform Bouwbesluit", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer drempels, deurbreedtes, toilet toegankelijkheid"}, - {"order": 5, "label": "Energielabel bepaald", "type": "text", "required": true, "photoRequired": false, "helpText": "Noteer het energielabel (A++++ t/m G)"}, - {"order": 6, "label": "As-built tekeningen ontvangen", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer of as-built tekeningen zijn aangeleverd"} + "active": true, + "sections": [ + { + "order": 1, + "name": "Bouwtoezicht fase 3 - Oplevering", + "items": [ + {"order": 1, "label": "Afwerking conform bestek", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer afwerking vloeren, wanden en plafonds"}, + {"order": 2, "label": "Installaties werkend", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Test verwarming, ventilatie, elektra en sanitair"}, + {"order": 3, "label": "Brandveiligheid gewaarborgd", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer rookmelders, vluchtroutes, noodverlichting"}, + {"order": 4, "label": "Toegankelijkheid conform Bouwbesluit", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer drempels, deurbreedtes, toilet toegankelijkheid"}, + {"order": 5, "label": "Energielabel bepaald", "responseType": "text", "required": true, "photoRequired": "nooit", "helpText": "Noteer het energielabel (A++++ t/m G)"}, + {"order": 6, "label": "As-built tekeningen ontvangen", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer of as-built tekeningen zijn aangeleverd"} + ] + } ] } ], diff --git a/tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php b/tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php new file mode 100644 index 000000000..5e09e8736 --- /dev/null +++ b/tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php @@ -0,0 +1,509 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Settings; + +use OCA\Dossiq\Service\Settings\RegisterFragmentMerger; +use PHPUnit\Framework\TestCase; + +/** + * Asserts every shipped seed payload only writes properties its schema declares. + * + * @covers \OCA\Dossiq\Repair\VthSeedDataRepairStep + * @covers \OCA\Dossiq\Service\Besluitvorming\TemplateBundleSeeder + * @covers \OCA\Dossiq\Service\SeedDataService + */ +class SeedPayloadSchemaConformanceTest extends TestCase { + + /** + * Collection key to the schema slug its records are written to. + * + * These are the fan-out points the seeders share: `TemplateBundleSeeder:: + * seedChildren()` and `SeedDataService` both take the records under these + * keys off the case-type payload and write each one, verbatim plus a + * `caseType` back-reference, into the named schema. A key listed here is + * therefore BOTH a collection of payloads to check AND a key the parent + * payload is allowed to carry, because the parent save never sees it. + * + * @var array + */ + private const COLLECTION_SCHEMA = [ + 'caseTypes' => 'caseType', + 'statusTypes' => 'statusType', + 'roleTypes' => 'roleType', + 'resultTypes' => 'resultType', + 'documentTypes' => 'documentType', + 'decisionTypes' => 'decisionType', + 'propertyDefinitions' => 'propertyDefinition', + 'inspectionChecklists' => 'inspectionChecklistTemplate', + ]; + + /** + * Keys OpenRegister consumes itself, before the schema whitelist runs. + * + * `id` addresses the object: `ObjectService::extractUuidAndNormalizeObject()` + * reads `@self.id` then `id` and uses it as the target uuid. `slug` is read + * by `SaveObject::setSelfMetadata()` and stored on the entity as `@self.slug`, + * which is where `VthSeedDataRepairStep::existingSlugs()` reads it back from. + * Neither is a schema property and neither is lost. + * + * `uuid` is deliberately NOT on this list. Nothing reads it: the uuid comes + * from the `$uuid` parameter or from `id`, so a payload `uuid` is dropped + * like any other undeclared key. + * + * @var array + */ + private const OPENREGISTER_METADATA_KEYS = ['id', 'slug']; + + /** + * Case-type keys a seeder reads and takes off the payload before saving it. + * + * These never reach OpenRegister, so they are not drops. The list mirrors + * the `unset()` calls exactly and is not a waiver: adding a key here without + * a matching `unset()` in the seeder re-opens the defect this sweep exists + * to catch. + * + * - `statusTypes` … `resultTypes`, `workflowTemplate`, `initialStatusName`: + * `TemplateBundleSeeder::splitBundle()`, lib/Service/Besluitvorming/TemplateBundleSeeder.php. + * - `parafeerroute`: `BesluitvormingTemplateService::activate()`, + * lib/Service/BesluitvormingTemplateService.php. + * - `caseTypeSlug`: `VthSeedDataRepairStep::seedInspectionChecklists()` + * resolves it to the `caseType` uuid and drops the slug. + * + * @var array + */ + private const SEEDER_CONSUMED_KEYS = [ + 'workflowTemplate', + 'initialStatusName', + 'parafeerroute', + 'caseTypeSlug', + ]; + + /** + * Seed files whose payloads reach OpenRegister on install or on demand. + * + * @var array + */ + private const SEED_FILES = [ + 'vth_seed_data.json', + 'case_flow_seed_data.json', + 'bezwaar_seed_data.json', + 'templates/bvw-college-besluit.json', + 'templates/bvw-mandaatbesluit.json', + 'templates/bvw-raadsbesluit.json', + 'templates/omgevingsvergunning.json', + 'templates/woo-verzoek.json', + 'templates/woo_verzoek.json', + 'templates/vth-handhavingszaak.json', + 'templates/vth-omgevingsvergunning.json', + 'templates/vth-toezichtzaak.json', + ]; + + /** + * The merged register configuration, keyed by schema slug. + * + * @var array> + */ + private array $schemas = []; + + /** + * Load the same merged configuration the installer imports. + * + * The base monolith alone is not the authority: `SettingsService:: + * loadConfiguration()` deep-merges `register.d/*.json` on top of it (ADR-037), + * and a fragment may add the very property a seed writes. Reading only the + * monolith would fail this sweep on properties that do install. + * + * @return void + */ + protected function setUp(): void { + $settingsDir = __DIR__ . '/../../../lib/Settings'; + $base = json_decode(file_get_contents($settingsDir . '/dossiq_register.json'), true); + $this->assertIsArray(actual: $base, message: 'dossiq_register.json must parse'); + + [$merged] = (new RegisterFragmentMerger())->merge( + base: $base, + fragmentDir: $settingsDir . '/register.d' + ); + + $this->schemas = ($merged['components']['schemas'] ?? []); + $this->assertNotEmpty(actual: $this->schemas, message: 'the merged configuration must carry schemas'); + + }//end setUp() + + /** + * Every key every shipped seed payload writes is declared by its schema. + * + * @return void + */ + public function testSeedPayloadsOnlyWriteDeclaredProperties(): void { + $settingsDir = __DIR__ . '/../../../lib/Settings'; + $findings = []; + + foreach (self::SEED_FILES as $relative) { + $path = $settingsDir . '/' . $relative; + if (file_exists($path) === false) { + continue; + } + + $data = json_decode(file_get_contents($path), true); + $this->assertIsArray(actual: $data, message: $relative . ' must parse as JSON'); + + $this->collectFindings( + node: $data, + pointer: $relative, + findings: $findings + ); + }//end foreach + + $this->assertSame( + expected: [], + actual: $findings, + message: + "Seed payloads write properties no schema declares. OpenRegister answers 200\n" + . "and stores nothing, so each of these values is silently lost on every install.\n" + . "Either declare the property on the schema, or stop writing it.\n\n" + . implode("\n", $findings) + ); + + }//end testSeedPayloadsOnlyWriteDeclaredProperties() + + /** + * Walk a decoded seed tree and record every undeclared payload key. + * + * A key named in COLLECTION_SCHEMA carries payloads for the schema it maps + * to. `caseType` is handled as its singular form too, because the + * besluitvorming templates wrap one case type in that key. + * + * @param mixed $node The current node in the decoded tree. + * @param string $pointer Human-readable path to the node, for the failure message. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function collectFindings(mixed $node, string $pointer, array &$findings): void { + if (is_array($node) === false) { + return; + } + + foreach ($node as $key => $value) { + $name = (string)$key; + $childPointer = $pointer . '/' . $name; + + if ($name === 'caseType' && $this->isPayload(node: $value) === true) { + $this->checkPayload( + payload: $value, + schemaSlug: 'caseType', + pointer: $childPointer, + findings: $findings + ); + continue; + } + + if (isset(self::COLLECTION_SCHEMA[$name]) === true && is_array($value) === true) { + $slug = self::COLLECTION_SCHEMA[$name]; + foreach ($value as $index => $record) { + if ($this->isPayload(node: $record) === false) { + // A record that is not an object is not a payload at + // all. `VTHTemplateService::seedSubObjects()` and + // `TemplateBundleSeeder::seedChildren()` both skip a + // non-array record, so a collection shipped as bare + // strings seeds NOTHING and still reports success. + $findings[] = $childPointer . '[' . $index . ']: not an object, so the' + . ' seeder skips it and no "' . $slug . '" row is written'; + continue; + } + + $this->checkPayload( + payload: $record, + schemaSlug: $slug, + pointer: $childPointer . '[' . $index . ']', + findings: $findings + ); + } + + continue; + } + + $this->collectFindings(node: $value, pointer: $childPointer, findings: $findings); + }//end foreach + + }//end collectFindings() + + /** + * Check one payload's keys against its schema's declared properties. + * + * Nested collections are checked in their own right by the walker, so they + * are skipped here: the seeders split them off the parent before saving it. + * + * @param array $payload The object payload as shipped. + * @param string $schemaSlug The schema it is written to. + * @param string $pointer Human-readable path to the payload. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkPayload( + array $payload, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + $schema = ($this->schemas[$schemaSlug] ?? null); + if (is_array($schema) === false) { + $findings[] = $pointer . ': no schema "' . $schemaSlug . '" in the merged configuration'; + return; + } + + $declared = array_keys(($schema['properties'] ?? [])); + + foreach (array_keys($payload) as $key) { + $name = (string)$key; + if ($name === '' || $name[0] === '@' || $name[0] === '_') { + continue; + } + + if (in_array($name, $declared, true) === true) { + continue; + } + + if (in_array($name, self::OPENREGISTER_METADATA_KEYS, true) === true) { + continue; + } + + if (isset(self::COLLECTION_SCHEMA[$name]) === true) { + continue; + } + + if (in_array($name, self::SEEDER_CONSUMED_KEYS, true) === true) { + continue; + } + + $findings[] = $pointer . '/' . $name . ': schema "' . $schemaSlug . '" does not declare it'; + }//end foreach + + $this->checkNested( + payload: $payload, + properties: ($schema['properties'] ?? []), + schemaSlug: $schemaSlug, + pointer: $pointer, + findings: $findings + ); + + $this->checkRequired( + payload: $payload, + schema: $schema, + schemaSlug: $schemaSlug, + pointer: $pointer, + findings: $findings + ); + + }//end checkPayload() + + /** + * Check the objects nested inside a payload against their sub-schemas. + * + * The top-level keys are only half the surface. An `inspectionChecklist + * Template` declares `sections`, each section declares `items`, and each + * item declares `responseType` — a checklist can name every top-level key + * correctly and still write `type` on every question. Those nested keys are + * stored as part of the parent column, so a wrong one does not vanish from + * the database the way a top-level key does; it lands as data no reader + * understands, which is the same defect wearing a different coat. + * + * @param array $payload The payload, or a nested object in it. + * @param array $properties The matching schema property map. + * @param string $schemaSlug The owning schema slug, for the message. + * @param string $pointer Human-readable path to the payload. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkNested( + array $payload, + array $properties, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + foreach ($properties as $name => $definition) { + if (is_array($definition) === false || array_key_exists($name, $payload) === false) { + continue; + } + + $value = $payload[$name]; + + if (($definition['type'] ?? '') === 'array') { + $itemSchema = ($definition['items'] ?? []); + if (is_array($value) === false || is_array($itemSchema) === false) { + continue; + } + + foreach ($value as $index => $entry) { + if ($this->isPayload(node: $entry) === false) { + continue; + } + + $this->checkNestedObject( + payload: $entry, + definition: $itemSchema, + schemaSlug: $schemaSlug, + pointer: $pointer . '/' . $name . '[' . $index . ']', + findings: $findings + ); + } + + continue; + } + + if ($this->isPayload(node: $value) === true) { + $this->checkNestedObject( + payload: $value, + definition: $definition, + schemaSlug: $schemaSlug, + pointer: $pointer . '/' . $name, + findings: $findings + ); + } + }//end foreach + + }//end checkNested() + + /** + * Check one nested object against the sub-schema that declares it. + * + * A sub-schema with no `properties` map declares nothing and constrains + * nothing, so a free-form object is left alone rather than reported. + * + * @param array $payload The nested object. + * @param array $definition Its sub-schema. + * @param string $schemaSlug The owning schema slug, for the message. + * @param string $pointer Human-readable path to the object. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkNestedObject( + array $payload, + array $definition, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + $properties = ($definition['properties'] ?? []); + if (is_array($properties) === false || $properties === []) { + return; + } + + foreach (array_keys($payload) as $key) { + $name = (string)$key; + if ($name === '' || $name[0] === '@' || $name[0] === '_') { + continue; + } + + if (array_key_exists($name, $properties) === false) { + $findings[] = $pointer . '/' . $name . ': schema "' . $schemaSlug + . '" does not declare it on this nested object'; + } + } + + foreach (($definition['required'] ?? []) as $required) { + $name = (string)$required; + if ($name !== '' && array_key_exists($name, $payload) === false) { + $findings[] = $pointer . ': schema "' . $schemaSlug . '" requires "' + . $name . '" on this nested object, which it does not carry'; + } + } + + $this->checkNested( + payload: $payload, + properties: $properties, + schemaSlug: $schemaSlug, + pointer: $pointer, + findings: $findings + ); + + }//end checkNestedObject() + + /** + * Check that a payload carries every property its schema requires. + * + * A missing required property is the loud half of the same defect: the save + * throws instead of dropping, the seeder logs a warning and carries on, and + * the collection ends at zero rows while the install reports success. That + * is how the three `bouwtoezicht-*` checklist templates never installed. + * + * @param array $payload The object payload as shipped. + * @param array $schema The schema definition. + * @param string $schemaSlug The schema slug, for the message. + * @param string $pointer Human-readable path to the payload. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkRequired( + array $payload, + array $schema, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + foreach (($schema['required'] ?? []) as $required) { + $name = (string)$required; + if ($name === '' || array_key_exists($name, $payload) === true) { + continue; + } + + // A `$ref` property the seeder resolves and injects itself (the + // `caseType` back-reference `seedChildren()` sets) is not expected + // in the shipped file. + if ($name === 'caseType' && $schemaSlug !== 'case') { + continue; + } + + $findings[] = $pointer . ': schema "' . $schemaSlug + . '" requires "' . $name . '", which the payload does not carry'; + }//end foreach + + }//end checkRequired() + + /** + * Whether a node is an object payload rather than a list or a scalar. + * + * @param mixed $node The node to test. + * + * @return bool True when the node is a string-keyed map. + */ + private function isPayload(mixed $node): bool { + return (is_array($node) === true && $node !== [] && array_is_list($node) === false); + + }//end isPayload() +}//end class