From c22846b2b1c74d70baa33ba17164c8d3f73f6d4a Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 30 Oct 2026 21:15:27 +0200 Subject: [PATCH] Mostrar oportunidades publicadas antes do registo de candidatos --- .../Controllers/CandidatePortalController.php | 11 +- app/Services/CandidateSubmissions.php | 20 ++ docs/desenvolvimento.md | 20 ++ .../candidate-documents-production.json | 253 ++++++++++++++++++ .../candidate-public-opportunities-pg.json | 16 ++ .../candidate-public-opportunities.json | 44 +++ resources/js/pages/candidate/entry.tsx | 79 +++++- resources/js/pages/candidate/profile.tsx | 17 ++ tests/Feature/CandidateSubmissionsTest.php | 38 +++ tests/frontend/candidate-portal.test.tsx | 38 +++ 10 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 docs/proofs/candidate-documents-production.json create mode 100644 docs/proofs/candidate-public-opportunities-pg.json create mode 100644 docs/proofs/candidate-public-opportunities.json diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php index 97a67a0..9fa91a6 100644 --- a/app/Http/Controllers/CandidatePortalController.php +++ b/app/Http/Controllers/CandidatePortalController.php @@ -48,9 +48,11 @@ class CandidatePortalController extends Controller $portal = $profiles->portal($company); abort_unless($portal !== null, 404); $tenant = Tenant::query()->findOrFail($company); + $filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']); return $this->page($request, 'candidate/entry', ['company' => ['id' => $company, 'name' => $tenant->name], - 'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice]]); + 'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice], + 'opportunities' => app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))]); } public function register(Request $request, int $company, CandidateProfiles $profiles): Response @@ -90,13 +92,12 @@ class CandidatePortalController extends Controller return redirect('/candidaturas/'.$company); } $profiles->event($account, 'profile.consulted', []); + $filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']); + $opportunities = app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))->withPath('/candidaturas/'.$company.'/perfil'); return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profiles->profile($account), 'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(), - 'opportunities' => Schema::hasTable('candidate_opportunity_publications') ? DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id') - ->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true)->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString()) - ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer')->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.version', '>', 'p.version')) - ->orderBy('r.closes_on')->limit(20)->get(['r.id', 'r.title', 'r.closes_on']) : [], + 'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()], 'submissions' => Schema::hasTable('candidate_submissions') ? DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->latest('id')->limit(20)->get(['id', 'reference', 'created_at']) : []]); } diff --git a/app/Services/CandidateSubmissions.php b/app/Services/CandidateSubmissions.php index d450cf1..2fc66bc 100644 --- a/app/Services/CandidateSubmissions.php +++ b/app/Services/CandidateSubmissions.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\CandidateAccount; use App\Models\PeopleProcess; use App\Models\User; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; @@ -15,6 +16,25 @@ use stdClass; class CandidateSubmissions { + /** Only explicitly published, current and open opportunities; no internal process fields. + * @return LengthAwarePaginator + */ + public function publicOpportunities(int $company, int $page = 1): LengthAwarePaginator + { + $path = '/candidaturas/'.$company; + if (! Schema::hasTable('candidate_opportunity_publications')) { + return (new LengthAwarePaginator([], 0, 12, $page))->withPath($path)->setPageName('opportunities_page'); + } + + return DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id') + ->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true) + ->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString()) + ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer') + ->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.tenant_id', 'p.tenant_id')->whereColumn('newer.version', '>', 'p.version')) + ->orderBy('r.closes_on')->orderBy('r.id') + ->paginate(12, ['r.id', 'r.title', 'r.kind', 'r.closes_on', 'p.description'], 'opportunities_page', $page)->withPath($path); + } + /** @param array $input */ public function publish(User $actor, int $processId, array $input): void { diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index 05aacdc..4cdfcad 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,25 @@ # Desenvolvimento do SGC +## Consulta de oportunidades antes do registo, 30 de Outubro de 2026 + +A entrada da área do candidato mostra oportunidades explicitamente publicadas, abertas e dentro do prazo, com título, tipo, data e descrição pública. Não expõe finalidade interna, regras, candidaturas, avaliações ou documentos. A consulta é paginada por empresa; a última versão da publicação controla a visibilidade, incluindo retirada. O fecho de novos registos mantém a consulta pública e a entrada das contas existentes. Preparar e enviar candidaturas continuam a exigir autenticação própria, perfil e confirmação de email conforme as regras já existentes. + +A área autenticada reutiliza a mesma consulta e passa a ter paginação, em vez de omitir oportunidades depois das primeiras 20. Retirada a mensagem desactualizada de que o envio de documentos ainda não estava disponível. Não foram abertas inscrições, publicadas oportunidades ou criadas contas para testar no domínio. + +Verificação dirigida: 24 testes PHP/374 asserções em perfil e submissões; PostgreSQL descartável: 64 testes/1401 asserções, incluindo Pessoas e formatos de documentos. Dez testes frontend dirigidos, tipos, PHPStan sem erros, Pint e lint dirigidos aprovados. As provas integrais aceites da entrega bf84575 são reutilizadas, sem alegar nova execução de toda a suíte. Conferência visual tentada novamente: conector devolveu native pipe indisponível e browser host indisponível. Este incremento continua local e não faz parte da release bf84575 activa no domínio. Provas `proofs/candidate-public-opportunities.json` e `candidate-public-opportunities-pg.json`. + +## Entrega em produção confirmada — bf84575, 30 de Outubro de 2026 + +O commit `bf84575d10d9063c2224945e6d5627da826148a9` está em main e activo no domínio, na release `20261030-190744-bf84575d10d9-KyKKDI71`. O serviço existente terminou com Result=success e ExecMainStatus=0. Esta confirmação actualiza as indicações históricas de instalação apenas local abaixo. + +O instalador foi corrigido para exigir HTTP 200 em `/dashboard`: o anterior teste da raiz recebia o redireccionamento público esperado e provocava rollback. A validação e o rollback do instalador foram mantidos. Configuração anterior salvaguardada antes da correcção. + +Delta reconciliado: 14 migrações, de 000028 a 000041, sem repetir as 30 já registadas. Backup fresco da base e armazenamento, restauro conferido e ensaio das migrações na cópia antes da transacção de produção. As 81 tabelas anteriores conservaram os seus dados nas colunas de origem; 18 novas tabelas conferidas vazias e schema final igual ao ensaio. Existem agora 44 migrações registadas. A migração 000028 acrescenta o parâmetro de IVA por empresa com valor inicial 16%, sem reescrever os valores e snapshots anteriores. Não foram criadas contas, abertas inscrições ou publicadas oportunidades automaticamente. + +Armazenamento privado em storage partilhado persistente, com escrita conferida para www-data. Limites específicos do domínio: ficheiro 50M, pedido PHP e Nginx 104M, até 20 ficheiros; configuração Nginx validada e recarregada. Os 2M/8M ainda apresentados pelo PHP CLI são os limites do terminal e não os parâmetros específicos do domínio. PHP 8.4 dispõe de DOM/ZIP/fileinfo; PHP 8.5 CLI continua sem DOM/ZIP e não foi usado na instalação. + +Conferência HTTP: 21 ecrãs públicos com componentes esperados, utilizador nulo e âmbito vazio; sete percursos privados protegidos. Prova `proofs/candidate-documents-production.json`. Conferência visual dirigida e ensaio com candidatura real no domínio continuam pendentes; não foram enviados ficheiros pessoais ou emails reais para os simular. + ## CV e documentos na candidatura electrónica, 30 de Outubro de 2026 Recepção de documentos integrada no mesmo envio da candidatura. A obrigatoriedade, formatos (PDF, DOCX, JPG e PNG) e limite por ficheiro vêm do formulário preservado na preparação do processo, sem procurar a versão actual da empresa. Recusados documentos desconhecidos, obrigatórios em falta, tamanho excessivo e divergência de extensão/MIME/conteúdo. DOCX exige estrutura Word/XML válida, sem declarações de entidades/DOCTYPE, macros ou executáveis conhecidos, com limites de entradas e expansão do arquivo. A conferência de formato não constitui antivírus, prova de autenticidade, validação do mérito ou aprovação documental. diff --git a/docs/proofs/candidate-documents-production.json b/docs/proofs/candidate-documents-production.json new file mode 100644 index 0000000..97c8065 --- /dev/null +++ b/docs/proofs/candidate-documents-production.json @@ -0,0 +1,253 @@ +{ + "checked_at": "2026-10-30T19:09:50.960374+00:00", + "commit": "bf84575d10d9063c2224945e6d5627da826148a9", + "release": "/var/www/compras-deploy/releases/20261030-190744-bf84575d10d9-KyKKDI71", + "deployment_service": "success", + "migration_count": 44, + "migrations_applied": 14, + "existing_tables_preserved": 81, + "new_tables_verified_empty": 18, + "backup": "/root/compras-release-backups/20261030T190740921286Z-bf84575", + "database_restore_verified": true, + "storage_restore_verified": true, + "private_storage_shared": true, + "web_user_can_write_private_uploads": true, + "nginx_validation_ok": true, + "site_upload_configuration": [ + "\n client_max_body_size 104M;", + " fastcgi_param PHP_VALUE \"upload_max_filesize=50M\\npost_max_size=104M\\nmax_file_uploads=20\";" + ], + "cli_limits_apply_to_cli_only": true, + "application": { + "database_driver": "pgsql", + "migration_count": 44, + "candidate_migrations": [ + "2026_10_30_000037_create_candidate_profiles", + "2026_10_30_000038_create_candidate_access_tokens", + "2026_10_30_000039_create_candidate_submissions", + "2026_10_30_000040_create_candidate_triage", + "2026_10_30_000041_create_candidate_submission_documents" + ], + "documents_table_exists": true, + "document_count": 0, + "public_navigation_enabled": true, + "candidate_disk_root": "/var/www/compras-deploy/releases/20261030-190744-bf84575d10d9-KyKKDI71/storage/app/candidate-documents" + }, + "http": { + "checked_at": "2026-10-30T19:08:35.710090+00:00", + "results": [ + { + "path": "/", + "status": 302, + "location": "https://compras.tempo.co.mz/dashboard" + }, + { + "path": "/dashboard", + "status": 200, + "component": "dashboard", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/requisicoes", + "status": 200, + "component": "requisitions/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/requisicoes/criar", + "status": 200, + "component": "requisitions/form", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/fornecedores", + "status": 200, + "component": "suppliers/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/cotacoes", + "status": 200, + "component": "quotations/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/ordens", + "status": 200, + "component": "purchases/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/recepcoes", + "status": 200, + "component": "receipts/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/stock", + "status": 200, + "component": "stock/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/stock/catalogo", + "status": 200, + "component": "stock/catalogue", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/stock/operacoes", + "status": 200, + "component": "stock/operations", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/equipamentos", + "status": 200, + "component": "equipment/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/planeamento", + "status": 200, + "component": "planning/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/pessoas", + "status": 200, + "component": "people/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/orcamentos", + "status": 200, + "component": "budgets/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/facturas", + "status": 200, + "component": "invoices/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/pagamentos", + "status": 200, + "component": "payments/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/regras-investimento", + "status": 200, + "component": "investment/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/politica-admissao", + "status": 200, + "component": "purchases/admission-policy", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/politica-recomendacao", + "status": 200, + "component": "purchases/recommendation-policy", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/revisao-compras", + "status": 200, + "component": "purchase-controls/index", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/settings/appearance", + "status": 200, + "component": "settings/appearance", + "anonymous": true, + "data_scope": "none", + "version": "d57b6033713dbe71fe3664abe9f56f4a" + }, + { + "path": "/pessoas/cadastro", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/pessoas/1", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/fornecedores/1/dados-bancarios", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/administracao/parametrizacao", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/relatorios", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/settings/profile", + "status": 302, + "location": "https://compras.tempo.co.mz/login" + }, + { + "path": "/candidaturas/1/submissoes/1/documentos/cv", + "status": 401 + } + ], + "public_pages_passed": 21, + "private_routes_protected": 7, + "server_activation_proof": "pending" + }, + "visual_review": "pending", + "real_candidate_submission_in_production": "not performed" +} diff --git a/docs/proofs/candidate-public-opportunities-pg.json b/docs/proofs/candidate-public-opportunities-pg.json new file mode 100644 index 0000000..1f4dc00 --- /dev/null +++ b/docs/proofs/candidate-public-opportunities-pg.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-30T19:14:38+00:00", + "database": "disposable PostgreSQL", + "application_role_reused_without_privilege_change": true, + "result": "passed", + "test_files": [ + "CandidateSubmissionsTest", + "PeopleHiringTest", + "CandidatePortalTest", + "CandidateDocumentFormatsTest" + ], + "original_evaluation_untouched": true, + "database_removed": true, + "tests": 64, + "assertions": 1401 +} diff --git a/docs/proofs/candidate-public-opportunities.json b/docs/proofs/candidate-public-opportunities.json new file mode 100644 index 0000000..fa5455e --- /dev/null +++ b/docs/proofs/candidate-public-opportunities.json @@ -0,0 +1,44 @@ +{ + "checked_at": "2026-10-30T19:15:14.138147+00:00", + "base_commit": "bf84575d10d9063c2224945e6d5627da826148a9", + "scope": [ + "public published opportunity discovery before candidate registration", + "current publication deadline company and process scope", + "public content minimisation", + "shared public and authenticated pagination", + "authenticated application and document guards preserved" + ], + "module_sqlite": { + "tests": 24, + "assertions": 374, + "passed": true + }, + "postgresql": { + "tests": 64, + "assertions": 1401, + "passed": true, + "disposable_database_removed": true + }, + "frontend": { + "tests": 10, + "passed": true + }, + "typescript": true, + "phpstan_errors": 0, + "pint": true, + "targeted_lint": true, + "build": { + "exit_code": 0, + "completed": true + }, + "full_suite": "reuse accepted bf84575 suite; not repeated", + "new_migrations": false, + "production_installed": false, + "visual_review": "blocked by unavailable native pipe and browser host", + "source_sha256": { + "app/Services/CandidateSubmissions.php": "63bebd4ccb04e9bc2ab19b0701567f082831814498e83cd7e9755ba2f550f6e4", + "app/Http/Controllers/CandidatePortalController.php": "e37a7df5933da0d9635e91180a027d1d00d5dd3d16efc15c8c36a3ad906f724d", + "resources/js/pages/candidate/entry.tsx": "1fb6c2f3ecd7153fe677c7c8164867df22f0cdc146c0bc32d6c82a0b61cd354e", + "resources/js/pages/candidate/profile.tsx": "3857fec4bb8c10164a13b710f95df916707ebb8e214a05117f1f530b0b7b34c5" + } +} diff --git a/resources/js/pages/candidate/entry.tsx b/resources/js/pages/candidate/entry.tsx index 2725292..f4b069e 100644 --- a/resources/js/pages/candidate/entry.tsx +++ b/resources/js/pages/candidate/entry.tsx @@ -5,9 +5,21 @@ import { Button } from '@/components/ui/button'; export default function Entry({ company, portal, + opportunities = { data: [], prev_page_url: null, next_page_url: null }, }: { company: { id: number; name: string }; portal: { version: number; registration_open: boolean; notice: string }; + opportunities?: { + data: { + id: number; + title: string; + kind: string; + closes_on: string; + description: string; + }[]; + prev_page_url: string | null; + next_page_url: string | null; + }; }) { const login = useForm({ email: '', password: '' }); const register = useForm({ @@ -61,10 +73,71 @@ export default function Entry({

Prepare o seu perfil e candidate-se às oportunidades - publicadas. O envio de anexos ainda está em desenvolvimento. - Criar um perfil não constitui candidatura ou contratação. + publicadas. Os documentos exigidos são indicados em cada + formulário. Criar um perfil não constitui candidatura ou + contratação.

-
+
+

+ Oportunidades publicadas +

+ {opportunities.data.length === 0 && ( +

Não existem oportunidades nesta página.

+ )} + {opportunities.data.map((item) => ( +
+

{item.title}

+

+ {item.kind === 'service' + ? 'Prestação de serviços' + : 'Recrutamento'}{' '} + · Prazo: {item.closes_on} +

+
+ + Consultar descrição publicada + +

+ {item.description} +

+
+ + Entrar para preparar a candidatura + +
+ ))} + +
+

Entrar

))} +

As minhas candidaturas

diff --git a/tests/Feature/CandidateSubmissionsTest.php b/tests/Feature/CandidateSubmissionsTest.php index c66525e..ec7a9b5 100644 --- a/tests/Feature/CandidateSubmissionsTest.php +++ b/tests/Feature/CandidateSubmissionsTest.php @@ -12,6 +12,7 @@ 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; @@ -44,6 +45,43 @@ beforeEach(function () { 'answers' => ['full_name' => 'Pessoa fictícia', 'availability_location' => $this->profile['availability']]]; }); +test('guests see only explicitly published public opportunity fields while submission remains authenticated', function () { + Auth::guard('candidate')->logout(); + Auth::guard('web')->logout(); + $this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 1) + ->where('opportunities.data.0', ['id' => $this->process->id, 'title' => 'Oportunidade fictícia', 'kind' => 'service', 'closes_on' => $this->process->closes_on, 'description' => $this->publication['description']]) + ->where('auth.user', null)->where('organizationAccess', null)); + $this->get($this->base.'/oportunidades/'.$this->process->id)->assertUnauthorized(); + $this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertUnauthorized(); + expect(DB::table('candidate_submissions')->count())->toBe(0); + $this->actingAs($this->admin); + app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]); + $this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0)); + app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 2]); + $this->travel(8)->days(); + $this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0)); +}); + +test('public opportunity listing is paginated within the company and remains visible to existing users when registration closes', function () { + $process = (array) DB::table('people_processes')->where('id', $this->process->id)->first(); + $publication = (array) DB::table('candidate_opportunity_publications')->where('people_process_id', $this->process->id)->first(); + unset($process['id'], $publication['id']); + for ($index = 0; $index < 12; $index++) { + $id = DB::table('people_processes')->insertGetId([...$process, 'operation_key' => (string) Str::uuid()]); + DB::table('candidate_opportunity_publications')->insert([...$publication, 'people_process_id' => $id]); + } + app(CandidateProfiles::class)->configure($this->admin, ['version' => 1, 'registration_open' => false, 'notice' => 'Aviso fictício de registos temporariamente encerrados.', 'reason' => 'Encerrar novos registos no ensaio.']); + $this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 12)->where('opportunities.total', 13)->where('opportunities.next_page_url', $this->base.'?opportunities_page=2')->where('portal.registration_open', false)); + $this->get($this->base.'?opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 1)); + $this->get($this->base.'/perfil?opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('opportunities', 1)->where('opportunityPagination.prev', $this->base.'/perfil?opportunities_page=1')->where('opportunityPagination.next', null)); + $other = Tenant::query()->create(['name' => 'Empresa sem oportunidades']); + $otherAdmin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]); + $this->actingAs($otherAdmin); + app(CandidateProfiles::class)->configure($otherAdmin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso fictício da outra empresa sem oportunidades.', 'reason' => 'Configurar portal separado no ensaio.']); + $this->get('/candidaturas/'.$other->id)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0)); + $this->getJson($this->base.'?opportunities_page=invalid')->assertUnprocessable(); +}); + test('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; diff --git a/tests/frontend/candidate-portal.test.tsx b/tests/frontend/candidate-portal.test.tsx index 117f355..7537d91 100644 --- a/tests/frontend/candidate-portal.test.tsx +++ b/tests/frontend/candidate-portal.test.tsx @@ -62,6 +62,44 @@ test.each([true, false])( expect(html).not.toContain('/dashboard'); }, ); +test('public entry shows published opportunities and pagination while keeping application behind candidate login', () => { + const initialPage = { + ...page, + props: { + ...page.props, + company: { id: 1, name: 'Empresa fictícia' }, + portal: { + version: 1, + registration_open: false, + notice: 'Aviso fictício.', + }, + opportunities: { + data: [ + { + id: 7, + title: 'Serviço publicado', + kind: 'service', + closes_on: '2026-11-10', + description: 'Descrição pública do serviço.', + }, + ], + prev_page_url: null, + next_page_url: '/candidaturas/1?opportunities_page=2', + }, + }, + }; + const html = renderToString( + , + ); + expect(html).toContain('Serviço publicado'); + expect(html).toContain('Descrição pública do serviço.'); + expect(html).toContain('Prestação de serviços'); + expect(html).toContain('href="#candidate-login"'); + expect(html).toContain('/candidaturas/1?opportunities_page=2'); + expect(html).not.toContain('/oportunidades/7'); + expect(html).not.toContain('anexos ainda está em desenvolvimento'); +}); + test('candidate profile renders geography and retained version without internal evaluation controls', () => { const initialPage = { ...page,