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

226 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentWorkspace;
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 InvestmentOpportunityAlertsTest extends TestCase
{
use RefreshDatabase;
private User $actor;
private Sector $sector;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->actor = User::factory()->create();
$this->sector = Sector::create(['name' => 'Synthetic alert sector', 'slug' => 'synthetic-alert-sector', 'is_active' => true, 'published_at' => now()]);
$this->actingAs($this->actor);
}
private function input(array $criteria = [], string $frequency = 'as_available', int $version = 0): array
{
return ['version' => $version, 'enabled' => true, 'channel' => 'in_app', 'frequency' => $frequency, 'consent' => true, 'criteria' => $criteria];
}
private function opportunity(array $attributes = []): Opportunity
{
$record = new Opportunity;
$record->forceFill([...['sector_id' => $this->sector->id, 'title' => 'Synthetic public alert opportunity', 'slug' => 'synthetic-'.Str::uuid(),
'reference' => 'SYNTHETIC-'.Str::uuid(), 'status' => 'published', 'published_at' => now()->subDay(), 'opportunity_category' => 'investment_project'], ...$attributes])->save();
return $record;
}
public function test_opt_in_is_private_validated_versioned_and_idempotent_and_cancellation_does_not_need_retired_choices(): void
{
$url = '/investidor/alert-preferences';
Sector::create(['name' => 'Synthetic unpublished private sector', 'slug' => 'synthetic-unpublished', 'is_active' => true]);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonPath('alertPreferences.enabled', false)->assertJsonPath('alertPreferences.version', 0);
$this->getJson('/investidor/notifications')->assertOk()->assertDontSee('Synthetic unpublished private sector');
$this->putJson($url, $this->input(['sector' => 'synthetic-unpublished']))->assertUnprocessable();
$this->putJson($url, [...$this->input(), 'consent' => false])->assertUnprocessable()->assertJsonValidationErrors('consent');
$this->putJson($url, [...$this->input(), 'channel' => 'email'])->assertUnprocessable()->assertJsonValidationErrors('channel');
$this->putJson($url, $this->input(['sector' => 'unknown']))->assertUnprocessable();
$this->putJson($url, $this->input(['province_id' => (string) Str::uuid()]))->assertUnprocessable();
$input = $this->input(['sector' => $this->sector->slug]);
$this->putJson($url, $input)->assertOk()->assertJsonPath('version', 1)->assertJsonPath('enabled', true);
$this->putJson($url, $input)->assertOk()->assertJsonPath('version', 1);
$this->assertDatabaseCount('investment_alert_preferences', 1);
$this->assertDatabaseCount('audit_events', 1);
$this->putJson($url, $this->input([], 'weekly', 0))->assertConflict();
$raw = DB::table('investment_alert_preferences')->value('criteria');
$this->assertStringNotContainsString($this->sector->slug, $raw);
$this->sector->update(['is_active' => false]);
$this->putJson($url, ['version' => 1, 'enabled' => false, 'frequency' => 'retired-choice', 'channel' => 'retired-channel',
'criteria' => ['sector' => 'retired-sector', 'category' => 'retired-category']])->assertOk()->assertJsonPath('version', 2)
->assertJsonPath('enabled', false)->assertJsonPath('frequency', 'as_available')->assertJsonPath('channel', 'in_app')
->assertJsonPath('criteria.sector', $this->sector->slug);
$this->actingAs(User::factory()->create())->getJson('/investidor/notifications')->assertOk()->assertJsonPath('alertPreferences.version', 0);
$this->actingAs(User::factory()->unverified()->create())->putJson($url, $this->input())->assertForbidden();
}
public function test_matching_reuses_catalogue_geography_and_excludes_old_private_future_and_incompatible_records(): void
{
$this->seed(TerritoryReferenceSeeder::class);
$province = DB::table('geo_locations')->where('location_type', 'province')->first();
$district = DB::table('geo_locations')->where('parent_id', $province->id)->where('location_type', 'district')->first();
$otherProvince = DB::table('geo_locations')->where('location_type', 'province')->where('id', '<>', $province->id)->first();
$old = $this->opportunity(['primary_territory_id' => $district->id]);
$input = $this->input(['sector' => $this->sector->slug, 'province_id' => $province->id, 'category' => 'investment_project']);
$this->putJson('/investidor/alert-preferences', $input)->assertOk();
$match = $this->opportunity(['primary_territory_id' => $district->id]);
$other = Sector::create(['name' => 'Other synthetic sector', 'slug' => 'other-synthetic', 'is_active' => true]);
$this->opportunity(['sector_id' => $other->id, 'primary_territory_id' => $district->id]);
$this->opportunity(['primary_territory_id' => $otherProvince->id]);
$this->opportunity(['primary_territory_id' => $district->id, 'opportunity_category' => 'financing']);
$this->opportunity(['primary_territory_id' => $district->id, 'status' => 'draft']);
$future = $this->opportunity(['primary_territory_id' => $district->id, 'published_at' => now()->addDay()]);
$this->opportunity(['primary_territory_id' => $district->id, 'owner_user_id' => $this->actor->id, 'workflow_context' => ['publication' => ['visibility' => 'private']]]);
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(1, $alerts->dispatchDue());
$this->assertSame(0, $alerts->dispatchDue());
$notification = DB::table('investment_notifications')->sole();
$this->assertSame($match->id, $notification->opportunity_id);
$this->assertNull($notification->submission_id);
$payload = json_decode(Crypt::decryptString($notification->payload), true);
$this->assertSame(1, $payload['preference_version']);
$this->assertSame($input['criteria']['province_id'], $payload['criteria']['province_id']);
$this->assertArrayNotHasKey('workflow_context', $payload);
$this->assertNotSame($old->id, $notification->opportunity_id);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonPath('unread', 1)->assertJsonPath('notifications.data.0.payload.title', $match->title);
$this->postJson('/investidor/notifications/'.$notification->id.'/read')->assertOk();
$this->postJson('/investidor/notifications/'.$notification->id.'/read')->assertOk();
$this->actingAs(User::factory()->create())->postJson('/investidor/notifications/'.$notification->id.'/read')->assertNotFound();
$this->actingAs($this->actor);
$match->update(['status' => 'draft']);
$this->getJson('/investidor/notifications')->assertOk()->assertJsonCount(0, 'notifications.data')->assertJsonPath('unread', 0);
$this->postJson('/investidor/notifications/'.$notification->id.'/read')->assertNotFound();
$this->assertDatabaseCount('investment_notifications', 1);
$this->travel(25)->hours();
$this->assertSame(1, $alerts->dispatchDue());
$this->assertDatabaseHas('investment_notifications', ['opportunity_id' => $future->id]);
}
public function test_frequency_cancellation_and_reactivation_preserve_history_without_backlog_or_duplicates(): void
{
$this->putJson('/investidor/alert-preferences', $this->input([], 'daily'))->assertOk();
$first = $this->opportunity();
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(0, $alerts->dispatchDue());
$this->travel(24)->hours();
$this->assertSame(1, $alerts->dispatchDue());
$second = $this->opportunity();
$this->assertSame(0, $alerts->dispatchDue());
$this->putJson('/investidor/alert-preferences', ['version' => 1, 'enabled' => false])->assertOk();
$this->travel(8)->days();
$this->assertSame(0, $alerts->dispatchDue());
$this->putJson('/investidor/alert-preferences', $this->input([], 'weekly', 2))->assertOk();
$third = $this->opportunity();
$this->travel(6)->days();
$this->assertSame(0, $alerts->dispatchDue());
$this->travel(1)->days();
$this->assertSame(1, $alerts->dispatchDue());
$this->assertDatabaseHas('investment_notifications', ['opportunity_id' => $first->id]);
$this->assertDatabaseMissing('investment_notifications', ['opportunity_id' => $second->id]);
$this->assertDatabaseHas('investment_notifications', ['opportunity_id' => $third->id]);
$this->assertDatabaseCount('investment_notifications', 2);
}
public function test_failed_delivery_rolls_back_seen_marker_and_next_run_and_can_be_retried(): void
{
$this->putJson('/investidor/alert-preferences', $this->input())->assertOk();
$record = $this->opportunity();
$before = DB::table('investment_alert_preferences')->value('next_run_at');
$workspace = \Mockery::mock(InvestmentWorkspace::class);
$workspace->shouldReceive('notifyOpportunity')->once()->andThrow(new \RuntimeException('Synthetic delivery failure'));
$this->app->instance(InvestmentWorkspace::class, $workspace);
try {
app(InvestmentOpportunityAlerts::class)->dispatchDue();
$this->fail('Delivery failure must propagate without consuming the opportunity.');
} catch (\RuntimeException $error) {
$this->assertSame('Synthetic delivery failure', $error->getMessage());
}
$this->assertDatabaseMissing('investment_alert_seen', ['opportunity_id' => $record->id]);
$this->assertDatabaseCount('investment_notifications', 0);
$this->assertSame($before, DB::table('investment_alert_preferences')->value('next_run_at'));
$this->app->forgetInstance(InvestmentWorkspace::class);
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->artisan('investmoz:dispatch-opportunity-alerts')->assertSuccessful();
$this->assertDatabaseCount('investment_notifications', 1);
}
public function test_a_retired_reference_does_not_block_other_subscribers_or_prevent_cancellation(): void
{
$this->seed(TerritoryReferenceSeeder::class);
$province = DB::table('geo_locations')->where('location_type', 'province')->value('id');
$this->putJson('/investidor/alert-preferences', $this->input(['province_id' => $province]))->assertOk();
$other = User::factory()->create();
$this->actingAs($other)->putJson('/investidor/alert-preferences', $this->input())->assertOk();
$record = $this->opportunity();
DB::table('geo_locations')->where('id', $province)->update(['data_classification' => 'internal']);
$this->assertSame(1, app(InvestmentOpportunityAlerts::class)->dispatchDue());
$this->assertDatabaseHas('investment_notifications', ['user_id' => $other->id, 'opportunity_id' => $record->id]);
$this->assertDatabaseMissing('investment_notifications', ['user_id' => $this->actor->id]);
$this->actingAs($this->actor)->getJson('/investidor/notifications')->assertOk()->assertJsonPath('alertPreferences.criteria_available', false)
->assertJsonPath('alertPreferences.criteria.province_id', $province);
$this->putJson('/investidor/alert-preferences', ['version' => 1, 'enabled' => false])->assertOk()->assertJsonPath('enabled', false);
}
public function test_a_batch_larger_than_one_run_is_drained_without_losing_or_repeating_notifications(): void
{
$this->putJson('/investidor/alert-preferences', $this->input())->assertOk();
for ($index = 0; $index < 201; $index++) {
$this->opportunity();
}
$alerts = app(InvestmentOpportunityAlerts::class);
$this->assertSame(200, $alerts->dispatchDue());
$this->assertSame(1, $alerts->dispatchDue());
$this->assertSame(0, $alerts->dispatchDue());
$this->assertDatabaseCount('investment_notifications', 201);
$this->assertDatabaseCount('investment_alert_seen', 201);
}
public function test_upgrade_preserves_legacy_notifications_and_rollback_refuses_to_destroy_preferences(): void
{
$submission = $this->postJson('/api/v1/investment/submissions')->assertCreated()->json('id');
app(InvestmentWorkspace::class)->notify($this->actor->id, InvestmentSubmission::findOrFail($submission), 'status', 'synthetic-upgrade', ['status' => 'draft']);
$original = (array) DB::table('investment_notifications')->sole();
$migration = require database_path('migrations/2026_11_02_190000_investment_opportunity_alerts.php');
$tenderMigration = require database_path('migrations/2026_11_02_200000_investment_tender_alerts.php');
$fundingMigration = require database_path('migrations/2026_11_02_220000_investment_funding_alerts.php');
$fundingMigration->down();
$tenderMigration->down();
$migration->down();
$this->assertFalse(app(InvestmentOpportunityAlerts::class)->installed());
$migration->up();
$tenderMigration->up();
$fundingMigration->up();
$this->assertSame($original, (array) DB::table('investment_notifications')->sole());
$this->putJson('/investidor/alert-preferences', $this->input())->assertOk();
try {
$migration->down();
$this->fail('Preferences and notification history must be preserved before rollback.');
} catch (\RuntimeException $error) {
$this->assertStringContainsString('Preserve alert preferences', $error->getMessage());
}
$this->assertTrue(app(InvestmentOpportunityAlerts::class)->installed());
$this->assertDatabaseCount('investment_notifications', 1);
$this->assertDatabaseCount('investment_alert_preferences', 1);
}
}