Files
compras/tests/Feature/PeopleHiringTest.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

715 lines
65 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\PeopleProcess;
use App\Models\Tenant;
use App\Models\User;
use App\Services\AiPreparationPlan;
use App\Services\PeopleAccess;
use App\Services\PeopleClarifications;
use App\Services\PeopleContractHandoff;
use App\Services\PeopleEvidenceBrief;
use App\Services\PeopleHiring;
use App\Services\PeopleOperationalInsights;
use App\Services\RequisitionWorkflow;
use App\Services\SubmissionForms;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
require_once __DIR__.'/../Support/AiPlanFixture.php';
test('people dossier block reuses approved selection and draft without changing decisions or contract status', 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' => 'Competências fictícias verificadas.'], ['score' => 8, 'evidence' => 'Experiência fictícia verificada.']]])->assertRedirect();
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-DOSSIER', 'starts_on' => today()->addWeek()->toDateString(), 'terms' => 'Minuta fictícia para preparação, sem assinatura.'])->assertRedirect();
$before = [];
foreach (['people_processes', 'people_applications', 'people_reviews', 'people_process_access', 'audit_logs'] as $table) {
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
}
$this->actingAs($this->preparer)->getJson('/pessoas/'.$record->id.'/dossier.json')->assertOk()
->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertHeader('X-Content-Type-Options', 'nosniff')
->assertJsonPath('schema', 'sgc.people.process-dossier.v1')->assertJsonPath('effect', 'preparation_only')
->assertJsonPath('process.status', 'contract_draft')->assertJsonPath('process.contract.status', 'draft')
->assertJsonPath('process.contract.reference', 'MINUTA-DOSSIER')->assertJsonPath('process.selected_application_id', $application->id)
->assertJsonMissingPath('process.creation_hash')->assertJsonMissingPath('process.tenant_id');
$this->getJson('/pessoas/'.$record->id.'/dossier.json')->assertOk();
foreach ($before as $table => $rows) {
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
}
expect(fn () => app(PeopleContractHandoff::class)->envelope($record->fresh()))->toThrow(HttpException::class);
})->with(['service', 'staff']);
test('people dossier block shares page redactions and rejects expired or foreign access', function () {
$this->body['annex'] = ['fields' => ['financial' => 'DOSSIER-FINANCIAL-SECRET'], 'requirements' => []];
$record = hiringAssessment($this);
$application = DB::table('people_applications')->sole();
hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [
['score' => 8, 'evidence' => 'DOSSIER-INTERVIEW-SECRET', 'evidence_type' => 'verified'], ['score' => 8, 'evidence' => 'Experiência fictícia documentada.', 'evidence_type' => 'verified']]])->assertRedirect();
hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect();
$this->actingAs($this->approver)->getJson('/pessoas/'.$record->id.'/dossier.json')->assertOk()
->assertDontSee('DOSSIER-FINANCIAL-SECRET')->assertDontSee('DOSSIER-INTERVIEW-SECRET')
->assertJsonMissingPath('process.rules.annex.fields.financial')->assertJsonMissingPath('process.selection.rules.annex.fields.financial')
->assertJsonCount(0, 'candidates.0.reviews')->assertJsonCount(0, 'process.selection.rows.0.reviews')->assertJsonPath('process.contract', null);
DB::table('people_process_access')->where('user_id', $this->approver->id)->update(['expires_at' => now()->subMinute()]);
$this->getJson('/pessoas/'.$record->id.'/dossier.json')->assertForbidden();
$foreign = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$user = User::factory()->create(['tenant_id' => $foreign->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($user)->getJson('/pessoas/'.$record->id.'/dossier.json')->assertNotFound();
});
beforeEach(function () {
$this->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));
});