225 lines
14 KiB
PHP
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);
|
|
}
|
|
}
|