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

864 lines
33 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
/**
* TopologyMoneyIntakeTest — Task 13, Phase 1 Portal Client Imitation.
*
* VERIFICATION tests against existing prod code.
* Proves topologies G1/G2/G4, money correctness, and intake validation.
* NOT TDD — no prod code is modified. Differences vs plan → FINDINGS.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 13
* Spec: §6.3 (topologies) + §7 Этап 0 (intake) + §7 Этап 4 (money)
*
* Money correctness verified facts (from LedgerService.php):
* - Tier price by delivered_in_month + 1 (PricingTierResolver uses count+1).
* - lead_charges.charge_source = 'rub'.
* - balance_transactions.amount_rub = '-<amountRub>' (negative string).
* - balance_rub decremented by bcdiv(priceKopecks, 100, 2).
* - supplier_lead_costs inserted when supplier resolved (B1/B2/B3/DIRECT).
* - delivered_in_month incremented on tenant after charge.
*
* Intake validation verified from SupplierWebhookController.php:
* - Bad secret → 404 (hash_equals fail).
* - Rate-limit: 600/min per-IP; 601st request → 429.
* - time outside ±24h → validation fail → 422 (Laravel validation).
* - phone not matching ^7\d{10}$ → 422.
* - IP allowlist: empty list on non-production env → fail-open (no 404 from IP).
*
* Worktree APP_KEY workaround: .env has 'base64:testingkeyplaceholderxxxxxxxxxxxxxxxo='
* which decodes to 31 bytes — invalid for AES-256-CBC (needs 32 bytes).
* We inject a valid 32-byte key before HTTP-layer tests (G6-style, see ScenarioG5G6).
*
* Region codes: ordinal 1..89 (constitutional order), NOT ГИБДД.
* Москва = 82, Санкт-Петербург = 83.
* Verified via App\Support\RussianRegions::CODE_TO_NAME.
*/
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Models\User;
use App\Services\DaData\DaDataPhoneClient;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\ImitationClientsSeeder;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class)->group('imitation');
// ---------------------------------------------------------------------------
// Values (using define() with a guard to allow multi-process safe re-use)
// ---------------------------------------------------------------------------
/** Москва ordinal subject code (App\Support\RussianRegions::CODE_TO_NAME[82]). */
defined('TOPO_MOSCOW') || define('TOPO_MOSCOW', 82);
/** Санкт-Петербург ordinal subject code (App\Support\RussianRegions::CODE_TO_NAME[83]). */
defined('TOPO_SPB') || define('TOPO_SPB', 83);
/** Webhook secret for intake tests — 33 chars, passes strlen>=32 guard. */
defined('TOPO_SECRET') || define('TOPO_SECRET', 'intake-test-secret-32chars-aaaaab');
// ---------------------------------------------------------------------------
// Shared beforeEach
// ---------------------------------------------------------------------------
beforeEach(function (): void {
// Seed pricing tiers (required by LedgerService::chargeForDelivery).
$this->seed(PricingTierSeeder::class);
// Global bypass for cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Disable DaData by default; individual tests enable as needed.
config([
'services.dadata.enabled' => false,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
});
// ---------------------------------------------------------------------------
// Helpers (file-scoped)
// ---------------------------------------------------------------------------
/**
* Fix the worktree APP_KEY to a valid 32-byte AES-256-CBC key.
* Required before any HTTP-layer test (SupplierWebhookController).
* Named with tmi_ prefix to avoid collision with helpers in other imitation test files.
*/
function tmi_fixAppKey(): void
{
if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) {
config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]);
try {
app('encrypter')->__construct(
str_repeat('a', 32),
config('app.cipher', 'AES-256-CBC')
);
} catch (Throwable) {
// Encrypter may already be initialized; ignore re-init errors.
}
}
}
/**
* Set a valid webhook secret in system_settings (for HTTP intake tests).
*/
function tmi_setIntakeSecret(string $secret = TOPO_SECRET): void
{
SystemSetting::query()
->where('key', 'supplier_webhook_secret')
->update(['value' => $secret]);
}
/**
* Ensure IP allowlist is empty → fail-open in non-production env.
*/
function tmi_clearIpAllowlist(): void
{
SystemSetting::query()
->where('key', 'supplier_ip_allowlist')
->update(['value' => '[]']);
}
// ===========================================================================
// ─── TOPOLOGIES (§6.3) ────────────────────────────────────────────────────
// ===========================================================================
/**
* G1: One client with one Project linked to TWO different SupplierProjects.
*
* A lead arriving via supplier B1 should reach the project if it is linked to B1.
* A lead arriving via supplier B2 should ALSO reach the same project if it is linked to B2.
*
* Proves: one project can receive leads from multiple supplier sources.
*/
it('G1: project linked to two supplier sources receives leads from each', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder;
$g1 = $seeder->seedG1('IMIT-G1-topo');
/** @var Tenant $tenant */
$tenant = $g1['tenant'];
/** @var Project $project */
$project = $g1['project'];
/** @var list<SupplierProject> $suppliers */
$suppliers = $g1['suppliers'];
[$supplierB1, $supplierB2] = $suppliers;
// Set ample balance.
DB::table('tenants')->where('id', $tenant->id)->update([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
// Set project active, all-RF regions (empty = all), all days.
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'regions' => '{}',
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
$activeDate = SnapshotForge::activeDate();
// Build snapshot for the project. For B1 (non-DIRECT), routing uses the pivot
// (project_supplier_links), not signal_identifier in snapshot. The snapshot just
// needs to exist with the correct project_id and date.
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier ?? 'g1-proj.test',
dailyLimit: 50,
regions: '{}',
);
// Lead via B1 source. Inject using the supplier's unique_key so resolveOrStub
// finds the SAME supplier_project that the pivot links to.
$phoneB1 = '79161111001';
$fake = new FakeDaDataPhoneClient;
$fake->stub($phoneB1, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector;
$leadB1 = $injector->site(
domain: $supplierB1->unique_key ?? 'g1-b1.test',
phone: $phoneB1,
tag: 'Москва',
platform: $supplierB1->platform,
vid: 9_100_000_001,
);
$dealsB1 = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->count();
expect($dealsB1)->toBeGreaterThan(0,
'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. '.
"supplier_project_id={$supplierB1->id}, project_id={$project->id}"
);
})->group('imitation');
/**
* G2: Two clients (Tenants/Projects) linked to the SAME SupplierProject.
*
* A lead on that supplier should be eligible for both projects (weighted lottery selects
* ≥1 recipient). Each client's project must receive at least 1 lead across N injections.
*/
it('G2: two clients on same supplier each receive at least one lead', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder;
$g2 = $seeder->seedG2(
['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]],
['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]],
);
/** @var SupplierProject $supplier */
$supplier = $g2['supplier'];
/** @var list<Project> $projects */
$projects = $g2['projects'];
/** @var list<Tenant> $tenants */
$tenants = $g2['tenants'];
$activeDate = SnapshotForge::activeDate();
foreach ([$projects[0], $projects[1]] as $idx => $project) {
DB::table('tenants')->where('id', $tenants[$idx]->id)->update([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'regions' => '{'.TOPO_MOSCOW.'}',
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier,
dailyLimit: 20,
regions: '{'.TOPO_MOSCOW.'}',
);
}
// Inject 10 leads — with two projects at equal weight (20) and a cap>1
// both should receive at least 1 deal across 10 leads.
// Use the supplier's unique_key as the domain — LeadInjector builds "B2_{unique_key}"
// and RouteSupplierLeadJob::resolveOrStub() looks up supplier_projects by (platform, unique_key).
$supplierDomain = $supplier->unique_key ?? 'g2-src.test';
$fake = new FakeDaDataPhoneClient;
for ($i = 1; $i <= 10; $i++) {
$phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$fake->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
}
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector;
for ($i = 1; $i <= 10; $i++) {
$phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$injector->site(
domain: $supplierDomain,
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_200_000_000 + $i,
);
}
$deals0 = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenants[0]->id)
->count();
$deals1 = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenants[1]->id)
->count();
$totalDeals = $deals0 + $deals1;
expect($totalDeals)->toBeGreaterThan(0,
'FINDING: G2 — no deals created for either client.'
);
// Each client should receive at least 1 deal across 10 leads with equal weight.
// With cap=3 per lead (LeadRouter selects up to 3) and 2 equally-weighted projects,
// both should receive deals. This is probabilistic but seed-based.
// If one is 0, it indicates routing bias — report as FINDING.
expect($deals0)->toBeGreaterThan(0,
"FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. ".
'Both clients have equal weight. Possible routing bias.'
);
expect($deals1)->toBeGreaterThan(0,
"FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. ".
'Both clients have equal weight. Possible routing bias.'
);
})->group('imitation');
/**
* G4: One client with TWO Projects on the SAME SupplierProject, each targeting a different region.
*
* Lead with subject_code=82 (Москва) must go to projectA (regions=[82]).
* Lead with subject_code=83 (СПб) must go to projectB (regions=[83]).
*/
it('G4: two projects on same supplier with different regions each receive region-matching leads', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder;
$g4 = $seeder->seedG4(TOPO_MOSCOW, TOPO_SPB);
/** @var SupplierProject $supplier */
$supplier = $g4['supplier'];
/** @var Tenant $tenant */
$tenant = $g4['tenant'];
/** @var Project $projectA */
$projectA = $g4['projectA']; // regions=[82] Москва
/** @var Project $projectB */
$projectB = $g4['projectB']; // regions=[83] СПб
DB::table('tenants')->where('id', $tenant->id)->update([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
foreach ([$projectA, $projectB] as $project) {
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
}
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: $projectA->signal_identifier,
dailyLimit: 50,
regions: '{'.TOPO_MOSCOW.'}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: $projectB->signal_identifier,
dailyLimit: 50,
regions: '{'.TOPO_SPB.'}',
);
// Use the supplier's unique_key as the domain — resolveOrStub looks up by (platform, unique_key).
$supplierKey = $supplier->unique_key ?? 'g4-src.test';
$injector = new LeadInjector;
// Lead A: Москва phone → resolved to code 82 → should go to projectA only.
$phoneMoscow = '79163000001';
$fakeMoscow = (new FakeDaDataPhoneClient)->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeMoscow);
$injector->site(
domain: $supplierKey,
phone: $phoneMoscow,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_400_000_001,
);
$dealsAfterMoscowLead_A = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectA->id)
->count();
$dealsAfterMoscowLead_B = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectB->id)
->count();
expect($dealsAfterMoscowLead_A)->toBeGreaterThan(0,
'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). '.
"projectA_id={$projectA->id}, projectB_id={$projectB->id}"
);
expect($dealsAfterMoscowLead_B)->toBe(0,
'FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). '.
"Expected 0 deals for projectB, got {$dealsAfterMoscowLead_B}."
);
// Lead B: СПб phone → resolved to code 83 → should go to projectB only.
$phoneSpb = '79163000002';
$fakeSpb = (new FakeDaDataPhoneClient)->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fakeSpb);
$injector->site(
domain: $supplierKey,
phone: $phoneSpb,
tag: 'Санкт-Петербург',
platform: $supplier->platform,
vid: 9_400_000_002,
);
$dealsAfterSpbLead_A = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectA->id)
->count();
$dealsAfterSpbLead_B = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectB->id)
->count();
// projectA should still have only the first lead (unchanged).
expect($dealsAfterSpbLead_A)->toBe($dealsAfterMoscowLead_A,
'FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). '.
"Expected {$dealsAfterMoscowLead_A} deals for projectA, got {$dealsAfterSpbLead_A}."
);
expect($dealsAfterSpbLead_B)->toBeGreaterThan(0,
'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). '.
"projectA_id={$projectA->id}, projectB_id={$projectB->id}"
);
})->group('imitation');
// ===========================================================================
// ─── MONEY CORRECTNESS (§7 Этап 4) ─────────────────────────────────────────
// ===========================================================================
/**
* After a successful delivery:
* 1. lead_charges row exists with correct tier price and charge_source='rub'.
* 2. balance_transactions row has negative amount_rub matching the price.
* 3. balance_transactions.balance_rub_after = balance_before price (bcmath, no kopeck loss).
* 4. supplier_lead_costs row exists.
* 5. tenants.balance_rub decreased by exactly the tier price.
* 6. tenants.delivered_in_month incremented.
*
* Tier lookup: delivered_in_month starts at 0; resolver uses count+1=1 → tier_no=1.
* Tier 1: leads_in_tier=100, price_per_lead_kopecks=50000 → 500.00 rub.
*/
it('money: lead_charges, balance_transactions, supplier_lead_costs are correct after delivery', function (): void {
config(['services.dadata.enabled' => true]);
// Create a fresh tenant with known starting balance.
// Tier 1 price = 50000 kopecks = 500.00 rub (delivered_in_month=0 → count+1=1).
$initialBalance = '1000.00';
$expectedPriceKopecks = 50000; // tier 1
$expectedAmountRub = '500.00'; // 50000 / 100
$tenant = Tenant::factory()->create([
'balance_rub' => $initialBalance,
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
]);
$phone = '79164000001';
// PostgresIntArray cast requires PHP array, not '{...}' string literal.
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test',
'regions' => [], // empty = all-RF; cast converts to '{}'
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_in_month' => 0,
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier,
dailyLimit: 50,
regions: '{}',
);
$fakeDaData = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
$injector = new LeadInjector;
$injector->site(
domain: ltrim($project->signal_identifier, '/'),
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_500_000_001,
);
// ── Reload tenant to see updated balance ────────────────────────────────
$tenantAfter = $tenant->fresh();
// ── Assert deal was created ─────────────────────────────────────────────
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->latest('id')
->first();
expect($deal)->not->toBeNull('FINDING: No deal was created for the test lead.');
// ── 1. lead_charges ─────────────────────────────────────────────────────
$charge = DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('deal_id', $deal->id)
->first();
expect($charge)->not->toBeNull('FINDING: lead_charges row missing after delivery.');
expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks,
"FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, ".
"expected {$expectedPriceKopecks} (tier 1, delivered_in_month was 0 → count+1=1)."
);
expect($charge->charge_source)->toBe('rub',
"FINDING: lead_charges.charge_source = '{$charge->charge_source}', expected 'rub'."
);
expect((int) $charge->tier_no)->toBe(1,
"FINDING: lead_charges.tier_no = {$charge->tier_no}, expected 1 (delivered_in_month+1=1 → tier 1)."
);
// ── 2. balance_transactions ─────────────────────────────────────────────
$bt = DB::table('balance_transactions')
->where('tenant_id', $tenant->id)
->orderBy('id', 'desc')
->first();
expect($bt)->not->toBeNull('FINDING: balance_transactions row missing after delivery.');
// amount_rub must be negative (stored as '-500.00').
$amountRub = (string) $bt->amount_rub;
expect(bccomp($amountRub, '0', 2))->toBe(-1,
"FINDING: balance_transactions.amount_rub = '{$amountRub}' is not negative."
);
// The absolute value must equal the tier price.
$absAmount = ltrim($amountRub, '-');
expect($absAmount)->toBe($expectedAmountRub,
"FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', ".
"expected '{$expectedAmountRub}' (kopecks={$expectedPriceKopecks} → rub=500.00)."
);
// ── 3. No kopeck loss (bcmath precision check) ───────────────────────────
// Expected new balance = 1000.00 - 500.00 = 500.00
$expectedNewBalance = bcsub($initialBalance, $expectedAmountRub, 2);
$actualNewBalance = (string) $tenantAfter->balance_rub;
// Normalize: bcmath may return '500.00'; DB may store '500.00' as well.
expect($actualNewBalance)->toBe($expectedNewBalance,
'FINDING: KOPECK LOSS detected. '.
"Initial balance: {$initialBalance}, price: {$expectedAmountRub}. ".
"Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. ".
'This indicates floating-point or bcmath precision error.'
);
// balance_rub_after in balance_transactions must match actual tenant balance.
$btBalanceAfter = (string) $bt->balance_rub_after;
expect($btBalanceAfter)->toBe($expectedNewBalance,
"FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', ".
"expected '{$expectedNewBalance}'. Ledger audit trail inconsistency."
);
// ── 4. supplier_lead_costs ───────────────────────────────────────────────
$slc = DB::table('supplier_lead_costs')
->where('deal_id', $deal->id)
->first();
expect($slc)->not->toBeNull(
'FINDING: supplier_lead_costs row missing after delivery. '.
'LedgerService should insert it when supplier resolved via platform B2.'
);
// ── 5. delivered_in_month incremented ───────────────────────────────────
expect((int) $tenantAfter->delivered_in_month)->toBe(1,
"FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, ".
'expected 1 after first lead delivery (started at 0).'
);
})->group('imitation');
/**
* Tier price uses delivered_in_month + 1 at the moment of charge.
*
* Tenant with delivered_in_month=99 → count+1=100 → still tier 1 (leads_in_tier=100).
* Tenant with delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks).
*/
it('money: tier is resolved by delivered_in_month+1 boundary', function (): void {
config(['services.dadata.enabled' => true]);
// delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks = 450.00 rub).
$expectedPriceKopecks = 45000;
$expectedAmountRub = '450.00';
$tenant = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1)
]);
User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$supplierKey2 = $supplier->unique_key; // used for injection domain
// Give the project the supplier's unique_key as signal_identifier so the factory
// asSiteSignal sets it correctly; alternatively pass signal_type/signal_identifier directly.
$project = Project::factory()
->asSiteSignal($supplierKey2)
->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_in_month' => 100,
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $supplierKey2,
dailyLimit: 50,
regions: '{}',
);
$phone = '79165000002';
$fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector;
$injector->site(
domain: $supplierKey2,
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_500_100_001,
);
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->latest('id')
->first();
expect($deal)->not->toBeNull('FINDING: No deal created for tier boundary test.');
$charge = DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('deal_id', $deal->id)
->first();
expect($charge)->not->toBeNull('FINDING: lead_charges row missing for tier boundary test.');
expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks,
'FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 '.
"(price=45000 kopecks). Got: {$charge->price_per_lead_kopecks}."
);
expect($charge->charge_source)->toBe('rub');
})->group('imitation');
// ===========================================================================
// ─── INTAKE VALIDATION (§7 Этап 0) ─────────────────────────────────────────
// ===========================================================================
/**
* Intake: bad secret → 404.
*
* SupplierWebhookController: verifySecret() uses hash_equals; wrong secret → 404.
*/
it('intake: bad secret returns 404', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/THIS-IS-THE-WRONG-SECRET-XXXXX', [
'vid' => 999_001,
'project' => 'B2_some-domain.test',
'phone' => '79161234567',
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(404,
'FINDING: Bad webhook secret did not return 404. '.
"Got HTTP {$response->status()}."
);
})->group('imitation');
/**
* Intake: phone not matching ^7\d{10}$ → 422.
*
* Controller validate: 'phone' => ['required', 'string', 'regex:/^7\d{10}$/'].
* An 11-digit number starting with 8 fails the regex → Laravel returns 422.
*/
it('intake: invalid phone (wrong prefix) returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_002,
'project' => 'B2_some-domain.test',
'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Phone starting with 8 (invalid) did not return 422. '.
"Got HTTP {$response->status()}. Response: ".$response->content()
);
})->group('imitation');
/**
* Intake: phone too short → 422.
*/
it('intake: too-short phone returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_003,
'project' => 'B2_some-domain.test',
'phone' => '7916123', // too short — only 7 digits after 7
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Short phone did not return 422. '.
"Got HTTP {$response->status()}."
);
})->group('imitation');
/**
* Intake: time outside ±24h → 422.
*
* Controller: min = now()-24h, max = now()+24h. timestamp 48h in past fails validation.
*/
it('intake: timestamp 48h in the past (beyond -24h window) returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$oldTime = now()->subHours(48)->getTimestamp(); // 48h ago — outside ±24h window
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_004,
'project' => 'B2_some-domain.test',
'phone' => '79161234568',
'time' => $oldTime,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Timestamp 48h in the past did not return 422. '.
"Got HTTP {$response->status()}. ".
'Controller requires time within ±24h (min=now-24h, max=now+24h).'
);
})->group('imitation');
/**
* Intake: time outside +24h (future) → 422.
*/
it('intake: timestamp 48h in the future (beyond +24h window) returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$futureTime = now()->addHours(48)->getTimestamp(); // 48h in future
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_005,
'project' => 'B2_some-domain.test',
'phone' => '79161234569',
'time' => $futureTime,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Timestamp 48h in the future did not return 422. '.
"Got HTTP {$response->status()}."
);
})->group('imitation');
/**
* Intake: flood >600/min from one IP → 429.
*
* Controller uses RateLimiter::tooManyAttempts($key, 600) per-IP.
* After 600 hits the 601st attempt should be rate-limited.
*
* Note: We manipulate the rate limiter counter directly via RateLimiter::hit()
* to avoid actually making 601 HTTP requests (too slow for a test).
* This verifies the rate-limit enforcement path, not the counter increment.
*/
it('intake: rate-limit 600/min per IP — 601st request returns 429', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
// Simulate the rate limiter already being at its limit for '127.0.0.1'.
// The controller uses key 'supplier-webhook:<ip>'.
$rateKey = 'supplier-webhook:127.0.0.1';
// Clear any existing state first, then saturate the limiter.
RateLimiter::clear($rateKey);
// Hit 600 times to reach the limit (the 601st should be too-many).
for ($i = 0; $i < 600; $i++) {
RateLimiter::hit($rateKey, 60);
}
// Now the 601st HTTP request should see tooManyAttempts = true.
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_006,
'project' => 'B2_some-domain.test',
'phone' => '79161234570',
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(429,
'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). '.
"Got HTTP {$response->status()}. ".
'Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken.'
);
// Clean up rate limiter state to avoid cross-test pollution.
RateLimiter::clear($rateKey);
})->group('imitation');