Files
portal/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php
T

527 lines
23 KiB
PHP

<?php
declare(strict_types=1);
use App\Mail\ZeroBalancePausedMail;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRouter;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenarios E1 / E2 / F — freeze + daily-limit verification tests.
*
* VERIFICATION tests against existing prod billing+routing code.
* NOT TDD — no prod code is modified. Differences vs plan → reported as FINDINGs.
*
* E1: auto-pause on insufficient balance.
* Client1 balance < price of one lead → InsufficientBalance on charge attempt →
* project.is_active=false, ZeroBalancePausedMail queued, lead delivered to Client2.
*
* E2: frozen tenant excluded at eligibility filter stage (before any charge).
* frozen_by_balance_at IS NOT NULL → LeadRouter WHERE tenants.frozen_by_balance_at IS NULL
* excludes the frozen tenant entirely; healthy Client2 gets the lead.
*
* F: daily limit reached → project excluded by delivered_today >= 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 = 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 = 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 = 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');