Files
Investidor-V2/tests/Feature/DeskServiceEditorTest.php
2026-11-02 20:31:15 +02:00

215 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
final class DeskServiceEditorTest extends TestCase
{
use RefreshDatabase;
private User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->manager = User::factory()->create();
config(['portal.managers' => [$this->manager->email]]);
}
/** @return array<string,mixed> */
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);
}
}