diff --git a/lib/Flow/DossiqAskPersonNode.php b/lib/Flow/DossiqAskPersonNode.php index de6cc042a..8ca45ebbf 100644 --- a/lib/Flow/DossiqAskPersonNode.php +++ b/lib/Flow/DossiqAskPersonNode.php @@ -536,11 +536,26 @@ private function caseIdFrom(array $items): string { * what orphaned every applicant task live: FlowRunAssignee compared real * uids against the un-rendered placeholder and refused all of them. * - * 🔴 AN EMPTY RENDERING REFUSES LOUDLY. A template that resolves to - * nothing would create an UNASSIGNED task, and OpenRegister's resume guard - * deliberately lets anyone answer a step that names no assignee — so a - * quiet fallback here would open the case's progress to any authenticated - * user. Failing the step is the safe direction. + * 🔴 AN EMPTY RENDERING NEVER FALLS BACK TO NOBODY. A template that + * resolves to nothing would create an UNASSIGNED task, and OpenRegister's + * resume guard deliberately lets anyone answer a step that names no + * assignee — so a quiet empty here would open the case's progress to any + * authenticated user. + * + * 🔴 BUT REFUSING WAS NOT A DEFINED BEHAVIOUR EITHER, AND IT KILLED RUNS. + * `{{ case.assignee }}` is the shipped spelling, and `assignee` is NOT in + * the case schema's `required`: a case filed from the New case dialog with + * only a title and a case type has none. The step then threw, the run + * failed, and the case sat in `Wacht op aanvulling` with nothing waiting + * on it and no task for anybody. Reproduced twice on clean installs. + * + * So a declaration may name an `assigneeFallback`: the principal the ask + * goes to when the primary resolves to nobody. That is a DECLARED second + * choice, not a silent one. It is written in the flow, CaseFlowDeclaration + * Test requires it of every templated assignee, and ProvisionAssignedGroups + * Test requires the group it names to be provisioned. A declaration with no + * fallback still refuses, and a fallback that itself resolves to nothing + * refuses too: failing closed stays the last word. * * The case is offered under both its own keys and a `case.` prefix, * because the declarations write `{{ case.assignee }}` — the same spelling @@ -551,42 +566,92 @@ private function caseIdFrom(array $items): string { * * @return string The rendered assignee. * - * @throws RuntimeException When the assignee renders empty or unresolved. - * - * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's - * canonical rendering API and is published as a static, final class — - * there is no instance to inject. + * @throws RuntimeException When neither the assignee nor its declared + * fallback resolves to a principal. * * @spec openspec/changes/case-flow-human-steps/specs/case-flow-human-steps/spec.md */ private function renderedAssignee(array $config, array $items): string { - $raw = trim((string) ($config['assignee'] ?? '')); - $case = []; $first = ($items[0] ?? null); if (is_array($first) === true) { $case = (array) ($first['json'] ?? []); } - $rendered = FlowValueTemplate::renderTracked(value: $raw, json: array_merge($case, ['case' => $case])); + $json = array_merge($case, ['case' => $case]); + + $primary = trim((string) ($config['assignee'] ?? '')); + $resolved = $this->renderPrincipal(raw: $primary, json: $json); + if ($resolved !== '') { + return $resolved; + } - $value = $rendered['value']; - if (is_array($value) === true || trim((string) $value) === '' || $rendered['unresolved'] !== []) { - $detail = ''; - if ($rendered['unresolved'] !== []) { - $detail = ' (unresolved: ' . implode(', ', $rendered['unresolved']) . ')'; + $fallback = trim((string) ($config['assigneeFallback'] ?? '')); + if ($fallback !== '') { + $resolved = $this->renderPrincipal(raw: $fallback, json: $json); + if ($resolved !== '') { + $this->logger->info( + 'Dossiq askPerson: "' . $primary . '" named nobody on this case, so the ask goes to its ' + . 'declared fallback "' . $resolved . '"', + ['case' => (string) ($case['id'] ?? ($case['uuid'] ?? ''))] + ); + + return $resolved; } + } - throw new RuntimeException( - sprintf('dossiq.askPerson could not resolve the assignee "%s" against the case%s', $raw, $detail) - ); + $why = 'the step declares no assigneeFallback to send the ask to instead'; + if ($fallback !== '') { + $why = sprintf('its fallback "%s" resolved to nobody either', $fallback); } - return trim((string) $value); + throw new RuntimeException( + sprintf( + 'dossiq.askPerson could not resolve the assignee "%s" against the case, and %s', + $primary, + $why + ) + ); }//end renderedAssignee() + /** + * Render one authored principal against the case, or return nothing. + * + * "Nothing" covers all three ways an authored value fails to name + * somebody: an empty rendering, one the engine could not resolve, and one + * that came back as a structure rather than a name. The caller decides + * what to do about it, because the answer differs between the primary + * assignee and its fallback. + * + * @param string $raw The authored value, template or literal. + * @param array $json The case, under its own keys and a `case.` prefix. + * + * @return string The rendered principal, or '' when it names nobody. + * + * @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's + * canonical rendering API and is published as a static, final class — + * there is no instance to inject. + * + * @spec openspec/changes/case-flow-human-steps/specs/case-flow-human-steps/spec.md + */ + private function renderPrincipal(string $raw, array $json): string { + if ($raw === '') { + return ''; + } + + $rendered = FlowValueTemplate::renderTracked(value: $raw, json: $json); + $value = $rendered['value']; + if (is_array($value) === true || $rendered['unresolved'] !== []) { + return ''; + } + + return trim((string) $value); + + }//end renderPrincipal() + + /** * The task record this step asks somebody to complete. * diff --git a/lib/Repair/ProvisionAssignedGroups.php b/lib/Repair/ProvisionAssignedGroups.php index a09a9eeac..08626e4cb 100644 --- a/lib/Repair/ProvisionAssignedGroups.php +++ b/lib/Repair/ProvisionAssignedGroups.php @@ -77,13 +77,23 @@ class ProvisionAssignedGroups implements IRepairStep { * Every group the shipped register data assigns steps to. * * This list is the provisioning counterpart of the literals in - * lib/Settings/dossiq_register.json; ProvisionAssignedGroupsTest sweeps - * the shipped flows and fails when a group is assigned there that this - * list does not provision, so the two cannot drift apart silently. + * lib/Settings/dossiq_register.json and lib/Settings/register.d/*.json; + * ProvisionAssignedGroupsTest sweeps the shipped flows and fails when a + * group is assigned there that this list does not provision, so the two + * cannot drift apart silently. The sweep reads `assigneeFallback` as well + * as `assignee`: a fallback nobody is a member of fails exactly as loudly + * as a primary nobody is a member of, and it fails on the case the primary + * was already unable to serve. + * + * `bezwaarcommissie` is the bezwaaradviescommissie's own group, kept + * separate from `behandelaars` on purpose: Awb art. 7:13 requires the + * advisory committee to be independent of the officials who handled the + * case, so routing its two steps to the handlers would be worse than the + * unassigned steps it replaces. * * @var array */ - public const ASSIGNED_GROUPS = ['behandelaars']; + public const ASSIGNED_GROUPS = ['behandelaars', 'bezwaarcommissie']; /** * Constructor. @@ -200,7 +210,7 @@ private function seedWithAdministrators(IGroup $group, string $groupId, IOutput $output->info( 'Dossiq: seeded group "' . $groupId . '" with ' . count($added) . ' administrator(s): ' - . implode(', ', $added) . '. Replace them with the real behandelaars in Users & groups.' + . implode(', ', $added) . '. Replace them with the real members in Users & groups.' ); $this->logger->info( 'Dossiq: seeded assigned group with administrators', diff --git a/lib/Settings/dossiq_register.json b/lib/Settings/dossiq_register.json index 228c4ca28..2b071942f 100644 --- a/lib/Settings/dossiq_register.json +++ b/lib/Settings/dossiq_register.json @@ -1109,12 +1109,14 @@ } }, { - "id": "ask-indiener", + "id": "ask-aanvulling", "type": "dossiq.askPerson", + "_note": "THIS STEP ADDRESSES THE BEHANDELAAR, NOT THE INDIENER, AND IT USED TO BE NAMED AND WORDED AS THOUGH IT DID THE OPPOSITE. An indiener has no Nextcloud identity in this app at all: the case carries them as `initiatorSourceId` (a BSN, a KvK number or a contact URI) or as the pseudonymous `portalSubject`, while a task's `assignee` is a Nextcloud uid or group id and the completion gate resolves it as one. So the ask cannot go to the applicant. It goes to the handler, who asks the applicant and records the supplement on the case, which is also the only way `description` gets filled and the loop back to `check-complete` ever takes the `compleet` exit.\n\nAND IT MUST NAME SOMEBODY WHEN THE CASE NAMES NOBODY. `assignee` is not in the case schema's `required`, so a case filed from the New case dialog with only a title and a case type has none, `{{ case.assignee }}` resolved to nothing, and the run DIED here: reproduced twice on clean installs. `assigneeFallback` is the declared second choice, the same `behandelaars` group `task-behandelaar` already uses and ProvisionAssignedGroups already creates. Unassigned work goes to the group's queue rather than killing the case.", "config": { - "question": "Vul uw aanvraag aan", - "details": "Uw aanvraag is nog niet compleet. Vul de ontbrekende gegevens aan zodat wij verder kunnen.", + "question": "Vraag de indiener om aanvulling", + "details": "Deze aanvraag is niet compleet. Vraag de indiener om de ontbrekende gegevens en vul ze aan in de zaak.", "assignee": "{{ case.assignee }}", + "assigneeFallback": "behandelaars", "dueInDays": "14", "signalKey": "aanvulling" } @@ -1251,11 +1253,11 @@ { "id": "e-count", "from": "count-round", - "to": "ask-indiener" + "to": "ask-aanvulling" }, { "id": "e-heraanbieding", - "from": "ask-indiener", + "from": "ask-aanvulling", "to": "check-complete" }, { @@ -1778,7 +1780,7 @@ "aanvulling": { "type": "object", "title": "Supplement answer", - "description": "The signal payload of the applicant's supplement task (ask-indiener, signalKey aanvulling). The flow stamps it onto the case when the task completes. Declared because the store strips what the schema does not declare: an undeclared signal field is silently gone the moment any writer saves the case.", + "description": "The signal payload of the supplement task (ask-aanvulling, signalKey aanvulling) the handler completes once the applicant has supplied what was missing. The flow stamps it onto the case when the task completes. Declared because the store strips what the schema does not declare: an undeclared signal field is silently gone the moment any writer saves the case.", "visible": false }, "voorbereiding": { diff --git a/lib/Settings/register.d/72-committees-to-decidiq.json b/lib/Settings/register.d/72-committees-to-decidiq.json index ab160908c..52515afa7 100644 --- a/lib/Settings/register.d/72-committees-to-decidiq.json +++ b/lib/Settings/register.d/72-committees-to-decidiq.json @@ -45,9 +45,11 @@ { "id": "open-deliberation", "type": "dossiq.askPerson", + "_note": "BOTH ASKS IN THIS FLOW USED TO NAME NOBODY, WHICH IS NOT A LOOSE END BUT A DEAD FLOW. DossiqAskPersonNode::validateConfig refuses an empty assignee outright (an unassigned task is answerable by anyone, because OpenRegister's resume guard reads silence as no restriction), so this node threw on its first execute and every bacAdviceRequest ever created failed its run at this step. The flow's own description says the secretary opens deliberation and the chair issues the advice, and both uids DO exist, on the bezwaaradviescommissie. They are not reachable from here: the item json is the bacAdviceRequest, whose `committee` is a bare reference the trigger does not expand, so `{{ committee.secretary }}` would resolve to nothing on every run and quietly mean the fallback. The group is the honest principal until the committee reference is expanded into the run.", "config": { "question": "Open de beraadslaging", "details": "De commissie heeft dit bezwaar toegewezen gekregen. Bevestig dat de beraadslaging is gestart zodra de zitting is belegd.", + "assignee": "bezwaarcommissie", "dueInDays": "14", "signalKey": "beraadslagingGestart" } @@ -69,6 +71,7 @@ "config": { "question": "Breng het advies uit", "details": "Leg de bevindingen, de juridische beoordeling en de aanbeveling vast en onderteken het advies. Awb art. 7:13 lid 7.", + "assignee": "bezwaarcommissie", "dueInDays": "84", "signalKey": "adviesUitgebracht" } diff --git a/src/dialogs/BeschikkingComposerDialog.vue b/src/dialogs/BeschikkingComposerDialog.vue index a7751a34a..0ea88202e 100644 --- a/src/dialogs/BeschikkingComposerDialog.vue +++ b/src/dialogs/BeschikkingComposerDialog.vue @@ -28,7 +28,7 @@ + @update:modelValue="(v) => (rationale = v)" /> {{ error }} diff --git a/src/dialogs/DsoCaseDetail.vue b/src/dialogs/DsoCaseDetail.vue index 6a1fbc298..f04394190 100644 --- a/src/dialogs/DsoCaseDetail.vue +++ b/src/dialogs/DsoCaseDetail.vue @@ -98,17 +98,17 @@ @@ -195,6 +195,27 @@ export default { }, computed: { + /** + * The case, under a name a template expression may actually use. + * + * 🔴 THE PROP IS CALLED `case`, AND A TEMPLATE CANNOT READ IT. Vue + * parses every template expression as JavaScript, and `case` is a + * reserved word: `{{ case.title }}` is a compile error, not a lookup + * that returns undefined. So this alias is not a nicety, it is the + * only way the template reaches the prop at all. + * + * The template was written against `zaak` and the prop was later + * renamed to `case` without it, which is why every field in this + * dialog rendered as nothing. + * + * @return {object} The case this dialog shows. + * + * @spec exclude presentational alias for a reserved-word prop name + */ + zaak() { + return this.case + }, + zaakId() { return this.case.uuid || this.case.id || '' }, @@ -250,7 +271,7 @@ export default { const { data } = await axios.post( generateUrl( '/apps/dossiq/api/dso/cases/' - + encodeURIComponent(this.caseId) + + encodeURIComponent(this.zaakId) + '/transition', ), payload, diff --git a/src/dialogs/SamenwerkverzoekDialog.vue b/src/dialogs/SamenwerkverzoekDialog.vue index c48d4d26d..eab134baf 100644 --- a/src/dialogs/SamenwerkverzoekDialog.vue +++ b/src/dialogs/SamenwerkverzoekDialog.vue @@ -31,7 +31,7 @@ v-for="org in commonOrganizations" :key="org" type="tertiary" - @click="aangezochtBevoegdGezag = org"> + @click="requestedCompetentAuthority = org"> {{ org }} @@ -56,7 +56,7 @@ {{ t('dossiq', 'Initiate') }} diff --git a/tests/Unit/Flow/CaseFlowDeclarationTest.php b/tests/Unit/Flow/CaseFlowDeclarationTest.php index 5ca0ca8fb..eddf74b06 100644 --- a/tests/Unit/Flow/CaseFlowDeclarationTest.php +++ b/tests/Unit/Flow/CaseFlowDeclarationTest.php @@ -429,34 +429,136 @@ private function flowDeclarationsIn(array $document): array { }//end flowDeclarationsIn() /** - * Each human step names an assignee. + * Every shipped ask names somebody, in EVERY shipped flow. * * An unassigned step is answerable by ANYONE — OpenRegister's resume guard * treats silence as "no restriction", deliberately, because webhook and * child-run signals record no assignee. In a case flow that would mean any - * authenticated user could advance somebody's application. + * authenticated user could advance somebody's application, which is why + * DossiqAskPersonNode::validateConfig refuses one outright. + * + * 🔴 THIS USED TO READ ONLY THE CASE FLOW, AND THE OTHER SHIPPED FLOW WAS + * BROKEN THE WHOLE TIME. `register.d/72-committees-to-decidiq.json` ships a + * bezwaar-advice flow whose two `dossiq.askPerson` nodes declared no + * assignee at all: validateConfig therefore threw on the first execute and + * every advice request ever created failed its run at the first human step. + * Reading `$this->flow` alone could not see it. The sweep now walks every + * declaration file, the same way testNoShippedFlowPutsAConditionOnAnEdge + * does, so a second flow cannot be exempt from the first flow's rules. */ public function testEveryAskNamesWhoIsBeingAsked(): void { - $asks = array_values( - array_filter($this->flow['nodes'], static fn (array $n): bool => ($n['type'] ?? '') === 'dossiq.askPerson') - ); + $asksSeen = 0; - $this->assertNotEmpty($asks); + foreach ($this->shippedFlows() as $entry) { + ['file' => $file, 'flow' => $flow] = $entry; - foreach ($asks as $ask) { - $this->assertNotSame( - '', - trim((string)($ask['config']['assignee'] ?? '')), - sprintf('Ask node "%s" names nobody, so anyone could answer it.', $ask['id']) - ); - $this->assertNotSame( - '', - trim((string)($ask['config']['question'] ?? '')), - sprintf('Ask node "%s" asks nothing.', $ask['id']) - ); + foreach ((array)($flow['nodes'] ?? []) as $node) { + if (($node['type'] ?? '') !== 'dossiq.askPerson') { + continue; + } + + $asksSeen++; + $where = sprintf('%s, flow "%s", node "%s"', $file, (string)($flow['name'] ?? '?'), (string)($node['id'] ?? '?')); + + $this->assertNotSame( + '', + trim((string)($node['config']['assignee'] ?? '')), + sprintf('%s names nobody. validateConfig refuses that, so the run dies at this step.', $where) + ); + $this->assertNotSame( + '', + trim((string)($node['config']['question'] ?? '')), + sprintf('%s asks nothing.', $where) + ); + } } + + $this->assertGreaterThan(0, $asksSeen, 'The sweep found no shipped asks at all: the query, not the data, is broken.'); }//end testEveryAskNamesWhoIsBeingAsked() + /** + * A templated assignee carries a literal fallback. + * + * 🔴 THE ONE THING A TEMPLATE CAN ALWAYS DO IS RESOLVE TO NOBODY, AND THAT + * KILLED RUNS ON A FRESH RIG. The case flow's supplement ask named + * `{{ case.assignee }}`, `assignee` is not in the case schema's `required`, + * and a case filed from the New case dialog with only a title and a case + * type therefore has none. The step threw + * `could not resolve the assignee "{{ case.assignee }}"`, the run failed, + * and the case sat in "Wacht op aanvulling" with no task for anybody. + * Reproduced twice on independent clean installs. + * + * So a declaration that MIGHT render to nobody must say where the ask goes + * when it does. The fallback must be a literal: a second template can fail + * the same way the first one did, on the same missing field, and would only + * move the failure one line down. ProvisionAssignedGroupsTest separately + * requires that literal to be a group the install actually creates. + */ + public function testEveryTemplatedAssigneeDeclaresALiteralFallback(): void { + $checked = 0; + + foreach ($this->shippedFlows() as $entry) { + ['file' => $file, 'flow' => $flow] = $entry; + + foreach ((array)($flow['nodes'] ?? []) as $node) { + if (($node['type'] ?? '') !== 'dossiq.askPerson') { + continue; + } + + $assignee = trim((string)($node['config']['assignee'] ?? '')); + if (str_contains($assignee, '{{') === false) { + continue; + } + + $checked++; + $where = sprintf('%s, flow "%s", node "%s"', $file, (string)($flow['name'] ?? '?'), (string)($node['id'] ?? '?')); + $fallback = trim((string)($node['config']['assigneeFallback'] ?? '')); + + $this->assertNotSame( + '', + $fallback, + sprintf( + '%s names the template "%s" and no assigneeFallback. When it resolves to nobody the ' + . 'step throws and the run dies, which is what a case filed with no assignee did.', + $where, + $assignee + ) + ); + $this->assertStringNotContainsString( + '{{', + $fallback, + sprintf('%s falls back to another template, which can fail exactly as the first one did.', $where) + ); + } + } + + $this->assertGreaterThan(0, $checked, 'No shipped ask templates its assignee: the query, not the data, is broken.'); + }//end testEveryTemplatedAssigneeDeclaresALiteralFallback() + + /** + * Every flow declared in every shipped register file. + * + * @return array}> The declarations. + */ + private function shippedFlows(): array { + $files = array_merge( + [__DIR__ . '/../../../lib/Settings/dossiq_register.json'], + (glob(__DIR__ . '/../../../lib/Settings/register.d/*.json') ?: []) + ); + + $found = []; + foreach ($files as $file) { + $document = json_decode((string)file_get_contents($file), true); + $this->assertIsArray($document, basename((string)$file) . ' must be valid JSON.'); + + foreach ($this->flowDeclarationsIn($document) as $flow) { + $found[] = ['file' => basename((string)$file), 'flow' => $flow]; + } + } + + return $found; + }//end shippedFlows() + /** * The case cannot reach its final status without its decision document. */ diff --git a/tests/Unit/Flow/DossiqAskPersonNodeTest.php b/tests/Unit/Flow/DossiqAskPersonNodeTest.php index 73ddd44fe..6a37d45b9 100644 --- a/tests/Unit/Flow/DossiqAskPersonNodeTest.php +++ b/tests/Unit/Flow/DossiqAskPersonNodeTest.php @@ -271,9 +271,10 @@ public function testATemplatedAssigneeIsRenderedAgainstTheCase(): void { }//end testATemplatedAssigneeIsRenderedAgainstTheCase() /** - * An assignee template that resolves to nothing refuses LOUDLY. A quiet - * empty assignee would create a task ANYONE can answer, because - * OpenRegister's resume guard treats silence as "no restriction". + * An assignee template that resolves to nothing refuses LOUDLY when the + * step declares nowhere else to send the ask. A quiet empty assignee would + * create a task ANYONE can answer, because OpenRegister's resume guard + * treats silence as "no restriction". */ public function testAnAssigneeTemplateThatResolvesToNothingRefuses(): void { $resume = self::resumeSlot('ask-indiener'); @@ -289,6 +290,97 @@ public function testAnAssigneeTemplateThatResolvesToNothingRefuses(): void { } }//end testAnAssigneeTemplateThatResolvesToNothingRefuses() + /** + * 🔴 A CASE WITH NO ASSIGNEE GETS THE DECLARED FALLBACK, NOT A DEAD RUN. + * + * `assignee` is not in the case schema's `required`, so a case filed from + * the New case dialog with only a title and a case type has none. The + * supplement ask then threw + * `could not resolve the assignee "{{ case.assignee }}"`, the run FAILED, + * and the case sat in "Wacht op aanvulling" with no task for anybody and + * nothing waiting on it. Reproduced twice on independent clean installs; + * the e2e never saw it because every case it files names an assignee. + * + * The shipped flow now declares `behandelaars` as the fallback, so + * unclaimed work reaches the handlers' queue instead of killing the case. + * + * @return void + */ + public function testTheDeclaredFallbackTakesTheAskWhenTheCaseNamesNobody(): void { + $resume = self::resumeSlot('ask-aanvulling'); + $config = array_merge( + $this->config(), + ['assignee' => '{{ case.assignee }}', 'assigneeFallback' => 'behandelaars'] + ); + + try { + // The case names no assignee: exactly the New case dialog's shape. + $this->node()->execute($this->items(), $config, $this->context($resume)); + } catch (FlowSuspension $e) { + // expected: the task is outstanding + } + + self::assertCount(1, $this->written, 'The ask must still produce exactly one task.'); + self::assertSame('behandelaars', $this->written[0]['assignee'], 'The task must carry the fallback group.'); + self::assertSame( + 'behandelaars', + $resume->get('assignee'), + 'So must the resume slot, or the guard refuses every member of that group.' + ); + }//end testTheDeclaredFallbackTakesTheAskWhenTheCaseNamesNobody() + + /** + * The fallback is a SECOND choice, never the first one. + * + * A case that does name a handler must reach that handler. A fallback that + * won over a resolvable assignee would quietly move every ask onto a group + * and lose the one person who owns the case. + * + * @return void + */ + public function testAResolvableAssigneeWinsOverTheFallback(): void { + $resume = self::resumeSlot('ask-aanvulling'); + $items = [['json' => ['id' => 'case-1', 'title' => 'Dakkapel', 'assignee' => 'alice']]]; + $config = array_merge( + $this->config(), + ['assignee' => '{{ case.assignee }}', 'assigneeFallback' => 'behandelaars'] + ); + + try { + $this->node()->execute($items, $config, $this->context($resume)); + } catch (FlowSuspension $e) { + // expected: the task is outstanding + } + + self::assertCount(1, $this->written); + self::assertSame('alice', $this->written[0]['assignee'], 'The case names a handler, so the handler is asked.'); + }//end testAResolvableAssigneeWinsOverTheFallback() + + /** + * A fallback that resolves to nobody either still fails CLOSED. + * + * The fallback exists to give the ask a defined destination, not to make + * "nobody" acceptable. If neither names a principal the step must refuse: + * an unassigned task is answerable by any authenticated user. + * + * @return void + */ + public function testAFallbackThatResolvesToNothingStillRefuses(): void { + $resume = self::resumeSlot('ask-aanvulling'); + $config = array_merge( + $this->config(), + ['assignee' => '{{ case.assignee }}', 'assigneeFallback' => '{{ case.caseTypeOwner }}'] + ); + + $this->expectException(RuntimeException::class); + + try { + $this->node()->execute($this->items(), $config, $this->context($resume)); + } finally { + self::assertSame([], $this->written, 'No task may be created when neither value names a principal.'); + } + }//end testAFallbackThatResolvesToNothingStillRefuses() + /** * 🔑 The task names the run AND the node — both are needed to resume. */ diff --git a/tests/Unit/Repair/ProvisionAssignedGroupsTest.php b/tests/Unit/Repair/ProvisionAssignedGroupsTest.php index 77f6494e6..82b61289e 100644 --- a/tests/Unit/Repair/ProvisionAssignedGroupsTest.php +++ b/tests/Unit/Repair/ProvisionAssignedGroupsTest.php @@ -59,8 +59,16 @@ public function testCreatesEveryMissingAssignedGroupAndSeedsIt(): void { $adminGroup = $this->createMock(IGroup::class); $adminGroup->method('getUsers')->willReturn([$administrator]); + // ONCE PER GROUP, NOT ONCE. This used to say `once()` while the step + // provisioned one group, and adding a second turned every symptom into + // somebody else's: PHPUnit throws its "called more than expected" from + // inside addUser(), seedWithAdministrators() catches Throwable, and the + // run then reported the second group as created-but-empty. The failure + // named a warning nobody had written. $created = $this->createMock(IGroup::class); - $created->expects($this->once())->method('addUser')->with($administrator); + $created->expects($this->exactly(count(ProvisionAssignedGroups::ASSIGNED_GROUPS))) + ->method('addUser') + ->with($administrator); $groupManager = $this->createMock(IGroupManager::class); $groupManager->method('groupExists')->willReturn(false); @@ -246,9 +254,18 @@ private function collectAskPersonAssignees(mixed $node, string $file, array &$fo $type = ($node['type'] ?? ''); if ($type === 'dossiq.askPerson') { $config = (array)($node['config'] ?? []); - $assignee = trim((string)($config['assignee'] ?? '')); - if ($assignee !== '' && str_contains($assignee, '{{') === false) { - $found[] = ['file' => $file, 'assignee' => $assignee]; + + // BOTH KEYS, NOT JUST THE PRIMARY. `assigneeFallback` is where the + // ask goes when `{{ case.assignee }}` names nobody, which is the + // exact case the primary could not serve. A fallback group nobody + // is a member of fails the completion gate the same way a missing + // primary group does, and it fails on the run that had already + // found no other principal to ask. + foreach (['assignee', 'assigneeFallback'] as $key) { + $assignee = trim((string)($config[$key] ?? '')); + if ($assignee !== '' && str_contains($assignee, '{{') === false) { + $found[] = ['file' => $file, 'assignee' => $assignee]; + } } } diff --git a/tests/e2e/case-flow-live-journeys.spec.ts b/tests/e2e/case-flow-live-journeys.spec.ts index e8f8bb33c..990c35ea6 100644 --- a/tests/e2e/case-flow-live-journeys.spec.ts +++ b/tests/e2e/case-flow-live-journeys.spec.ts @@ -372,12 +372,12 @@ test.describe('Case flow — live journeys on an adopted flow', () => { await shoot(page, '01-incomplete-case-filed.png') }) - test('Then one worker pass gives the applicant a task and the case says "Wacht op aanvulling"', async ({ + test('Then one worker pass gives the handler a supplement task and the case says "Wacht op aanvulling"', async ({ page, }) => { workerPass() - // What the applicant reads, captured BEFORE the assertions so a + // What the applicant reads on the case, captured BEFORE the assertions so a // failing run still leaves the evidence of what a person saw. await openCase(page, incompleteCase, 'Carport Molenweg 5') await shoot(page, '02-applicant-waiting-case.png') @@ -385,19 +385,19 @@ test.describe('Case flow — live journeys on an adopted flow', () => { const run = await getJson(api, `${OR}/flow-runs/${incompleteRun}`) expect( run.status, - `After the worker pass the run must be suspended on the applicant. ${await describeRun(api, incompleteRun)}`, + `After the worker pass the run must be suspended on the supplement ask. ${await describeRun(api, incompleteRun)}`, ).toBe('suspended') const tasks = await tasksForCase(api, incompleteCase) expect( tasks, - 'The incomplete case must have exactly one task for the applicant.', + 'The incomplete case must have exactly one supplement task.', ).toHaveLength(1) const task = tasks[0] applicantTask = String(task.id) - expect(String(task.title)).toBe('Vul uw aanvraag aan') + expect(String(task.title)).toBe('Vraag de indiener om aanvulling') expect(String(task.flowRun ?? '')).toBe(incompleteRun) - expect(String(task.flowNode ?? '')).toBe('ask-indiener') + expect(String(task.flowNode ?? '')).toBe('ask-aanvulling') // The flow names `{{ case.assignee }}`; the task must carry the PERSON, // not the placeholder, or nobody is allowed to answer it. expect(String(task.assignee ?? '')).toBe(ADMIN_USER) @@ -414,15 +414,18 @@ test.describe('Case flow — live journeys on an adopted flow', () => { await page.goto(`/index.php/apps/dossiq/tasks/${applicantTask}`, { waitUntil: 'domcontentloaded', }) - await expect(page.locator('body')).toContainText('Vul uw aanvraag aan', { - timeout: 20_000, - }) + await expect(page.locator('body')).toContainText( + 'Vraag de indiener om aanvulling', + { + timeout: 20_000, + }, + ) // The task says WHICH case is waiting on it. await expect(page.locator('body')).toContainText('Carport Molenweg 5') await shoot(page, '03-applicant-task.png') }) - test('When the applicant supplies what was missing and completes the task, the case moves to "In behandeling"', async ({ + test('When the missing detail is supplied and the task completed, the case moves to "In behandeling"', async ({ page, }) => { await updateObject(api, 'case', incompleteCase, { @@ -443,7 +446,7 @@ test.describe('Case flow — live journeys on an adopted flow', () => { ) expect( status, - `Completing the applicant task must resume the run at the step that asked and re-check completeness. ${await describeRun(api, incompleteRun)}`, + `Completing the supplement task must resume the run at the step that asked and re-check completeness. ${await describeRun(api, incompleteRun)}`, ).toBe('In behandeling') await expect(page.locator('body')).toContainText('In behandeling') @@ -479,7 +482,9 @@ test.describe('Case flow — live journeys on an adopted flow', () => { const tasks = await tasksForCase(api, completeCase) expect( - tasks.filter((t) => String(t.title) === 'Vul uw aanvraag aan'), + tasks.filter( + (t) => String(t.title) === 'Vraag de indiener om aanvulling', + ), 'A complete case must never be asked for more.', ).toHaveLength(0) diff --git a/tests/vitest/dialogTemplateBindings.spec.js b/tests/vitest/dialogTemplateBindings.spec.js new file mode 100644 index 000000000..fe903ad1f --- /dev/null +++ b/tests/vitest/dialogTemplateBindings.spec.js @@ -0,0 +1,339 @@ +// @vitest-environment jsdom +/** + * SPDX-FileCopyrightText: 2026 Conduction / Dossiq Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * These three dialogs RENDER, with a realistic object, and the values reach + * the screen. + * + * 🔴 WHY A FULL MOUNT AND NOT A SHALLOW ONE. A shallow mount that never + * evaluates the template cannot see the defect this file exists for: a rename + * that moved a name in the `