53fb7b7760
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
527 lines
23 KiB
PHP
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');
|