catalogue()['records'])->filter(fn ($record) => $record['level'] === 4 && $record['selectable'] && $record['id'] !== '5')->values()->get($index); return ['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $record['id']]; } beforeEach(function () { $this->withoutVite(); config(['geography.enabled' => true]); $this->f = receiptFixture(); $this->admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($this->admin); app(CandidateProfiles::class)->configure($this->admin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso sintético de testes, sem dados ou contas reais.', 'reason' => 'Configuração de teste da geografia.']); $this->base = '/candidaturas/'.$this->f['tenant']->id; $this->post($this->base.'/registar', ['email' => 'geo-candidate@example.test', 'password' => 'Synthetic-Geo-123!', 'password_confirmation' => 'Synthetic-Geo-123!', 'notice_acknowledged' => true, 'portal_version' => 1])->assertRedirect(); $this->account = CandidateAccount::query()->sole(); $this->reference = ['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => '5']; $this->profileData = ['version' => 0, 'name' => 'Pessoa sintética', 'phone' => '', 'summary' => 'Perfil de competências de teste.', 'availability' => ['province' => 'Texto original', 'district' => 'Distrito original', 'locality' => 'Localidade original'], 'reason' => 'Registo sintético de disponibilidade.']; }); test('candidate geography catalog is scoped to a current candidate session without staff grants', function () { Auth::guard('web')->logout(); $this->getJson($this->base.'/divisao-administrativa')->assertOk()->assertJsonCount(11, 'data')->assertHeader('Cache-Control', 'max-age=0, no-store, private'); $this->getJson($this->base.'/divisao-administrativa?parent=2')->assertOk(); $this->getJson('/api/divisao-administrativa')->assertUnauthorized(); $this->getJson('/candidaturas/999999/divisao-administrativa')->assertUnauthorized(); $this->account->increment('session_version'); $this->getJson($this->base.'/divisao-administrativa')->assertUnauthorized(); }); test('candidate geography endpoint rejects guests and staff identities without candidate authentication', function () { Auth::guard('candidate')->logout(); $this->getJson($this->base.'/divisao-administrativa')->assertUnauthorized(); Auth::guard('web')->logout(); $this->getJson($this->base.'/divisao-administrativa')->assertUnauthorized(); }); test('candidate geography derives official availability from the reference and preserves earlier manual versions', function () { $this->post($this->base.'/perfil', $this->profileData)->assertSessionHasNoErrors(); $first = DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->first()->data; $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1, 'availability_reference' => $this->reference])->assertSessionHasNoErrors(); $profile = app(CandidateProfiles::class)->profile($this->account); expect($profile['data']['availability_reference'])->toBe($this->reference)->and($profile['data']['availability'])->toBe(['province' => 'Niassa', 'district' => 'CIDADE DE LICHINGA', 'locality' => 'Urbana nº 1']); expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->orderBy('version')->first()->data)->toBe($first); $this->get($this->base.'/perfil')->assertInertia(fn (Assert $page) => $page->where('geographyEnabled', true)->where('availabilityGeography.reference.record_id', '5')->where('auth.user', null)); }); test('candidate geography legacy updates preserve known references only while availability text is unchanged', function () { $this->post($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $this->reference])->assertSessionHasNoErrors(); $current = app(CandidateProfiles::class)->profile($this->account); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1, 'availability' => array_reverse($current['data']['availability'], true)])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data']['availability_reference'])->toBe($this->reference); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 2])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data'])->not->toHaveKey('availability_reference'); }); test('candidate geography rejects ambiguous incomplete and obsolete references without creating profile versions', function () { $bad = collect(app(AdministrativeGeography::class)->catalogue()['records'])->first(fn ($record) => in_array('duplicate_code_path', $record['issues'], true)); foreach ([['record_id' => '999999'], ['record_id' => $bad['id']], ['record_id' => '2'], ['catalogue' => str_repeat('0', 64)]] as $change) { $this->postJson($this->base.'/perfil', [...$this->profileData, 'availability_reference' => [...$this->reference, ...$change]])->assertUnprocessable(); } expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->count())->toBe(0); }); test('candidate geography keeps native profile version and duplicate availability rules', function () { $this->post($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $this->reference])->assertSessionHasNoErrors(); $this->postJson($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $this->reference])->assertUnprocessable()->assertJsonValidationErrors('version'); $location = app(CandidateProfiles::class)->profile($this->account)['data']['availability']; $this->postJson($this->base.'/perfil', [...$this->profileData, 'version' => 1, 'availability_reference' => $this->reference, 'additional_availability' => [$location]])->assertUnprocessable()->assertJsonValidationErrors('additional_availability'); expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->count())->toBe(1); }); test('candidate geography can be cleared explicitly while foreign and unresolved locations stay manual', function () { $this->post($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $this->reference])->assertSessionHasNoErrors(); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1, 'availability_reference' => null])->assertSessionHasNoErrors(); $profile = app(CandidateProfiles::class)->profile($this->account); expect($profile['data']['availability_reference'])->toBeNull()->and($profile['data']['availability'])->toBe($this->profileData['availability']); }); test('candidate geography disabled keeps manual profiles and historical references usable', function () { $this->post($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $this->reference])->assertSessionHasNoErrors(); $location = app(CandidateProfiles::class)->profile($this->account)['data']['availability']; config(['geography.enabled' => false]); $this->getJson($this->base.'/divisao-administrativa')->assertServiceUnavailable(); $this->get($this->base.'/perfil')->assertInertia(fn (Assert $page) => $page->where('geographyEnabled', false)); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1, 'availability' => $location])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data']['availability_reference'])->toBe($this->reference); }); test('candidate geography event failure rolls back profile and resolved reference together', function () { $this->partialMock(CandidateProfiles::class)->shouldReceive('event')->andThrow(new RuntimeException('Synthetic candidate event failure')); $this->withoutExceptionHandling(); expect(fn () => $this->post($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $this->reference]))->toThrow(RuntimeException::class, 'Synthetic candidate event failure'); expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->count())->toBe(0); }); test('candidate additional geography resolves each selected locality and preserves manual entries', function () { $reference = additionalGeographyReference(); $manual = $this->profileData['availability']; $manual['locality'] = 'Outro local manual'; $this->post($this->base.'/perfil', [...$this->profileData, 'additional_availability' => [$manual, []], 'additional_availability_references' => [null, $reference]])->assertSessionHasNoErrors(); $data = app(CandidateProfiles::class)->profile($this->account)['data']; expect($data['additional_availability_references'])->toBe([null, $reference])->and($data['additional_availability'][0])->toBe($manual); $path = app(AdministrativeGeography::class)->describe($reference)['path']; $names = array_column($path, 'name', 'level'); expect($data['additional_availability'][1])->toBe(['province' => $names[1], 'district' => $names[2], 'locality' => $names[4]]); }); test('candidate additional geography validates alignment list shape limits and invalid references', function () { $entry = $this->profileData['availability']; foreach ([ ['additional_availability' => [$entry], 'additional_availability_references' => []], ['additional_availability' => [$entry], 'additional_availability_references' => [1 => additionalGeographyReference()]], ['additional_availability' => [$entry], 'additional_availability_references' => [[...$this->reference, 'record_id' => '999999']]], ['additional_availability' => array_fill(0, 21, $entry), 'additional_availability_references' => array_fill(0, 21, null)], ['additional_availability_references' => []], ] as $case) { $this->postJson($this->base.'/perfil', [...$this->profileData, ...$case])->assertUnprocessable(); } expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->count())->toBe(0); }); test('candidate additional geography legacy reorder removal and edits retain only known unchanged associations', function () { $references = [additionalGeographyReference(), additionalGeographyReference(1)]; $this->post($this->base.'/perfil', [...$this->profileData, 'additional_availability' => [[], []], 'additional_availability_references' => $references])->assertSessionHasNoErrors(); $first = app(CandidateProfiles::class)->profile($this->account)['data']; $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1, 'additional_availability' => array_reverse($first['additional_availability'])])->assertSessionHasNoErrors(); $second = app(CandidateProfiles::class)->profile($this->account)['data']; expect($second['additional_availability_references'])->toBe(array_reverse($references)); $edited = $second['additional_availability']; $edited[0]['locality'] = 'Modificação manual sem associação'; $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 2, 'additional_availability' => $edited])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data']['additional_availability_references'])->toBe([null, $references[0]]); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 3, 'additional_availability' => []])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data']['additional_availability_references'])->toBe([]); $old = json_decode(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->orderBy('version')->first()->data, true); expect($old['additional_availability_references'])->toBe($references); }); test('candidate additional geography omitted data stays intact while explicit null associations become manual', function () { $reference = additionalGeographyReference(); $this->post($this->base.'/perfil', [...$this->profileData, 'additional_availability' => [[]], 'additional_availability_references' => [$reference]])->assertSessionHasNoErrors(); $first = app(CandidateProfiles::class)->profile($this->account)['data']; $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1])->assertSessionHasNoErrors(); $second = app(CandidateProfiles::class)->profile($this->account)['data']; expect($second['additional_availability'])->toBe($first['additional_availability'])->and($second['additional_availability_references'])->toBe([$reference]); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 2, 'additional_availability' => $second['additional_availability'], 'additional_availability_references' => [null]])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data']['additional_availability_references'])->toBe([null]); }); test('candidate additional geography keeps duplicate and obsolete catalogue checks across all locations', function () { $reference = additionalGeographyReference(); $this->postJson($this->base.'/perfil', [...$this->profileData, 'availability_reference' => $reference, 'additional_availability' => [[]], 'additional_availability_references' => [$reference]])->assertUnprocessable()->assertJsonValidationErrors('additional_availability'); $this->postJson($this->base.'/perfil', [...$this->profileData, 'additional_availability' => [[], []], 'additional_availability_references' => [$reference, $reference]])->assertUnprocessable()->assertJsonValidationErrors('additional_availability'); $this->postJson($this->base.'/perfil', [...$this->profileData, 'additional_availability' => [[]], 'additional_availability_references' => [[...$reference, 'catalogue' => str_repeat('0', 64)]]])->assertUnprocessable()->assertJsonValidationErrors('additional_availability_references.0'); expect(DB::table('candidate_profile_versions')->where('candidate_account_id', $this->account->id)->count())->toBe(0); }); test('candidate additional geography suspended catalogue preserves existing associations through legacy requests', function () { $reference = additionalGeographyReference(); $this->post($this->base.'/perfil', [...$this->profileData, 'additional_availability' => [[]], 'additional_availability_references' => [$reference]])->assertSessionHasNoErrors(); config(['geography.enabled' => false]); $this->post($this->base.'/perfil', [...$this->profileData, 'version' => 1])->assertSessionHasNoErrors(); expect(app(CandidateProfiles::class)->profile($this->account)['data']['additional_availability_references'])->toBe([$reference]); });