withoutVite(); $this->owner = User::factory()->create(); $this->manager = User::factory()->create(); config(['portal.managers' => [$this->manager->email], 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false]); $edition = $this->actingAs($this->manager)->putJson('/management/balcao/servicos/project-change', ['base_version' => 0, 'definition' => config('virtual-desk.services.project-change'), 'confirm' => true, 'reason' => 'SYNTHETIC owner lifecycle QA'])->assertCreated()->json('id'); $this->caseId = $this->actingAs($this->owner)->postJson('/balcao-virtual/dossiers', ['title' => 'SYNTHETIC owned dossier', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $this->requestId = $this->postJson('/balcao-virtual/dossiers/'.$this->caseId.'/pedidos', ['service_version_id' => $edition, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id'); } private function operation(string $state, int $version): array { return ['state' => $state, 'version' => $version, 'reason' => 'SYNTHETIC owner decision '.$state, 'client_key' => (string) Str::uuid()]; } private function change(array $input): TestResponse { return $this->putJson('/balcao-virtual/dossiers/'.$this->caseId.'/estado', $input); } public function test_owner_closes_and_reopens_without_changing_payload_workflow_or_child_states(): void { $task = CrmTask::create(['desk_case_id' => $this->caseId, 'guidance_key' => 'synthetic_open_task', 'title' => 'SYNTHETIC accepted task', 'status' => 'open', 'accepted_by' => $this->owner->id, 'accepted_at' => now(), 'assignee_user_id' => $this->owner->id, 'external_sync_status' => 'pending_authorization', 'data_classification' => 'confidential']); $payment = (string) Str::uuid(); DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $this->requestId, 'reference' => 'SYNTHETIC-PENDING', 'amount' => '25.50', 'currency' => 'MZN', 'source_reference' => 'SYNTHETIC pending declaration', 'status' => 'pending']); $before = DB::table('desk_cases')->where('id', $this->caseId)->first(); $request = (array) DB::table('desk_requests')->where('id', $this->requestId)->first(); $this->getJson('/balcao-virtual/dossiers/'.$this->caseId)->assertOk()->assertJsonPath('canChangeState', true)->assertJsonPath('activity.open_requests', 1)->assertJsonPath('activity.open_tasks', 1)->assertJsonPath('activity.pending_payments', 1); $this->change($this->operation('closed', 1))->assertOk()->assertJsonPath('version', 2)->assertJsonPath('replayed', false); $closed = DB::table('desk_cases')->where('id', $this->caseId)->first(); $this->assertNotNull($closed->closed_at); $this->assertSame($before->payload, $closed->payload); $this->assertSame($before->workflow, $closed->workflow); $this->getJson('/balcao-virtual/pedidos/'.$this->requestId)->assertOk()->assertJsonPath('operationsEditable', false)->assertJsonPath('request.status', 'draft'); $this->change($this->operation('open', 2))->assertOk()->assertJsonPath('version', 3)->assertJsonPath('closed_at', null); $this->assertSame($request, (array) DB::table('desk_requests')->where('id', $this->requestId)->first()); $this->assertDatabaseHas('tasks', ['id' => $task->id, 'status' => 'open', 'version_no' => 1]); $this->assertDatabaseHas('desk_payments', ['id' => $payment, 'status' => 'pending', 'amount' => '25.50', 'version' => 1]); $this->getJson('/balcao-virtual/pedidos/'.$this->requestId)->assertOk()->assertJsonPath('fieldsEditable', true); } public function test_manager_or_other_organization_writer_is_not_the_registered_owner(): void { $this->actingAs($this->manager)->change($this->operation('closed', 1))->assertForbidden(); $org = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC owner organization', 'organization_type' => 'private']); DB::table('desk_cases')->where('id', $this->caseId)->update(['organization_id' => $org]); foreach ([$this->owner, $this->manager] as $member) { DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'organization_id' => $org, 'user_id' => $member->id, 'role' => 'admin']); } $this->getJson('/balcao-virtual/dossiers/'.$this->caseId)->assertOk()->assertJsonPath('canWrite', true)->assertJsonPath('canChangeState', false); $this->change($this->operation('closed', 1))->assertForbidden(); $this->actingAs($this->owner)->change($this->operation('closed', 1))->assertOk(); DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->owner->id)->update(['deleted_at' => now()]); $this->change($this->operation('open', 2))->assertForbidden(); $this->assertDatabaseHas('desk_cases', ['id' => $this->caseId, 'version' => 2]); } public function test_exact_retry_is_idempotent_and_old_close_retry_does_not_reverse_a_later_reopening(): void { $close = $this->operation('closed', 1); $this->change($close)->assertOk(); $this->change([...$close, 'version' => '1'])->assertOk()->assertJsonPath('version', 2)->assertJsonPath('replayed', true); $this->change([...$close, 'reason' => 'SYNTHETIC changed operation'])->assertConflict(); $this->change($this->operation('open', 2))->assertOk(); $this->change($close)->assertOk()->assertJsonPath('version', 3)->assertJsonPath('closed_at', null)->assertJsonPath('replayed', true); $this->assertSame(2, DB::table('desk_events')->where('action', 'case_state_changed')->count()); } public function test_stale_version_missing_reason_or_same_state_cannot_change_the_dossier(): void { $this->change($this->operation('open', 1))->assertConflict(); $this->change([...$this->operation('closed', 1), 'reason' => ''])->assertUnprocessable()->assertJsonValidationErrors('reason'); $this->postJson('/balcao-virtual/dossiers/'.$this->caseId.'/etapas', ['version' => 1, 'stage' => 'E0', 'status' => 'preparing', 'reason' => 'SYNTHETIC intervening change'])->assertOk(); $this->change($this->operation('closed', 1))->assertConflict(); $this->assertDatabaseHas('desk_cases', ['id' => $this->caseId, 'closed_at' => null, 'version' => 2]); } public function test_reopening_does_not_reopen_a_cancelled_request_and_records_owner_reason_and_summary(): void { $this->postJson('/balcao-virtual/pedidos/'.$this->requestId.'/estado', ['version' => 1, 'status' => 'cancelled', 'reason' => 'SYNTHETIC request cancellation'])->assertOk(); $this->change($this->operation('closed', 1))->assertOk(); $this->change($this->operation('open', 2))->assertOk(); $this->getJson('/balcao-virtual/pedidos/'.$this->requestId)->assertOk()->assertJsonPath('request.status', 'cancelled')->assertJsonPath('operationsEditable', false); $event = DB::table('desk_events')->where('case_id', $this->caseId)->where('action', 'case_state_changed')->orderBy('event_order')->first(); $data = app(VirtualDesk::class)->decode($event->payload); $this->assertSame($this->owner->id, (int) $event->actor_id); $this->assertSame('SYNTHETIC owner decision closed', $data['reason']); $this->assertSame(0, $data['activity']['open_requests']); $this->assertNull($data['before']['closed_at']); $this->assertNotNull($data['after']['closed_at']); } public function test_unknown_optional_task_count_is_not_reported_as_zero(): void { DB::statement('ALTER TABLE tasks RENAME TO tasks_qa_unavailable'); try { $activity = app(VirtualDesk::class)->caseActivity(DB::table('desk_cases')->where('id', $this->caseId)->first()); $this->assertNull($activity['open_tasks']); $this->assertSame(1, $activity['open_requests']); } finally { DB::statement('ALTER TABLE tasks_qa_unavailable RENAME TO tasks'); } } }