'SINTÉTICO QA — Fundo de ensaio', 'kind' => 'fund', 'summary' => 'Referência fictícia para validação técnica.', 'manager_name' => 'Entidade sintética', 'source_url' => 'https://example.invalid/fund', 'source_reference' => 'Documento sintético QA', 'source_checked_on' => today()->toDateString(), 'source_status' => 'identified', 'coverage' => 'national', 'sectors_scope' => 'unknown', 'criteria' => [['code' => 'registration', 'question' => 'A entidade tem registo?', 'applies_to' => 'entity', 'expected' => 'yes', 'source_reference' => 'Critério sintético 1', 'explanation' => 'Exigência fictícia para ensaio.']], 'windows' => [['code' => 'closed-call', 'title' => 'Chamada histórica sintética', 'mode' => 'dated', 'end_on' => today()->subDay()->toDateString(), 'coverage' => 'programme', 'source_url' => 'https://example.invalid/call', 'source_reference' => 'Chamada fictícia QA', 'checked_on' => today()->toDateString()]]]; } private function publishDraft(User $editor, string $code, array $draft): string { $service = app(FundingProgrammes::class); $id = $service->save($editor, null, ['version' => 0, 'code' => $code, 'reason' => 'Preparação sintética isolada para ensaio.', 'draft' => $draft]); $plan = $service->preview($editor, $id); $service->publish($editor, $id, ['version' => 1, 'hash' => $plan['hash'], 'reason' => 'Publicação sintética isolada para ensaio.', 'confirm' => true]); return $id; } public function test_packaged_official_reference_prepares_only_a_draft_and_reuses_existing_fiche_without_overwriting(): void { $this->withoutVite(); $this->travelTo(Carbon::parse('2026-11-02 12:00:00')); $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $this->actingAs($editor)->get('/management/fundos/referencias/fdel')->assertOk() ->assertInertia(fn ($page) => $page->component('funding/reference')->where('reference.draft.coverage', 'national') ->where('reference.draft.windows.0.end_on', '2025-10-20')->where('reference.draft.windows.0.coverage', 'unknown')); $references = app(FundingProgrammeReferences::class); $plan = $references->preview($editor, 'fdel'); $this->post('/management/fundos/referencias/fdel/preparar', ['hash' => str_repeat('0', 64)])->assertConflict(); $this->assertSame(0, DB::table('funding_programmes')->count()); $this->post('/management/fundos/referencias/fdel/preparar', ['hash' => $plan['hash']])->assertRedirect(); $row = DB::table('funding_programmes')->where('code', 'fdel')->first(); $this->assertNotNull($row); $this->assertNull($row->published_version_id); $this->assertSame(0, DB::table('funding_programme_versions')->count()); $service = app(FundingProgrammes::class); $publication = $service->preview($editor, $row->id); $this->assertSame('closed', $service->windowState($publication['payload']['windows'][0])); $this->assertSame([], $publication['payload']['windows'][0]['territory_ids']); $this->assertSame([], $publication['payload']['resource_ids']); $audit = DB::table('audit_events')->where('entity_id', $row->id)->where('action', 'reference_prepared')->first(); $this->assertSame($plan['package_sha256'], json_decode($audit->after_data, true)['package_sha256']); $draft = json_decode($row->draft, true); $draft['summary'] = 'Alteração editorial deliberada de ensaio; preservar sem substituição pela referência.'; $service->save($editor, $row->id, ['version' => 1, 'code' => 'fdel', 'reason' => 'Actualização deliberada de ensaio antes de repetir.', 'draft' => $draft]); $before = DB::table('funding_programmes')->where('id', $row->id)->first(); $this->post('/management/fundos/referencias/fdel/preparar', ['hash' => $plan['hash']])->assertRedirect('/management/fundos/'.$row->id); $this->assertSame($before->draft, DB::table('funding_programmes')->where('id', $row->id)->value('draft')); $this->assertSame(1, DB::table('funding_programmes')->count()); $this->assertSame(1, DB::table('audit_events')->where('entity_id', $row->id)->where('action', 'reference_prepared')->count()); $this->actingAs(User::factory()->create())->get('/management/fundos/referencias/fdel')->assertForbidden(); $this->post('/management/fundos/referencias/fdel/preparar', ['hash' => $plan['hash']])->assertForbidden(); $this->travelBack(); } public function test_download_is_pinned_to_published_library_version_and_rechecks_rights_scan_and_actual_bytes(): void { $this->withoutVite(); Storage::fake('local'); $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $document = (string) Str::uuid(); $bytes = 'SINTÉTICO QA — Condições fictícias, versão um.'; $key = 'funding-qa/'.$document.'.txt'; Storage::disk('local')->put($key, $bytes); DB::table('documents')->insert(['id' => $document, 'source_system' => 'synthetic-funding-test', 'file_name' => 'funding-qa.txt', 'media_type' => 'text/plain', 'storage_key' => $key, 'size_bytes' => strlen($bytes), 'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean', 'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public', 'version_no' => 1]); $resource = DB::table('portal_resources')->insertGetId(['document_id' => $document, 'title' => 'SINTÉTICO QA — Condições', 'category' => 'funding-qa', 'topic' => 'Ensaio sintético de financiamento', 'published' => true, 'rights_status' => 'authorized', 'source_url' => 'https://example.invalid/funding-qa', 'source_reference' => 'Referência sintética QA', 'metadata_version' => 1]); $id = $this->publishDraft($editor, 'qa-document', [...$this->draft(), 'resource_ids' => [$resource]]); $service = app(FundingProgrammes::class); $published = $service->published($id); $url = '/fundos/'.$id.'/versoes/'.$published['version_id'].'/documentos/'.$resource; $this->get($url)->assertOk()->assertDownload('funding-qa.txt'); $this->assertTrue($published['payload']['documents'][0]['available']); Storage::disk('local')->put($key, 'SINTÉTICO QA — Ficheiro alterado sem nova análise.'); $this->get($url)->assertNotFound(); Storage::disk('local')->put($key, $bytes); foreach ([['malware_scan_status' => 'pending'], ['rights_status' => 'restricted'], ['data_classification' => 'confidential'], ['malware_scanned_sha256' => str_repeat('0', 64)]] as $patch) { DB::table('documents')->where('id', $document)->update($patch); $this->get($url)->assertNotFound(); $this->assertFalse($service->published($id)['payload']['documents'][0]['available']); DB::table('documents')->where('id', $document)->update(['malware_scan_status' => 'clean', 'rights_status' => 'authorized', 'data_classification' => 'public', 'malware_scanned_sha256' => hash('sha256', $bytes)]); } $oldPlan = $service->preview($editor, $id); DB::table('documents')->where('id', $document)->update(['version_no' => 2]); $this->get($url)->assertNotFound(); $this->assertFalse($service->published($id)['payload']['documents'][0]['available']); $newPlan = $service->preview($editor, $id); $this->assertNotSame($oldPlan['hash'], $newPlan['hash']); $this->actingAs($editor)->post('/management/fundos/'.$id.'/publicar', ['version' => 1, 'hash' => $oldPlan['hash'], 'reason' => 'Publicação sintética para ensaio de versão.', 'confirm' => true])->assertConflict(); $version = $service->publish($editor, $id, ['version' => 1, 'hash' => $newPlan['hash'], 'reason' => 'Publicação sintética para ensaio de versão.', 'confirm' => true]); $this->get('/fundos/'.$id.'/versoes/'.$version.'/documentos/'.$resource)->assertOk(); $this->get($url)->assertNotFound(); DB::table('portal_resources')->where('id', $resource)->update(['published' => false]); $this->get('/fundos/'.$id.'/versoes/'.$version.'/documentos/'.$resource)->assertNotFound(); $this->assertSame(1, json_decode(DB::table('funding_programme_versions')->where('id', $published['version_id'])->value('payload'), true)['document_snapshots'][0]['version']); } public function test_filters_explain_unknown_and_partial_coverage_preserve_scopes_and_distinguish_window_dates(): void { $this->withoutVite(); $this->seed(TerritoryReferenceSeeder::class); $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $sector = Sector::create(['name' => 'SINTÉTICO QA — Agricultura', 'slug' => 'qa-funding-agriculture', 'is_active' => true, 'published_at' => now()]); $otherSector = Sector::create(['name' => 'SINTÉTICO QA — Indústria', 'slug' => 'qa-funding-industry', 'is_active' => true, 'published_at' => now()]); $province = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Nampula')->value('id'); $otherProvince = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Tete')->value('id'); $district = DB::table('geo_locations')->where('parent_id', $province)->where('location_type', 'district')->value('id'); $national = $this->publishDraft($editor, 'qa-national', [...$this->draft(), 'sectors_scope' => 'all', 'windows' => []]); $unknown = $this->publishDraft($editor, 'qa-unknown', [...$this->draft(), 'coverage' => 'unknown', 'windows' => []]); $window = [...$this->draft()['windows'][0], 'mode' => 'dated', 'start_on' => today()->toDateString(), 'end_on' => today()->toDateString()]; $local = $this->publishDraft($editor, 'qa-local', [...$this->draft(), 'coverage' => 'territorial', 'territory_ids' => [$province], 'sectors_scope' => 'selected', 'sector_ids' => [$sector->id], 'windows' => [$window]]); $outside = $this->publishDraft($editor, 'qa-outside', [...$this->draft(), 'coverage' => 'territorial', 'territory_ids' => [$otherProvince], 'sectors_scope' => 'selected', 'sector_ids' => [$sector->id]]); $partial = $this->publishDraft($editor, 'qa-district', [...$this->draft(), 'coverage' => 'territorial', 'territory_ids' => [$district], 'sectors_scope' => 'selected', 'sector_ids' => [$otherSector->id], 'windows' => [[...$window, 'start_on' => today()->addDay()->toDateString(), 'end_on' => today()->addWeek()->toDateString()]]]); $get = fn (array $filters) => $this->get('/fundos?'.http_build_query($filters))->assertOk(); $filters = ['sector' => $sector->id, 'territory' => $province]; $get($filters)->assertInertia(fn ($page) => $page->has('items.data', 3)->where('items.data', fn ($rows) => collect($rows)->pluck('id')->sort()->values()->all() === collect([$national, $unknown, $local])->sort()->values()->all())); $get([...$filters, 'certainty' => 'documented'])->assertInertia(fn ($page) => $page->has('items.data', 2)); $get([...$filters, 'territory' => $district, 'certainty' => 'documented'])->assertInertia(fn ($page) => $page->has('items.data', 2)); $get(['sector' => $otherSector->id, 'territory' => $province, 'certainty' => 'documented'])->assertInertia(fn ($page) => $page->has('items.data', 2) ->where('items.data', fn ($rows) => str_contains(collect($rows)->firstWhere('id', $partial)['filter_explanation']['territory'], 'uma parte'))); $get(['state' => 'open'])->assertInertia(fn ($page) => $page->has('items.data', 1)->where('items.data.0.id', $local)); $get(['state' => 'closed'])->assertInertia(fn ($page) => $page->has('items.data', 1)->where('items.data.0.id', $outside)); $get(['state' => 'upcoming'])->assertInertia(fn ($page) => $page->has('items.data', 1)->where('items.data.0.id', $partial)); $get(['state' => 'unknown'])->assertInertia(fn ($page) => $page->has('items.data', 2)); $get(['territory' => $otherProvince, 'sector' => $sector->id, 'certainty' => 'documented'])->assertInertia(fn ($page) => $page->has('items.data', 2)); $this->getJson('/fundos?territory='.Str::uuid())->assertUnprocessable(); $snapshot = app(FundingProgrammes::class)->published($local); DB::table('geo_locations')->where('id', $province)->update(['name' => 'SINTÉTICO QA — Nome alterado']); $this->assertSame($snapshot['payload']['territory_paths'], app(FundingProgrammes::class)->published($local)['payload']['territory_paths']); } public function test_public_versions_and_private_interests_preserve_the_original_snapshot(): void { $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $service = app(FundingProgrammes::class); $input = ['version' => 0, 'code' => 'synthetic-fund', 'reason' => 'Preparação sintética isolada para ensaio.', 'draft' => $this->draft()]; $id = $service->save($editor, null, $input); $plan = $service->preview($editor, $id); $publication = ['version' => 1, 'hash' => $plan['hash'], 'reason' => 'Publicação sintética isolada para ensaio.', 'confirm' => true]; $first = $service->publish($editor, $id, $publication); $this->withoutVite(); $this->get('/fundos')->assertOk(); $this->get('/fundos/'.$id)->assertOk(); $this->get('/fundos?q=sem-correspondencia')->assertOk()->assertInertia(fn ($page) => $page->has('items.data', 0)); $this->put('/fundos/'.$id.'/interesse', ['version_id' => $first, 'consent' => true])->assertRedirect('/login'); $this->postJson('/fundos/'.$id.'/criterios', ['version_id' => $first, 'applicant_kind' => 'entity', 'answers' => ['registration' => 'no']]) ->assertOk()->assertJsonPath('criteria.0.status', 'does_not_match'); $this->assertSame($first, $service->publish($editor, $id, $publication)); $this->assertSame(1, DB::table('funding_programme_versions')->count()); $this->assertSame('closed', $service->published($id)['payload']['windows'][0]['state']); $person = User::factory()->create(); $service->saveInterest($person, $id, $first, true); $this->actingAs($person)->get('/fundos/guardados')->assertOk()->assertHeader('Cache-Control', 'no-store, private') ->assertInertia(fn ($page) => $page->has('items.data', 1)->where('items.data.0.saved_version', 1)); $this->actingAs(User::factory()->create())->get('/fundos/guardados')->assertOk()->assertInertia(fn ($page) => $page->has('items.data', 0)); $input['version'] = 1; $input['draft']['summary'] = 'Descrição sintética actualizada.'; $service->save($editor, $id, $input); $this->assertSame($first, $service->published($id)['version_id']); $plan = $service->preview($editor, $id); $second = $service->publish($editor, $id, [...$publication, 'version' => 2, 'hash' => $plan['hash']]); $this->assertNotSame($first, $second); $service->saveInterest($person, $id, $second, true); $this->assertSame($first, DB::table('funding_programme_interests')->where('user_id', $person->id)->value('version_id')); $service->removeInterest(User::factory()->create(), $id); $this->assertNull(DB::table('funding_programme_interests')->where('user_id', $person->id)->value('removed_at')); $result = $service->assess($id, ['version_id' => $second, 'applicant_kind' => 'unknown', 'answers' => ['registration' => 'yes']]); $this->assertSame('unknown', $result['criteria'][0]['status']); $result = $service->assess($id, ['version_id' => $second, 'applicant_kind' => 'individual', 'answers' => []]); $this->assertSame('not_applicable', $result['criteria'][0]['status']); $service->withdraw($editor, $id, 2, 'Retirada sintética para verificar conservação privada.'); $this->actingAs($person)->get('/fundos/guardados')->assertOk()->assertInertia(fn ($page) => $page ->has('items.data', 1)->where('items.data.0.available', false)->where('items.data.0.saved_version', 1)); $this->get('/fundos/'.$id)->assertNotFound(); $this->delete('/fundos/'.$id.'/interesse')->assertRedirect(); $this->get('/fundos/guardados')->assertOk()->assertInertia(fn ($page) => $page->has('items.data', 0)); } public function test_example_content_cannot_be_published_as_an_actual_programme(): void { $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $service = app(FundingProgrammes::class); $draft = [...$this->draft(), 'source_status' => 'example']; $id = $service->save($editor, null, ['version' => 0, 'code' => 'synthetic-example', 'reason' => 'Preparação sintética isolada para ensaio.', 'draft' => $draft]); $this->expectException(ValidationException::class); $service->preview($editor, $id); } public function test_editor_routes_enforce_authority_preview_and_stale_versions_independently_of_procurement(): void { $this->withoutVite(); $editor = User::factory()->create(); $outsider = User::factory()->create(); config(['portal.managers' => [$editor->email], 'procurement.enabled' => false]); $this->actingAs($outsider)->get('/management/fundos')->assertForbidden(); $this->post('/management/fundos', [])->assertForbidden(); $this->actingAs($editor)->get('/management/fundos/novo')->assertOk(); $input = ['version' => 0, 'code' => 'synthetic-http-editor', 'reason' => 'Preparação sintética de ensaio editorial.', 'draft' => $this->draft()]; $this->post('/management/fundos', $input)->assertRedirect(); $id = DB::table('funding_programmes')->where('code', $input['code'])->value('id'); $this->get('/management/fundos/'.$id)->assertOk(); $this->get('/management/fundos')->assertOk(); $plan = $this->postJson('/management/fundos/'.$id.'/previa')->assertOk()->json(); $publication = ['version' => 1, 'hash' => $plan['hash'], 'reason' => 'Publicação sintética de ensaio editorial.', 'confirm' => true]; $this->post('/management/fundos/'.$id.'/publicar', [...$publication, 'hash' => str_repeat('0', 64)])->assertConflict(); $this->post('/management/fundos/'.$id.'/publicar', $publication)->assertRedirect(); $this->put('/management/fundos/'.$id, $input)->assertConflict(); $this->actingAs($outsider)->post('/management/fundos/'.$id.'/retirar', ['version' => 1, 'reason' => $input['reason']])->assertForbidden(); $this->postJson('/management/fundos/'.$id.'/previa')->assertForbidden(); $this->get('/management/fundos/'.$id)->assertForbidden(); $this->assertSame(1, DB::table('funding_programme_versions')->count()); } public function test_changed_master_reference_invalidates_preview_and_publishes_a_distinct_snapshot_without_overwriting_history(): void { $editor = User::factory()->create(); config(['portal.managers' => [$editor->email]]); $organization = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $organization, 'name' => 'SINTÉTICO QA — Gestor original', 'organization_type' => 'private']); $service = app(FundingProgrammes::class); $draft = [...$this->draft(), 'manager_organization_id' => $organization]; $id = $service->save($editor, null, ['version' => 0, 'code' => 'synthetic-master-change', 'reason' => 'Preparação sintética isolada para ensaio.', 'draft' => $draft]); $plan = $service->preview($editor, $id); $publication = ['version' => 1, 'hash' => $plan['hash'], 'reason' => 'Publicação sintética isolada para ensaio.', 'confirm' => true]; $first = $service->publish($editor, $id, $publication); DB::table('organizations')->where('id', $organization)->update(['name' => 'SINTÉTICO QA — Gestor actualizado']); $newPlan = $service->preview($editor, $id); $this->assertNotSame($plan['hash'], $newPlan['hash']); $this->assertSame('SINTÉTICO QA — Gestor original', $service->published($id)['payload']['manager_name']); $second = $service->publish($editor, $id, [...$publication, 'hash' => $newPlan['hash']]); $this->assertNotSame($first, $second); $this->assertSame(2, $service->published($id)['version']); $this->assertSame('SINTÉTICO QA — Gestor original', json_decode(DB::table('funding_programme_versions')->where('id', $first)->value('payload'), true)['manager_name']); $this->assertSame(2, DB::table('funding_programme_versions')->count()); } }