Files
portal/app/tests/Feature/Integration/SupplierLeadFlowTest.php
T
Дмитрий e8db184e99 feat(slepok): Task 2.5 — LeadRouter reads from project_routing_snapshots (R-01 closure)
LeadRouter SQL переписан на JOIN с project_routing_snapshots по active_slepok_date:
до 21:00 МСК = today, после 21:00 МСК = today+1. is_active / delivery_days_mask /
daily_limit / regions / signal_type / signal_identifier берутся из snapshot.
Из live projects — только delivered_today (счётчик остатка лимита). Из tenants —
balance_rub (live auto-pause при нулевом балансе).

Active snapshot date вычисляется в PHP (метод activeSnapshotDate()) и
передаётся в SQL как параметр — тестируемо через Carbon::setTestNow,
исключает дрейф между PHP- и DB-часами.

Fail-loud: Log::error('lead_router.no_snapshot_for_active_date', ...) если
по активной дате слепка вообще нет ни одной строки snapshot'а (cron не отработал).

Closes R-01, R-04, R-06, R-07, R-08, R-15.
Partial: R-02 (через шеринг), R-09 (race), R-10 (editable identifier) — закрываются Task 2.6+.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.5
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3

Tests added:
- tests/Feature/LeadRouter/SnapshotRoutingTest.php (4 tests, all GREEN locally)

Tests patched (downstream — добавлен createRoutingSnapshotFromProject() helper):
- tests/Pest.php — global helper createRoutingSnapshotFromProject()
- tests/Feature/LeadRouter/BalanceFilterTest.php (2/2 GREEN)
- tests/Feature/Services/LeadRouterTest.php (10/10 GREEN)
- tests/Feature/Jobs/RouteSupplierLeadJobTest.php (14/14 GREEN)
- tests/Feature/Supplier/DirectPlatformTest.php (6/6 GREEN)
- tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php (3/3 GREEN)
- tests/Feature/Supplier/SupplierConnectionTest.php (5/5 GREEN)
- tests/Feature/Integration/SupplierLeadFlowTest.php (2/2 GREEN)
- tests/Feature/Pd/DealCreatePdLogTest.php (2/2 GREEN)

Each test file isolated regression: GREEN. Combined run 49/50 with 1 flake on
quirk #77 (Faker unique domainName + cross-connection pgsql/pgsql_supplier
DatabaseTransactions scope mismatch) — pre-existing, NOT regression от Task 2.5.

Patched via 7 parallel Sonnet subagents per Pravila §15.1; controller-verified
isolated + combined regression (latter caught 1 subagent over-application:
paused project in SupplierLeadFlowTest получил snapshot, что нарушило логику
теста — fixed inline, по semantic match with SnapshotBackfillCommand SQL
WHERE p.is_active = true).
2026-05-28 05:48:15 +03:00

132 lines
5.1 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);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
// Plan 4 Task 4: LedgerService требует наличия активных PricingTier'ов
// для tier-resolve в chargeForDelivery (вызывается из RouteSupplierLeadJob).
$this->seed(PricingTierSeeder::class);
SystemSetting::query()->where('key', 'supplier_webhook_secret')
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
});
it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'vashinvestor.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '1000.00']);
$tenants->push($t);
$project = Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
]);
$projects->push($project);
// v8.26 (Plan 1-2): LeadRouter eligibility — через pivot project_supplier_links,
// не legacy supplier_b1_project_id. Без pivot-связи проект не eligible → 0 сделок.
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, null, 'site', 'vashinvestor.ru');
}
// 4-й tenant — paused (is_active=false). Связь в pivot есть, чтобы проверялся
// именно фильтр is_active, а не отсутствие связи.
$pausedTenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '1000.00']);
$pausedProject = Project::factory()->create([
'tenant_id' => $pausedTenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => false,
]);
linkProjectToSupplier($pausedProject, $supplier);
// NB: snapshot для paused-проекта НЕ создаём — SnapshotBackfillCommand в prod
// фильтрует `WHERE p.is_active = true`. Это и есть Task 2.5 R-01 защита от
// обратного case'а: paused after slepok всё равно НЕ получит лиды (потому что
// snapshot fix'нут до его paused).
$vid = 432176649;
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => $vid,
'project' => 'B1_vashinvestor.ru',
'tag' => 'Ваш инвестор',
'phone' => '79991234567',
'phones' => ['79991234567'],
'time' => time(),
]);
$response->assertStatus(202);
foreach ($projects as $i => $project) {
$tenant = $tenants[$i];
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$deals = Deal::query()
->where('tenant_id', $tenant->id)
->where('source_crm_id', $vid)
->get();
expect($deals)->toHaveCount(1, "tenant {$tenant->id} expected 1 deal copy");
$deal = $deals->first();
expect($deal->phone)->toBe('79991234567');
expect($deal->project_id)->toBe($project->id);
expect($deal->duplicate_of_id)->toBeNull();
expect((string) $tenant->fresh()->balance_rub)->toBe('500.00');
expect($project->fresh()->delivered_today)->toBe(1);
expect($project->fresh()->delivered_in_month)->toBe(1);
}
DB::statement("SET LOCAL app.current_tenant_id = '{$pausedTenant->id}'");
expect(Deal::query()
->where('tenant_id', $pausedTenant->id)
->where('source_crm_id', $vid)
->count())->toBe(0);
});
it('end-to-end: lead orphan (no matching projects) — 0 deals, lead stored', function (): void {
$vid = 999_888_777;
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => $vid,
'project' => 'B1_orphan.ru',
'phone' => '79991234567',
'time' => time(),
]);
$response->assertStatus(202);
$lead = SupplierLead::where('vid', $vid)->firstOrFail();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(0);
expect($lead->supplier_project_id)->not->toBeNull(); // resolveOrStub stub
});