Files
portal/app/tests/Feature/ClientTg/ApproveNotifyTest.php
T
Дмитрий 18135b47b4 feat(телеграм-реклама): Этап 3.5/3.6 + 4.3 — чтение вердикта, пересдача, уведомление об одобрении
Закрывает Этап 3 «Жизненный цикл и модерация» целиком (3.1–3.6) и бэкенд-часть 4.3.
План docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.

## 3.5 — робот читает статус/причину модерации по mts_campaign_id (Node)
- `parseModerationStatus` (cabinet.js) — чистый парсер статус-текста кабинета в канон
  Laravel-опросчика: «Отклонена»→rejected, «Одобрена»/«Активна»→approved,
  «На модерации»→moderating, «Черновик»→draft, иначе null (тогда опросчик ждёт).
  Терминальные вердикты приоритетнее слова «модераци…» в строке-блобе. Юнит-тест
  read-status.test.mjs (11 кейсов: регистр/nbsp/блоб/приоритет/мусор).
- `readModerationStatus` (cabinet.js) + `runReadStatus` (runner.js) + режим `read-status`
  в bin/run.js: открывает список кампаний, находит ряд по id, читает статус; при отказе —
  причину из слайд-модалки «Причины» (#slide-modal-root). Отдаёт JSON
  {ok, moderationStatus, reason?, campaignId} — его уже разбирает RobotResult (3.4).
  🔴 Читалка кабинета помечена <FLOW-CONFIRM>: DOM-обёртки ряда/модалки собраны по
  живой разведке «Сессии 6» (FLOW-FINDINGS.md), но именно этим кодом live ещё не прогнаны —
  подтвердить на следующем цикле модерации с разрешения владельца. Парсер от DOM не зависит.

## 3.6 — пересдача отклонённой кампании (rejected → queued + документ модератору)
- Миграция 000013: колонка `client_tg_campaigns.moderator_file_path` (varchar 500 NULL,
  после media_path) + CHANGELOG схемы v8.90; rls-reviewer прогнан — чисто (nullable-колонка
  данных, не tenant-скоуп, RLS не меняется). Модель — fillable.
- Endpoint `POST /api/telegram/campaigns/{id}/resubmit`: только отклонённую (иначе 422);
  правки ad_text/ad_link/ord_category (валидация как store) + опц. файл модератору
  (.png/.jpeg/.jpg/.pdf ≤10 МБ, сохраняется на диск local). Успех: поля обновлены,
  status_reason и mts_campaign_id очищены (робот создаст новую кампанию в кабинете),
  rejected→queued, dispatch RunTelegramCampaignJob afterCommit. В бою — гейт аудитории
  + freeze budget_cap_rub заново (при отказе бронь вернул опросчик 3.4; freeze идемпотентен
  по ACTIVE-холду), нехватка → 409, остаётся rejected. Песочница — без брони.
- Робот: task `moderatorFile` (task.js passthrough + TelegramRobotRunner.taskPayload),
  RunTelegramCampaignJob отдаёт moderator_file_path; fillAd грузит файл в поле «Комментарий
  для модератора» (третий file-input, accept pdf) — помечено <FLOW-CONFIRM> (live не прогнан).
- Тесты: ResubmitTest.php (7 кейсов: rejected→queued+очистка+джоб / файл сохранён /
  не-rejected→422 / live 409 / валидация / .exe→422 / чужой→404); Node task.test.js (+2).

## 4.3 — уведомление об одобрении (бэкенд был готов в 3.4, добор покрытия)
- ApproveNotifyTest.php (4 кейса): notifyTelegramCampaignApproved шлёт in-app всем активным
  юзерам тенанта без pref-гейта, тело «одобрена/показы пошли»; неактивный/чужой не получают.

TDD. Приёмка (моя область, чистый прогон): весь ClientTg 150/150, робот 78/78,
ApproveNotify 4/4; phpstan 0, deptrac 0, pint чисто.

Приёмочный лист 3.6 — docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
Осталось в Этапе 4: фронтенд 4.1/4.2/4.4/4.5 (Vue-экран + Vitest) — НЕ начато.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:29:23 +03:00

84 lines
3.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Задача 4.3 — уведомление об ОДОБРЕНИИ/запуске Telegram-кампании (зеркало отказа
* 5.2). Опросчик вердикта (задача 3.4) при `approved` зовёт
* notifyTelegramCampaignApproved: in-app всем активным юзерам тенанта, БЕЗ
* pref-гейта (важное операционное сообщение доходит всегда, как отказ).
*/
uses(RefreshDatabase::class);
function tgApproveCampaign(int $tenantId): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_LAUNCHED,
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
}
it('одобрение шлёт in-app всем активным юзерам тенанта с текстом «одобрена/запущена»', function () {
$tenant = Tenant::factory()->create();
$u1 = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$u2 = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::where('user_id', $u1->id)->exists())->toBeTrue()
->and(InAppNotification::where('user_id', $u2->id)->exists())->toBeTrue();
$notif = InAppNotification::where('user_id', $u1->id)->first();
expect($notif->title)->toContain('одобрена')
->and($notif->body)->toContain('показы пошли');
});
it('уведомление об одобрении доходит даже без включённых настроек уведомлений', function () {
$tenant = Tenant::factory()->create();
// Дефолтные настройки фабрики НЕ содержат события tg_campaign_approved —
// проверяем, что уведомление доходит без pref-гейта.
$user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::where('user_id', $user->id)->exists())->toBeTrue();
});
it('неактивный пользователь уведомление об одобрении НЕ получает', function () {
$tenant = Tenant::factory()->create();
User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::count())->toBe(0);
});
it('изоляция тенанта: чужой активный юзер уведомление НЕ получает', function () {
$tenant = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$foreign = User::factory()->create(['tenant_id' => $tenantB->id, 'is_active' => true]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::where('user_id', $foreign->id)->exists())->toBeFalse();
});