config()->set('client_tg.sandbox', true)); function tgRejectCampaign(int $tenantId, string $status = Campaign::STATUS_QUEUED): Campaign { $campaign = Campaign::query()->create([ 'tenant_id' => $tenantId, 'status' => $status, 'ad_text' => 'Займ под залог недвижимости', 'ad_link' => 'https://t.me/example_test', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '1500.00', 'audience_kind' => Campaign::AUDIENCE_LIST, 'planned_count' => 2, 'estimated_cost_rub' => '0.00', 'created_by' => 1, ]); foreach (['79990000001', '79990000002'] as $phone) { CampaignPhone::query()->create([ 'tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'phone' => $phone, ]); } return $campaign; } function mockRobotFail(RobotResult $result): void { $mock = Mockery::mock(TelegramRobotRunner::class); $mock->shouldReceive('run')->andReturn($result); app()->instance(TelegramRobotRunner::class, $mock); } // 🔑 Приёмка 06.08.2026: причина поломки робота — ВНУТРЕННЯЯ. Раньше она ложилась // клиенту в карточку дословно, вместе с «npm run login» и адресами кабинета. Теперь // клиент видит человеческую фразу, а полный текст остаётся в журнале. // (Отказ МОДЕРАЦИИ МТС этого перевода не проходит — он приходит другим путём, // через TelegramModerationVerdictApplier, и показывается дословно.) it('ошибка робота сохраняет причину в status_reason человеческой фразой', function () { $tenant = Tenant::factory()->create(); $campaign = tgRejectCampaign($tenant->id); mockRobotFail(RobotResult::failed('Канал недоступен', 'finalize')); RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); $fresh = Campaign::find($campaign->id); expect($fresh->status)->toBe(Campaign::STATUS_FAILED) ->and($fresh->status_reason)->toBe(PrichinaDlyaKlienta::perevesti('Канал недоступен')) ->and($fresh->status_reason)->not->toBeNull(); }); it('при провале клиент получает in-app уведомление с причиной', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); $campaign = tgRejectCampaign($tenant->id); mockRobotFail(RobotResult::failed('submitWithPayment: кнопка оплаты неактивна', 'finalize')); RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); $notif = InAppNotification::where('tenant_id', $tenant->id) ->where('user_id', $user->id) ->first(); expect($notif)->not->toBeNull() ->and($notif->body)->toContain('Не удалось запустить рекламу') ->and($notif->body)->not->toContain('submitWithPayment'); }); it('уведомление об отказе доходит даже без включённых настроек уведомлений', function () { $tenant = Tenant::factory()->create(); // Дефолтные настройки фабрики НЕ содержат события tg_campaign_rejected — // это и проверяет, что уведомление доходит без pref-гейта. $user = User::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, ]); // 🔴 08.08.2026: статус здесь был STATUS_RUNNING, а причина — словами МОДЕРАТОРА. // В жизни такой пары не бывает: слова модератора появляются только вместе с // переходом в `rejected` (TelegramModerationVerdictApplier ставит и текст, и статус // в одной транзакции). А с 08.08 колокольчик переводит техническую причину на // человеческий (боевая утечка «вход в кабинет МТС … npm run login» в карточки // клиента) — и при «running» съел бы этот текст как технический. // // Ставим статус, который реально сопровождает такую причину. Проверяемое тестом // НЕ меняется: уведомление обязано дойти БЕЗ включённой настройки и донести слова // модератора. Отдельным сторожем выше остаётся то, что технический текст робота // («submitWithPayment») в колокольчик не попадает. $campaign = tgRejectCampaign($tenant->id, Campaign::STATUS_REJECTED); $campaign->status_reason = 'Реклама займов запрещена без лицензии'; app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign); $notif = InAppNotification::where('user_id', $user->id)->first(); expect($notif)->not->toBeNull() ->and($notif->body)->toContain('займов'); }); it('неактивный пользователь уведомление НЕ получает', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); $campaign = tgRejectCampaign($tenant->id, Campaign::STATUS_RUNNING); $campaign->status_reason = 'Причина'; app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign); // 🪤 Считаем уведомления ИМЕННО этого пользователя, а не всю таблицу целиком — // см. такую же правку в ApproveNotifyTest. expect(InAppNotification::where('user_id', $user->id)->count())->toBe(0); });