658 lines
57 KiB
PHP
658 lines
57 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\CandidateAccount;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\AdministrativeGeography;
|
|
use App\Services\CandidateProfiles;
|
|
use App\Services\CandidateSubmissions;
|
|
use App\Services\CandidateTriage;
|
|
use App\Services\PeopleAccess;
|
|
use App\Services\PeopleHiring;
|
|
use App\Services\ReceivedCandidateAvailability;
|
|
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']]];
|
|
});
|
|
|
|
function territoryReceived(object $test, array $changes = []): int
|
|
{
|
|
$account = app(CandidateProfiles::class)->register($test->tenant->id, [...$test->credentials, 'email' => Str::uuid().'@territory.example.test']);
|
|
$account->setAttribute('email_verified_at', now())->save();
|
|
app(CandidateProfiles::class)->save($account, [...$test->profile, ...$changes]);
|
|
$location = app(CandidateProfiles::class)->profile($account)['data']['availability'];
|
|
|
|
return app(CandidateSubmissions::class)->submit($account, $test->process->id, [...$test->body, 'operation_key' => (string) Str::uuid(), 'answers' => [...$test->body['answers'], 'availability_location' => $location]]);
|
|
}
|
|
|
|
function territoryReference(bool $otherProvince = false): array
|
|
{
|
|
$record = collect(app(AdministrativeGeography::class)->catalogue()['records'])->first(fn ($record) => $record['selectable'] && $record['level'] === 4 && ($otherProvince ? $record['codes'][0] !== '01' : $record['codes'][0] === '01'));
|
|
|
|
return ['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $record['id']];
|
|
}
|
|
|
|
function receivedAvailabilityApplication(object $test, array $profileChanges = []): int
|
|
{
|
|
config(['geography.enabled' => true]);
|
|
$submission = territoryReceived($test, ['availability_reference' => territoryReference(), ...$profileChanges]);
|
|
$test->postJson('/pessoas/'.$test->process->id.'/recebidas/'.$submission.'/triagem', electronicTriagePayload($test))->assertRedirect();
|
|
|
|
return $submission;
|
|
}
|
|
|
|
test('received availability projection reuses main additional and manual submitted data on page and dossier', function () {
|
|
$this->withoutVite();
|
|
$reference = territoryReference(true);
|
|
$submission = receivedAvailabilityApplication($this, ['additional_availability' => [$this->profile['availability'], []], 'additional_availability_references' => [null, $reference]]);
|
|
$received = json_decode(DB::table('candidate_submissions')->where('id', $submission)->value('snapshot'), true);
|
|
$current = CandidateAccount::query()->findOrFail(DB::table('candidate_submissions')->where('id', $submission)->value('candidate_account_id'));
|
|
app(CandidateProfiles::class)->save($current, [...$this->profile, 'version' => 1, 'additional_availability' => [], 'additional_availability_references' => [], 'availability_reference' => territoryReference(true)]);
|
|
$before = [];
|
|
foreach (['people_processes', 'people_applications', 'people_reviews', 'candidate_submissions', 'candidate_triage', 'audit_logs'] as $table) {
|
|
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
|
}
|
|
$page = $this->get('/pessoas/'.$this->process->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('candidates.0.received_availability.submission_id', $submission)->where('candidates.0.received_availability.profile_version', 1));
|
|
$availability = $page->inertiaProps('candidates.0.received_availability');
|
|
expect($availability['availability'])->toBe($received['profile']['data']['availability'])->and($availability['additional_availability'])->toBe($received['profile']['data']['additional_availability'])->and($availability['additional_availability_references'])->toBe([null, $reference]);
|
|
expect($availability)->not->toHaveKeys(['email', 'phone', 'documents', 'answers', 'candidate_account_id']);
|
|
$this->getJson('/pessoas/'.$this->process->id.'/dossier.json')->assertOk()->assertJsonPath('candidates.0.received_availability', $availability);
|
|
foreach ($before as $table => $rows) {
|
|
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
|
}
|
|
});
|
|
|
|
test('received availability projection hides the data from view-only and review-only scopes', function () {
|
|
$this->withoutVite();
|
|
receivedAvailabilityApplication($this);
|
|
$url = '/pessoas/'.$this->process->id;
|
|
$this->actingAs($this->reviewer)->get($url)->assertOk()->assertInertia(fn (Assert $page) => $page->missing('candidates.0.received_availability'));
|
|
$this->getJson($url.'/dossier.json')->assertOk()->assertJsonMissingPath('candidates.0.received_availability');
|
|
$this->actingAs($this->admin);
|
|
app(PeopleAccess::class)->assign($this->admin, $this->process->id, ['version' => DB::table('people_access_states')->where('people_process_id', $this->process->id)->value('version'), 'user_id' => $this->reviewer->id, 'scopes' => ['review', 'interviews'], 'reason' => 'Consultar fontes do perfil recebido para conferência.']);
|
|
$this->actingAs($this->reviewer)->get($url)->assertInertia(fn (Assert $page) => $page->has('candidates.0.received_availability'));
|
|
DB::table('people_process_access')->where('people_process_id', $this->process->id)->where('user_id', $this->reviewer->id)->update(['scopes' => json_encode(['view', 'review'])]);
|
|
$this->getJson($url.'/dossier.json')->assertOk()->assertJsonMissingPath('candidates.0.received_availability');
|
|
});
|
|
|
|
test('received availability projection is not added to selection matrices or their frozen decision basis', function () {
|
|
$this->withoutVite();
|
|
receivedAvailabilityApplication($this);
|
|
$this->travel(8)->days();
|
|
$record = $this->process->fresh();
|
|
app(PeopleHiring::class)->execute($this->admin, $record->id, 'close', ['version' => $record->version, 'reason' => 'Fechar recepção sintética para avaliar.']);
|
|
$application = DB::table('people_applications')->sole();
|
|
$this->actingAs($this->reviewer);
|
|
app(PeopleHiring::class)->execute($this->reviewer, $record->id, 'evaluate', ['version' => $record->fresh()->version, 'reason' => 'Avaliação sintética pelas competências.', 'no_conflict' => true, 'application_id' => $application->id, 'answers' => [['score' => '8', 'evidence' => 'Competências verificadas nesta avaliação sintética.']]]);
|
|
$this->actingAs($this->admin);
|
|
app(PeopleHiring::class)->execute($this->admin, $record->id, 'select', ['version' => $record->fresh()->version, 'reason' => 'Proposta sintética com base nas avaliações.', 'no_conflict' => true, 'application_id' => $application->id]);
|
|
$before = $record->fresh()->selection;
|
|
expect($before['rows'][0])->not->toHaveKey('received_availability');
|
|
$this->get('/pessoas/'.$record->id)->assertOk()->assertInertia(fn (Assert $page) => $page->has('candidates.0.received_availability')->missing('process.selection.rows.0.received_availability'));
|
|
$this->getJson('/pessoas/'.$record->id.'/dossier.json')->assertOk()->assertJsonMissingPath('process.selection.rows.0.received_availability');
|
|
expect($record->fresh()->selection)->toBe($before)->and(app(PeopleHiring::class)->matrix($record->fresh())[0])->not->toHaveKey('received_availability');
|
|
});
|
|
|
|
test('received availability projection does not invent geography for manual internal applications or refused submissions', function () {
|
|
$this->withoutVite();
|
|
$id = territoryReceived($this);
|
|
$this->postJson('/pessoas/'.$this->process->id.'/recebidas/'.$id.'/triagem', electronicTriagePayload($this, 'not_accepted'))->assertRedirect();
|
|
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(), 'reference' => 'MANUAL-SEM-GEO', 'name' => 'Pessoa manual sintética', 'summary' => 'Resumo sem origem electrónica associada.', 'received_on' => today(), 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->get('/pessoas/'.$this->process->id)->assertInertia(fn (Assert $page) => $page->has('candidates', 1)->missing('candidates.0.received_availability'));
|
|
});
|
|
|
|
test('received availability projection keeps historical locality names when the catalogue is disabled', function () {
|
|
$this->withoutVite();
|
|
receivedAvailabilityApplication($this);
|
|
config(['geography.enabled' => false]);
|
|
$this->getJson('/pessoas/'.$this->process->id.'/dossier.json')->assertOk()->assertJsonPath('candidates.0.received_availability.availability.province', 'Niassa');
|
|
});
|
|
|
|
test('received availability projection preserves manual submitted text without assigning catalogue references', function () {
|
|
$this->withoutVite();
|
|
receivedAvailabilityApplication($this, ['availability_reference' => null]);
|
|
$this->getJson('/pessoas/'.$this->process->id.'/dossier.json')->assertOk()
|
|
->assertJsonPath('candidates.0.received_availability.availability', $this->profile['availability'])
|
|
->assertJsonPath('candidates.0.received_availability.availability_reference', null)
|
|
->assertJsonPath('candidates.0.received_availability.additional_availability_references', []);
|
|
});
|
|
|
|
test('received availability projection revalidates current actor authority and cannot cross processes', function () {
|
|
receivedAvailabilityApplication($this);
|
|
$record = $this->process->fresh();
|
|
$rows = app(PeopleHiring::class)->matrix($record);
|
|
$stale = $this->admin->fresh();
|
|
DB::table('users')->where('id', $this->admin->id)->update(['purchasing_role' => PurchasingRole::Requester->value]);
|
|
expect(app(ReceivedCandidateAvailability::class)->attach($stale, $record, $rows)[0])->not->toHaveKey('received_availability');
|
|
DB::table('users')->where('id', $this->admin->id)->update(['purchasing_role' => PurchasingRole::Admin->value]);
|
|
$other = app(PeopleHiring::class)->execute($this->admin, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Outro processo da projecção', 'purpose' => 'Processo sintético distinto para verificar o âmbito.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
|
|
expect(app(ReceivedCandidateAvailability::class)->attach($this->admin, $other, $rows)[0])->not->toHaveKey('received_availability');
|
|
});
|
|
|
|
test('candidate territory filters main and additional frozen references without classifying matching manual names', function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$reference = territoryReference();
|
|
$main = territoryReceived($this, ['availability_reference' => $reference]);
|
|
$extra = territoryReceived($this, ['availability_reference' => territoryReference(true), 'additional_availability' => [$this->profile['availability'], []], 'additional_availability_references' => [null, $reference]]);
|
|
$snapshot = json_decode(DB::table('candidate_submissions')->where('id', $main)->value('snapshot'), true);
|
|
territoryReceived($this, ['availability' => $snapshot['profile']['data']['availability']]);
|
|
territoryReceived($this, ['availability_reference' => territoryReference(true)]);
|
|
$url = '/pessoas/'.$this->process->id.'/recebidas';
|
|
$this->get($url.'?location=2')->assertInertia(fn (Assert $page) => $page->where('submissions.total', 2)->where('submissions.data.0.id', $main)->where('submissions.data.1.id', $extra)->where('geographyFilter.path.0.name', 'Niassa'));
|
|
$this->get($url)->assertInertia(fn (Assert $page) => $page->where('submissions.total', 4));
|
|
expect(DB::table('candidate_triage')->count())->toBe(0)->and(DB::table('people_reviews')->count())->toBe(0);
|
|
expect(app(CandidateTriage::class)->pending($this->process->fresh()))->toBe(4);
|
|
});
|
|
|
|
test('candidate territory never replaces the received location with the current profile location', function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$id = territoryReceived($this, ['availability_reference' => territoryReference()]);
|
|
$row = DB::table('candidate_submissions')->where('id', $id)->first();
|
|
$account = CandidateAccount::query()->findOrFail($row->candidate_account_id);
|
|
app(CandidateProfiles::class)->save($account, [...$this->profile, 'version' => 1, 'availability_reference' => territoryReference(true)]);
|
|
$this->get('/pessoas/'.$this->process->id.'/recebidas?location=2')->assertInertia(fn (Assert $page) => $page->where('submissions.total', 1)->where('submissions.data.0.id', $id));
|
|
expect(DB::table('candidate_submissions')->where('id', $id)->value('snapshot'))->toBe($row->snapshot);
|
|
});
|
|
|
|
test('candidate territory paginates oldest matching submissions and keeps focus and filter together', function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$ids = [];
|
|
for ($index = 0; $index < 21; $index++) {
|
|
$ids[] = territoryReceived($this, ['availability_reference' => territoryReference()]);
|
|
}
|
|
$url = '/pessoas/'.$this->process->id.'/recebidas';
|
|
$this->get($url.'?location=2')->assertInertia(fn (Assert $page) => $page->where('submissions.total', 21)->has('submissions.data', 20)->where('submissions.data.0.id', $ids[0]));
|
|
$response = $this->get($url.'?location=2&page=2')->assertInertia(fn (Assert $page) => $page->has('submissions.data', 1)->where('submissions.data.0.id', $ids[20]));
|
|
expect($response->inertiaProps('submissions.prev_page_url'))->toContain('location=2');
|
|
$this->get($url.'?location=2&submission='.$ids[0])->assertInertia(fn (Assert $page) => $page->where('submissions.total', 1)->where('submissions.data.0.id', $ids[0]));
|
|
});
|
|
|
|
test('candidate territory keeps the OR branches inside the process and tenant boundaries', function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$id = territoryReceived($this, ['additional_availability' => [[]], 'additional_availability_references' => [territoryReference()]]);
|
|
$copy = (array) DB::table('candidate_submissions')->where('id', $id)->first();
|
|
unset($copy['id']);
|
|
$other = $this->process->replicate();
|
|
$other->operation_key = (string) Str::uuid();
|
|
$other->title = 'Outro processo sintético';
|
|
$other->save();
|
|
DB::table('candidate_submissions')->insert([...$copy, 'people_process_id' => $other->id, 'operation_key' => (string) Str::uuid(), 'reference' => (string) Str::uuid()]);
|
|
$foreign = Tenant::query()->create(['name' => 'Outra empresa territorial']);
|
|
$foreignAdmin = User::factory()->create(['tenant_id' => $foreign->id, 'purchasing_role' => PurchasingRole::Admin]);
|
|
$this->actingAs($foreignAdmin);
|
|
app(CandidateProfiles::class)->configure($foreignAdmin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso sintético da outra empresa para teste de isolamento.', 'reason' => 'Preparar origem sintética de outra empresa.']);
|
|
$foreignAccount = app(CandidateProfiles::class)->register($foreign->id, [...$this->credentials, 'email' => Str::uuid().'@foreign.example.test']);
|
|
$this->actingAs($this->admin);
|
|
$foreignProcess = $this->process->replicate();
|
|
$foreignProcess->operation_key = (string) Str::uuid();
|
|
$foreignProcess->tenant_id = $foreign->id;
|
|
$foreignProcess->created_by = $foreignAdmin->id;
|
|
$foreignProcess->save();
|
|
DB::table('candidate_submissions')->insert([...$copy, 'candidate_account_id' => $foreignAccount->id, 'tenant_id' => $foreign->id, 'people_process_id' => $foreignProcess->id, 'operation_key' => (string) Str::uuid(), 'reference' => (string) Str::uuid()]);
|
|
$this->get('/pessoas/'.$this->process->id.'/recebidas?location=2')->assertInertia(fn (Assert $page) => $page->where('submissions.total', 1)->where('submissions.data.0.id', $id));
|
|
$this->getJson('/pessoas/'.$foreignProcess->id.'/divisao-administrativa')->assertNotFound();
|
|
});
|
|
|
|
test('candidate territory respects revoked scoped access and candidate identities on both routes', function () {
|
|
config(['geography.enabled' => true]);
|
|
$base = '/pessoas/'.$this->process->id;
|
|
$this->actingAs($this->reviewer)->getJson($base.'/divisao-administrativa')->assertForbidden();
|
|
$this->actingAs($this->admin);
|
|
DB::table('people_process_access')->where('people_process_id', $this->process->id)->where('user_id', $this->admin->id)->update(['expires_at' => now()->subMinute()]);
|
|
$this->getJson($base.'/divisao-administrativa')->assertForbidden();
|
|
$this->getJson($base.'/recebidas?location=2')->assertForbidden();
|
|
Auth::guard('web')->logout();
|
|
$this->getJson($base.'/divisao-administrativa')->assertUnauthorized();
|
|
});
|
|
|
|
test('candidate territory has a people-scoped catalogue without opening the procurement context', function () {
|
|
config(['geography.enabled' => true, 'work_context.entitlements.'.$this->tenant->id => ['people.internal']]);
|
|
$this->tenant->update(['work_context_policy' => ['revision' => 1, 'enabled' => ['people.internal']]]);
|
|
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
|
|
$this->getJson('/pessoas/'.$this->process->id.'/divisao-administrativa')->assertOk()->assertJsonCount(11, 'data')->assertHeader('Cache-Control', 'max-age=0, no-store, private');
|
|
$this->getJson('/api/divisao-administrativa')->assertForbidden();
|
|
});
|
|
|
|
test('candidate territory rejects invalid locations and can be disabled without losing the unfiltered inbox', function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$url = '/pessoas/'.$this->process->id;
|
|
$this->getJson($url.'/recebidas?location=999999')->assertUnprocessable();
|
|
$this->getJson($url.'/recebidas?location[]=2')->assertUnprocessable();
|
|
$this->getJson($url.'/recebidas?page=0')->assertUnprocessable();
|
|
config(['geography.enabled' => false]);
|
|
$this->getJson($url.'/divisao-administrativa')->assertServiceUnavailable();
|
|
$this->getJson($url.'/recebidas?location=2')->assertServiceUnavailable();
|
|
$this->get($url.'/recebidas')->assertInertia(fn (Assert $page) => $page->where('geographyEnabled', false));
|
|
});
|
|
|
|
test('candidate territory excludes references from other editions and handles missing additional lists', function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$id = territoryReceived($this, ['availability_reference' => territoryReference()]);
|
|
$snapshot = json_decode(DB::table('candidate_submissions')->where('id', $id)->value('snapshot'), true);
|
|
$snapshot['profile']['data']['availability_reference']['catalogue'] = str_repeat('0', 64);
|
|
$snapshot['profile']['data']['additional_availability_references'] = ['not_a_list' => territoryReference()];
|
|
$copy = (array) DB::table('candidate_submissions')->where('id', $id)->first();
|
|
unset($copy['id']);
|
|
$account = CandidateAccount::query()->findOrFail($copy['candidate_account_id'])->replicate();
|
|
$account->email = Str::uuid().'@historical.example.test';
|
|
$account->save();
|
|
$stale = DB::table('candidate_submissions')->insertGetId([...$copy, 'candidate_account_id' => $account->id, 'operation_key' => (string) Str::uuid(), 'reference' => (string) Str::uuid(), 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR)]);
|
|
territoryReceived($this);
|
|
$url = '/pessoas/'.$this->process->id.'/recebidas?location=2';
|
|
$this->get($url)->assertInertia(fn (Assert $page) => $page->where('submissions.total', 1)->where('submissions.data.0.id', $id));
|
|
$this->get($url.'&submission='.$stale)->assertInertia(fn (Assert $page) => $page->where('submissions.total', 0));
|
|
});
|
|
|
|
test('candidate geography submission freezes canonical location and does not change when profile is edited later', function () {
|
|
config(['geography.enabled' => true]);
|
|
$reference = ['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => '5'];
|
|
$this->post($this->base.'/perfil', [...$this->profile, 'version' => 1, 'availability_reference' => $reference])->assertSessionHasNoErrors();
|
|
$location = app(CandidateProfiles::class)->profile($this->account)['data']['availability'];
|
|
$body = [...$this->body, 'profile_version' => 2, 'answers' => [...$this->body['answers'], 'availability_location' => $location]];
|
|
$this->post($this->base.'/oportunidades/'.$this->process->id, $body)->assertRedirect();
|
|
$submission = DB::table('candidate_submissions')->sole();
|
|
$snapshot = json_decode($submission->snapshot, true);
|
|
expect($snapshot['profile']['data']['availability_reference'])->toBe($reference)->and($snapshot['profile']['data']['availability'])->toBe($location);
|
|
$this->post($this->base.'/perfil', [...$this->profile, 'version' => 2, 'availability_reference' => null])->assertSessionHasNoErrors();
|
|
$this->post($this->base.'/oportunidades/'.$this->process->id, $body)->assertRedirect();
|
|
expect(DB::table('candidate_submissions')->count())->toBe(1)->and(DB::table('candidate_submissions')->sole()->snapshot)->toBe($submission->snapshot);
|
|
});
|
|
|
|
test('candidate additional geography submission reuses a selected additional location and freezes references', function () {
|
|
config(['geography.enabled' => true]);
|
|
$record = collect(app(AdministrativeGeography::class)->catalogue()['records'])->first(fn ($record) => $record['selectable'] && $record['level'] === 4);
|
|
$reference = ['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $record['id']];
|
|
$this->post($this->base.'/perfil', [...$this->profile, 'version' => 1, 'additional_availability' => [[]], 'additional_availability_references' => [$reference]])->assertSessionHasNoErrors();
|
|
$location = app(CandidateProfiles::class)->profile($this->account)['data']['additional_availability'][0];
|
|
$body = [...$this->body, 'profile_version' => 2, 'answers' => [...$this->body['answers'], 'availability_location' => $location]];
|
|
$this->post($this->base.'/oportunidades/'.$this->process->id, $body)->assertRedirect();
|
|
$submission = DB::table('candidate_submissions')->sole();
|
|
$snapshot = json_decode($submission->snapshot, true);
|
|
expect($snapshot['profile']['data']['additional_availability_references'])->toBe([$reference])->and($snapshot['answers']['availability_location'])->toBe($location);
|
|
$this->post($this->base.'/perfil', [...$this->profile, 'version' => 2, 'additional_availability' => [], 'additional_availability_references' => []])->assertSessionHasNoErrors();
|
|
$this->post($this->base.'/oportunidades/'.$this->process->id, $body)->assertRedirect();
|
|
expect(DB::table('candidate_submissions')->count())->toBe(1)->and(DB::table('candidate_submissions')->sole()->snapshot)->toBe($submission->snapshot)->and(DB::table('candidate_triage')->count())->toBe(0);
|
|
});
|
|
|
|
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);
|
|
});
|