test(supplier): regression-guard неизменности received_at при merge CSV-recovered сделки

This commit is contained in:
Дмитрий
2026-06-21 08:23:13 +03:00
parent d784df50a8
commit b9184a6aea
3 changed files with 76 additions and 2 deletions
+2 -2
View File
@@ -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
-
@@ -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:1205: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 НЕ должен блокировать это.
@@ -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`, не регрессия. Требует отдельной чистки сидов.