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); });