diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 850e3e5b..d1b12557 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2547,13 +2547,13 @@ parameters: - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#' identifier: property.notFound - count: 7 + count: 9 path: tests/Feature/Supplier/CsvWebhookRaceTest.php - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 8 + count: 10 path: tests/Feature/Supplier/CsvWebhookRaceTest.php - diff --git a/app/tests/Feature/Supplier/CsvWebhookRaceTest.php b/app/tests/Feature/Supplier/CsvWebhookRaceTest.php index d2b88c29..3d80593b 100644 --- a/app/tests/Feature/Supplier/CsvWebhookRaceTest.php +++ b/app/tests/Feature/Supplier/CsvWebhookRaceTest.php @@ -178,6 +178,79 @@ it('webhook after CSV-recovered merges into existing deal (no duplicate, no doub expect($deliveredLeadIds)->toContain($webhookLead->id); }); +// --------------------------------------------------------------------------- +// Test 1b — merge НЕ меняет received_at CSV-recovered сделки. +// Regression-guard инцидента 26.05.2026 04:12–05:03 UTC (9 failed_jobs): +// received_at — partition key, lead_charges имеет FK (deal_id, deal_received_at) +// ON DELETE CASCADE / ON UPDATE NO ACTION. Любое изменение received_at при merge +// ломало FK на COMMIT → каскадный риск. Код (RouteSupplierLeadJob:371-393) сохраняет +// received_at как есть; этот тест пиннит инвариант (раньше не assert'ился). +// --------------------------------------------------------------------------- +it('merge preserves CSV-recovered received_at unchanged (FK-partition safety, no extra charge)', function (): void { + $phone = '79991000004'; + $csvTime = now()->subHours(3); + + // ── Step 1: CSV-recovered сделка с известным received_at (3 часа назад) ── + $csvLead = SupplierLead::factory()->create([ + 'platform' => 'B1', + 'phone' => $phone, + 'vid' => null, + 'supplier_project_id' => $this->sp->id, + 'raw_payload' => [ + 'project' => 'B1_race-csv.ru', + 'phone' => $phone, + 'time' => $csvTime->getTimestamp(), + ], + 'received_at' => $csvTime, + 'recovered_from_csv_at' => $csvTime, + 'source' => 'csv_recovery', + 'processed_at' => null, + ]); + runRaceJob($csvLead->id); + + DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); + $csvDeal = Deal::where('phone', $phone)->first(); + expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal'); + $receivedAtBefore = $csvDeal->received_at; + expect($receivedAtBefore->getTimestamp())->toBe($csvTime->getTimestamp(), 'received_at сделки = время CSV-лида'); + + // ── Step 2: догоняющий webhook с реальным vid и ДРУГИМ time (15 мин назад) ── + $webhookLead = SupplierLead::factory()->create([ + 'platform' => 'B1', + 'phone' => $phone, + 'vid' => 1672819990, + 'supplier_project_id' => $this->sp->id, + 'raw_payload' => [ + 'vid' => 1672819990, + 'project' => 'B1_race-csv.ru', + 'phone' => $phone, + 'time' => now()->subMinutes(15)->getTimestamp(), + ], + 'received_at' => now()->subMinutes(15), + 'source' => 'webhook', + 'processed_at' => null, + ]); + runRaceJob($webhookLead->id); + + DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); + + // ── Assertions ── + $deals = Deal::where('phone', $phone)->get(); + expect($deals)->toHaveCount(1, 'merge: одна сделка, не две'); + + $merged = $deals->first(); + // Ключевой инвариант: received_at НЕ изменился webhook-временем (15 мин назад), + // остался временем CSV-лида (3 часа назад) — FK-partition safety. + expect($merged->received_at->getTimestamp())->toBe( + $csvTime->getTimestamp(), + 'merge НЕ должен менять received_at (partition key + lead_charges FK)', + ); + expect((int) $merged->source_crm_id)->toBe(1672819990, 'source_crm_id обновлён webhook vid'); + + // Списание ровно одно — merge не доначисляет. + expect(LeadCharge::where('deal_id', $merged->id)->count())->toBe(1, 'merge: второго списания нет'); +}); + // --------------------------------------------------------------------------- // Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design). // Наш Phase 2 fix НЕ должен блокировать это. diff --git a/docs/superpowers/specs/2026-06-21-acceptance-owner-decisions.md b/docs/superpowers/specs/2026-06-21-acceptance-owner-decisions.md index f59000d0..d5ad0d66 100644 --- a/docs/superpowers/specs/2026-06-21-acceptance-owner-decisions.md +++ b/docs/superpowers/specs/2026-06-21-acceptance-owner-decisions.md @@ -10,6 +10,7 @@ - **apiv1-rate — СДЕЛАНО (TDD).** `throttle:api-v1` 120/мин/источник (Bearer-ключ→IP) ПЕРЕД `apikey` на `/api/v1/deals`. Лимитер в `AppServiceProvider`, тест `PublicDealsApiTest` 8/8, Pint/Larastan=0. - **M-1 — СДЕЛАНО локально (TDD), вариант А (fail-closed app-гейт), allowlist A2 config/env.** `EnsureSaasAdmin` требует непустой `REMOTE_USER` ∈ `config('admin.basic_auth_allowlist')` при `admin.basic_auth_gate` (вкл вне local/testing). Новый `config/admin.php`, тест `EnsureSaasAdminGateTest` 4/4, Pint/Larastan=0. Спека+план в `docs/superpowers/`. **🔴 Деплой — твой шаг: дыра на боевом ОТКРЫТА до выката** (`APP_ENV=production` включит гейт сам, дефолт allowlist=`admin`). - **M-2 — прод-.env сверён.** В прод-`.env` НЕТ CAPTCHA-переменных → капча выключена (любой непустой токен проходит). Решение владельца: **ставить реальный Yandex SmartCaptcha** (отдельная фича, нужны ключи) — в работу следующей. +- **Раздел B #1 (CsvReconcile/merge) — углублён.** Сверка показала: merge-без-2-го-списания (`CsvWebhookRaceTest`), drift-алерты webhook-loss + business-drift R-05 (`CsvReconcileJobTest`), регион-улучшение при merge (`RouteSupplierLeadJobRegionResolutionTest`) — **уже покрыты** (док B.1 устарел). Единственная неприкрытая денежно-критичная ассерция — **неизменность `received_at` при merge** (regression-guard FK-partition инцидента 26.05) — добавлена в `CsvWebhookRaceTest` (4/4), фальсификацией подтверждено, что тест ловит регресс. > ⚠️ **Побочно (вне scope M-1):** `AdminSuppliersControllerTest` падает (4 поставщика вместо 3) и на чистом HEAD — предсуществующее состояние тест-БД `liderra_testing`, не регрессия. Требует отдельной чистки сидов.