218 lines
15 KiB
PHP
218 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Services\Malware\MalwareScanner;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
use Tests\TestCase;
|
|
|
|
class ProcurementTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
private User $editor;
|
|
|
|
private string $organization;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$identity = DB::selectOne('select current_database() as database,inet_server_port() as port');
|
|
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->database);
|
|
$this->assertSame(55441, (int) $identity->port);
|
|
$this->withoutVite();
|
|
$this->editor = User::factory()->create();
|
|
config(['procurement.enabled' => true, 'portal.managers' => [$this->editor->email]]);
|
|
$this->organization = (string) Str::uuid();
|
|
DB::table('organizations')->insert(['id' => $this->organization, 'name' => 'SINTÉTICO QA — Entidade de ensaio', 'organization_type' => 'private']);
|
|
$this->actingAs($this->editor);
|
|
}
|
|
|
|
private function payload(string $regime = 'state'): array
|
|
{
|
|
return ['title' => 'SINTÉTICO QA — '.$regime, 'reference' => 'QA-'.Str::uuid(), 'organization_id' => $this->organization,
|
|
'regime' => $regime, 'modality' => 'Modalidade documentada de ensaio', 'object' => 'Objecto sintético de concurso para ensaio QA; não corresponde a contratação real.',
|
|
'instrument' => $regime === 'state' ? 'Decreto n.º 79/2022 — referência do âmbito estatal' : 'Regras documentadas do procedimento sintético',
|
|
'edition' => $regime === 'external' ? 'Banco Mundial, Setembro/2025 — exemplar de referência' : 'QA-2026',
|
|
'rule_source' => $regime === 'state' ? 'https://www.ufsa.gov.mz/divnovoreg.php' : 'https://example.invalid/qa-rules',
|
|
'origin_published_at' => '2026-10-20T09:00', 'deadline' => '2026-11-20T15:00', 'timezone' => 'Africa/Maputo',
|
|
'delivery_channel' => 'Canal externo documentado', 'delivery_address' => 'Endereço sintético para ensaio; não enviar propostas.',
|
|
'public_contact' => 'contacto-interno-que-nao-deve-sair@example.test', 'contact_authorized' => false, 'internal_notes' => 'SEGREDO-INTERNO-QA',
|
|
'public_reason' => 'Divulgação de anúncio sintético para ensaio QA, sem contratação real.',
|
|
'lots' => [['code' => 'L1', 'description' => 'Lote de bens sintéticos', 'quantities' => '10 unidades de ensaio']], 'attachments' => []];
|
|
}
|
|
|
|
private function create(array $payload): string
|
|
{
|
|
$response = $this->post('/management/concursos', ['version' => 0, 'reason' => 'Preparação sintética documentada para ensaio QA.', 'draft' => $payload])->assertSessionHasNoErrors()->assertRedirect();
|
|
|
|
return basename(parse_url($response->headers->get('Location'), PHP_URL_PATH));
|
|
}
|
|
|
|
private function save(string $id, array $payload): void
|
|
{
|
|
$this->put('/management/concursos/'.$id, ['version' => DB::table('portal_tenders')->where('id', $id)->value('version'), 'reason' => 'Actualização sintética de ensaio com histórico conservado.', 'draft' => $payload])->assertRedirect()->assertSessionHasNoErrors();
|
|
}
|
|
|
|
private function publish(string $id, string $act = 'initial'): array
|
|
{
|
|
$plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => $act])->assertOk()->json();
|
|
$this->post('/management/concursos/'.$id.'/publicar', ['act' => $act, 'hash' => $plan['hash'], 'reason' => $plan['payload']['public_reason']])->assertRedirect()->assertSessionHasNoErrors();
|
|
|
|
return $plan;
|
|
}
|
|
|
|
public function test_incomplete_draft_recovers_without_becoming_public_and_rejects_stale_save(): void
|
|
{
|
|
$id = $this->create(['title' => 'Preparação incompleta']);
|
|
$this->get('/management/concursos/'.$id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->component('procurement/edit')->where('record.draft.title', 'Preparação incompleta'));
|
|
$this->get('/concursos/'.$id)->assertNotFound();
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors(['organization_id', 'deadline', 'regime']);
|
|
$this->putJson('/management/concursos/'.$id, ['version' => 0, 'reason' => 'Pedido concorrente obsoleto, sem sobrescrever o rascunho.', 'draft' => ['title' => 'Não guardar']])->assertConflict();
|
|
$this->assertSame(1, DB::table('portal_tender_versions')->where('tender_id', $id)->count());
|
|
}
|
|
|
|
public function test_state_external_private_regimes_and_filters_are_public_without_ug_ea_or_guarantee_defaults(): void
|
|
{
|
|
$ids = [];
|
|
foreach (['state', 'external', 'private'] as $regime) {
|
|
$p = $this->payload($regime);
|
|
$ids[$regime] = $this->create($p);
|
|
$plan = $this->publish($ids[$regime]);
|
|
$this->assertArrayNotHasKey('responsible_unit', $plan['payload']);
|
|
$this->assertArrayNotHasKey('internal_notes', $plan['payload']);
|
|
$this->assertArrayNotHasKey('public_contact', $plan['payload']);
|
|
}
|
|
Auth::logout();
|
|
$this->get('/concursos?regime=external&entity='.$this->organization.'&q=SINT')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/index')->where('filters.regime', 'external')->has('items.data', 1)->where('items.data.0.id', $ids['external']));
|
|
$this->get('/concursos?q=&entity=§or=&state=open')->assertOk()->assertInertia(fn (Assert $p) => $p->where('filters', ['state' => 'open']));
|
|
$this->get('/concursos/'.$ids['private'].'?regime=private&q=SINT&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/show')->where('backUrl', '/concursos?q=SINT®ime=private&page=2')->where('publication.payload.regime', 'private')->missing('publication.payload.internal_notes')->missing('publication.payload.public_contact')->has('history', 1));
|
|
$this->get('/concursos/'.$ids['state'].'/imprimir')->assertOk()->assertInertia(fn (Assert $p) => $p->where('printMode', true));
|
|
}
|
|
|
|
public function test_extension_preserves_publication_origin_and_history_and_expiry_is_not_an_award(): void
|
|
{
|
|
$p = $this->payload();
|
|
$id = $this->create($p);
|
|
$this->publish($id);
|
|
$first = DB::table('portal_tender_versions')->where('tender_id', $id)->where('act', 'initial')->first();
|
|
$p['deadline'] = '2026-11-25T16:00';
|
|
$this->save($id, $p);
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'correction'])->assertUnprocessable()->assertJsonValidationErrors('act');
|
|
$plan = $this->publish($id, 'extension');
|
|
$this->assertSame('2026-11-20T15:00', $plan['payload']['deadline_previous']);
|
|
$this->assertSame('2026-10-20T09:00', $plan['payload']['origin_published_at']);
|
|
$this->assertSame($first->payload, DB::table('portal_tender_versions')->where('id', $first->id)->value('payload'));
|
|
$this->travelTo(now()->setDate(2026, 11, 26));
|
|
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'deadline_ended')->where('publication.act', 'extension')->has('history', 2));
|
|
$this->travelBack();
|
|
$p['result'] = 'Resultado sintético de ensaio, não adjudicação real.';
|
|
$this->save($id, $p);
|
|
$this->publish($id, 'award');
|
|
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'results')->has('history', 3));
|
|
}
|
|
|
|
public function test_cancellation_and_later_clarification_do_not_reopen_the_procedure(): void
|
|
{
|
|
$p = $this->payload('private');
|
|
$id = $this->create($p);
|
|
$this->publish($id);
|
|
$p['result'] = 'Cancelamento sintético fundamentado para ensaio.';
|
|
$this->save($id, $p);
|
|
$this->publish($id, 'cancellation');
|
|
$p['object'] .= ' Esclarecimento posterior ao cancelamento.';
|
|
$this->save($id, $p);
|
|
$this->publish($id, 'clarification');
|
|
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'cancelled')->has('history', 3));
|
|
}
|
|
|
|
public function test_stale_preview_cannot_publish_new_draft_and_public_snapshot_stays_unchanged_until_explicit_publication(): void
|
|
{
|
|
$p = $this->payload();
|
|
$id = $this->create($p);
|
|
$plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk()->json();
|
|
$p['title'] = 'Nova edição ainda privada';
|
|
$this->save($id, $p);
|
|
$this->postJson('/management/concursos/'.$id.'/publicar', ['act' => 'initial', 'hash' => $plan['hash'], 'reason' => $p['public_reason']])->assertConflict();
|
|
$this->publish($id);
|
|
$p['title'] = 'Rascunho futuro privado';
|
|
$this->save($id, $p);
|
|
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.title', 'Nova edição ainda privada'));
|
|
}
|
|
|
|
public function test_documented_minimum_dates_and_business_calendar_are_specific_to_the_procedure(): void
|
|
{
|
|
$p = $this->payload();
|
|
$p['deadline'] = '2026-10-19T15:00';
|
|
$id = $this->create($p);
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
|
|
$p['origin_published_at'] = '2026-10-23T09:00';
|
|
$p['deadline'] = '2026-10-27T09:00';
|
|
$p += ['minimum_days' => 2, 'minimum_source' => 'Regra sintética de contagem, cláusula QA-1', 'minimum_edition' => 'QA-2026', 'minimum_modality' => $p['modality'], 'minimum_day_basis' => 'business', 'minimum_working_days' => '1,2,3,4,5', 'minimum_holidays' => '2026-10-26', 'minimum_calendar_source' => 'Calendário sintético QA: segunda a sexta, exclui 26/10. Não é calendário oficial.'];
|
|
$this->save($id, $p);
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
|
|
$p['deadline'] = '2026-10-28T09:00';
|
|
$this->save($id, $p);
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk();
|
|
$p['minimum_modality'] = 'Outra modalidade';
|
|
$this->save($id, $p);
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('minimum_modality');
|
|
}
|
|
|
|
public function test_simple_account_can_follow_but_cannot_edit_or_publish_and_tracking_is_not_a_bid(): void
|
|
{
|
|
$id = $this->create($this->payload());
|
|
$this->publish($id);
|
|
$reader = User::factory()->unverified()->create();
|
|
$before = DB::table('portal_role_assignments')->count();
|
|
$this->actingAs($reader)->put('/concursos/'.$id.'/acompanhar')->assertRedirect();
|
|
$this->get('/concursos/acompanhar')->assertOk()->assertInertia(fn (Assert $p) => $p->where('watched', true)->has('items', 1)->where('items.0.changed', false));
|
|
$this->actingAs($this->editor);
|
|
$p = $this->payload();
|
|
$this->save($id, $p);
|
|
$this->publish($id, 'clarification');
|
|
$reader->forceFill(['email_verified_at' => now()])->save();
|
|
$this->actingAs($reader);
|
|
$this->get('/concursos/acompanhar')->assertInertia(fn (Assert $p) => $p->where('items.0.changed', true));
|
|
$this->get('/management/concursos')->assertForbidden();
|
|
$this->postJson('/management/concursos/'.$id.'/publicar', [])->assertForbidden();
|
|
$this->delete('/concursos/'.$id.'/acompanhar')->assertRedirect();
|
|
$this->assertSame($before, DB::table('portal_role_assignments')->count());
|
|
}
|
|
|
|
public function test_real_scanned_library_attachment_private_redaction_and_withdrawal_are_reused(): void
|
|
{
|
|
Storage::fake('local');
|
|
config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.socket' => null, 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
|
$this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Current real scanner required');
|
|
$name = 'synthetic-tender-'.Str::uuid().'.txt';
|
|
$this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent($name, 'Exemplo sintético de anúncio QA, sem valor jurídico.')])->assertSessionHasNoErrors();
|
|
$doc = DB::table('documents')->where('file_name', $name)->first();
|
|
$this->assertSame('clean', $doc->malware_scan_status);
|
|
$metadata = ['document_id' => $doc->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Tender QA', 'document_type' => 'unknown', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'unknown', 'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA only, not an official notice.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
|
|
$this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
|
|
$resource = DB::table('portal_resources')->where('document_id', $doc->id)->first();
|
|
$this->assertNotNull($resource);
|
|
$p = $this->payload('external');
|
|
$p['attachments'] = [['resource_id' => $resource->id, 'public' => true, 'purpose' => 'Anúncio de ensaio']];
|
|
$id = $this->create($p);
|
|
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('attachments');
|
|
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
|
|
$this->publish($id);
|
|
Auth::logout();
|
|
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
|
|
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', true));
|
|
$this->actingAs($this->editor)->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
|
|
Auth::logout();
|
|
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
|
|
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', false));
|
|
}
|
|
}
|