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(); } }