diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index d1b12557..2930cd13 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -519,7 +519,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound - count: 9 + count: 14 path: tests/Feature/Api/V1/PublicDealsApiTest.php - diff --git a/app/tests/Feature/Api/V1/PublicDealsApiTest.php b/app/tests/Feature/Api/V1/PublicDealsApiTest.php index 6fcb708e..2c64ee75 100644 --- a/app/tests/Feature/Api/V1/PublicDealsApiTest.php +++ b/app/tests/Feature/Api/V1/PublicDealsApiTest.php @@ -118,3 +118,57 @@ test('since-фильтр отсекает старые сделки', function ( $r->assertOk(); expect($r->json('data'))->toHaveCount(1); }); + +test('невалидный since → 422', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $key = makeApiKey($tenant->id, $user->id); + + $this->getJson('/api/v1/deals?since=не-дата-вовсе', ['Authorization' => "Bearer {$key}"]) + ->assertStatus(422); +}); + +test('невалидный cursor → 422', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $key = makeApiKey($tenant->id, $user->id); + + // Валидный base64, но не JSON с ключами r/i → 422. + $bad = base64_encode('просто строка'); + $this->getJson("/api/v1/deals?cursor={$bad}", ['Authorization' => "Bearer {$key}"]) + ->assertStatus(422); +}); + +test('ключ без scope read → 403', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $key = makeApiKey($tenant->id, $user->id, ['scopes' => ['write']]); + + $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"]) + ->assertStatus(403); +}); + +test('keyset-пагинация: limit=1 → next_cursor → вторая страница без перекрытия', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subMinutes(1)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subMinutes(2)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subMinutes(3)]); + + $key = makeApiKey($tenant->id, $user->id); + $auth = ['Authorization' => "Bearer {$key}"]; + + $p1 = $this->getJson('/api/v1/deals?limit=1', $auth); + $p1->assertOk(); + expect($p1->json('data'))->toHaveCount(1); + $cursor = $p1->json('next_cursor'); + expect($cursor)->not->toBeNull(); + $firstId = $p1->json('data.0.id'); + + $p2 = $this->getJson("/api/v1/deals?limit=1&cursor={$cursor}", $auth); + $p2->assertOk(); + expect($p2->json('data'))->toHaveCount(1); + // Вторая страница не повторяет первую (keyset, не offset). + expect($p2->json('data.0.id'))->not->toBe($firstId); +}); diff --git a/app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php b/app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php index d16288fa..6a6a3769 100644 --- a/app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php +++ b/app/tests/Feature/Billing/BalanceFrozenReminderJobTest.php @@ -81,6 +81,40 @@ it('sends nothing for freshly frozen tenant', function () { Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); }); +it('sends nothing in dead-zone between reminder and final windows (48-72h)', function () { + Mail::fake(); + // frozen 60h назад — после reminder-окна (24-48h), до final-окна (72-96h). + // matchWindow(60) → null. Пиннит разрыв: расширение reminder-окна до 72h + // ошибочно выстрелит reminder здесь — тест это поймает. + Carbon::setTestNow('2026-05-25 12:00:00'); + $tenant = Tenant::factory()->create([ + 'balance_rub' => '0.00', + 'frozen_by_balance_at' => Carbon::now()->subHours(60), + ]); + Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); + + (new BalanceFrozenReminderJob)->handle(); + + Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); + Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); +}); + +it('sends nothing after final window closes (>96h)', function () { + Mail::fake(); + // frozen 100h назад — за пределами final-окна (72-96h). matchWindow(100) → null. + Carbon::setTestNow('2026-05-25 12:00:00'); + $tenant = Tenant::factory()->create([ + 'balance_rub' => '0.00', + 'frozen_by_balance_at' => Carbon::now()->subHours(100), + ]); + Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); + + (new BalanceFrozenReminderJob)->handle(); + + Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); + Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); +}); + it('is throttled — does not re-send reminder for same tenant in window', function () { Mail::fake(); Carbon::setTestNow('2026-05-25 12:00:00'); diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index 55f634c8..34b9b1c7 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -377,6 +377,74 @@ it('handles partial failure: one project throws, others continue routing', funct expect((string) $tenants[2]->fresh()->balance_rub)->toBe('99500.00'); }); +it('fast-fails terminal-error lead without creating a deal or re-queueing', function (): void { + // Finding 2 (2026-05-29, fast-fail plan): лид с terminal-error и processed_at=null + // раньше прокручивался заново → failed_webhook_jobs storm. Fast-fail помечает его + // processed с суффиксом [fast-failed...], без сделки и без повторной очереди. + $countBefore = DB::table('deals')->count(); + + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'vid' => 4444, + 'phone' => '79991234599', + 'raw_payload' => ['vid' => 4444, 'project' => 'B1_whatever.ru', 'phone' => '79991234599', 'time' => now()->getTimestamp()], + 'processed_at' => null, + 'error' => 'no matching supplier_project for B1/site/whatever.ru', + ]); + + runRouteJob($lead->id); + + $lead->refresh(); + expect($lead->processed_at)->not->toBeNull(); + expect($lead->error)->toContain('[fast-failed by RouteSupplierLeadJob]'); + expect(DB::table('deals')->count())->toBe($countBefore); // сделок не создано +}); + +it('throws RuntimeException when ALL eligible projects fail routing', function (): void { + // Терминальный all-fail (RouteSupplierLeadJob:188-193): selected непуст, но КАЖДЫЙ + // createDealCopyForProject бросает → createdCount=0 → RuntimeException (очередь + // ловит → retry → failed_webhook_jobs). Форсируем soft-delete'ом ВСЕХ тенантов: + // Tenant::firstOrFail() внутри createDealCopyForProject падает для каждого проекта + // (тот же приём, что и в partial-failure тесте, но на 100% проектов). + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'all-fail.ru', + ]); + + $tenants = collect(); + for ($i = 0; $i < 2; $i++) { + $t = Tenant::factory()->create(['balance_rub' => '100000.00']); + $tenants->push($t); + $project = Project::factory()->create([ + 'tenant_id' => $t->id, + 'supplier_b1_project_id' => $supplier->id, + 'signal_type' => 'site', + 'signal_identifier' => 'all-fail.ru', + 'is_active' => true, + 'delivered_today' => 0, + ]); + linkProjectToSupplier($project, $supplier); + createRoutingSnapshotFromProject($project); + } + + foreach ($tenants as $t) { + $t->delete(); // soft-delete → firstOrFail упадёт для каждого проекта + } + + $vid = 5555; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'vid' => $vid, + 'phone' => '79991234588', + 'raw_payload' => ['vid' => $vid, 'project' => 'B1_all-fail.ru', 'phone' => '79991234588', 'time' => now()->getTimestamp()], + ]); + + expect(fn () => runRouteJob($lead->id))->toThrow(RuntimeException::class); +}); + it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void { // Регрессия 18.05.2026: поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный // текст со встроенным URL/доменом ('B1_заявка carmoney.ru/'). Старый parseProjectField 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 d5ad0d66..abd410ad 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,11 @@ - **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:** при сверке топ-5 оказалось, что док `coverage-expansion-NEW.md` **систематически переоценивает «непокрытое»** — merge, drift-алерты, заморозка с сохранением ручных пауз, идемпотентность, lead==null/partial, retry/cancel/destroy/signed-URL отчётов — **уже покрыты** существующими тестами. Реальные гапы оказались узкими и точечными (ниже). Раздел B как «~25 непокрытых путей» — преувеличение; фактически добавлено 9 целевых тестов на реальные края. +- **Раздел B #2 (заморозка/reminder) — углублён.** Заморозка+ручные паузы+идемпотентность уже покрыты. Добавлены границы reminder/final: dead-zone 48–72h → ничего, >96h → ничего (`BalanceFrozenReminderJobTest`, фальсификация ✓). +- **Раздел B #3 (терминальные пути оркестратора) — углублён.** lead==null (25k-guard)/partial уже покрыты. Добавлены **fast-fail terminal-error** (storm Finding-2) и **all-fail → RuntimeException** (`RouteSupplierLeadJobTest`, фальсификация ✓). +- **Раздел B #4 (жизненный цикл отчётов) — УЖЕ ПОЛНОСТЬЮ ПОКРЫТ.** retry(owner/status/3-попытки/7д/quota), cancel(pending/owner), destroy(terminal/file/NULL/pd-аудит), download(410/404/невалидная+просроченная+tenant-tamper подпись→403) — всё есть. Без добавлений. Незакрыт только quota-TOCTOU (недетерминированная гонка — не характеризуется). +- **Раздел B #5 (G6-edges) — углублён.** 401-варианты/last_used/since-happy уже покрыты. Добавлены: невалидный since→422, невалидный cursor→422, ключ без scope read→403, keyset-пагинация (`PublicDealsApiTest`). Impersonation-границы (lpimp_→403) уже покрыты `ImpersonationDoorTest` + M-1. - **Раздел 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`, не регрессия. Требует отдельной чистки сидов.