Files
Investidor-V2/tests/Feature/InvestmentTenderAlertsTest.php
T
2026-11-02 20:31:15 +02:00

225 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentWorkspace;
use App\Services\Procurement;
use Database\Seeders\TerritoryReferenceSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestmentTenderAlertsTest extends TestCase
{
use RefreshDatabase;
private User $actor;
private User $editor;
private Sector $sector;
private string $organization;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->actor = User::factory()->create();
$this->editor = User::factory()->create();
config(['procurement.enabled' => true, 'portal.managers' => [$this->editor->email]]);
$this->sector = Sector::create(['name' => 'Synthetic tender sector', 'slug' => 'synthetic-tender', 'is_active' => true, 'published_at' => now()]);
$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->actor);
}
private function input(array $sources = ['tenders'], array $criteria = [], int $version = 0): array
{
return ['version' => $version, 'enabled' => true, 'frequency' => 'as_available', 'channel' => 'in_app', 'consent' => true, 'sources' => $sources, 'criteria' => $criteria];
}
private function payload(array $overrides = []): array
{
return [...['title' => 'SINTÉTICO QA — Concurso de ensaio', 'reference' => 'QA-'.Str::uuid(), 'organization_id' => $this->organization,
'sector_id' => $this->sector->id, 'regime' => 'private', 'modality' => 'Modalidade documentada de ensaio',
'object' => 'Objecto sintético de ensaio; sem contratação real.', 'instrument' => 'Regras sintéticas documentadas', 'edition' => 'QA-2026',
'rule_source' => 'https://example.invalid/synthetic-rules', 'origin_published_at' => now()->subDay()->format('Y-m-d\TH:i'),
'deadline' => now()->addMonth()->format('Y-m-d\TH:i'), 'timezone' => 'Africa/Maputo', 'delivery_channel' => 'Canal externo documentado de ensaio',
'public_reason' => 'Publicação sintética de ensaio, sem contratação ou proposta real.',
'internal_notes' => 'SECRET-SYNTHETIC-DRAFT', 'public_contact' => 'synthetic-private@example.invalid', 'contact_authorized' => false,
'attachments' => [], 'lots' => []], ...$overrides];
}
/** @return array{id:string,version_id:string,payload:array} */
private function publish(array $payload = [], ?string $id = null, string $act = 'initial'): array
{
$service = app(Procurement::class);
$payload = $payload ?: $this->payload();
$id = $service->save($this->editor, $id, ['version' => $id ? DB::table('portal_tenders')->where('id', $id)->value('version') : 0,
'reason' => 'Preparação sintética documentada para ensaio QA.', 'draft' => $payload]);
$plan = $service->preview($this->editor, $id, $act);
$service->publish($this->editor, $id, ['act' => $act, 'hash' => $plan['hash'], 'reason' => $plan['payload']['public_reason']]);
return ['id' => $id, 'version_id' => DB::table('portal_tenders')->where('id', $id)->value('published_version_id'), 'payload' => $payload];
}
private function opportunity(): Opportunity
{
return Opportunity::create(['sector_id' => $this->sector->id, 'title' => 'Synthetic opportunity', 'slug' => 'synthetic-'.Str::uuid(),
'reference' => 'SYNTHETIC-'.Str::uuid(), 'status' => 'published', 'published_at' => now()]);
}
public function test_new_tenders_match_public_sector_province_and_regime_without_drafts_or_private_data(): void
{
$this->seed(TerritoryReferenceSeeder::class);
$province = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Nampula')->value('id');
$district = DB::table('geo_locations')->where('parent_id', $province)->where('location_type', 'district')->value('id');
$otherProvince = DB::table('geo_locations')->where('location_type', 'province')->where('id', '<>', $province)->value('id');
$this->publish($this->payload(['territory_id' => $district]));
$criteria = ['sector' => $this->sector->slug, 'province_id' => $province, 'tender_regime' => 'private', 'category' => 'not-applicable-to-tenders'];
$this->putJson('/investidor/alert-preferences', $this->input(['tenders'], $criteria))->assertOk()->assertJsonPath('sources', ['tenders'])->assertJsonMissingPath('criteria.category');
$match = $this->publish($this->payload(['territory_id' => $district]));
$this->publish($this->payload(['territory_id' => $otherProvince]));
$this->publish($this->payload(['territory_id' => $district, 'regime' => 'external']));
$otherSector = Sector::create(['name' => 'Other synthetic sector', 'slug' => 'other-synthetic-tender', 'is_active' => true]);
$this->publish($this->payload(['territory_id' => $district, 'sector_id' => $otherSector->id]));
app(Procurement::class)->save($this->editor, null, ['version' => 0, 'reason' => 'Rascunho sintético sem publicação de ensaio.', 'draft' => $this->payload(['territory_id' => $district])]);
$this->opportunity();
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->assertSame(0, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$notification = DB::table('investment_notifications')->sole();
$this->assertSame($match['version_id'], $notification->tender_version_id);
$this->assertNull($notification->submission_id);
$this->assertNull($notification->opportunity_id);
$payload = json_decode(Crypt::decryptString($notification->payload), true);
$this->assertSame($match['id'], $payload['tender_id']);
$this->assertSame('initial', $payload['act']);
$this->assertSame(1, $payload['preference_version']);
$this->assertArrayNotHasKey('internal_notes', $payload);
$this->assertArrayNotHasKey('public_contact', $payload);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonPath('notifications.data.0.kind', 'tender_match')->assertDontSee('SECRET-SYNTHETIC-DRAFT');
$this->postJson('/investidor/notifications/'.$notification->id.'/read')->assertOk();
$this->actingAs(User::factory()->create())->postJson('/investidor/notifications/'.$notification->id.'/read')->assertNotFound();
$this->assertDatabaseCount('portal_tender_watches', 0);
}
public function test_new_published_acts_preserve_old_alerts_and_draft_edits_do_not_generate_alerts(): void
{
$this->putJson('/investidor/alert-preferences', $this->input())->assertOk();
$initial = $this->publish($this->payload());
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(1, $alerts->dispatchDue());
$old = DB::table('investment_notifications')->sole();
$changed = $initial['payload'];
$changed['deadline'] = now()->addMonths(2)->format('Y-m-d\TH:i');
$service = app(Procurement::class);
$service->save($this->editor, $initial['id'], ['version' => 2, 'reason' => 'Alteração sintética de prazo ainda em preparação.', 'draft' => $changed]);
$this->travel(5)->minutes();
$this->assertSame(0, $alerts->dispatchDue());
$plan = $service->preview($this->editor, $initial['id'], 'extension');
$service->publish($this->editor, $initial['id'], ['act' => 'extension', 'hash' => $plan['hash'], 'reason' => $plan['payload']['public_reason']]);
$this->travel(5)->minutes();
$this->assertSame(1, $alerts->dispatchDue());
$this->getJson('/investidor/notifications')->assertOk()->assertJsonCount(2, 'notifications.data');
$this->postJson('/investidor/notifications/'.$old->id.'/read')->assertOk();
$this->assertSame($old->payload, DB::table('investment_notifications')->where('id', $old->id)->value('payload'));
DB::table('portal_tenders')->where('id', $initial['id'])->update(['published_version_id' => null]);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonCount(0, 'notifications.data');
$this->postJson('/investidor/notifications/'.$old->id.'/read')->assertNotFound();
$this->assertDatabaseCount('investment_notifications', 2);
}
public function test_temporary_unavailability_only_pauses_tenders_and_cancellation_never_depends_on_that_catalogue(): void
{
$this->putJson('/investidor/alert-preferences', $this->input(['opportunities', 'tenders']))->assertOk();
$this->publish();
$opportunity = $this->opportunity();
config(['procurement.enabled' => false]);
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(1, $alerts->dispatchDue());
$this->assertDatabaseHas('investment_notifications', ['opportunity_id' => $opportunity->id]);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonPath('alertPreferences.source_statuses.opportunities', true)
->assertJsonPath('alertPreferences.source_statuses.tenders', false);
$this->putJson('/investidor/alert-preferences', $this->input(['tenders'], [], 1))->assertUnprocessable()->assertJsonValidationErrors('sources');
config(['procurement.enabled' => true]);
$this->travel(5)->minutes();
$this->assertSame(1, $alerts->dispatchDue());
config(['procurement.enabled' => false]);
$this->putJson('/investidor/alert-preferences', ['version' => 1, 'enabled' => false])->assertOk()->assertJsonPath('enabled', false);
$this->travel(8)->days();
$this->assertSame(0, $alerts->dispatchDue());
$this->assertDatabaseCount('investment_notifications', 2);
}
public function test_delivery_failure_rolls_back_both_sources_and_retry_delivers_each_once(): void
{
$this->putJson('/investidor/alert-preferences', $this->input(['opportunities', 'tenders']))->assertOk();
$this->opportunity();
$this->publish();
$before = DB::table('investment_alert_preferences')->value('next_run_at');
$workspace = \Mockery::mock(InvestmentWorkspace::class)->makePartial();
$workspace->shouldReceive('notifyTender')->once()->andThrow(new \RuntimeException('Synthetic tender delivery failure'));
$this->app->instance(InvestmentWorkspace::class, $workspace);
try {
app(InvestmentOpportunityAlerts::class)->dispatchDue();
$this->fail('The shared delivery transaction must fail atomically.');
} catch (\RuntimeException $error) {
$this->assertSame('Synthetic tender delivery failure', $error->getMessage());
}
$this->assertDatabaseCount('investment_notifications', 0);
$this->assertDatabaseCount('investment_alert_seen', 0);
$this->assertDatabaseCount('investment_tender_alert_seen', 0);
$this->assertSame($before, DB::table('investment_alert_preferences')->value('next_run_at'));
$this->app->forgetInstance(InvestmentWorkspace::class);
$this->assertSame(2, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->assertDatabaseCount('investment_notifications', 2);
}
public function test_source_selection_is_explicit_and_migration_preserves_existing_preferences_and_history(): void
{
$this->putJson('/investidor/alert-preferences', $this->input(['opportunities']))->assertOk();
$before = (array) DB::table('investment_alert_preferences')->sole();
$migration = require database_path('migrations/2026_11_02_200000_investment_tender_alerts.php');
$migration->down();
$this->assertFalse(app(InvestmentOpportunityAlerts::class)->tendersInstalled());
$migration->up();
$this->assertSame($before, (array) DB::table('investment_alert_preferences')->sole());
$this->publish();
$this->assertSame(0, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->putJson('/investidor/alert-preferences', $this->input(['tenders'], [], 1))->assertOk();
$this->publish();
$this->travel(5)->minutes();
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
try {
$migration->down();
$this->fail('Tender preferences and history must be preserved before rollback.');
} catch (\RuntimeException $error) {
$this->assertStringContainsString('Preserve tender alert preferences', $error->getMessage());
}
$this->assertDatabaseCount('investment_notifications', 1);
}
public function test_opportunity_backlog_does_not_starve_tenders_or_lose_the_remaining_batch(): void
{
$this->putJson('/investidor/alert-preferences', $this->input(['opportunities', 'tenders']))->assertOk();
for ($index = 0; $index < 201; $index++) {
$this->opportunity();
}
$this->publish();
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(201, $alerts->dispatchDue(), 'One tender is delivered alongside the bounded opportunity batch.');
$this->assertDatabaseCount('investment_tender_alert_seen', 1);
$this->assertSame(1, $alerts->dispatchDue(), 'The unconsumed opportunity remains due for the next run.');
$this->assertSame(0, $alerts->dispatchDue());
$this->assertDatabaseCount('investment_notifications', 202);
$this->assertDatabaseCount('investment_alert_seen', 201);
}
}