From ebca32a212e5d1ffc9492f24c2cb9f62642b1dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 24 May 2026 16:15:49 +0300 Subject: [PATCH] =?UTF-8?q?refactor(billing):=20Phase=202=20=E2=80=94=20re?= =?UTF-8?q?move=20legacy=20ProcessWebhookJob=20+=20cascade=20test=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалён рудимент pre-sharing эпохи: - app/app/Jobs/ProcessWebhookJob.php (job целиком, 342 строки) - app/tests/Feature/ProcessWebhookJobTest.php (тест целиком, 362 строки) Каскадная чистка 4 тест-файлов: - BalanceNotificationsTest: -128 строк (оставлены topup_success/invoice_paid) - InAppNotificationTest: -168 строк (остался notifyInApp direct) - NewLeadNotificationTest: целиком удалён (-199 строк) - DealCreatePdLogTest: -36 строк webhook-кейса (остались API+Route) Локальный smoke (7 тестов без --parallel): 7 passed / 20 assertions. Phase 2 плана 2026-05-24-legacy-direct-webhook-removal.md. Co-Authored-By: Claude Opus 4.7 --- app/app/Jobs/ProcessWebhookJob.php | 342 ----------------- .../BalanceNotificationsTest.php | 129 ------- .../Notifications/InAppNotificationTest.php | 168 +------- .../Notifications/NewLeadNotificationTest.php | 199 ---------- app/tests/Feature/Pd/DealCreatePdLogTest.php | 37 +- app/tests/Feature/ProcessWebhookJobTest.php | 362 ------------------ 6 files changed, 3 insertions(+), 1234 deletions(-) delete mode 100644 app/app/Jobs/ProcessWebhookJob.php delete mode 100644 app/tests/Feature/Notifications/NewLeadNotificationTest.php delete mode 100644 app/tests/Feature/ProcessWebhookJobTest.php diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php deleted file mode 100644 index 16786459..00000000 --- a/app/app/Jobs/ProcessWebhookJob.php +++ /dev/null @@ -1,342 +0,0 @@ - $data Webhook payload: vid, project, tag, phone, phones, time - */ - public function __construct( - public int $tenantId, - public array $data, - public ?int $webhookLogId = null, - ) {} - - public function handle(): void - { - DB::transaction(function (): void { - DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); - - $tenant = Tenant::query() - ->whereKey($this->tenantId) - ->lockForUpdate() - ->first(); - - if ($tenant === null) { - throw new RuntimeException("Tenant {$this->tenantId} not found"); - } - - if ((int) $tenant->balance_leads <= 0) { - $this->logRejection($tenant, RejectedDealsLog::REASON_ZERO_BALANCE); - - return; - } - - $cleanProjectName = preg_replace('/^B[123]_/', '', (string) $this->data['project']); - $project = Project::firstOrCreate( - ['tenant_id' => $tenant->id, 'name' => $cleanProjectName], - ['type' => 'webhook'], - ); - - $receivedAt = Carbon::createFromTimestamp((int) $this->data['time']); - $sourceCrmId = (int) $this->data['vid']; - - $deal = $this->upsertDeal( - tenant: $tenant, - project: $project, - sourceCrmId: $sourceCrmId, - receivedAt: $receivedAt, - ); - - if (! $deal->wasRecentlyCreated) { - return; - } - - $this->chargeNewLead($tenant, $project, $deal); - }); - } - - private function logRejection(Tenant $tenant, string $reason): void - { - $rejected = RejectedDealsLog::create([ - 'tenant_id' => $tenant->id, - 'webhook_log_id' => $this->webhookLogId, - 'reason' => $reason, - 'payload' => $this->data, - 'created_at' => now(), - ]); - - Log::info("webhook.rejected.{$reason}", [ - 'tenant_id' => $tenant->id, - 'vid' => $this->data['vid'] ?? null, - ]); - - // ТЗ §18.5: zero_balance — уведомить тенант. Anti-spam: не более - // 1 email/час на тенант. Исключаем только что вставленную запись - // через id (timestamp-сравнение ненадёжно из-за microsecond precision). - if ($reason === RejectedDealsLog::REASON_ZERO_BALANCE) { - $previousCount = RejectedDealsLog::query() - ->where('tenant_id', $tenant->id) - ->where('reason', $reason) - ->where('created_at', '>=', now()->subHour()) - ->where('id', '!=', $rejected->id) - ->count(); - - if ($previousCount === 0) { - app(NotificationService::class)->notifyZeroBalance($tenant); - } - } - } - - /** - * Списание баланса при создании НОВОЙ сделки + аудит-записи. - * - * Все INSERT'ы в одной транзакции — целостность гарантирована (Ю-2): - * deal + supplier_lead_cost + balance_transaction появляются атомарно. - */ - private function chargeNewLead(Tenant $tenant, Project $project, Deal $deal): void - { - $tenant->decrement('balance_leads'); - $tenant->refresh(); - - BalanceTransaction::create([ - 'tenant_id' => $tenant->id, - 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, - 'amount_leads' => -1, - 'balance_leads_after' => (int) $tenant->balance_leads, - 'related_type' => Deal::class, - 'related_id' => $deal->id, - 'created_at' => now(), - ]); - - $resolver = app(SupplierResolver::class); - $supplierId = $resolver->resolveForProject($project); - if ($supplierId !== null) { - SupplierLeadCost::create([ - 'deal_id' => $deal->id, - 'received_at' => $deal->received_at, - 'supplier_id' => $supplierId, - 'cost_rub' => $resolver->costRubSnapshot($supplierId), - 'supplier_lead_id' => (int) $this->data['vid'], - 'created_at' => now(), - ]); - } else { - Log::warning('webhook.no_active_supplier', [ - 'tenant_id' => $tenant->id, - 'project_id' => $project->id, - 'deal_id' => $deal->id, - ]); - } - - ActivityLog::create([ - 'tenant_id' => $tenant->id, - 'user_id' => null, - 'deal_id' => $deal->id, - 'event' => ActivityLog::EVENT_DEAL_CREATED, - 'context' => ['source' => 'webhook'], - 'created_at' => now(), - ]); - - app(PdAuditLogger::class)->record( - action: 'created', subjectType: 'lead', subjectId: $deal->id, - purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id, - actorTenantUserId: null, actorAdminUserId: null, ip: null, - ); - - // Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей - // в БД, чтобы при ошибке отправки транзакция уже была зафиксирована. - // NotificationService сам ловит Throwable от Mail::send и логирует — - // отказ канала не должен валить webhook. - $deal->setRelation('project', $project); - $service = app(NotificationService::class); - $service->notifyNewLead($tenant, $deal); - - // ТЗ §18.5: low_balance — после lead_charge проверяем порог. Триггерим - // ТОЛЬКО когда баланс пересекает порог сверху-вниз: balance_after <= - // threshold AND (balance_after + 1) > threshold. Иначе шлёт спам после - // каждого lead_charge при balance < threshold. - $threshold = $this->lowBalanceThreshold(); - $balanceAfter = (int) $tenant->balance_leads; - if ($balanceAfter <= $threshold && ($balanceAfter + 1) > $threshold) { - $service->notifyLowBalance($tenant, $threshold); - } - } - - /** - * Читает порог из system_settings.low_balance_threshold_leads. - * Default 10 (см. schema.sql:2239 seed). - */ - private function lowBalanceThreshold(): int - { - $setting = SystemSetting::query()->where('key', 'low_balance_threshold_leads')->first(); - if ($setting === null) { - return 10; - } - - return (int) $setting->value; - } - - /** - * Идемпотентная upsert-логика через advisory lock (§5.5 v8.7). - * - * Стратегия: - * 1. pg_advisory_xact_lock(tenant_id, vid) — сериализует все операции - * с (tenant_id, source_crm_id) на время транзакции. - * 2. SELECT в webhook_dedup_keys — атомарно из-за lock. - * 3a. Если найдено — UPDATE deal по composite-ключу (id, received_at). - * 3b. Иначе — INSERT deal первым (FK immediate OK), затем INSERT dedup_key. - * - * См. db/CHANGELOG_schema.md §W для архитектурного обоснования - * (PG savepoint+DEFERRED quirk, отказ от двустадийного INSERT-в-dedup-keys-первым). - */ - private function upsertDeal( - Tenant $tenant, - Project $project, - int $sourceCrmId, - Carbon $receivedAt, - ): Deal { - // pg_advisory_xact_lock(bigint): комбинируем (tenant_id, source_crm_id) - // в один bigint — верхние 32 бита tenant_id, нижние 32 — source_crm_id. - $lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF); - DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]); - - $existing = DB::selectOne( - 'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?', - [$tenant->id, $sourceCrmId], - ); - - if ($existing !== null) { - $deal = Deal::query() - ->where('id', $existing->deal_id) - ->where('received_at', $existing->deal_received_at) - ->firstOrFail(); - - $deal->update([ - 'phone' => (string) $this->data['phone'], - 'phones' => $this->data['phones'] ?? [(string) $this->data['phone']], - // status НЕ перезаписываем — менеджер мог изменить. - ]); - - DB::table('webhook_dedup_keys') - ->where('tenant_id', $tenant->id) - ->where('source_crm_id', $sourceCrmId) - ->update(['updated_at' => now()]); - - $deal->wasRecentlyCreated = false; - - return $deal; - } - - $deal = Deal::create([ - 'tenant_id' => $tenant->id, - 'source_crm_id' => $sourceCrmId, - 'project_id' => $project->id, - 'phone' => (string) $this->data['phone'], - 'phones' => $this->data['phones'] ?? [(string) $this->data['phone']], - 'status' => 'new', - 'received_at' => $receivedAt, - ]); - - DB::table('webhook_dedup_keys')->insert([ - 'tenant_id' => $tenant->id, - 'source_crm_id' => $sourceCrmId, - 'deal_id' => $deal->id, - 'deal_received_at' => $deal->received_at, - 'created_at' => now(), - ]); - - return $deal; - } - - /** - * Финальный callback после исчерпания всех ретраев ($tries=3). - * - * Сохраняет упавший job в `failed_webhook_jobs` для ручного разбора и - * возможного повторного запуска через админку SaaS. RLS не задаём — - * tenant_id из job-state передаётся как есть (failed-callback запускается - * вне транзакции воркера). На production добавляется Sentry::captureException. - * - * NB: записывается через DB::table (не через FailedWebhookJob::create), - * чтобы избежать RLS-фильтрации при отсутствии app.current_tenant_id — - * запись должна попасть в БД даже в катастрофическом сценарии. - */ - public function failed(Throwable $e): void - { - // failed_webhook_jobs is an RLS-protected table. On production crm_app_user - // (non-BYPASSRLS) there is no app.current_tenant_id GUC in the failed() - // callback context. Use pgsql_supplier (crm_supplier_worker, BYPASSRLS) — - // same pattern as RouteSupplierLeadJob::failed(). - DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([ - 'tenant_id' => $this->tenantId, - 'webhook_log_id' => $this->webhookLogId, - 'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE), - 'exception' => $e->getMessage(), - 'retry_count' => $this->tries, - 'failed_at' => now(), - ]); - - Log::error('webhook.job_failed_permanently', [ - 'tenant_id' => $this->tenantId, - 'vid' => $this->data['vid'] ?? null, - 'exception' => $e->getMessage(), - ]); - - // TODO(production): Sentry::captureException($e); - } -} diff --git a/app/tests/Feature/Notifications/BalanceNotificationsTest.php b/app/tests/Feature/Notifications/BalanceNotificationsTest.php index 820e8baa..73bd58af 100644 --- a/app/tests/Feature/Notifications/BalanceNotificationsTest.php +++ b/app/tests/Feature/Notifications/BalanceNotificationsTest.php @@ -2,19 +2,13 @@ declare(strict_types=1); -use App\Jobs\ProcessWebhookJob; use App\Mail\InvoicePaidNotification; -use App\Mail\LowBalanceNotification; use App\Mail\TopupSuccessNotification; -use App\Mail\ZeroBalanceNotification; use App\Models\InAppNotification; -use App\Models\RejectedDealsLog; use App\Models\Tenant; use App\Models\User; use App\Services\NotificationService; use Illuminate\Foundation\Testing\DatabaseTransactions; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; uses(DatabaseTransactions::class); @@ -23,18 +17,6 @@ beforeEach(function () { Mail::fake(); }); -function balancePayload(int $vid = 500): array -{ - return [ - 'vid' => $vid, - 'project' => 'B2_Caranga', - 'tag' => 'Caranga', - 'phone' => '79000000'.$vid, - 'phones' => ['79000000'.$vid], - 'time' => time(), - ]; -} - function makeUserForBalance(Tenant $tenant, string $email, array $events = []): User { return User::factory()->create([ @@ -53,102 +35,6 @@ function makeUserForBalance(Tenant $tenant, string $email, array $events = []): ]); } -// ============== low_balance ============== - -test('low_balance: при пересечении порога сверху-вниз → email + inapp', function () { - // Default threshold: 10 (system_settings seeded). Установим balance=11. - $tenant = Tenant::factory()->create(['balance_leads' => 11]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(10); // 11 → 10 (пересекли порог) - - Mail::assertSent(LowBalanceNotification::class, 1); - expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1); -}); - -test('low_balance: balance уже < threshold — НЕ шлёт повторно', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 5]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(4); // 5 → 4 (всё ещё < threshold=10) - - // Не пересекали порог — НЕ шлём. - Mail::assertNothingSent(); - expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(0); -}); - -test('low_balance: balance > threshold после decrement — НЕ шлёт', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 50]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(49); - - Mail::assertNothingSent(); -}); - -test('low_balance: prefs.low_balance.email=false — только inapp', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 11]); - makeUserForBalance($tenant, 'on@example.ru', [ - 'low_balance' => ['email' => false, 'inapp' => true], - ]); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - Mail::assertNothingSent(); - expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1); -}); - -// ============== zero_balance ============== - -test('zero_balance: первое отклонение → email + inapp', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - Mail::assertSent(ZeroBalanceNotification::class, 1); - expect(InAppNotification::query()->where('event', 'zero_balance')->count())->toBe(1); - expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(1); -}); - -test('zero_balance: 2-е отклонение в течение часа — НЕ дублирует email', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload(vid: 1)))->handle(); - Mail::assertSent(ZeroBalanceNotification::class, 1); - - (new ProcessWebhookJob($tenant->id, balancePayload(vid: 2)))->handle(); - Mail::assertSent(ZeroBalanceNotification::class, 1); // всё ещё один - expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(2); -}); - -test('zero_balance: отклонение через >1ч — снова шлёт', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserForBalance($tenant, 'on@example.ru'); - - // Создаём старый RejectedDealsLog (>1ч назад) — он не должен суппрессить. - DB::table('rejected_deals_log')->insert([ - 'tenant_id' => $tenant->id, - 'reason' => RejectedDealsLog::REASON_ZERO_BALANCE, - 'payload' => json_encode(['vid' => 999]), - 'created_at' => Carbon::now()->subHours(2), - ]); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - Mail::assertSent(ZeroBalanceNotification::class, 1); -}); - // ============== topup_success ============== test('topup_success: notifyTopupSuccess создаёт email + inapp', function () { @@ -204,18 +90,3 @@ test('invoice_paid: prefs=email:false — только inapp', function () { Mail::assertNothingSent(); expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1); }); - -// ============== isolation ============== - -test('balance events изолированы между тенантами', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 11]); - $tenantB = Tenant::factory()->create(['balance_leads' => 11]); - $userA = makeUserForBalance($tenantA, 'a@example.ru'); - makeUserForBalance($tenantB, 'b@example.ru'); - - (new ProcessWebhookJob($tenantA->id, balancePayload()))->handle(); - - Mail::assertSent(LowBalanceNotification::class, 1); - Mail::assertSent(fn (LowBalanceNotification $m) => $m->hasTo($userA->email)); - Mail::assertNotSent(fn (LowBalanceNotification $m) => $m->hasTo('b@example.ru')); -}); diff --git a/app/tests/Feature/Notifications/InAppNotificationTest.php b/app/tests/Feature/Notifications/InAppNotificationTest.php index 4f65e6f5..e050c072 100644 --- a/app/tests/Feature/Notifications/InAppNotificationTest.php +++ b/app/tests/Feature/Notifications/InAppNotificationTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -use App\Jobs\ProcessWebhookJob; -use App\Mail\NewLeadNotification; use App\Models\InAppNotification; use App\Models\Tenant; use App\Models\User; @@ -14,12 +12,8 @@ use Illuminate\Support\Facades\Mail; /** * Тесты in-app канала уведомлений (schema v8.10 in_app_notifications). * - * Канал inapp в матрице users.notification_preferences. INSERT row при - * триггере события (new_lead/...). UI читает unread-count и список - * последних 50 (этап 2b — отдельный коммит). - * - * Schema-default: notification_preferences.new_lead.inapp=true → в отличие - * от email, большинство user'ов получает in-app по умолчанию. + * Тесты через ProcessWebhookJob удалены — job убран как legacy-рудимент. + * Оставлен прямой вызов NotificationService::notifyInApp. */ uses(DatabaseTransactions::class); @@ -27,164 +21,6 @@ beforeEach(function () { Mail::fake(); }); -/** - * @return array - */ -function inAppPayload(int $vid = 300, ?int $time = null): array -{ - return [ - 'vid' => $vid, - 'project' => 'B2_Caranga', - 'tag' => 'Caranga', - 'phone' => '79001234567', - 'phones' => ['79001234567'], - 'time' => $time ?? time(), - ]; -} - -/** - * @param array $newLeadPrefs - */ -function makeUserWithInAppPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User -{ - return User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => $email, - 'notification_preferences' => [ - 'new_lead' => $newLeadPrefs, - 'reminder' => ['inapp' => true, 'push' => true, 'email' => true], - 'low_balance' => ['email' => true], - 'zero_balance' => ['email' => true], - 'topup_success' => ['email' => true], - 'invoice_paid' => ['email' => true], - 'new_device_login' => ['email' => true], - 'marketing' => ['email' => false], - ], - ]); -} - -test('webhook: in_app_notification создаётся для user с inapp=true', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $user = makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true, 'email' => false]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); - $notif = InAppNotification::query()->first(); - expect($notif->user_id)->toBe($user->id); - expect($notif->tenant_id)->toBe($tenant->id); - expect($notif->event)->toBe('new_lead'); - expect($notif->title)->toContain('Caranga'); - expect($notif->body)->toBe('79001234567'); // phone (no contact_name) - expect($notif->read_at)->toBeNull(); - expect($notif->payload['project_name'])->toBe('Caranga'); -}); - -test('webhook: user с inapp=false НЕ получает in-app row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'off@example.ru', ['inapp' => false, 'email' => false]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(0); -}); - -test('webhook: schema-default (inapp=true) ставит row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - // Без override prefs — берётся schema DEFAULT (new_lead.inapp=true). - User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'default@example.ru', - ]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); -}); - -test('webhook: 2 user\'а с inapp=true получают по 1 row, 1 user с inapp=false — нет', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $a = makeUserWithInAppPrefs($tenant, 'a@example.ru', ['inapp' => true]); - $b = makeUserWithInAppPrefs($tenant, 'b@example.ru', ['inapp' => true]); - makeUserWithInAppPrefs($tenant, 'c@example.ru', ['inapp' => false]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(2); - expect(InAppNotification::query()->where('user_id', $a->id)->exists())->toBeTrue(); - expect(InAppNotification::query()->where('user_id', $b->id)->exists())->toBeTrue(); -}); - -test('webhook: inactive user НЕ получает in-app', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - User::factory()->inactive()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'inactive@example.ru', - 'notification_preferences' => ['new_lead' => ['inapp' => true]], - ]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(0); -}); - -test('webhook: user другого тенанта НЕ получает (RLS isolation)', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 10]); - $tenantB = Tenant::factory()->create(['balance_leads' => 10]); - $userA = makeUserWithInAppPrefs($tenantA, 'a@example.ru', ['inapp' => true]); - makeUserWithInAppPrefs($tenantB, 'b@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenantA->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); - expect(InAppNotification::query()->first()->user_id)->toBe($userA->id); -}); - -test('webhook: дубль (Биз-19) НЕ создаёт повторный in-app row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 1)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); - - // Второй webhook с тем же phone в окне 24ч → дубль, нет chargeNewLead → нет notify. - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 2)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); -}); - -test('webhook: повторный vid (UPDATE) НЕ создаёт повторный in-app row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); - - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); -}); - -test('webhook: оба канала (inapp+email=true) — 1 in-app row + 1 email', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'both@example.ru', ['inapp' => true, 'email' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); - Mail::assertSent(NewLeadNotification::class, 1); -}); - -test('webhook: payload содержит deal_id для UI deep-link', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - $notif = InAppNotification::query()->first(); - expect($notif->deal_id)->not->toBeNull(); - expect($notif->payload)->toHaveKey('deal_id'); - expect($notif->payload['deal_id'])->toBe($notif->deal_id); -}); - test('NotificationService::notifyInApp: вызов напрямую создаёт row', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); diff --git a/app/tests/Feature/Notifications/NewLeadNotificationTest.php b/app/tests/Feature/Notifications/NewLeadNotificationTest.php deleted file mode 100644 index 99dd930d..00000000 --- a/app/tests/Feature/Notifications/NewLeadNotificationTest.php +++ /dev/null @@ -1,199 +0,0 @@ - - */ -function newLeadPayload(int $vid = 200, ?int $time = null): array -{ - return [ - 'vid' => $vid, - 'project' => 'B2_Caranga', - 'tag' => 'Caranga', - 'phone' => '79001234567', - 'phones' => ['79001234567'], - 'time' => $time ?? time(), - ]; -} - -/** - * @param array $newLeadPrefs - */ -function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User -{ - return User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => $email, - 'notification_preferences' => [ - 'new_lead' => $newLeadPrefs, - 'reminder' => ['inapp' => true, 'push' => true, 'email' => true], - 'low_balance' => ['email' => true], - 'zero_balance' => ['email' => true], - 'topup_success' => ['email' => true], - 'invoice_paid' => ['email' => true], - 'new_device_login' => ['email' => true], - 'marketing' => ['email' => false], - ], - ]); -} - -test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, 1); - Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool { - return $mail->manager->id === $userOn->id - && $mail->hasTo('on@example.ru'); - }); -}); - -test('webhook: user с email=false НЕ получает', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - // Не передаём notification_preferences — берётся schema DEFAULT. - User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'default@example.ru', - ]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: рассылается всем активным user\'ам с email=true', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]); - makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]); - makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, 2); - Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru')); - Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru')); - Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru')); -}); - -test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - User::factory()->inactive()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'inactive@example.ru', - 'notification_preferences' => [ - 'new_lead' => ['email' => true], - ], - ]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: soft-deleted user НЕ получает', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]); - $user->delete(); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: user другого тенанта НЕ получает (изоляция)', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 10]); - $tenantB = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]); - makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, 1); - Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru')); - Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru')); -}); - -test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - // Первая сделка — master. - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); - - // Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается, - // chargeNewLead НЕ вызывается, уведомление НЕ шлётся. - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно -}); - -test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); - - // Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return. - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); -}); - -test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт NewLeadNotification', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - // chargeNewLead НЕ вызывается при balance=0 — NewLeadNotification не шлётся. - // (ZeroBalanceNotification ШЛЁТСЯ — это покрывается отдельным тестом.) - Mail::assertNotSent(NewLeadNotification::class); - expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); -}); - -test('NewLeadNotification: subject содержит project_name', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool { - return str_contains($mail->envelope()->subject, 'Caranga'); - }); -}); diff --git a/app/tests/Feature/Pd/DealCreatePdLogTest.php b/app/tests/Feature/Pd/DealCreatePdLogTest.php index 390fe42c..b7a193aa 100644 --- a/app/tests/Feature/Pd/DealCreatePdLogTest.php +++ b/app/tests/Feature/Pd/DealCreatePdLogTest.php @@ -4,11 +4,9 @@ declare(strict_types=1); /** * 152-ФЗ: pd_processing_log 'created' записывается при создании сделки - * по всем трём путям — ручной API, поставщик (RouteSupplierLeadJob), - * вебхук (ProcessWebhookJob). + * по двум живым путям — ручной API, поставщик (RouteSupplierLeadJob). */ -use App\Jobs\ProcessWebhookJob; use App\Jobs\RouteSupplierLeadJob; use App\Models\Deal; use App\Models\Project; @@ -128,36 +126,3 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl expect($rows)->toBe(1); }); - -// ────────────────────────────────────────────────────────────────────────── -// Path C: webhook via ProcessWebhookJob -// ────────────────────────────────────────────────────────────────────────── - -it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - $vid = 55566; - (new ProcessWebhookJob($tenant->id, [ - 'vid' => $vid, - 'project' => 'B2_PdWebhookTest', - 'tag' => 'PdWebhookTest', - 'phone' => '79001112233', - 'phones' => ['79001112233'], - 'time' => time(), - ]))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first(); - expect($deal)->not->toBeNull(); - - $rows = DB::table('pd_processing_log') - ->where('action', 'created') - ->where('purpose', 'lead_create_webhook') - ->where('subject_type', 'lead') - ->where('subject_id', $deal->id) - ->where('tenant_id', $tenant->id) - ->whereNull('actor_tenant_user_id') - ->whereNull('actor_admin_user_id') - ->count(); - - expect($rows)->toBe(1); -}); diff --git a/app/tests/Feature/ProcessWebhookJobTest.php b/app/tests/Feature/ProcessWebhookJobTest.php deleted file mode 100644 index 919d1f93..00000000 --- a/app/tests/Feature/ProcessWebhookJobTest.php +++ /dev/null @@ -1,362 +0,0 @@ - $vid, - 'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga' - 'tag' => 'Caranga', - 'phone' => '79001234567', - 'phones' => ['79001234567'], - 'time' => $time ?? time(), - ]; -} - -/** - * Создаёт активного поставщика и привязывает его к проекту через project_suppliers. - * Используется в тестах SupplierLeadCost-ветки. - */ -function seedSupplierForProject(Project $project, float $costRub = 50.00): int -{ - $supplierId = (int) DB::table('suppliers')->insertGetId([ - 'code' => 'b1-test-'.Str::lower(Str::random(6)), - 'name' => 'B1 Test', - 'accepts_types' => '{websites,calls}', - 'cost_rub' => $costRub, - 'channel' => 'sites', - 'quality_score' => 1.00, - 'is_active' => true, - 'sort_order' => 0, - ]); - - DB::table('project_suppliers')->insert([ - 'project_id' => $project->id, - 'supplier_id' => $supplierId, - 'is_active' => true, - 'created_at' => now(), - ]); - - return $supplierId; -} - -test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - expect($deal)->not->toBeNull(); - expect($deal->source_crm_id)->toBe(100); - expect($deal->phone)->toBe('79001234567'); - expect($deal->status)->toBe('new'); - expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан - - $dedup = WebhookDedupKey::query() - ->where('tenant_id', $tenant->id) - ->where('source_crm_id', 100) - ->first(); - expect($dedup)->not->toBeNull(); - expect($dedup->deal_id)->toBe($deal->id); -}); - -test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $vid = 200; - - // Первый webhook - (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); - $dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count(); - - // Второй webhook с тем же vid (но новым phone — будет UPDATE) - $payload2 = makePayload(vid: $vid); - $payload2['phone'] = '79009999999'; - (new ProcessWebhookJob($tenant->id, $payload2))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); // баланс не изменился - expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first(); - expect($deal->phone)->toBe('79009999999'); // обновлён phone - - // dedup-ключ всё ещё ровно один - expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1); -}); - -test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(0); - expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); - expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0); -}); - -test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 10]); - $tenantB = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle(); - (new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle(); - - expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1); - expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1); - expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2); - - $tenantA->refresh(); - $tenantB->refresh(); - expect($tenantA->balance_leads)->toBe(9); - expect($tenantB->balance_leads)->toBe(9); -}); - -test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle(); - (new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle(); - - expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1); -}); - -test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - DB::table('deals') - ->where('id', $deal->id) - ->where('received_at', $deal->received_at) - ->delete(); - - expect(WebhookDedupKey::query() - ->where('tenant_id', $tenant->id) - ->where('source_crm_id', 700) - ->count())->toBe(0); -}); - -test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - $tx = BalanceTransaction::query() - ->where('tenant_id', $tenant->id) - ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE) - ->first(); - - expect($tx)->not->toBeNull(); - expect($tx->amount_leads)->toBe(-1); - expect($tx->balance_leads_after)->toBe(9); - expect($tx->related_type)->toBe(Deal::class); - expect($tx->related_id)->toBe($deal->id); -}); - -test('дубль vid НЕ создаёт BalanceTransaction', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $vid = 801; - - (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); - (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); - - expect(BalanceTransaction::query() - ->where('tenant_id', $tenant->id) - ->count())->toBe(1); -}); - -test('новая сделка создаёт ActivityLog event=deal.created', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - $log = ActivityLog::query() - ->where('tenant_id', $tenant->id) - ->where('deal_id', $deal->id) - ->first(); - - expect($log)->not->toBeNull(); - expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED); - expect($log->user_id)->toBeNull(); - expect($log->context)->toBe(['source' => 'webhook']); -}); - -test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle(); - - $rejected = RejectedDealsLog::query() - ->where('tenant_id', $tenant->id) - ->first(); - - expect($rejected)->not->toBeNull(); - expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE); - expect($rejected->payload['vid'])->toBe(803); -}); - -test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Caranga', // совпадает с обрезанным project из payload - ]); - $supplierId = seedSupplierForProject($project, costRub: 75.50); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - $cost = SupplierLeadCost::query() - ->where('deal_id', $deal->id) - ->where('received_at', $deal->received_at) - ->first(); - - expect($cost)->not->toBeNull(); - expect($cost->supplier_id)->toBe($supplierId); - expect((string) $cost->cost_rub)->toBe('75.50'); - expect($cost->supplier_lead_id)->toBe(804); -}); - -test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - expect(SupplierLeadCost::query() - ->where('deal_id', $deal->id) - ->count())->toBe(0); - - // Сделка всё равно создаётся, баланс списан, ActivityLog есть. - expect($deal)->not->toBeNull(); - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); -}); - -// ============================================================================= -// Spec B: no phone dedup — supplier owns dedup, Лидерра charges everything delivered -// ============================================================================= - -test('charges both leads with same phone but different vid (no phone dedup, Spec B)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 5]); - $phone = '79007770010'; - - // First webhook — distinct vid - $payload1 = makePayload(vid: 951); - $payload1['phone'] = $phone; - $payload1['phones'] = [$phone]; - (new ProcessWebhookJob($tenant->id, $payload1))->handle(); - - // Second webhook — same phone, different vid - $payload2 = makePayload(vid: 952); - $payload2['phone'] = $phone; - $payload2['phones'] = [$phone]; - (new ProcessWebhookJob($tenant->id, $payload2))->handle(); - - $tenant->refresh(); - // Both charged — balance_leads decremented twice. - expect($tenant->balance_leads)->toBe(3); - - // Two distinct deals exist for this tenant. - $deals = Deal::query()->where('tenant_id', $tenant->id)->get(); - expect($deals)->toHaveCount(2); - - // Neither deal has duplicate_of_id set. - foreach ($deals as $deal) { - expect($deal->duplicate_of_id)->toBeNull(); - } -}); - -// ============================================================================= -// failed() callback — финальная обработка после исчерпания ретраев -// ============================================================================= - -test('failed() пишет упавший job в failed_webhook_jobs', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $webhookLogId = (int) DB::table('webhook_log')->insertGetId([ - 'tenant_id' => $tenant->id, - 'raw_payload' => json_encode(['vid' => 1001]), - 'received_at' => now(), - ]); - $payload = makePayload(vid: 1001); - - $job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId); - $job->failed(new RuntimeException('boom: db down')); - - $row = DB::table('failed_webhook_jobs') - ->where('tenant_id', $tenant->id) - ->first(); - - expect($row)->not->toBeNull(); - expect($row->webhook_log_id)->toBe($webhookLogId); - expect($row->exception)->toBe('boom: db down'); - expect($row->retry_count)->toBe(3); - expect($row->resolved_at)->toBeNull(); - expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001); -}); - -test('failed() работает БЕЗ webhookLogId (NULL ok)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - $job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002)); - $job->failed(new RuntimeException('no webhook log id')); - - $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first(); - expect($row)->not->toBeNull(); - expect($row->webhook_log_id)->toBeNull(); -}); - -test('failed() записывает payload с UTF-8 кириллицей корректно', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $payload = makePayload(vid: 1003); - $payload['contact_name'] = 'Дмитрий Петров'; - - $job = new ProcessWebhookJob($tenant->id, $payload); - $job->failed(new RuntimeException('utf-8 test')); - - $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first(); - $decoded = json_decode($row->raw_payload, true); - - expect($decoded['contact_name'])->toBe('Дмитрий Петров'); -});