withoutVite(); $this->manager = User::factory()->create(); config(['portal.managers' => [$this->manager->email]]); } /** @return array */ private function input(int $version = 0): array { return ['base_version' => $version, 'definition' => config('virtual-desk.services.project-change'), 'reason' => 'Synthetic QA definition change; no actual service or applicant.', 'confirm' => true]; } public function test_numeric_choice_values_do_not_bypass_conditional_requirements(): void { $definition = ['fields' => [ 'choice' => ['label' => 'Choice', 'type' => 'select', 'required' => true, 'options' => ['0' => 'No', '1' => 'Yes']], 'detail' => ['label' => 'Detail', 'type' => 'text', 'required' => true, 'when' => ['field' => 'choice', 'equals' => '1']], ]]; $desk = app(VirtualDesk::class); try { $desk->validateFields(['choice' => 1], $definition, true); $this->fail('A numeric option identifier must not bypass the required dependent answer.'); } catch (ValidationException $error) { $this->assertArrayHasKey('payload.detail', $error->errors()); } $valid = $desk->validateFields(['choice' => 1, 'detail' => 'Synthetic detail'], $definition, true); $this->assertSame('1', $valid['choice']); $this->assertSame('Synthetic detail', $valid['detail']); } public function test_hidden_parent_answers_do_not_activate_nested_required_questions(): void { // Children deliberately precede parents: field order must not change applicability. $definition = ['fields' => [ 'detail' => ['label' => 'Deep detail', 'type' => 'text', 'required' => true, 'when' => ['field' => 'branch', 'equals' => '1']], 'branch' => ['label' => 'Branch', 'type' => 'select', 'required' => true, 'options' => ['0' => 'No', '1' => 'Yes'], 'when' => ['field' => 'root', 'equals' => '1']], 'root' => ['label' => 'Root', 'type' => 'select', 'required' => true, 'options' => ['0' => 'No', '1' => 'Yes']], ]]; $desk = app(VirtualDesk::class); $inactive = $desk->validateFields(['root' => '0', 'branch' => '1'], $definition, true); $this->assertSame('1', $inactive['branch'], 'Keep previous answers without making their descendants applicable.'); $this->assertArrayNotHasKey('detail', $inactive); $saved = $desk->validateFields(['root' => '0', 'branch' => '1', 'detail' => 'Preserved answer'], $definition, true); $this->assertSame('Preserved answer', $saved['detail']); $draft = $desk->validateFields(['root' => '1', 'branch' => 1], $definition, false); $this->assertSame('1', $draft['branch']); try { $desk->validateFields(['root' => '1', 'branch' => 1], $definition, true); $this->fail('An applicable nested question must still be required on submission.'); } catch (ValidationException $error) { $this->assertArrayHasKey('payload.detail', $error->errors()); } $active = $desk->validateFields(['root' => '1', 'branch' => '1', 'detail' => 'Applicable answer'], $definition, true); $this->assertSame('Applicable answer', $active['detail']); } public function test_published_versions_preserve_existing_request_schema_payload_and_source_and_retry_without_duplicates(): void { $url = '/management/balcao/servicos/project-change'; $v1 = $this->actingAs($this->manager)->putJson($url, $this->input())->assertCreated()->assertJsonPath('version', 1)->json('id'); $before = DB::table('desk_service_versions')->where('id', $v1)->first(); $investor = User::factory()->create(); $case = $this->actingAs($investor)->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic schema preservation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $request = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $v1, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id'); $answers = ['title' => 'Existing synthetic answer', 'description' => 'Synthetic description of the proposed change', 'original_reference' => 'SYNTHETIC-REF', 'change_reason' => 'Synthetic justification for version preservation']; $this->putJson('/balcao-virtual/pedidos/'.$request, ['version' => 1, 'payload' => $answers, 'submit' => true, 'consent' => true])->assertOk()->assertJsonPath('status', 'submitted'); $saved = DB::table('desk_requests')->where('id', $request)->first(); $input = $this->input(1); $input['definition']['fields']['extra_question'] = ['label' => 'Additional email', 'type' => 'email', 'required' => true]; $v2 = $this->actingAs($this->manager)->putJson($url, $input)->assertCreated()->assertJsonPath('version', 2)->json('id'); $this->putJson($url, $input)->assertCreated()->assertJsonPath('id', $v2); $this->assertSame(2, DB::table('desk_service_versions')->where('code', 'project-change')->count()); $this->assertSame($before->definition, DB::table('desk_service_versions')->where('id', $v1)->value('definition')); $this->assertSame($before->source, DB::table('desk_service_versions')->where('id', $v2)->value('source')); $after = DB::table('desk_requests')->where('id', $request)->first(); $this->assertSame($saved->payload, $after->payload); $this->assertSame($saved->submitted_snapshot, $after->submitted_snapshot); $this->assertSame('submitted', $after->status); $this->assertSame($v1, $after->service_version_id); $this->actingAs($investor)->getJson('/balcao-virtual/pedidos/'.$request)->assertOk()->assertJsonMissingPath('definition.fields.extra_question'); $next = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $v2, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id'); $this->getJson('/balcao-virtual/pedidos/'.$next)->assertOk()->assertJsonPath('definition.fields.extra_question.required', true); $this->putJson('/balcao-virtual/pedidos/'.$next, ['version' => 1, 'payload' => $answers, 'submit' => true, 'consent' => true]) ->assertUnprocessable()->assertJsonValidationErrors('payload.extra_question'); $this->putJson('/balcao-virtual/pedidos/'.$next, ['version' => 1, 'payload' => [...$answers, 'extra_question' => 'invalid-email'], 'submit' => true, 'consent' => true]) ->assertUnprocessable()->assertJsonValidationErrors('payload.extra_question'); $this->putJson('/balcao-virtual/pedidos/'.$next, ['version' => 1, 'payload' => [...$answers, 'extra_question' => 'synthetic@example.invalid'], 'submit' => true, 'consent' => true]) ->assertOk()->assertJsonPath('status', 'submitted'); $this->assertSame(2, DB::table('audit_events')->where('action', 'service_definition_version_published')->count()); $history = '/management/balcao/servicos/project-change/versoes'; $this->getJson($history)->assertForbidden(); $this->actingAs($this->manager)->getJson($history)->assertOk()->assertJsonPath('total', 2) ->assertJsonPath('data.0.id', $v2)->assertJsonPath('data.1.id', $v1) ->assertJsonPath('data.0.reason', $input['reason'])->assertJsonPath('data.0.author_id', $this->manager->id) ->assertJsonPath('data.0.definition.fields.extra_question.type', 'email') ->assertJsonMissingPath('data.1.definition.fields.extra_question')->assertJsonMissingPath('data.0.payload') ->assertDontSee('Existing synthetic answer', false); $this->assertSame($saved->submitted_snapshot, DB::table('desk_requests')->where('id', $request)->value('submitted_snapshot')); } public function test_authority_conflicts_conditions_reserved_keys_and_stage_changes_are_checked_on_server(): void { $url = '/management/balcao/servicos/project-change'; $this->actingAs(User::factory()->create())->getJson('/management/balcao/servicos')->assertForbidden(); $this->putJson($url, $this->input())->assertForbidden(); $this->actingAs($this->manager)->getJson('/management/balcao/servicos')->assertOk()->assertJsonCount(3, 'services'); $this->putJson($url, $this->input())->assertCreated(); $this->putJson($url, $this->input(1))->assertUnprocessable()->assertJsonValidationErrors('definition'); $stale = $this->input(); $stale['definition']['title'] = 'Stale alternative'; $this->putJson($url, $stale)->assertConflict(); $invalid = $this->input(1); $invalid['definition']['fields']['regime']['type'] = 'text'; unset($invalid['definition']['fields']['regime']['options']); $this->putJson($url, $invalid)->assertUnprocessable()->assertJsonValidationErrors('definition.fields.regime.type'); $invalid = $this->input(1); $invalid['definition']['stage'] = 'E1'; $this->putJson($url, $invalid)->assertUnprocessable()->assertJsonValidationErrors('definition.stage'); $invalid = $this->input(1); $invalid['definition']['fields']['context_reuse'] = ['label' => 'Reserved', 'type' => 'text', 'required' => false]; $this->putJson($url, $invalid)->assertUnprocessable(); $invalid = $this->input(1); $invalid['definition']['fields']['title']['when'] = ['field' => 'description', 'equals' => 'x']; $invalid['definition']['fields']['description']['when'] = ['field' => 'title', 'equals' => 'x']; $this->putJson($url, $invalid)->assertUnprocessable(); $invalid = $this->input(1); $invalid['definition']['fields']['description']['when'] = ['field' => 'regime', 'equals' => 'missing-option']; $this->putJson($url, $invalid)->assertUnprocessable(); $invalid = $this->input(1); $invalid['definition']['fields']['regime']['options'] = []; $this->putJson($url, $invalid)->assertUnprocessable(); $this->assertSame(1, DB::table('desk_service_versions')->where('code', 'project-change')->count()); } public function test_history_pagination_is_bounded_and_does_not_mix_service_definitions(): void { $definition = config('virtual-desk.services.project-change'); for ($version = 1; $version <= 12; $version++) { DB::table('desk_service_versions')->insert(['id' => (string) Str::uuid(), 'code' => 'project-change', 'version' => $version, 'status' => 'published', 'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $this->manager->id, 'published_by' => $this->manager->id, 'published_at' => now()]); } $url = '/management/balcao/servicos/project-change/versoes'; $this->actingAs($this->manager)->getJson($url)->assertOk()->assertJsonCount(10, 'data')->assertJsonPath('total', 12) ->assertJsonPath('data.0.version', 12)->assertJsonPath('data.9.version', 3); $this->getJson($url.'?page=2')->assertOk()->assertJsonCount(2, 'data')->assertJsonPath('data.0.version', 2)->assertJsonPath('data.1.version', 1); $this->getJson($url.'?page=999999999')->assertOk()->assertJsonPath('page', 2)->assertJsonCount(2, 'data'); $this->getJson($url.'?page=0')->assertUnprocessable(); $this->getJson('/management/balcao/servicos/project-follow-up/versoes')->assertOk()->assertJsonCount(0, 'data')->assertJsonPath('total', 0); $this->getJson('/management/balcao/servicos/missing-service/versoes')->assertNotFound(); $this->assertSame(12, DB::table('desk_service_versions')->count()); DB::table('desk_service_versions')->insert(['id' => (string) Str::uuid(), 'code' => 'historical-service', 'version' => 1, 'status' => 'published', 'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $this->manager->id]); $this->getJson('/management/balcao/servicos/historical-service/versoes')->assertOk()->assertJsonCount(1, 'data'); $this->getJson('/management/balcao/servicos')->assertOk()->assertJsonPath('services.3.code', 'historical-service')->assertJsonPath('services.3.editable', false); $this->putJson('/management/balcao/servicos/historical-service', $this->input(1))->assertNotFound(); $this->assertSame(13, DB::table('desk_service_versions')->count()); } public function test_location_source_remains_available_whenever_a_configured_location_applies(): void { $url = '/management/balcao/servicos/project-change'; $this->actingAs($this->manager); $missing = $this->input(); unset($missing['definition']['fields']['location_source']); $this->putJson($url, $missing)->assertUnprocessable()->assertJsonValidationErrors('definition.fields.location_source'); $narrower = $this->input(); $narrower['definition']['fields']['location_source']['when'] = ['field' => 'regime', 'equals' => 'special']; $this->putJson($url, $narrower)->assertUnprocessable()->assertJsonValidationErrors('definition.fields.location_source.when'); $this->assertDatabaseCount('desk_service_versions', 0); // The source can have broader applicability than the location, including ancestor conditions. $broader = $this->input(); $broader['definition']['fields']['branch'] = ['label' => 'Synthetic branch', 'type' => 'select', 'required' => false, 'options' => ['yes' => 'Yes', 'no' => 'No'], 'when' => ['field' => 'regime', 'equals' => 'special']]; $broader['definition']['fields']['territory_id']['when'] = ['field' => 'branch', 'equals' => 'yes']; $broader['definition']['fields']['location_source']['when'] = ['field' => 'regime', 'equals' => 'special']; $this->putJson($url, $broader)->assertCreated(); $same = $broader; $same['base_version'] = 1; $same['definition']['fields']['location_source']['when'] = ['field' => 'branch', 'equals' => 'yes']; $this->putJson($url, $same)->assertCreated(); $unconditional = $same; $unconditional['base_version'] = 2; unset($unconditional['definition']['fields']['location_source']['when']); $this->putJson($url, $unconditional)->assertCreated(); $withoutLocation = $this->input(3); unset($withoutLocation['definition']['fields']['territory_id'], $withoutLocation['definition']['fields']['location_source']); $this->putJson($url, $withoutLocation)->assertCreated(); $this->assertDatabaseCount('desk_service_versions', 4); } }