test(coverage): расширение охвата Раздел B №2-5 — границы reminder/final, терминальные пути оркестратора, G6 API edges
This commit is contained in:
@@ -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
|
||||
|
||||
-
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, не регрессия. Требует отдельной чистки сидов.
|
||||
|
||||
Reference in New Issue
Block a user