Files
compras/tests/Feature/CandidateSubmissionsTest.php
T
2026-11-02 08:08:05 +02:00

391 lines
35 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\CandidateAccount;
use App\Models\Tenant;
use App\Models\User;
use App\Services\CandidateProfiles;
use App\Services\CandidateSubmissions;
use App\Services\CandidateTriage;
use App\Services\PeopleAccess;
use App\Services\PeopleHiring;
use App\Services\RequisitionWorkflow;
use App\Services\SubmissionForms;
use Illuminate\Database\QueryException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
beforeEach(function () {
Storage::fake('candidate_documents');
$this->tenant = Tenant::query()->create(['name' => 'Empresa fictícia de submissões']);
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->reviewer = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($this->admin);
app(CandidateProfiles::class)->configure($this->admin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso fictício para submissões sem dados reais.', 'reason' => 'Activação fictícia para testar submissões.']);
$this->definition = ['available' => true, 'fields' => [['key' => 'full_name', 'label' => 'Nome completo', 'type' => 'text', 'required' => true], ['key' => 'availability_location', 'label' => 'Disponibilidade', 'type' => 'location', 'required' => true]], 'documents' => []];
app(SubmissionForms::class)->save($this->admin, ['audience' => 'service', 'modality' => 'competition', 'version' => 0, 'definition' => $this->definition, 'reason' => 'Formulário fictício para esta oportunidade.']);
$this->process = app(PeopleHiring::class)->execute($this->admin, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Oportunidade fictícia', 'purpose' => 'Finalidade interna que não deve ser publicada.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
app(PeopleAccess::class)->assign($this->admin, $this->process->id, ['version' => 1, 'user_id' => $this->reviewer->id, 'scopes' => ['review'], 'reason' => 'Conferência independente da abertura.']);
$this->actingAs($this->reviewer);
app(PeopleHiring::class)->execute($this->reviewer, $this->process->id, 'open', ['version' => 1, 'reason' => 'Abertura fictícia do processo.', 'no_conflict' => true]);
$this->actingAs($this->admin);
$this->publication = ['version' => 0, 'active' => true, 'description' => 'Descrição pública fictícia da oportunidade.', 'reason' => 'Publicação fictícia para candidaturas.'];
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, $this->publication);
$this->credentials = ['email' => 'submit@example.invalid', 'password' => 'Candidate-Test-123!', 'password_confirmation' => 'Candidate-Test-123!', 'portal_version' => 1, 'notice_acknowledged' => true];
$this->account = app(CandidateProfiles::class)->register($this->tenant->id, $this->credentials);
$this->account->setAttribute('email_verified_at', now())->save();
$this->profile = ['version' => 0, 'name' => 'Pessoa fictícia', 'phone' => '000000', 'summary' => 'Perfil fictício de competências.', 'availability' => ['province' => 'Província fictícia', 'district' => 'Distrito fictício', 'locality' => 'Localidade fictícia'], 'reason' => 'Preencher perfil fictício para candidatura.'];
app(CandidateProfiles::class)->save($this->account, $this->profile);
$this->base = '/candidaturas/'.$this->tenant->id;
$this->postJson($this->base.'/entrar', $this->credentials)->assertRedirect();
$this->body = ['operation_key' => (string) Str::uuid(), 'profile_version' => 1, 'publication_version' => 1,
'answers' => ['full_name' => 'Pessoa fictícia', 'availability_location' => $this->profile['availability']]];
});
test('guests see only explicitly published public opportunity fields while submission remains authenticated', function () {
Auth::guard('candidate')->logout();
Auth::guard('web')->logout();
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 1)
->where('opportunities.data.0', ['id' => $this->process->id, 'title' => 'Oportunidade fictícia', 'kind' => 'service', 'closes_on' => $this->process->closes_on, 'description' => $this->publication['description']])
->where('auth.user', null)->where('organizationAccess', null));
$this->get($this->base.'/oportunidades/'.$this->process->id)->assertUnauthorized();
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertUnauthorized();
expect(DB::table('candidate_submissions')->count())->toBe(0);
$this->actingAs($this->admin);
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]);
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0));
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 2]);
$this->travel(8)->days();
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0));
});
test('public opportunity listing is paginated within the company and remains visible to existing users when registration closes', function () {
$process = (array) DB::table('people_processes')->where('id', $this->process->id)->first();
$publication = (array) DB::table('candidate_opportunity_publications')->where('people_process_id', $this->process->id)->first();
unset($process['id'], $publication['id']);
for ($index = 0; $index < 12; $index++) {
$id = DB::table('people_processes')->insertGetId([...$process, 'operation_key' => (string) Str::uuid()]);
DB::table('candidate_opportunity_publications')->insert([...$publication, 'people_process_id' => $id]);
}
app(CandidateProfiles::class)->configure($this->admin, ['version' => 1, 'registration_open' => false, 'notice' => 'Aviso fictício de registos temporariamente encerrados.', 'reason' => 'Encerrar novos registos no ensaio.']);
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 12)->where('opportunities.total', 13)->where('opportunities.next_page_url', $this->base.'?opportunities_page=2')->where('portal.registration_open', false));
$this->get($this->base.'?opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 1));
$this->get($this->base.'/perfil?opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('opportunities', 1)->where('opportunityPagination.prev', $this->base.'/perfil?opportunities_page=1')->where('opportunityPagination.next', null));
$other = Tenant::query()->create(['name' => 'Empresa sem oportunidades']);
$otherAdmin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($otherAdmin);
app(CandidateProfiles::class)->configure($otherAdmin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso fictício da outra empresa sem oportunidades.', 'reason' => 'Configurar portal separado no ensaio.']);
$this->get('/candidaturas/'.$other->id)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0));
$this->getJson($this->base.'?opportunities_page=invalid')->assertUnprocessable();
});
test('candidate login opens the selected published opportunity without accepting an arbitrary redirect', function () {
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id, 'return_to' => 'https://example.invalid/untrusted'])
->assertRedirect($this->base.'/oportunidades/'.$this->process->id);
$this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.id', $this->process->id));
$this->postJson($this->base.'/entrar', [...$this->credentials, 'return_to' => 'https://example.invalid/untrusted'])->assertRedirect($this->base.'/perfil');
$other = Tenant::query()->create(['name' => 'Outra empresa do ensaio de destino']);
$otherAdmin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
$foreignProcess = (array) DB::table('people_processes')->where('id', $this->process->id)->first();
$foreignPublication = (array) DB::table('candidate_opportunity_publications')->where('people_process_id', $this->process->id)->first();
unset($foreignProcess['id'], $foreignPublication['id']);
$foreignId = DB::table('people_processes')->insertGetId([...$foreignProcess, 'tenant_id' => $other->id, 'created_by' => $otherAdmin->id, 'operation_key' => (string) Str::uuid()]);
DB::table('candidate_opportunity_publications')->insert([...$foreignPublication, 'tenant_id' => $other->id, 'people_process_id' => $foreignId, 'created_by' => $otherAdmin->id]);
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $foreignId])->assertRedirect($this->base.'/perfil');
expect(DB::table('candidate_submissions')->count())->toBe(0);
});
test('login rechecks selected publication and deadline and falls back to the profile for stale destinations', function () {
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => 'https://example.invalid/untrusted'])->assertUnprocessable();
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => 2147483647])->assertRedirect($this->base.'/perfil')->assertSessionHas('candidate_access_status', 'opportunity_unavailable');
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]);
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil');
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 2]);
$this->travel(8)->days();
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil');
expect(DB::table('candidate_submissions')->count())->toBe(0);
});
test('selected opportunity survives registration and profile preparation until an explicit verified submission', function () {
$this->post($this->base.'/sair')->assertRedirect();
$this->postJson($this->base.'/registar', [...$this->credentials, 'email' => 'selected@example.invalid', 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil');
$this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity.id', $this->process->id)->where('profile.version', 0)->where('emailVerified', false));
$this->postJson($this->base.'/perfil', $this->profile)->assertRedirect();
$this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity.id', $this->process->id)->where('profile.version', 1));
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertForbidden();
expect(DB::table('candidate_submissions')->count())->toBe(0);
$new = CandidateAccount::query()->where('email', 'selected@example.invalid')->firstOrFail();
$new->setAttribute('email_verified_at', now())->save();
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect()->assertSessionMissing('candidate.selected_opportunity');
expect(DB::table('candidate_submissions')->count())->toBe(1);
});
test('selection is cleared on withdrawal logout and account mismatch and malformed registration cannot create an account', function () {
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect();
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]);
$this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity', null)->where('accessStatus', 'opportunity_unavailable'))->assertSessionMissing('candidate.selected_opportunity');
$this->withSession(['candidate.selected_opportunity' => ['company' => $this->tenant->id, 'account' => $this->account->id + 999, 'id' => $this->process->id]])
->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity', null))->assertSessionMissing('candidate.selected_opportunity');
$this->post($this->base.'/sair')->assertSessionMissing('candidate.selected_opportunity');
$count = CandidateAccount::query()->count();
$this->postJson($this->base.'/registar', [...$this->credentials, 'email' => 'invalid-selection@example.invalid', 'opportunity_id' => 'https://example.invalid'])->assertUnprocessable();
expect(CandidateAccount::query()->count())->toBe($count);
});
test('all own receipts remain reachable with frozen titles and independent pagination without exposing another candidate', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$receipt = (array) DB::table('candidate_submissions')->sole();
$process = (array) DB::table('people_processes')->where('id', $this->process->id)->first();
unset($receipt['id'], $process['id']);
$snapshot = json_decode($receipt['snapshot'], true, 32, JSON_THROW_ON_ERROR);
$snapshot['opportunity']['title'] = 'Título preservado no comprovativo';
for ($index = 0; $index < 20; $index++) {
$id = DB::table('people_processes')->insertGetId([...$process, 'operation_key' => (string) Str::uuid(), 'title' => 'Outro título actual do processo']);
DB::table('candidate_submissions')->insert([...$receipt, 'people_process_id' => $id, 'operation_key' => (string) Str::uuid(), 'reference' => 'CAND-'.Str::uuid(), 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR)]);
}
$foreign = app(CandidateProfiles::class)->register($this->tenant->id, [...$this->credentials, 'email' => 'private-receipt@example.invalid']);
DB::table('candidate_submissions')->insert([...$receipt, 'candidate_account_id' => $foreign->id, 'operation_key' => (string) Str::uuid(), 'reference' => 'CAND-PRIVADO']);
$this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->has('submissions', 12)->where('submissionPagination.total', 21)->where('submissions.0.title', 'Título preservado no comprovativo')->missing('submissions.0.snapshot')->missing('submissions.0.candidate_account_id'));
$this->get($this->base.'/perfil?submissions_page=2&opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('submissions', 9)->where('submissionPagination.total', 21)
->where('submissionPagination.prev', fn ($url) => str_contains($url, 'opportunities_page=2') && str_contains($url, 'submissions_page=1'))->where('submissionPagination.next', null));
$this->getJson($this->base.'/perfil?submissions_page=-1')->assertUnprocessable();
});
test('electronic submission returns a scoped receipt and preserves the actual profile and answers', function () {
$this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.description', 'Descrição pública fictícia da oportunidade.')->missing('opportunity.purpose')->missing('opportunity.selection'));
$url = $this->base.'/oportunidades/'.$this->process->id;
$this->postJson($url, $this->body)->assertRedirect();
$row = DB::table('candidate_submissions')->sole();
$this->get($this->base.'/submissoes/'.$row->id)->assertInertia(fn (Assert $p) => $p->where('receipt.snapshot.profile.version', 1)->where('receipt.snapshot.answers.availability_location.district', 'Distrito fictício')->where('receipt.snapshot.status', 'received')->missing('receipt.snapshot.reviews'));
app(CandidateProfiles::class)->save($this->account, [...$this->profile, 'version' => 1, 'name' => 'Nome posterior']);
$this->postJson($url, $this->body)->assertRedirect();
expect(DB::table('candidate_submissions')->count())->toBe(1)->and(json_decode($row->snapshot, true)['profile']['data']['name'])->toBe('Pessoa fictícia');
$this->postJson($url, [...$this->body, 'operation_key' => (string) Str::uuid(), 'profile_version' => 2])->assertConflict();
});
test('all availability locations travel with the submitted profile and remain unchanged after profile edits', function () {
$extra = ['province' => 'Outra província', 'district' => 'Outro distrito', 'locality' => 'Outra localidade'];
app(CandidateProfiles::class)->save($this->account, [...$this->profile, 'version' => 1, 'additional_availability' => [$extra]]);
$this->postJson($this->base.'/oportunidades/'.$this->process->id, [...$this->body, 'profile_version' => 2])->assertRedirect();
$row = DB::table('candidate_submissions')->sole();
app(CandidateProfiles::class)->save($this->account, [...$this->profile, 'version' => 2, 'additional_availability' => []]);
$this->get($this->base.'/submissoes/'.$row->id)->assertInertia(fn (Assert $p) => $p->where('receipt.snapshot.profile.data.additional_availability', [$extra]));
$this->get('/pessoas/'.$this->process->id.'/recebidas')->assertInertia(fn (Assert $p) => $p->where('submissions.data.0.snapshot.profile.data.additional_availability', [$extra]));
expect(DB::table('candidate_submissions')->sole()->snapshot)->toBe($row->snapshot);
});
test('withdrawn opportunities stale profiles and unverified contacts cannot submit', function () {
$url = $this->base.'/oportunidades/'.$this->process->id;
$this->postJson($url, [...$this->body, 'profile_version' => 99])->assertUnprocessable();
$this->account->setAttribute('email_verified_at', null)->save();
$this->postJson($url, $this->body)->assertForbidden();
$this->account->setAttribute('email_verified_at', now())->save();
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]);
$this->postJson($url, $this->body)->assertNotFound();
expect(DB::table('candidate_submissions')->count())->toBe(0);
});
test('received submissions do not become internal evaluations and only the assigned preparer reads the inbox', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
expect(DB::table('people_applications')->count())->toBe(0);
$url = '/pessoas/'.$this->process->id.'/recebidas';
$this->get($url)->assertInertia(fn (Assert $p) => $p->has('submissions.data', 1));
$this->actingAs($this->reviewer)->get($url)->assertForbidden();
$other = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$this->get('/candidaturas/'.$other->id.'/submissoes/1')->assertUnauthorized();
$this->admin->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->actingAs($this->admin)->get($url)->assertForbidden();
$this->postJson('/pessoas/'.$this->process->id.'/publicacao', [...$this->publication, 'version' => 1, 'active' => false])->assertForbidden();
});
test('an opportunity requiring a CV can be published but cannot receive an incomplete submission', function () {
$this->actingAs($this->admin);
$definition = [...$this->definition, 'documents' => [['key' => 'cv', 'label' => 'CV', 'formats' => ['pdf'], 'max_kb' => 1024, 'required' => true]]];
app(SubmissionForms::class)->save($this->admin, ['audience' => 'service', 'modality' => 'competition', 'version' => 1, 'definition' => $definition, 'reason' => 'Exigir CV no novo formulário fictício.']);
$record = app(PeopleHiring::class)->execute($this->admin, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Oportunidade com CV', 'purpose' => 'Teste de documento obrigatório na candidatura.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
app(PeopleAccess::class)->assign($this->admin, $record->id, ['version' => 1, 'user_id' => $this->reviewer->id, 'scopes' => ['review'], 'reason' => 'Conferência independente do novo processo.']);
$this->actingAs($this->reviewer);
app(PeopleHiring::class)->execute($this->reviewer, $record->id, 'open', ['version' => 1, 'reason' => 'Abertura fictícia para testar os anexos.', 'no_conflict' => true]);
$this->actingAs($this->admin)->postJson('/pessoas/'.$record->id.'/publicacao', $this->publication)->assertRedirect();
$this->postJson($this->base.'/oportunidades/'.$record->id, $this->body)->assertUnprocessable();
expect(DB::table('candidate_submissions')->where('people_process_id', $record->id)->count())->toBe(0);
});
function requireElectronicCv(object $test): void
{
$test->actingAs($test->admin);
$definition = [...$test->definition, 'documents' => [['key' => 'cv', 'label' => 'Currículo', 'formats' => ['pdf'], 'max_kb' => 1024, 'required' => true]]];
app(SubmissionForms::class)->save($test->admin, ['audience' => 'service', 'modality' => 'competition', 'version' => 1, 'definition' => $definition, 'reason' => 'Exigir currículo no formulário de teste.']);
$test->process = app(PeopleHiring::class)->execute($test->admin, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Processo com currículo', 'purpose' => 'Conferência fictícia da recepção de documentos.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
app(PeopleAccess::class)->assign($test->admin, $test->process->id, ['version' => 1, 'user_id' => $test->reviewer->id, 'scopes' => ['review'], 'reason' => 'Conferência independente do processo com CV.']);
$test->actingAs($test->reviewer);
app(PeopleHiring::class)->execute($test->reviewer, $test->process->id, 'open', ['version' => 1, 'reason' => 'Abrir processo fictício com documentos.', 'no_conflict' => true]);
$test->actingAs($test->admin);
app(CandidateSubmissions::class)->publish($test->admin, $test->process->id, $test->publication);
}
function electronicPdf(string $extra = ''): UploadedFile
{
return UploadedFile::fake()->createWithContent('curriculo.pdf', "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".str_repeat(' ', 1024).$extra."\n%%EOF\n");
}
test('private CV receipt is immutable idempotent and downloadable only by its owner and authorised staff', function () {
requireElectronicCv($this);
$url = $this->base.'/oportunidades/'.$this->process->id;
$payload = [...$this->body, 'documents' => ['cv' => electronicPdf()]];
$this->post($url, $payload)->assertRedirect()->assertSessionHasNoErrors();
$row = DB::table('candidate_submissions')->sole();
$document = DB::table('candidate_submission_documents')->sole();
Storage::disk('candidate_documents')->assertExists($document->path);
$receipt = $this->base.'/submissoes/'.$row->id;
$this->get($receipt)->assertInertia(fn (Assert $p) => $p->where('receipt.snapshot.documents.cv.sha256', $document->sha256)->missing('receipt.snapshot.documents.cv.path'));
$this->get($receipt.'/documentos/cv')->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff')->assertDownload('curriculo.pdf');
$foreign = Tenant::query()->create(['name' => 'Empresa estrangeira fictícia']);
$this->get('/candidaturas/'.$foreign->id.'/submissoes/'.$row->id.'/documentos/cv')->assertUnauthorized();
$staffUrl = '/pessoas/'.$this->process->id.'/recebidas/'.$row->id.'/documentos/cv';
$this->get($staffUrl)->assertOk()->assertDownload('curriculo.pdf');
$this->post($url, [...$this->body, 'documents' => ['cv' => electronicPdf()]])->assertRedirect();
expect(DB::table('candidate_submission_documents')->count())->toBe(1)->and(Storage::disk('candidate_documents')->allFiles())->toHaveCount(1);
$this->post($url, [...$this->body, 'documents' => ['cv' => electronicPdf('changed')]])->assertConflict();
$this->actingAs($this->reviewer)->get($staffUrl)->assertForbidden();
$this->post($this->base.'/sair')->assertRedirect();
$this->get($receipt.'/documentos/cv')->assertUnauthorized();
$this->postJson($this->base.'/registar', [...$this->credentials, 'email' => 'other@example.invalid'])->assertRedirect();
$this->get($receipt.'/documentos/cv')->assertNotFound();
expect(DB::table('candidate_submissions')->sole()->snapshot)->toBe($row->snapshot);
});
test('missing forged unknown oversized and globally excessive files are rejected without stored files', function () {
requireElectronicCv($this);
$url = $this->base.'/oportunidades/'.$this->process->id;
foreach ([[], ['cv' => UploadedFile::fake()->createWithContent('fake.pdf', 'not a pdf')], ['unknown' => electronicPdf()], ['cv' => UploadedFile::fake()->create('large.pdf', 1025, 'application/pdf')]] as $documents) {
$this->postJson($url, [...$this->body, 'documents' => $documents])->assertUnprocessable();
}
config(['candidate_documents.max_total_kb' => 1]);
$this->postJson($url, [...$this->body, 'documents' => ['cv' => electronicPdf(str_repeat('x', 2048))]])->assertUnprocessable();
expect(DB::table('candidate_submissions')->count())->toBe(0)->and(Storage::disk('candidate_documents')->allFiles())->toBe([]);
});
test('failed audit removes uploaded bytes and rolls back the submission and document metadata', function () {
requireElectronicCv($this);
$this->partialMock(CandidateProfiles::class, fn ($mock) => $mock->shouldReceive('event')->once()->andThrow(new RuntimeException('Injected upload audit failure')));
$this->withoutExceptionHandling();
try {
$this->post($this->base.'/oportunidades/'.$this->process->id, [...$this->body, 'documents' => ['cv' => electronicPdf()]]);
$this->fail('Expected audit failure.');
} catch (RuntimeException $error) {
expect($error->getMessage())->toBe('Injected upload audit failure');
}
expect(DB::table('candidate_submissions')->count())->toBe(0)->and(DB::table('candidate_submission_documents')->count())->toBe(0)->and(Storage::disk('candidate_documents')->allFiles())->toBe([]);
});
test('download refuses changed stored bytes and document rows cannot be rewritten', function () {
requireElectronicCv($this);
$this->post($this->base.'/oportunidades/'.$this->process->id, [...$this->body, 'documents' => ['cv' => electronicPdf()]])->assertRedirect();
$document = DB::table('candidate_submission_documents')->sole();
Storage::disk('candidate_documents')->put($document->path, 'changed bytes');
$this->get($this->base.'/submissoes/'.$document->candidate_submission_id.'/documentos/cv')->assertConflict();
expect(fn () => DB::table('candidate_submission_documents')->where('id', $document->id)->update(['original_name' => 'other.pdf']))->toThrow(QueryException::class);
});
function electronicTriagePayload(object $test, string $outcome = 'accepted_for_analysis'): array
{
return ['operation_key' => (string) Str::uuid(), 'process_version' => $test->process->fresh()->version, 'outcome' => $outcome,
'reason' => 'MOTIVO-INTERNO-RESERVADO da conferência fictícia.', 'public_message' => 'A empresa concluiu a triagem desta candidatura.', 'no_conflict' => true];
}
test('triage reuses the submitted profile after the deadline without rewriting the receipt or admitting the triager as evaluator', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$submission = DB::table('candidate_submissions')->sole();
$snapshot = $submission->snapshot;
app(CandidateProfiles::class)->save($this->account, [...$this->profile, 'version' => 1, 'name' => 'Nome alterado depois de submeter']);
$this->travel(8)->days();
$close = ['version' => $this->process->fresh()->version, 'reason' => 'Concluir recepção do processo fictício.'];
$this->postJson('/pessoas/'.$this->process->id.'/close', $close)->assertUnprocessable();
$this->get('/pessoas')->assertInertia(fn (Assert $p) => $p->where('workdesk.tasks.0.action', 'triage'));
$body = electronicTriagePayload($this);
$url = '/pessoas/'.$this->process->id.'/recebidas/'.$submission->id.'/triagem';
$this->get('/pessoas/relatorios?view=applications')->assertInertia(fn (Assert $p) => $p
->where('applicationMetrics.'.$this->process->id.'.portal_received', 1)
->where('applicationMetrics.'.$this->process->id.'.portal_pending', 1)
->where('applicationMetrics.'.$this->process->id.'.registered', 0));
$this->postJson($url, $body)->assertRedirect();
$this->postJson($url, $body)->assertRedirect();
$application = DB::table('people_applications')->sole();
$this->get('/pessoas/relatorios?view=applications')->assertInertia(fn (Assert $p) => $p
->where('applicationMetrics.'.$this->process->id.'.registered', 1)
->where('applicationMetrics.'.$this->process->id.'.portal_received', 1)
->where('applicationMetrics.'.$this->process->id.'.portal_accepted', 1)
->where('applicationMetrics.'.$this->process->id.'.portal_pending', 0));
expect($application->name)->toBe('Pessoa fictícia')->and($application->reference)->toBe($submission->reference)->and($application->created_by)->toBe($this->admin->id)
->and(DB::table('candidate_submissions')->sole()->snapshot)->toBe($snapshot)->and(DB::table('candidate_triage')->count())->toBe(1);
$this->postJson('/pessoas/'.$this->process->id.'/close', $close)->assertRedirect();
$record = $this->process->fresh();
$evaluation = ['version' => $record->version, 'reason' => 'Avaliar candidatura fictícia com evidências.', 'no_conflict' => true, 'application_id' => $application->id,
'answers' => [['score' => '8', 'evidence' => 'Evidência fictícia das competências.']]];
$this->postJson('/pessoas/'.$record->id.'/evaluate', $evaluation)->assertForbidden();
$this->actingAs($this->reviewer)->postJson('/pessoas/'.$record->id.'/evaluate', $evaluation)->assertRedirect();
expect(app(PeopleHiring::class)->matrix($record)[0]['electronic_source']['profile_version'])->toBe(1);
});
test('candidate sees only the approved triage communication and refusal creates no assessment row', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$submission = DB::table('candidate_submissions')->sole();
$body = electronicTriagePayload($this, 'not_accepted');
$url = '/pessoas/'.$this->process->id.'/recebidas/'.$submission->id.'/triagem';
$this->postJson($url, $body)->assertRedirect();
expect(DB::table('people_applications')->count())->toBe(0);
$this->get($this->base.'/submissoes/'.$submission->id)->assertInertia(fn (Assert $p) => $p->where('decision.outcome', 'not_accepted')->where('decision.message', $body['public_message'])->missing('decision.reason')->missing('decision.created_by')->missing('decision.people_application_id'))->assertDontSee('MOTIVO-INTERNO-RESERVADO');
$this->postJson($url, electronicTriagePayload($this))->assertConflict();
});
test('triage respects current assignment stale process versions and separates confidential source reading', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$submission = DB::table('candidate_submissions')->sole();
$body = electronicTriagePayload($this);
$url = '/pessoas/'.$this->process->id.'/recebidas/'.$submission->id.'/triagem';
$this->postJson($url, [...$body, 'process_version' => 99])->assertUnprocessable();
$this->actingAs($this->reviewer)->postJson($url, $body)->assertForbidden();
$this->get('/pessoas/'.$this->process->id.'/recebidas')->assertForbidden();
$this->actingAs($this->admin);
$state = DB::table('people_access_states')->where('people_process_id', $this->process->id)->value('version');
app(PeopleAccess::class)->assign($this->admin, $this->process->id, ['version' => $state, 'user_id' => $this->reviewer->id, 'scopes' => ['review', 'interviews'], 'reason' => 'Autorizar consulta das evidências para análise.']);
$this->actingAs($this->reviewer)->get('/pessoas/'.$this->process->id.'/recebidas')->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canTriage', false)->has('submissions.data', 1));
$this->postJson($url, $body)->assertForbidden();
});
test('triage audit failure rolls back the linked internal candidature decision and candidate event', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$submission = DB::table('candidate_submissions')->sole();
$events = DB::table('candidate_events')->count();
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected triage audit failure'));
expect(fn () => app(CandidateTriage::class)->decide($this->admin, $this->process->id, $submission->id, electronicTriagePayload($this)))->toThrow(RuntimeException::class);
expect(DB::table('candidate_triage')->count())->toBe(0)->and(DB::table('people_applications')->count())->toBe(0)->and(DB::table('candidate_events')->count())->toBe($events);
});
test('a reference already entered manually requires reconciliation instead of automatic merging', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$submission = DB::table('candidate_submissions')->sole();
DB::table('people_applications')->insert(['tenant_id' => $this->tenant->id, 'people_process_id' => $this->process->id, 'created_by' => $this->admin->id, 'operation_key' => (string) Str::uuid(), 'name' => 'Registo manual anterior', 'summary' => 'Não fundir este registo automaticamente.', 'reference' => $submission->reference, 'received_on' => today(), 'created_at' => now(), 'updated_at' => now()]);
$this->postJson('/pessoas/'.$this->process->id.'/recebidas/'.$submission->id.'/triagem', electronicTriagePayload($this))->assertConflict();
expect(DB::table('candidate_triage')->count())->toBe(0)->and(DB::table('people_applications')->count())->toBe(1);
});
test('legacy assessment already under review cannot receive a new admitted candidature through triage', function () {
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
$submission = DB::table('candidate_submissions')->sole();
// Fixture for a process whose assessment began before triage was introduced.
$application = DB::table('people_applications')->insertGetId(['tenant_id' => $this->tenant->id, 'people_process_id' => $this->process->id, 'created_by' => $this->admin->id,
'operation_key' => (string) Str::uuid(), 'name' => 'Candidatura anterior', 'summary' => 'Registo anterior à introdução da triagem.', 'reference' => 'LEGACY-TEST', 'received_on' => today(), 'created_at' => now(), 'updated_at' => now()]);
DB::table('people_reviews')->insert(['tenant_id' => $this->tenant->id, 'people_application_id' => $application, 'reviewed_by' => $this->reviewer->id, 'answers' => '[]', 'reason' => 'Avaliação anterior da fixture.', 'score_minor' => 800, 'created_at' => now(), 'updated_at' => now()]);
DB::table('people_processes')->where('id', $this->process->id)->update(['status' => 'assessment', 'version' => $this->process->fresh()->version + 1]);
$url = '/pessoas/'.$this->process->id.'/recebidas/'.$submission->id.'/triagem';
$this->postJson($url, electronicTriagePayload($this))->assertConflict();
$this->postJson($url, electronicTriagePayload($this, 'not_accepted'))->assertRedirect();
expect(DB::table('people_applications')->count())->toBe(1);
});