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

864 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');