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