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

199 lines
12 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\FundingProgrammes;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentWorkspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestmentFundingAlertsTest extends TestCase
{
use RefreshDatabase;
private User $actor;
private User $editor;
private Sector $sector;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->actor = User::factory()->create();
$this->editor = User::factory()->create();
config(['portal.managers' => [$this->editor->email]]);
$this->sector = Sector::create(['name' => 'SYNTHETIC funding sector', 'slug' => 'synthetic-funding-sector', 'is_active' => true, 'published_at' => now()]);
$this->actingAs($this->actor);
}
private function preferences(array $sources = ['funding'], 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 draft(array $patch = []): array
{
return [...['title' => 'SYNTHETIC funding QA', 'kind' => 'fund', 'summary' => 'SYNTHETIC QA only, not a real financing offer.',
'manager_name' => 'Synthetic manager', 'source_url' => 'https://example.invalid/funding', 'source_reference' => 'Synthetic edition QA',
'source_checked_on' => today()->toDateString(), 'source_status' => 'identified', 'coverage' => 'national', 'sectors_scope' => 'selected',
'sector_ids' => [$this->sector->id], 'criteria' => [], 'windows' => []], ...$patch];
}
private function publish(array $draft = [], ?string $id = null): array
{
$service = app(FundingProgrammes::class);
$row = $id ? DB::table('funding_programmes')->where('id', $id)->firstOrFail() : null;
$id = $service->save($this->editor, $id, ['code' => $row->code ?? 'synthetic-'.Str::uuid(), 'version' => $row->version ?? 0,
'reason' => 'Synthetic preparation for isolated funding alert QA.', 'draft' => $this->draft($draft)]);
$plan = $service->preview($this->editor, $id);
$version = $service->publish($this->editor, $id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'reason' => 'Synthetic publication for isolated funding alert QA.', 'confirm' => true]);
return ['id' => $id, 'version_id' => $version];
}
private function opportunity(): void
{
Opportunity::create(['sector_id' => $this->sector->id, 'title' => 'SYNTHETIC opportunity QA', 'slug' => 'qa-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), 'status' => 'published', 'published_at' => now()->subMinute()]);
}
public function test_opt_in_baselines_existing_publications_and_matches_only_selected_public_scope(): void
{
$this->publish();
$criteria = ['sector' => $this->sector->slug, 'funding_certainty' => 'documented', 'category' => 'irrelevant', 'tender_regime' => 'irrelevant'];
$this->putJson('/investidor/alert-preferences', $this->preferences(criteria: $criteria))->assertOk()
->assertJsonMissingPath('criteria.category')->assertJsonMissingPath('criteria.tender_regime');
$match = $this->publish(['title' => 'SYNTHETIC matching publication']);
$this->publish(['sectors_scope' => 'unknown', 'sector_ids' => []]);
$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->funding_version_id);
$this->assertNull($notification->opportunity_id);
$this->assertNull($notification->tender_version_id);
$payload = json_decode(Crypt::decryptString($notification->payload), true);
$this->assertSame($match['id'], $payload['programme_id']);
$this->assertArrayNotHasKey('criteria_answers', $payload);
$this->assertArrayNotHasKey('documents', $payload);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonPath('notifications.data.0.kind', 'funding_match');
$this->assertDatabaseCount('funding_programme_interests', 0);
}
public function test_all_three_sources_can_be_selected_explicitly_without_opt_in_by_migration(): void
{
config(['procurement.enabled' => true]);
$this->putJson('/investidor/alert-preferences', $this->preferences(['opportunities', 'tenders', 'funding']))->assertOk()
->assertJsonPath('sources', ['funding', 'opportunities', 'tenders']);
$this->assertDatabaseCount('investment_notifications', 0);
$this->assertSame(['funding', 'opportunities', 'tenders'], json_decode(DB::table('investment_alert_preferences')->sole()->sources, true));
}
public function test_window_state_filter_uses_documented_dates_without_treating_unknown_windows_as_open(): void
{
$this->putJson('/investidor/alert-preferences', $this->preferences(criteria: ['funding_state' => 'open']))->assertOk();
$this->publish();
$window = ['code' => 'synthetic-window', 'title' => 'Synthetic dated window', 'mode' => 'dated', 'coverage' => 'programme',
'source_url' => 'https://example.invalid/window', 'source_reference' => 'Synthetic window edition', 'checked_on' => today()->toDateString(), 'end_on' => today()->toDateString(), 'criteria' => []];
$match = $this->publish(['windows' => [$window]]);
$this->publish(['windows' => [[...$window, 'end_on' => today()->subDay()->toDateString()]]]);
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$notice = DB::table('investment_notifications')->sole();
$this->assertSame($match['version_id'], $notice->funding_version_id);
$this->assertSame('open', json_decode(Crypt::decryptString($notice->payload), true)['window_states'][0]['state']);
}
public function test_unknown_scope_is_explicit_and_new_versions_preserve_old_notices_until_withdrawal(): void
{
$this->putJson('/investidor/alert-preferences', $this->preferences(criteria: ['sector' => $this->sector->slug, 'funding_certainty' => 'include_unknown']))->assertOk();
$first = $this->publish(['sectors_scope' => 'unknown', 'sector_ids' => []]);
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(1, $alerts->dispatchDue());
$old = DB::table('investment_notifications')->sole();
$payload = json_decode(Crypt::decryptString($old->payload), true);
$this->assertStringContainsString('por confirmar', $payload['scope_explanation']['sector']);
$this->publish(['title' => 'SYNTHETIC changed title'], $first['id']);
$this->travel(5)->minutes();
$this->assertSame(1, $alerts->dispatchDue());
$this->assertSame($old->payload, DB::table('investment_notifications')->where('id', $old->id)->value('payload'));
$this->postJson('/investidor/notifications/'.$old->id.'/read')->assertOk();
$this->actingAs(User::factory()->create())->postJson('/investidor/notifications/'.$old->id.'/read')->assertNotFound();
$this->actingAs($this->actor);
app(FundingProgrammes::class)->withdraw($this->editor, $first['id'], 2, 'Synthetic withdrawal for historical notice QA.');
$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_funding_unavailability_does_not_block_other_sources_or_cancellation(): void
{
$this->putJson('/investidor/alert-preferences', $this->preferences(['opportunities', 'funding']))->assertOk();
Schema::rename('investment_funding_alert_seen', 'investment_funding_alert_seen_unavailable_qa');
$this->opportunity();
$this->publish();
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->getJson('/investidor/notifications')->assertOk()->assertJsonPath('alertPreferences.source_statuses.funding', false);
$this->putJson('/investidor/alert-preferences', ['version' => 1, 'enabled' => false, 'criteria' => ['funding_state' => 'retired']])->assertOk()->assertJsonPath('enabled', false);
Schema::rename('investment_funding_alert_seen_unavailable_qa', 'investment_funding_alert_seen');
}
public function test_batch_is_bounded_per_source_and_funding_failure_rolls_back_all_deliveries(): void
{
$this->putJson('/investidor/alert-preferences', $this->preferences(['opportunities', 'funding']))->assertOk();
$sample = $this->publish();
$payload = DB::table('funding_programme_versions')->where('id', $sample['version_id'])->value('payload');
for ($index = 0; $index < 200; $index++) {
$id = (string) Str::uuid();
$version = (string) Str::uuid();
DB::table('funding_programmes')->insert(['id' => $id, 'code' => 'batch-'.$index, 'draft' => $payload, 'created_by' => $this->editor->id]);
DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => 1, 'payload' => $payload, 'reason' => 'Synthetic batch fixture only', 'actor_id' => $this->editor->id]);
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version]);
}
$this->opportunity();
$mock = \Mockery::mock(InvestmentWorkspace::class)->makePartial();
$mock->shouldReceive('notifyFunding')->andThrow(new \RuntimeException('Synthetic delivery failure'));
$this->app->instance(InvestmentWorkspace::class, $mock);
try {
app(InvestmentOpportunityAlerts::class)->dispatchDue();
$this->fail('Delivery failure must escape and roll back.');
} catch (\RuntimeException $error) {
$this->assertSame('Synthetic delivery failure', $error->getMessage());
}
$this->assertDatabaseCount('investment_notifications', 0);
$this->assertDatabaseCount('investment_funding_alert_seen', 0);
$this->assertDatabaseCount('investment_alert_seen', 0);
$this->app->instance(InvestmentWorkspace::class, new InvestmentWorkspace);
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(201, $alerts->dispatchDue());
$this->assertSame(1, $alerts->dispatchDue());
$this->assertSame(0, $alerts->dispatchDue());
$this->assertDatabaseCount('investment_notifications', 202);
}
public function test_upgrade_preserves_preferences_and_rollback_refuses_funding_history(): void
{
$this->putJson('/investidor/alert-preferences', $this->preferences(['opportunities']))->assertOk();
$before = (array) DB::table('investment_alert_preferences')->sole();
$migration = require database_path('migrations/2026_11_02_220000_investment_funding_alerts.php');
$migration->down();
$migration->up();
$this->assertSame($before, (array) DB::table('investment_alert_preferences')->sole());
$this->assertSame(['opportunities'], app(InvestmentOpportunityAlerts::class)->preferences($this->actor->id)['sources']);
$this->putJson('/investidor/alert-preferences', $this->preferences(['funding'], version: 1))->assertOk();
$this->publish();
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Preserve funding');
$migration->down();
}
}