tenant = Tenant::query()->create(['name' => 'Empresa de contratação fictícia']); foreach (['preparer', 'reviewer', 'approver'] as $key) { $this->$key = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); } $this->body = ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Processo fictício de contratação', 'purpose' => 'Necessidade fictícia documentada para avaliação.', 'closes_on' => today()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 60], ['label' => 'Experiência', 'weight' => 40]]]; $this->actingAs($this->preparer); }); test('operational intelligence counts occurrences separately from processes and ranks calendar delays without private content', function () { config(['app.timezone' => 'Africa/Maputo']); $alerts = []; foreach (range(1, 7) as $i) { $alerts[] = ['id' => 'clarification-'.$i, 'process_id' => 42, 'reference' => 'PES-42', 'label' => 'Prazo vencido', 'due_on' => '2026-10-'.(30 - $i), 'href' => '/pessoas/42#clarification-'.$i, 'private_text' => 'DO-NOT-EXPOSE']; } $input = ['updated_at' => '2026-10-29T22:05:00+00:00', 'counts' => ['processes' => 2], 'tasks' => [ ['action' => 'clarification_respond', 'due_on' => null], ['action' => 'evaluate', 'due_on' => null], ['action' => 'evaluate', 'due_on' => '2026-10-30'], ], 'approvals' => [['id' => '42:confirm']], 'alerts' => $alerts]; $facts = app(PeopleOperationalInsights::class)->build($input); expect($facts['coverage'])->toBe(['processes' => 2, 'tasks' => 3, 'approvals' => 1]) ->and($facts['overdue']['items'])->toBe(7)->and($facts['overdue']['processes'])->toBe(1) ->and($facts['overdue']['maximum_days'])->toBe(7)->and($facts['overdue']['oldest'])->toHaveCount(5) ->and($facts['overdue']['oldest'][0]['href'])->toBe('/pessoas/42#clarification-7') ->and($facts['tasks_without_deadline'])->toBe(2)->and($facts['task_actions']['evaluate'])->toBe(2) ->and(json_encode($facts))->not->toContain('DO-NOT-EXPOSE'); }); test('operational intelligence does not invent overdue work or a maximum delay for an empty authorised scope', function () { $facts = app(PeopleOperationalInsights::class)->build(['updated_at' => now()->toIso8601String(), 'counts' => ['processes' => 0], 'tasks' => [], 'approvals' => [], 'alerts' => []]); expect($facts['overdue'])->toBe(['items' => 0, 'processes' => 0, 'maximum_days' => null, 'oldest' => []]) ->and($facts['task_actions'])->toBe([])->and($facts['tasks_without_deadline'])->toBe(0); }); test('people evidence brief follows returned approved and contract states without revealing reserved content', function (string $kind) { $record = hiringAssessment($this, $kind); $application = DB::table('people_applications')->sole(); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [ ['score' => '8', 'evidence' => 'Evidência fictícia de competências.'], ['score' => '7', 'evidence' => 'Evidência fictícia de experiência.'], ]])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect(); $this->actingAs($this->approver)->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page ->where('brief.method', 'people_evidence_brief_v1')->where('brief.claims.1.source.pointer', 'process.selection.rows') ->where('brief.claims.2.text', 'Existe uma proposta de selecção pendente de conferência independente.')); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'returned'])->assertRedirect(); $this->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('brief.claims.1.source.pointer', 'candidates') ->where('brief.claims.2.text', 'A proposta de selecção foi devolvida para nova análise; não está aprovada.')); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => 'MINUTA-RESERVADA', 'starts_on' => today()->toDateString(), 'terms' => 'TERMOS-RESERVADOS-APENAS-AOS-AUTORIZADOS'])->assertRedirect(); $audit = AuditLog::query()->count(); $this->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('brief.claims.3.id', 'contract')->has('brief.claims', 5)); $this->actingAs($this->approver)->get('/pessoas/'.$record->id)->assertDontSee('TERMOS-RESERVADOS')->assertInertia(fn (Assert $page) => $page ->where('brief.claims.2.text', 'Existe uma selecção aprovada e preservada; não equivale a contrato assinado ou admissão efectiva.') ->has('brief.claims', 4)->where('brief.claims.3.id', 'clarifications')); expect(AuditLog::query()->count())->toBe($audit); })->with(['service', 'staff']); test('people brief fingerprints only its redacted inputs and remains stable on a repeated consultation', function () { $record = hiringCreate($this); $snapshot = app(PeopleHiring::class)->snapshot($record); $builder = app(PeopleEvidenceBrief::class); $clarifications = ['ready' => true, 'items' => []]; $first = $builder->build($snapshot, [], $clarifications, false, false); $this->travel(1)->minutes(); expect($builder->build($snapshot, [], $clarifications, false, false)['basis_sha256'])->toBe($first['basis_sha256']); $snapshot['contract'] = ['terms' => 'HIDDEN-CONTRACT-123']; expect($builder->build($snapshot, [], $clarifications, false, false)['basis_sha256'])->toBe($first['basis_sha256']) ->and(json_encode($first))->not->toContain('HIDDEN-CONTRACT'); }); test('disabling people summaries preserves the authorised report and never grants access to another participant', function () { $record = hiringCreate($this); $this->tenant->update(['intelligence_policy' => ['revision' => 1, 'configured' => true, 'procurement_brief' => true, 'people_brief' => false]]); $this->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('brief', null)->where('process.id', $record->id)); $outsider = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($outsider)->get('/pessoas/'.$record->id)->assertForbidden(); $this->tenant->update(['intelligence_policy' => ['revision' => 2, 'configured' => true, 'procurement_brief' => true, 'people_brief' => true]]); $this->get('/pessoas/'.$record->id)->assertForbidden(); $this->actingAs($this->preparer)->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('brief.method', 'people_evidence_brief_v1')); }); test('people AI preparation only contains allowed counts without candidate identities or interview evidence', function () { $record = hiringAssessment($this); $plan = app(AiPreparationPlan::class)->checked([...aiPlanInput(), 'data_scopes' => ['candidate_counts']]); $this->tenant->update(['intelligence_policy' => ['revision' => 1, 'configured' => true, 'procurement_brief' => true, 'people_brief' => true, 'ai_plan' => $plan]]); $response = $this->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('aiPreparation.status', 'draft') ->has('aiPreparation.facts', 1)->where('aiPreparation.facts.0.id', 'candidates')); $preview = $response->original->getData()['page']['props']['aiPreparation']; expect(json_encode($preview))->not->toContain('Pessoa fictícia A')->not->toContain('CAND-A')->not->toContain('summary'); }); function hiringCreate(object $test, string $kind = 'service'): PeopleProcess { $test->actingAs($test->preparer)->postJson('/pessoas', [...$test->body, 'kind' => $kind])->assertRedirect(); $record = PeopleProcess::query()->sole(); app(PeopleAccess::class)->assign($test->preparer, $record->id, ['version' => 1, 'user_id' => $test->reviewer->id, 'scopes' => ['review', 'interviews'], 'reason' => 'Avaliador designado para o processo fictício.']); app(PeopleAccess::class)->assign($test->preparer, $record->id, ['version' => 2, 'user_id' => $test->approver->id, 'scopes' => ['approve'], 'reason' => 'Decisor independente do processo fictício.']); return $record; } function hiringCommand(object $test, User $actor, PeopleProcess $record, string $command, array $data = []): TestResponse { return $test->actingAs($actor)->postJson('/pessoas/'.$record->id.'/'.$command, ['version' => $record->fresh()->version, 'reason' => 'Fundamento fictício para esta intervenção.', 'no_conflict' => true, ...$data]); } function hiringAssessment(object $test, string $kind = 'service'): PeopleProcess { $record = hiringCreate($test, $kind); hiringCommand($test, $test->reviewer, $record, 'open')->assertRedirect(); hiringCommand($test, $test->preparer, $record, 'apply', ['operation_key' => (string) Str::uuid(), 'name' => 'Pessoa fictícia A', 'reference' => 'CAND-A', 'summary' => 'Experiência fictícia pertinente ao processo.', 'received_on' => today()->toDateString()])->assertRedirect(); hiringCommand($test, $test->preparer, $record, 'close')->assertRedirect(); return $record->fresh(); } test('both hiring paths preserve criteria selection and an explicitly unsigned contract draft', function (string $kind) { $record = hiringAssessment($this, $kind); $application = DB::table('people_applications')->sole(); $answers = [['score' => '8.25', 'evidence' => 'Competências fictícias documentadas.'], ['score' => '7.50', 'evidence' => 'Experiência fictícia documentada.']]; hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => $answers])->assertRedirect(); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => $answers])->assertConflict(); expect(app(PeopleHiring::class)->matrix($record)[0]['score_minor'])->toBe(795); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect(); hiringCommand($this, $this->reviewer, $record, 'confirm', ['outcome' => 'approved'])->assertForbidden(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => 'MINUTA-01', 'starts_on' => today()->addWeek()->toDateString(), 'terms' => 'Condições fictícias da minuta, ainda sujeitas a conferência e assinatura.'])->assertRedirect(); $record->refresh(); expect($record->status)->toBe('contract_draft')->and($record->contract['status'])->toBe('draft')->and($record->contract['kind'])->toBe($kind) ->and($record->selection['rows'][0]['score_minor'])->toBe(795)->and(AuditLog::query()->where('action', 'like', 'people.%')->count())->toBe(10); $this->get('/pessoas/'.$record->id)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private') ->assertInertia(fn (Assert $page) => $page->component('people/show')->where('process.status', 'contract_draft')->has('history', 10)); hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => 'TROCA', 'starts_on' => today()->toDateString(), 'terms' => 'Tentativa de substituir a minuta.'])->assertConflict(); })->with(['service', 'staff']); test('opening evaluation and selection preserve independent people', function () { $record = hiringCreate($this); hiringCommand($this, $this->preparer, $record, 'open')->assertForbidden(); hiringCommand($this, $this->reviewer, $record, 'open', ['no_conflict' => false])->assertUnprocessable(); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'apply', ['operation_key' => (string) Str::uuid(), 'name' => 'Pessoa fictícia A', 'reference' => 'CAND-A', 'summary' => 'Experiência fictícia pertinente ao processo.', 'received_on' => today()->toDateString()])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'close')->assertRedirect(); $id = DB::table('people_applications')->value('id'); hiringCommand($this, $this->preparer, $record, 'evaluate', ['application_id' => $id, 'answers' => []])->assertForbidden(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id])->assertUnprocessable(); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $id, 'answers' => [['score' => 8, 'evidence' => 'Um critério apenas não é suficiente.']]])->assertUnprocessable(); expect(DB::table('people_reviews')->count())->toBe(0); }); test('opening freezes the configured submission form without changing existing opportunities', function () { $definition = ['available' => true, 'fields' => [['key' => 'full_name', 'label' => 'Nome completo', 'type' => 'text', 'required' => true]], 'documents' => []]; $this->actingAs($this->preparer); $forms = app(SubmissionForms::class); $forms->save($this->preparer, ['audience' => 'service', 'modality' => 'competition', 'version' => 0, 'reason' => 'Definição inicial fictícia de candidatura.', 'definition' => $definition]); $record = hiringCreate($this); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); $frozen = $record->fresh()->rules['submission_form']; $this->actingAs($this->preparer); $forms->save($this->preparer, ['audience' => 'service', 'modality' => 'competition', 'version' => 1, 'reason' => 'Desactivar recepção para oportunidades futuras.', 'definition' => [...$definition, 'available' => false]]); expect($record->fresh()->rules['submission_form'])->toBe($frozen)->and($frozen['version'])->toBe(1)->and($frozen['definition']['available'])->toBeTrue(); $this->actingAs($this->reviewer)->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $p) => $p->where('process.rules.submission_form.version', 1)); }); test('hiring creation is idempotent and stale operations cannot overwrite history', function () { $record = hiringCreate($this); $this->postJson('/pessoas', $this->body)->assertRedirect(); expect(PeopleProcess::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'people.create')->count())->toBe(1); $this->postJson('/pessoas', [...$this->body, 'title' => 'Conteúdo diferente'])->assertConflict(); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'close', ['version' => 1])->assertUnprocessable(); expect($record->fresh()->status)->toBe('open'); }); test('invalid weights and early closing cannot start an unfair comparison', function () { $this->postJson('/pessoas', [...$this->body, 'criteria' => [['label' => 'Único critério', 'weight' => 99]]])->assertUnprocessable(); $this->body['closes_on'] = today()->addWeek()->toDateString(); $record = hiringCreate($this); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'close')->assertUnprocessable(); }); test('company isolation and ordinary purchasing roles do not expose recruitment data', function () { $record = hiringCreate($this, 'staff'); $finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($finance)->get('/pessoas')->assertForbidden(); $this->get('/pessoas/'.$record->id)->assertForbidden(); $other = Tenant::query()->create(['name' => 'Outra empresa']); $outsider = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($outsider)->get('/pessoas/'.$record->id)->assertNotFound(); hiringCommand($this, $outsider, $record, 'open')->assertNotFound(); }); test('configured service access never grants staff recruitment access', function () { $record = hiringCreate($this, 'staff'); $profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $this->tenant->id, 'name' => 'Serviços individuais', 'permissions' => json_encode(['people_service_prepare', 'people_service_review']), 'created_at' => now(), 'updated_at' => now()]); DB::table('organization_memberships')->insert(['tenant_id' => $this->tenant->id, 'user_id' => $this->preparer->id, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]); DB::table('organization_access')->insert(['tenant_id' => $this->tenant->id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]); $this->get('/pessoas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('kinds', ['service'])->has('processes.data', 0)); $this->get('/pessoas/'.$record->id)->assertForbidden(); }); test('evidence and process criteria cannot be rewritten or deleted', function () { $record = hiringAssessment($this); expect(fn () => DB::transaction(fn () => DB::table('people_processes')->where('id', $record->id)->update(['rules' => '{}'])))->toThrow(QueryException::class); expect(fn () => DB::transaction(fn () => DB::table('people_applications')->update(['name' => 'Substituição'])))->toThrow(QueryException::class); expect(fn () => DB::transaction(fn () => DB::table('people_applications')->delete()))->toThrow(QueryException::class); }); test('tied candidates remain unselected until a reasoned proposal and independent decision', function () { $record = hiringCreate($this); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); foreach (['A', 'B'] as $suffix) { hiringCommand($this, $this->preparer, $record, 'apply', ['operation_key' => (string) Str::uuid(), 'name' => 'Pessoa fictícia '.$suffix, 'reference' => 'CAND-'.$suffix, 'summary' => 'Experiência fictícia pertinente ao processo.', 'received_on' => today()->toDateString()])->assertRedirect(); } hiringCommand($this, $this->preparer, $record, 'close')->assertRedirect(); foreach (DB::table('people_applications')->orderBy('id')->get() as $application) { hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [['score' => 8, 'evidence' => 'Competência comprovada ficticiamente.'], ['score' => 8, 'evidence' => 'Experiência comprovada ficticiamente.']]])->assertRedirect(); } expect($record->fresh()->selected_application_id)->toBeNull(); $id = DB::table('people_applications')->orderByDesc('id')->value('id'); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id, 'reason' => 'Escolha fundamentada do segundo candidato empatado.'])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'returned'])->assertRedirect(); expect($record->fresh()->status)->toBe('assessment')->and($record->fresh()->selection['outcome'])->toBe('returned')->and($record->fresh()->selected_application_id)->toBeNull(); }); test('revoked evaluation permissions prevent confirmation of the old selection basis', function () { $record = hiringAssessment($this); $id = DB::table('people_applications')->value('id'); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $id, 'answers' => [['score' => 8, 'evidence' => 'Competência fictícia documentada.'], ['score' => 8, 'evidence' => 'Experiência fictícia documentada.']]])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id])->assertRedirect(); foreach ([$this->preparer, $this->reviewer, $this->approver] as $user) { $profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $this->tenant->id, 'name' => 'Perfil '.$user->id, 'permissions' => json_encode($user->id === $this->reviewer->id ? ['people_staff_review'] : ['admin']), 'created_at' => now(), 'updated_at' => now()]); DB::table('organization_memberships')->insert(['tenant_id' => $this->tenant->id, 'user_id' => $user->id, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]); } DB::table('organization_access')->insert(['tenant_id' => $this->tenant->id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertConflict(); expect($record->fresh()->status)->toBe('selection_review'); }); test('failure to record the audit rolls back hiring creation', function () { $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Audit unavailable')); expect(fn () => app(PeopleHiring::class)->execute($this->preparer, null, 'create', $this->body))->toThrow(RuntimeException::class); expect(PeopleProcess::query()->count())->toBe(0); }); test('an administrator can organise a team without automatic access to candidate content', function () { $record = hiringAssessment($this); $administrator = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($administrator)->get('/pessoas/'.$record->id)->assertForbidden(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->has('processes.data', 0)->has('administration', 1)); $this->get('/pessoas/'.$record->id.'/acessos')->assertOk()->assertInertia(fn (Assert $page) => $page->missing('process.title')->missing('process.purpose')->has('users', 4)->missing('users.0.tenant_id')); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 3, 'user_id' => $administrator->id, 'scopes' => ['view'], 'reason' => 'Consulta atribuída explicitamente ao processo.'])->assertRedirect(); $this->get('/pessoas/'.$record->id)->assertOk(); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 4, 'user_id' => $administrator->id, 'scopes' => [], 'reason' => 'Participação concluída e consulta revogada.'])->assertRedirect(); $this->get('/pessoas/'.$record->id)->assertForbidden(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->has('processes.data', 0)); $this->actingAs($this->preparer)->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 5, 'user_id' => $this->reviewer->id, 'scopes' => [], 'expires_at' => now()->subHour()->toIso8601String(), 'reason' => 'Retirar atribuição que já terminou.'])->assertRedirect(); }); test('financial and interview data are independently redacted from the page and selection snapshot', function () { $this->body['annex'] = ['fields' => ['scope' => 'Âmbito técnico fictício.', 'financial' => 'SEGREDO-FINANCEIRO-123'], 'requirements' => [['description' => 'Habilitação necessária para a tarefa', 'classification' => 'mandatory', 'verification' => 'Comprovativo documental verificado', 'equivalences' => 'Equivalência documentada']]]; $record = hiringAssessment($this); $id = DB::table('people_applications')->value('id'); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $id, 'answers' => [ ['score' => 8, 'evidence' => 'ENTREVISTA-RESERVADA-123', 'evidence_type' => 'observed', 'clarification' => 'Confirmar certificação.'], ['score' => 8, 'evidence' => 'Certificação verificada ficticiamente.', 'evidence_type' => 'verified']]])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id])->assertRedirect(); $this->actingAs($this->approver)->get('/pessoas/'.$record->id)->assertOk()->assertDontSee('SEGREDO-FINANCEIRO-123')->assertDontSee('ENTREVISTA-RESERVADA-123') ->assertInertia(fn (Assert $page) => $page->missing('process.rules.annex.fields.financial')->missing('process.selection.rules.annex.fields.financial')->has('process.selection.rows.0.reviews', 0)); $this->actingAs($this->reviewer)->get('/pessoas/'.$record->id)->assertOk()->assertDontSee('SEGREDO-FINANCEIRO-123')->assertSee('ENTREVISTA-RESERVADA-123'); $this->actingAs($this->preparer)->get('/pessoas/'.$record->id)->assertOk()->assertSee('SEGREDO-FINANCEIRO-123')->assertDontSee('ENTREVISTA-RESERVADA-123'); }); test('process assignment expiry stale versions and foreign users remain enforced', function () { $record = hiringCreate($this); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 2, 'user_id' => $this->reviewer->id, 'scopes' => [], 'reason' => 'Versão antiga não pode alterar a equipa.'])->assertUnprocessable(); $other = Tenant::query()->create(['name' => 'Outra entidade']); $foreign = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 3, 'user_id' => $foreign->id, 'scopes' => ['view'], 'reason' => 'Não associar pessoas de outra empresa.'])->assertNotFound(); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 3, 'user_id' => $this->reviewer->id, 'scopes' => ['review'], 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Intervenção temporária explicitamente atribuída.'])->assertRedirect(); $this->actingAs($this->reviewer)->get('/pessoas/'.$record->id)->assertOk(); $this->travel(2)->hours(); $this->get('/pessoas/'.$record->id)->assertForbidden(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->has('processes.data', 0)); }); test('annexes preserve classified requirements and require explicit evidence types in new interviews', function (string $kind, string $field, string $template) { $this->body['annex'] = ['fields' => [$field => 'Âmbito próprio da função ou serviço.'], 'requirements' => [ ['description' => 'Competência a verificar', 'classification' => 'mandatory', 'verification' => 'Exercício associado à tarefa', 'equivalences' => 'Percurso equivalente aceite'], ['description' => 'Conhecimento adicional', 'classification' => 'desirable', 'verification' => 'Entrevista e esclarecimentos', 'equivalences' => 'Aprendizagem na integração']]]; $record = hiringAssessment($this, $kind); expect($record->rules['annex']['template'])->toBe($template)->and($record->rules['annex']['requirements'][1]['classification'])->toBe('desirable'); $this->actingAs($this->preparer)->get('/pessoas?kind='.$kind)->assertInertia(fn (Assert $page) => $page->has('processes.data', 1)->where('processes.data.0.id', $record->id)); $this->get('/pessoas?kind='.($kind === 'service' ? 'staff' : 'service'))->assertInertia(fn (Assert $page) => $page->has('processes.data', 0)); $id = DB::table('people_applications')->value('id'); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $id, 'answers' => [['score' => 8, 'evidence' => 'Relato ainda não classificado.'], ['score' => 7, 'evidence' => 'Exercício ainda não classificado.']]])->assertUnprocessable(); $this->actingAs($this->reviewer)->get('/pessoas/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('canReview', true)->where('canApprove', false)->where('canPrepare', false)); })->with([['service', 'scope', 'C'], ['staff', 'responsibilities', 'A']]); test('personal operational tasks and formal approvals follow the original process without duplicates', function () { $record = hiringCreate($this); $this->actingAs($this->reviewer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 1)->where('workdesk.counts.tasks', 0)->where('workdesk.approvals.0.action', 'open')->where('workdesk.approvals.0.process_id', $record->id)); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 0)); hiringCommand($this, $this->preparer, $record, 'apply', ['operation_key' => (string) Str::uuid(), 'name' => 'Pessoa fictícia', 'reference' => 'CAND-QUEUE', 'summary' => 'Resumo profissional fictício para teste.', 'received_on' => today()->toDateString()])->assertRedirect(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks.0.action', 'close')->where('workdesk.counts.approvals', 0)); hiringCommand($this, $this->preparer, $record, 'close')->assertRedirect(); $id = DB::table('people_applications')->value('id'); $this->actingAs($this->reviewer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.tasks', 1)->where('workdesk.tasks.0.action', 'evaluate')->where('workdesk.tasks.0.href', '/pessoas/'.$record->id.'?candidatura='.$id.'#people-evaluate')); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $id, 'answers' => [['score' => 8, 'evidence' => 'Competência fictícia documentada.'], ['score' => 7, 'evidence' => 'Experiência fictícia documentada.']]])->assertRedirect(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.tasks', 0)); $this->actingAs($this->preparer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks.0.action', 'select')->where('workdesk.counts.approvals', 0)); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id])->assertRedirect(); $this->actingAs($this->approver)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 1)->where('workdesk.approvals.0.action', 'confirm')->where('workdesk.counts.tasks', 0)); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 0)); $this->actingAs($this->preparer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks.0.action', 'contract')); hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => 'MINUTA-QUEUE', 'starts_on' => today()->toDateString(), 'terms' => 'Condições fictícias da minuta a conferir e assinar.'])->assertRedirect(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.tasks', 0)->where('workdesk.counts.active', 1)->where('workdesk.counts.contract_drafts', 1)); }); test('workdesk filters counts and pagination only include visible processes and minimise private data', function () { $this->body['annex'] = ['fields' => ['financial' => 'SEGREDO-LISTAGEM-123'], 'requirements' => []]; $record = hiringCreate($this, 'staff'); $this->actingAs($this->reviewer)->get('/pessoas')->assertOk()->assertDontSee('SEGREDO-LISTAGEM-123')->assertInertia(fn (Assert $page) => $page->missing('processes.data.0.rules')->missing('processes.data.0.selection')->missing('processes.data.0.contract')->where('workdesk.counts.processes', 1)); $this->get('/pessoas?search=PES-'.$record->id.'&status=draft&kind=staff&from='.today()->toDateString().'&to='.today()->toDateString())->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 1)->has('processes.data', 1)); $this->get('/pessoas?kind=service')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 0)->where('workdesk.counts.approvals', 0)->where('insights.coverage.processes', 0)->where('insights.overdue.oldest', [])); $this->get('/pessoas?search=Inexistente')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 0)->has('processes.data', 0)); $this->get('/pessoas?search=PES-999999999999999999999999999')->assertOk()->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 0)); $this->getJson('/pessoas?from=2026-10-30&to=2026-10-29')->assertUnprocessable(); $administrator = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($administrator)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 0)->where('workdesk.counts.tasks', 0)->where('workdesk.counts.approvals', 0)->where('insights.coverage.processes', 0)->where('insights.task_actions', [])->has('administration', 1)); }); test('expired or revoked assignments remove pending approvals and self approval is never offered', function () { $record = hiringCreate($this); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 3, 'user_id' => $this->preparer->id, 'scopes' => ['prepare', 'review', 'approve'], 'reason' => 'Acumulação de funções sem dispensar independência.'])->assertRedirect(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 0)); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 4, 'user_id' => $this->reviewer->id, 'scopes' => ['review'], 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Atribuição temporária para conferência.'])->assertRedirect(); $this->actingAs($this->reviewer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 1)); $this->travel(2)->hours(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 0)->where('workdesk.counts.processes', 0)); }); test('workdesk counts the complete authorised filter beyond the first page without writing business data', function () { for ($i = 1; $i <= 16; $i++) { $this->postJson('/pessoas', [...$this->body, 'operation_key' => (string) Str::uuid(), 'title' => 'Processo visível '.$i])->assertRedirect(); } $administrator = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($administrator)->postJson('/pessoas', [...$this->body, 'operation_key' => (string) Str::uuid(), 'title' => 'TITULO-RESTRITO-SEM-ATRIBUICAO'])->assertRedirect(); $before = AuditLog::query()->count(); $this->actingAs($this->preparer)->get('/pessoas')->assertOk()->assertDontSee('TITULO-RESTRITO-SEM-ATRIBUICAO')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 16)->where('processes.total', 16)->has('processes.data', 15)->where('workdesk.counts.approvals', 0)); $this->get('/pessoas?page=2')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.processes', 16)->has('processes.data', 1)); expect(AuditLog::query()->count())->toBe($before); }); test('deadline alerts remain separate from operational tasks and disappear with access revocation', function () { $record = hiringCreate($this); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); $this->travel(2)->days(); $this->actingAs($this->preparer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.overdue', 1)->where('workdesk.tasks.0.action', 'close')->where('workdesk.counts.approvals', 0)); $this->postJson('/pessoas/'.$record->id.'/acessos', ['version' => 3, 'user_id' => $this->reviewer->id, 'scopes' => [], 'reason' => 'Participação terminada no processo.'])->assertRedirect(); $this->actingAs($this->reviewer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.overdue', 0)->where('workdesk.counts.processes', 0)); }); function hiringClarification(object $test, string $kind = 'service'): array { $record = hiringAssessment($test, $kind); $application = DB::table('people_applications')->value('id'); hiringCommand($test, $test->reviewer, $record, 'evaluate', ['application_id' => $application, 'answers' => [ ['score' => '8', 'evidence' => 'ENTREVISTA-RESERVADA-ESCLARECIMENTO', 'clarification' => 'QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO'], ['score' => '7', 'evidence' => 'Experiência fictícia documentada.']]])->assertRedirect(); $review = DB::table('people_reviews')->sole(); $test->get('/pessoas')->assertDontSee('QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks', fn ($items) => collect($items)->contains('action', 'clarification_assign'))); $body = ['operation_key' => (string) Str::uuid(), 'review_id' => $review->id, 'criterion_index' => 0, 'responsible_id' => $test->preparer->id, 'due_on' => today()->subDay()->toDateString(), 'reason' => 'Seguimento do comprovativo ainda por confirmar.']; $test->actingAs($test->reviewer)->postJson('/pessoas/'.$record->id.'/esclarecimentos/create', $body)->assertRedirect(); return [$record->fresh(), $review, $body, DB::table('people_clarifications')->sole()]; } function hiringClarificationCommand(object $test, User $actor, PeopleProcess $record, string $command, array $body = []): TestResponse { $row = DB::table('people_clarifications')->where('people_process_id', $record->id)->sole(); return $test->actingAs($actor)->postJson('/pessoas/'.$record->id.'/esclarecimentos/'.$command, ['clarification_id' => $row->id, 'version' => $row->version, 'reason' => 'Fundamento fictício para o esclarecimento.', 'no_conflict' => true, ...$body]); } test('clarification responses and independent findings preserve the original evaluation in both paths', function (string $kind) { [$record, $original, $body, $row] = hiringClarification($this, $kind); $version = $record->version; $this->actingAs($this->reviewer)->postJson('/pessoas/'.$record->id.'/esclarecimentos/create', $body)->assertRedirect(); expect(DB::table('people_clarifications')->count())->toBe(1); $this->actingAs($this->preparer)->get('/pessoas/'.$record->id)->assertSee('QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO')->assertDontSee('ENTREVISTA-RESERVADA-ESCLARECIMENTO'); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks', fn ($items) => collect($items)->contains('action', 'clarification_respond'))->where('workdesk.counts.overdue', 1)); hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Documento apresentado sem indicar uma fonte.', 'evidence_type' => 'verified'])->assertUnprocessable(); hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Documento apresentado para a primeira conferência.', 'evidence_type' => 'verified', 'reference' => 'DOC-FICTICIO-1'])->assertRedirect(); hiringClarificationCommand($this, $this->preparer, $record, 'review', ['outcome' => 'accepted'])->assertForbidden(); $this->actingAs($this->reviewer)->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks', fn ($items) => collect($items)->contains('action', 'clarification_review'))); hiringClarificationCommand($this, $this->reviewer, $record, 'review', ['outcome' => 'returned'])->assertRedirect(); hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Novo relato com esclarecimentos adicionais por confirmar.', 'evidence_type' => 'reported'])->assertRedirect(); hiringClarificationCommand($this, $this->reviewer, $record, 'review', ['outcome' => 'insufficient'])->assertRedirect(); $finished = DB::table('people_clarifications')->sole(); expect($finished->status)->toBe('resolved')->and(json_decode($finished->responses, true))->toHaveCount(2)->and(json_decode($finished->decisions, true))->toHaveCount(2) ->and(DB::table('people_reviews')->where('id', $original->id)->value('answers'))->toBe($original->answers) ->and($record->fresh()->version)->toBe($version)->and(app(PeopleHiring::class)->matrix($record)[0]['score_minor'])->toBe(760); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.tasks', fn ($items) => ! collect($items)->contains(fn ($item) => str_starts_with($item['action'], 'clarification_')))); hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Não substituir uma conclusão já preservada.', 'evidence_type' => 'reported'])->assertConflict(); expect(fn () => DB::transaction(fn () => DB::table('people_clarifications')->where('id', $row->id)->update(['version' => $finished->version + 1, 'question' => 'Substituição'])))->toThrow(QueryException::class); })->with(['service', 'staff']); test('clarification data stays private while operational metadata can support a decision', function () { [$record] = hiringClarification($this); $this->actingAs($this->approver)->get('/pessoas/'.$record->id)->assertOk()->assertDontSee('QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO')->assertDontSee('ENTREVISTA-RESERVADA-ESCLARECIMENTO') ->assertInertia(fn (Assert $page) => $page->missing('clarifications.items.0.question')->missing('clarifications.items.0.responses')->where('clarifications.items.0.status', 'open')); $this->actingAs($this->preparer)->get('/pessoas')->assertDontSee('QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO')->assertDontSee('ENTREVISTA-RESERVADA-ESCLARECIMENTO'); $outsider = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); hiringClarificationCommand($this, $outsider, $record, 'respond', ['answer' => 'Sem atribuição não pode consultar nem responder.', 'evidence_type' => 'reported'])->assertForbidden(); $foreign = User::factory()->create(['tenant_id' => Tenant::query()->create(['name' => 'Outra entidade'])->id, 'purchasing_role' => PurchasingRole::Admin]); hiringClarificationCommand($this, $foreign, $record, 'respond', ['answer' => 'Sem acesso entre entidades.', 'evidence_type' => 'reported'])->assertNotFound(); }); test('changes to pending clarification invalidate a stale selection without changing scores', function () { [$record] = hiringClarification($this); $id = DB::table('people_applications')->value('id'); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id])->assertRedirect(); hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Novo documento comunicado depois da proposta.', 'evidence_type' => 'reported'])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertConflict(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'returned'])->assertRedirect(); hiringClarificationCommand($this, $this->reviewer, $record, 'review', ['outcome' => 'accepted'])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $id])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); expect($record->fresh()->status)->toBe('selected'); }); test('late responses reassignments and revocations do not overwrite the question or grant interview access', function () { [$record, $original, $body, $row] = hiringClarification($this); $other = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]); app(PeopleAccess::class)->assign($this->reviewer, $record->id, ['version' => 3, 'user_id' => $other->id, 'scopes' => ['prepare'], 'reason' => 'Responsável adicional para responder ao esclarecimento.']); hiringClarificationCommand($this, $this->reviewer, $record, 'reassign', ['responsible_id' => $other->id, 'due_on' => $body['due_on']])->assertRedirect(); $this->actingAs($this->preparer)->get('/pessoas/'.$record->id)->assertDontSee('QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO'); $this->actingAs($other)->get('/pessoas/'.$record->id)->assertSee('QUESTAO-RESERVADA-CONFIRMAR-CERTIFICACAO')->assertDontSee('ENTREVISTA-RESERVADA-ESCLARECIMENTO'); hiringClarificationCommand($this, $other, $record, 'respond', ['version' => 1, 'answer' => 'Versão antiga não pode substituir os dados.', 'evidence_type' => 'reported'])->assertUnprocessable(); hiringClarificationCommand($this, $other, $record, 'respond', ['answer' => 'Resposta registada mesmo com prazo já ultrapassado.', 'evidence_type' => 'reported'])->assertRedirect(); $this->actingAs($this->reviewer); app(PeopleAccess::class)->assign($this->reviewer, $record->id, ['version' => 4, 'user_id' => $other->id, 'scopes' => [], 'reason' => 'Participação encerrada e acesso retirado.']); hiringClarificationCommand($this, $other, $record, 'respond', ['answer' => 'Não responder depois da revogação.', 'evidence_type' => 'reported'])->assertForbidden(); expect(DB::table('people_clarifications')->sole()->question)->toBe($row->question); }); test('clarification creation rolls back when audit cannot be written', function () { $record = hiringAssessment($this); $id = DB::table('people_applications')->value('id'); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $id, 'answers' => [['score' => 8, 'evidence' => 'Competência fictícia documentada.', 'clarification' => 'Confirmar documento'], ['score' => 7, 'evidence' => 'Experiência fictícia documentada.']]])->assertRedirect(); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Audit unavailable')); expect(fn () => app(PeopleClarifications::class)->execute($this->reviewer, $record->id, null, 'create', ['review_id' => DB::table('people_reviews')->value('id'), 'criterion_index' => 0, 'operation_key' => (string) Str::uuid(), 'responsible_id' => $this->preparer->id, 'due_on' => today()->toDateString(), 'reason' => 'Esclarecimento pendente documentado.']))->toThrow(RuntimeException::class); expect(DB::table('people_clarifications')->count())->toBe(0); }); test('rescheduling an unchanged question does not invalidate the selection or rewrite its evidence', function () { [$record] = hiringClarification($this); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => DB::table('people_applications')->value('id')])->assertRedirect(); hiringClarificationCommand($this, $this->reviewer, $record, 'reassign', ['responsible_id' => $this->preparer->id, 'due_on' => today()->addWeek()->toDateString()])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); $snapshot = $record->fresh()->selection; hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => 'MINUTA-ESCLARECIMENTO', 'starts_on' => today()->toDateString(), 'terms' => 'Condições fictícias da minuta ainda não assinada.'])->assertRedirect(); $version = $record->fresh()->version; hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Informação complementar recebida após a decisão.', 'evidence_type' => 'reported'])->assertRedirect(); hiringClarificationCommand($this, $this->reviewer, $record, 'review', ['outcome' => 'accepted'])->assertRedirect(); expect($record->fresh()->status)->toBe('contract_draft')->and($record->fresh()->version)->toBe($version)->and($record->fresh()->selection)->toBe($snapshot); }); test('a reasoned waiver can conclude an unnecessary follow up without inventing a response', function () { [$record] = hiringClarification($this); hiringClarificationCommand($this, $this->reviewer, $record, 'review', ['outcome' => 'not_required'])->assertRedirect(); $row = DB::table('people_clarifications')->sole(); expect($row->status)->toBe('resolved')->and(json_decode($row->responses, true))->toBe([]) ->and(json_decode($row->decisions, true)[0]['outcome'])->toBe('not_required'); }); test('the person concluding technical clarification cannot approve the hiring selection', function () { [$record] = hiringClarification($this); hiringClarificationCommand($this, $this->preparer, $record, 'respond', ['answer' => 'Informação complementar para conferência técnica.', 'evidence_type' => 'reported'])->assertRedirect(); $this->actingAs($this->preparer); app(PeopleAccess::class)->assign($this->preparer, $record->id, ['version' => 3, 'user_id' => $this->approver->id, 'scopes' => ['review', 'interviews', 'approve'], 'reason' => 'Conferência técnica adicional atribuída explicitamente.']); hiringClarificationCommand($this, $this->approver, $record, 'review', ['outcome' => 'accepted'])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => DB::table('people_applications')->value('id')])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertForbidden(); $this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 0)); }); test('people reports reuse only visible processes and export filtered facts without private fields', function () { $record = hiringCreate($this); $this->actingAs($this->approver)->postJson('/pessoas', [...$this->body, 'operation_key' => (string) Str::uuid(), 'title' => 'PROCESSO RESERVADO', 'kind' => 'staff'])->assertRedirect(); $this->actingAs($this->preparer)->get('/pessoas/relatorios')->assertOk()->assertInertia(fn (Assert $page) => $page ->component('people/reports')->where('processes.total', 1)->has('summary', 1) ->where('processes.data.0.id', $record->id)->missing('processes.data.0.rules')->missing('processes.data.0.contract')); $csv = $this->get('/pessoas/relatorios.csv')->assertOk(); expect($csv->streamedContent())->toContain('PES-'.$record->id)->not->toContain('PROCESSO RESERVADO'); expect($csv->headers->get('Cache-Control'))->toContain('no-store'); $this->get('/pessoas/relatorios?kind=staff')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0)->has('summary', 0)); $this->get('/pessoas/relatorios?status=selected')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0)); $this->get('/pessoas/relatorios?search=PES-'.$record->id)->assertInertia(fn (Assert $page) => $page->where('processes.total', 1)); DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->preparer->id)->update(['expires_at' => now()->subMinute()]); $this->get('/pessoas/relatorios')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0)); expect($this->get('/pessoas/relatorios.csv')->streamedContent())->not->toContain('PES-'.$record->id); }); test('people report dates and csv protect scope and spreadsheet cells', function () { $this->body['title'] = '=FORMULA DE ENSAIO'; $record = hiringCreate($this); $csv = $this->get('/pessoas/relatorios.csv')->assertOk()->streamedContent(); expect($csv)->toContain("'=FORMULA DE ENSAIO"); $this->get('/pessoas/relatorios?from='.today()->addDay()->toDateString())->assertInertia(fn (Assert $page) => $page->where('processes.total', 0)); $this->getJson('/pessoas/relatorios?from=2026-11-02&to=2026-11-01')->assertUnprocessable(); $foreign = User::factory()->create(['tenant_id' => Tenant::query()->create(['name' => 'Empresa distinta'])->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($foreign)->get('/pessoas/relatorios')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0)); expect($this->get('/pessoas/relatorios.csv')->streamedContent())->not->toContain('PES-'.$record->id); }); test('application report reuses eligible assessments and preserves approved selection counts without private content', function () { $record = hiringAssessment($this); $application = DB::table('people_applications')->sole(); $url = '/pessoas/relatorios?view=applications'; $this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page ->where('applicationMetrics.'.$record->id.'.registered', 1) ->where('applicationMetrics.'.$record->id.'.evaluation_pending', 1) ->where('applicationMetrics.'.$record->id.'.evaluation_unstarted', 1)); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [ ['score' => '8', 'evidence' => 'Evidência fictícia de competências.'], ['score' => '7', 'evidence' => 'Evidência fictícia de experiência.'], ]])->assertRedirect(); $this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page ->where('applicationMetrics.'.$record->id.'.evaluation_complete', 1) ->where('applicationMetrics.'.$record->id.'.evaluation_pending', 0) ->missing('applicationMetrics.'.$record->id.'.reviews')->missing('applicationMetrics.'.$record->id.'.name')); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->reviewer->id)->update(['expires_at' => now()->subMinute()]); $this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page ->where('applicationMetrics.'.$record->id.'.basis', 'selection_snapshot') ->where('applicationMetrics.'.$record->id.'.evaluation_complete', 1)); $this->actingAs($this->approver)->get($url)->assertInertia(fn (Assert $page) => $page ->where('applicationMetrics.'.$record->id.'.portal_received', null)); expect($this->get('/pessoas/relatorios.csv?view=applications')->streamedContent())->toContain('Sem acesso')->toContain('Selecção preservada')->not->toContain($application->name); }); test('selection report follows return approval and draft while restricting contract metadata', function (string $kind) { $record = hiringAssessment($this, $kind); $application = DB::table('people_applications')->sole(); $url = '/pessoas/relatorios?view=selections'; $prefix = 'selectionMetrics.'.$record->id.'.'; $this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page ->where($prefix.'selection_state', 'Sem proposta de selecção')->where($prefix.'contract_reference', null)); hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [ ['score' => '8', 'evidence' => 'Evidência fictícia de competências.'], ['score' => '7', 'evidence' => 'Evidência fictícia de experiência.'], ]])->assertRedirect(); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect(); $this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'selection_state', 'Proposta por conferir')); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'returned'])->assertRedirect(); $this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'selection_state', 'Proposta devolvida')); hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect(); hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect(); $this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'selection_state', 'Selecção aprovada')->where($prefix.'pending', 'Preparar minuta contratual')); hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => '=MINUTA-RESERVADA', 'starts_on' => today()->toDateString(), 'terms' => 'TERMOS-CONTRATUAIS-RESERVADOS-NAO-EXPORTAR'])->assertRedirect(); $this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'contract_reference', '=MINUTA-RESERVADA')->missing($prefix.'terms')); $csv = $this->get('/pessoas/relatorios.csv?view=selections')->streamedContent(); expect($csv)->toContain("'=MINUTA-RESERVADA")->not->toContain('TERMOS-CONTRATUAIS-RESERVADOS'); $this->actingAs($this->approver)->get($url)->assertInertia(fn (Assert $page) => $page ->where($prefix.'contract_reference', 'Sem acesso')->where($prefix.'contract_starts_on', 'Sem acesso')); expect($this->get('/pessoas/relatorios.csv?view=selections')->streamedContent())->not->toContain('MINUTA-RESERVADA'); DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->approver->id)->update(['expires_at' => now()->subMinute()]); $this->get($url)->assertInertia(fn (Assert $page) => $page->where('selectionMetrics', [])->where('processes.total', 0)); })->with(['service', 'staff']); test('deadline report distinguishes process alerts from personal tasks and respects expired access', function () { $record = hiringCreate($this); $this->travel(2)->days(); $url = '/pessoas/relatorios?view=deadlines'; $this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $p) => $p ->where('deadlineReport.tasks', 0)->where('deadlineReport.alerts', 1)->where('deadlineReport.overdue_items', 1) ->where('deadlineReport.items.0.days_overdue', 2) ->where('deadlineReport.items.0.responsibility', 'Alerta do processo; sem atribuição pessoal')); expect($this->get('/pessoas/relatorios.csv?view=deadlines')->streamedContent())->toContain('PES-'.$record->id)->toContain('Dias de atraso'); DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->preparer->id)->update(['expires_at' => now()->subMinute()]); $this->get($url)->assertInertia(fn (Assert $p) => $p->has('deadlineReport.items', 0)->where('deadlineReport.overdue_items', 0)); expect($this->get('/pessoas/relatorios.csv?view=deadlines')->streamedContent())->not->toContain('PES-'.$record->id); }); test('deadline report reuses only the current actors work and never invents evaluation deadlines', function () { $record = hiringAssessment($this); $url = '/pessoas/relatorios?view=deadlines'; $this->actingAs($this->reviewer)->get($url)->assertInertia(fn (Assert $p) => $p ->where('deadlineReport.tasks', 1)->where('deadlineReport.without_deadline', 1)->where('deadlineReport.overdue_items', 0) ->where('deadlineReport.items.0.due_on', null)->where('deadlineReport.items.0.days_overdue', null) ->where('deadlineReport.items.0.responsibility', 'Minha intervenção autorizada') ->missing('deadlineReport.items.0.answers')->missing('deadlineReport.items.0.score_minor')); expect($this->get('/pessoas/relatorios.csv?view=deadlines')->streamedContent())->toContain('Sem prazo definido')->toContain('Não determinável'); $this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $p) => $p->where('deadlineReport.tasks', 0)); }); test('deadline windows distinguish future today and overdue calendar dates', function () { $this->body['closes_on'] = today()->addDays(2)->toDateString(); hiringCreate($this); $this->actingAs($this->reviewer); $base = '/pessoas/relatorios?view=deadlines&deadline_window='; $this->get($base.'future')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 1)->where('deadlineReport.approvals', 1)); $this->get($base.'today')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0)); $this->travel(2)->days(); $this->get($base.'today')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 1)->where('deadlineReport.items.0.days_overdue', 0)); $this->get($base.'overdue')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0)); $this->travel(1)->days(); $this->get($base.'overdue')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 1)->where('deadlineReport.items.0.days_overdue', 1)); $this->get($base.'undated')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0)); $this->getJson($base.'invalid')->assertUnprocessable(); $this->getJson('/pessoas/relatorios?view=deadlines&deadline_page=0')->assertUnprocessable(); }); test('deadline totals cover all matching occurrences while screen pagination and csv keep the same scope', function () { $record = hiringCreate($this); hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect(); foreach (range(1, 30) as $number) { hiringCommand($this, $this->preparer, $record, 'apply', ['operation_key' => (string) Str::uuid(), 'name' => 'Pessoa fictícia '.$number, 'reference' => 'CAND-'.$number, 'summary' => 'Experiência fictícia pertinente ao processo.', 'received_on' => today()->toDateString()])->assertRedirect(); } hiringCommand($this, $this->preparer, $record, 'close')->assertRedirect(); $this->actingAs($this->reviewer); $url = '/pessoas/relatorios?view=deadlines&deadline_window=undated'; $this->get($url)->assertInertia(fn (Assert $p) => $p->where('deadlineReport.tasks', 30)->where('deadlineReport.total_items', 30) ->where('deadlineReport.matching_processes', 1)->has('deadlineReport.items', 25)->where('deadlineReport.pagination.last_page', 2)); $this->get($url.'&deadline_page=2')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.tasks', 30)->has('deadlineReport.items', 5)); $csv = $this->get('/pessoas/relatorios.csv?view=deadlines&deadline_window=undated&deadline_page=2')->streamedContent(); expect(substr_count($csv, 'Avaliar candidatura #'))->toBe(30); $this->get('/pessoas/relatorios?view=deadlines&deadline_window=overdue')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0)); });