199 lines
12 KiB
PHP
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();
|
|
}
|
|
}
|