270 lines
21 KiB
PHP
270 lines
21 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Sector;
|
|
use App\Models\User;
|
|
use App\Services\FundingProgrammeReferences;
|
|
use App\Services\FundingProgrammes;
|
|
use Database\Seeders\TerritoryReferenceSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Tests\TestCase;
|
|
|
|
final class FundingProgrammesTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function draft(): array
|
|
{
|
|
return ['title' => '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());
|
|
}
|
|
}
|