From 5720458f7b8ee43cde87cbf6c948abc89fa5e2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 4 Jun 2026 04:25:00 +0300 Subject: [PATCH] test(imitation): scenarios E1/E2/F freezes + limit --- .../Imitation/ScenarioEF_FreezeLimitTest.php | 525 ++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php diff --git a/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php b/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php new file mode 100644 index 00000000..8bb83b40 --- /dev/null +++ b/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php @@ -0,0 +1,525 @@ += snap.daily_limit. + * Client1 delivered_today == its snapshot daily_limit → ineligible; Client2 gets the lead. + * + * Subject codes: порядковые 1..89. Москва = 82. Confirmed via RussianRegions::CODE_TO_NAME. + * Tier 1 price = 50 000 kopecks = 500 RUB (PricingTierSeeder). + * + * Task 9 — Phase 1 Portal Client Imitation. + * Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 9 + * Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 E1/E2/F + */ + +// ── Shared constants ────────────────────────────────────────────────────────── + +/** Москва subject code (порядковый, НЕ ГИБДД). */ +const EF_MOSCOW_CODE = 82; + +/** Domain for B1 site signal shared across all three scenarios. */ +const EF_DOMAIN = 'scenario-ef-test.ru'; + +/** Platform prefix. */ +const EF_PLATFORM = 'B1'; + +/** Deterministic seed — makes weighted lottery pick reproducible. */ +const EF_SEED = 99; + +/** Tier-1 price in RUB (first 100 leads of month): 500 RUB. */ +const EF_TIER1_PRICE_RUB = '500.00'; + +/** Daily limit used for healthy clients — large enough to never block. */ +const EF_HEALTHY_LIMIT = 50; + +// ── Shared beforeEach setup ─────────────────────────────────────────────────── + +beforeEach(function (): void { + // Seed pricing tiers (tier 1: first 100 leads → 50 000 kopecks = 500 RUB/lead). + $this->seed(PricingTierSeeder::class); + + // Allow cross-tenant reads during seeding (shared helper pattern). + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + // DaData config — required by LeadRegionResolver even when FakeDaDataPhoneClient is used. + config([ + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', + 'services.dadata.daily_cap_rub' => 1_000_000, + ]); + + // Deterministic LeadRouter: Mt19937 seed so weighted pick is reproducible. + app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(EF_SEED)))); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// E1 — Auto-pause on insufficient balance +// ───────────────────────────────────────────────────────────────────────────── + +it('E1: project is paused and mail sent when balance below tier price; lead goes to healthy client', function (): void { + Mail::fake(); + + // ── ARRANGE ────────────────────────────────────────────────────────────── + + // One shared supplier project (B1 site signal). + $supplier = SupplierProject::factory()->create([ + 'platform' => EF_PLATFORM, + 'signal_type' => 'site', + 'unique_key' => EF_DOMAIN, + ]); + + // Client1: balance BELOW tier-1 price (500 RUB). Even 1 kopeck short triggers pause. + // We set balance to 0 which is definitely below 500 RUB threshold. + $tenant1 = Tenant::factory()->create([ + 'balance_rub' => '0.00', + 'frozen_by_balance_at' => null, + ]); + $project1 = Project::factory()->create([ + 'tenant_id' => $tenant1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], + ]); + linkProjectToSupplier($project1, $supplier); + + // Client2: healthy balance — should receive the lead. + $tenant2 = Tenant::factory()->create([ + 'balance_rub' => '9999.00', + 'frozen_by_balance_at' => null, + ]); + $project2 = Project::factory()->create([ + 'tenant_id' => $tenant2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], + ]); + linkProjectToSupplier($project2, $supplier); + + // Snapshot: both clients eligible (positive balance + unfrozen are live-state checks, + // but snapshot itself is built before the charge; LeadRouter's SQL also checks balance > 0 + // and frozen_by_balance_at IS NULL). Client1 has balance=0 so it will NOT appear in the + // router's eligibility query (balance_rub > 0 filter), making this effectively test the + // case where balance becomes 0 AFTER snapshot is built but before the charge. + // + // IMPORTANT FINDING NOTE: LeadRouter SQL WHERE tenants.balance_rub > 0 means that if + // balance is already 0 before the route call, Client1 is excluded at the query stage + // (not at the charge stage). To truly test E1 (insufficient balance at charge time), + // we must set balance to a non-zero amount that is nonetheless below the tier price. + // Tier 1 = 500 RUB. Set to 499.99 — positive but insufficient. + ConditionLevers::setBalance($tenant1, '499.99'); + + $activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate(); + + // Build snapshots for both clients so both pass the snapshot filter. + createRoutingSnapshotFromProject( + project: $project1, + date: $activeDate, + signalType: 'site', + signalIdentifier: EF_DOMAIN, + dailyLimit: EF_HEALTHY_LIMIT, + regions: '{' . EF_MOSCOW_CODE . '}', + ); + createRoutingSnapshotFromProject( + project: $project2, + date: $activeDate, + signalType: 'site', + signalIdentifier: EF_DOMAIN, + dailyLimit: EF_HEALTHY_LIMIT, + regions: '{' . EF_MOSCOW_CODE . '}', + ); + + // FakeDaData: phone → Москва (qc=0, subject_code=82). + $phone = '79161234001'; + $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + app()->instance(DaDataPhoneClient::class, $fakeDaData); + + // ── ACT ────────────────────────────────────────────────────────────────── + + $injector = new LeadInjector(); + $lead = $injector->site( + domain: EF_DOMAIN, + phone: $phone, + tag: 'Москва', + platform: EF_PLATFORM, + vid: 1_100_000_001, + ); + + // ── ASSERT ─────────────────────────────────────────────────────────────── + + // Client1's project must be paused (is_active=false) after the balance failure. + $project1->refresh(); + expect($project1->is_active)->toBeFalse( + 'FINDING E1: project1 was NOT paused after InsufficientBalance. ' . + 'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. ' . + 'Actual is_active=' . ($project1->is_active ? 'true' : 'false') + ); + + // ZeroBalancePausedMail must have been sent for tenant1 (rate-limit: first call always fires). + Mail::assertSent(ZeroBalancePausedMail::class, function (ZeroBalancePausedMail $mail) use ($tenant1): bool { + return $mail->hasTo($tenant1->contact_email); + }); + + // Client2 (healthy) must have received the lead. + $tenant2Deals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant2->id) + ->count(); + + expect($tenant2Deals)->toBe(1, + 'FINDING E1: Client2 (healthy) did NOT receive the lead. ' . + "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " . + 'The auto-pause flow should skip Client1 and continue routing to Client2.' + ); + + // Client1 must NOT have a deal (charge rolled back). + $tenant1Deals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant1->id) + ->count(); + + expect($tenant1Deals)->toBe(0, + 'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. ' . + "Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. " . + 'InsufficientBalance should roll back the transaction, preventing deal creation.' + ); + + // lead must be marked processed. + expect($lead->processed_at)->not->toBeNull( + 'FINDING E1: SupplierLead.processed_at is null — lead was not marked processed.' + ); +})->group('imitation'); + +// ───────────────────────────────────────────────────────────────────────────── +// E2 — Frozen tenant excluded at eligibility filter (before charge) +// ───────────────────────────────────────────────────────────────────────────── + +it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead', function (): void { + // ── ARRANGE ────────────────────────────────────────────────────────────── + + $supplier = SupplierProject::factory()->create([ + 'platform' => EF_PLATFORM, + 'signal_type' => 'site', + 'unique_key' => EF_DOMAIN, + ]); + + // Client1: frozen via ConditionLevers::freeze(). + // After freeze(), frozen_by_balance_at IS NOT NULL → excluded by LeadRouter SQL + // WHERE tenants.frozen_by_balance_at IS NULL. + $tenant1 = Tenant::factory()->create([ + 'balance_rub' => '9999.00', // balance is fine — frozen is the blocker + 'frozen_by_balance_at' => null, + ]); + $project1 = Project::factory()->create([ + 'tenant_id' => $tenant1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], + ]); + linkProjectToSupplier($project1, $supplier); + + // Freeze Client1 BEFORE snapshot rebuild. + // LeadRouter SQL: AND tenants.frozen_by_balance_at IS NULL + // Frozen clients are excluded at the query stage — no charge attempt is made. + ConditionLevers::freeze($tenant1); + + // Client2: healthy. + $tenant2 = Tenant::factory()->create([ + 'balance_rub' => '9999.00', + 'frozen_by_balance_at' => null, + ]); + $project2 = Project::factory()->create([ + 'tenant_id' => $tenant2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], + ]); + linkProjectToSupplier($project2, $supplier); + + $activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate(); + + // Snapshots for both clients. The snapshot itself may include Client1 (snapshot was + // built from static project data), but LeadRouter's SQL live-checks frozen_by_balance_at. + createRoutingSnapshotFromProject( + project: $project1, + date: $activeDate, + signalType: 'site', + signalIdentifier: EF_DOMAIN, + dailyLimit: EF_HEALTHY_LIMIT, + regions: '{' . EF_MOSCOW_CODE . '}', + ); + createRoutingSnapshotFromProject( + project: $project2, + date: $activeDate, + signalType: 'site', + signalIdentifier: EF_DOMAIN, + dailyLimit: EF_HEALTHY_LIMIT, + regions: '{' . EF_MOSCOW_CODE . '}', + ); + + $phone = '79161234002'; + $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + app()->instance(DaDataPhoneClient::class, $fakeDaData); + + // ── ACT ────────────────────────────────────────────────────────────────── + + $injector = new LeadInjector(); + $lead = $injector->site( + domain: EF_DOMAIN, + phone: $phone, + tag: 'Москва', + platform: EF_PLATFORM, + vid: 1_100_000_002, + ); + + // ── ASSERT ─────────────────────────────────────────────────────────────── + + // Client1 (frozen) must have ZERO deals — excluded at filter stage. + $tenant1Deals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant1->id) + ->count(); + + expect($tenant1Deals)->toBe(0, + 'FINDING E2: Frozen client (tenant1) received a deal. ' . + "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " . + 'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. ' . + 'This is a serious billing/freeze bug.' + ); + + // Client2 (healthy) must have received the lead. + $tenant2Deals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant2->id) + ->count(); + + expect($tenant2Deals)->toBe(1, + 'FINDING E2: Healthy Client2 did NOT receive the lead. ' . + "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " . + 'With frozen Client1 excluded, Client2 should be the sole eligible recipient.' + ); + + // Verify tenant1 IS still frozen (no accidental unfreeze by any path). + $tenant1->refresh(); + expect($tenant1->frozen_by_balance_at)->not->toBeNull( + 'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. ' . + 'Freeze state must be preserved across the routing cycle.' + ); + + // lead processed. + expect($lead->processed_at)->not->toBeNull( + 'FINDING E2: SupplierLead.processed_at is null — lead was not marked processed.' + ); +})->group('imitation'); + +// ───────────────────────────────────────────────────────────────────────────── +// F — Daily limit reached: project excluded from eligibility +// ───────────────────────────────────────────────────────────────────────────── + +it('F: client at daily limit is excluded; lead goes to client with remaining capacity', function (): void { + // ── ARRANGE ────────────────────────────────────────────────────────────── + + $supplier = SupplierProject::factory()->create([ + 'platform' => EF_PLATFORM, + 'signal_type' => 'site', + 'unique_key' => EF_DOMAIN, + ]); + + $dailyLimit = 5; // small limit so fillToLimit is clear + + // Client1: delivered_today EQUAL to daily_limit → excluded (delivered_today < daily_limit is false). + $tenant1 = Tenant::factory()->create([ + 'balance_rub' => '9999.00', + 'frozen_by_balance_at' => null, + ]); + $project1 = Project::factory()->create([ + 'tenant_id' => $tenant1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => $dailyLimit, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], + ]); + linkProjectToSupplier($project1, $supplier); + + // Client2: healthy with headroom. + $tenant2 = Tenant::factory()->create([ + 'balance_rub' => '9999.00', + 'frozen_by_balance_at' => null, + ]); + $project2 = Project::factory()->create([ + 'tenant_id' => $tenant2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], + ]); + linkProjectToSupplier($project2, $supplier); + + $activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate(); + + // Build snapshots BEFORE setting delivered_today to limit, + // so both clients appear in the snapshot. + createRoutingSnapshotFromProject( + project: $project1, + date: $activeDate, + signalType: 'site', + signalIdentifier: EF_DOMAIN, + dailyLimit: $dailyLimit, + regions: '{' . EF_MOSCOW_CODE . '}', + ); + createRoutingSnapshotFromProject( + project: $project2, + date: $activeDate, + signalType: 'site', + signalIdentifier: EF_DOMAIN, + dailyLimit: EF_HEALTHY_LIMIT, + regions: '{' . EF_MOSCOW_CODE . '}', + ); + + // NOW set Client1 delivered_today = limit (5) so it fails the eligibility check: + // LeadRouter SQL: AND projects.delivered_today < snap.daily_limit + // Also the inner createDealCopyForProject recheck: $lockedProject->delivered_today >= $effectiveLimit + ConditionLevers::fillToLimit($project1); + + $phone = '79161234003'; + $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + app()->instance(DaDataPhoneClient::class, $fakeDaData); + + // ── ACT ────────────────────────────────────────────────────────────────── + + $injector = new LeadInjector(); + $lead = $injector->site( + domain: EF_DOMAIN, + phone: $phone, + tag: 'Москва', + platform: EF_PLATFORM, + vid: 1_100_000_003, + ); + + // ── ASSERT ─────────────────────────────────────────────────────────────── + + // Client1 (at limit) must have ZERO deals. + $tenant1Deals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant1->id) + ->count(); + + expect($tenant1Deals)->toBe(0, + 'FINDING F: Client1 (at daily limit) received a deal. ' . + "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " . + "LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. " . + "project1.daily_limit_target={$dailyLimit}, delivered_today should be {$dailyLimit} after fillToLimit." + ); + + // Client2 (has headroom) must receive exactly 1 deal. + $tenant2Deals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant2->id) + ->count(); + + expect($tenant2Deals)->toBe(1, + 'FINDING F: Client2 (with headroom) did NOT receive the lead. ' . + "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " . + 'With Client1 at limit and excluded, Client2 should be the sole eligible recipient.' + ); + + // Verify project1 delivered_today is still at limit (nothing was delivered to it). + $project1->refresh(); + expect((int) $project1->delivered_today)->toBe($dailyLimit, + 'FINDING F: project1.delivered_today changed during routing. ' . + "Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. " . + 'A lead was delivered to a project that exceeded its limit.' + ); + + // project1 is_active should still be true — limit exhaustion alone does NOT trigger + // auto-pause (only InsufficientBalance does). This is a deliberate design check. + expect($project1->is_active)->toBeTrue( + 'FINDING F: project1.is_active was set to false due to limit exhaustion. ' . + 'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. ' . + 'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). ' . + 'If this fails: auto-pause is being triggered by the wrong condition — report as bug.' + ); + + // lead processed. + expect($lead->processed_at)->not->toBeNull( + 'FINDING F: SupplierLead.processed_at is null — lead was not marked processed.' + ); +})->group('imitation');