Files
portal/app/tests/Feature/ClientTg/ResubmitTest.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

161 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
/**
* Задача 3.6 — пересдача отклонённой Telegram-кампании.
*
* Отклонённую модерацией кампанию (status=rejected, задача 3.4) клиент правит
* (текст/ссылка/категория ОРД + при желании документ модератору) и отправляет
* заново: rejected → queued + обычный джоб робота (создаст в кабинете новую
* кампанию с правками и догрузит файл на шаге «Сообщение»). Деньги: в бою бронь
* ставится заново (при отказе её вернул опросчик 3.4). Приёмка — файл
* docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Отклонённая кампания с причиной и id кабинета (self-contained). */
function rejectedCampaign(int $tenantId, int $userId, array $overrides = []): Campaign
{
return Campaign::create(array_merge([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_REJECTED,
'status_reason' => 'Ссылка недоступна',
'mts_campaign_id' => '2231134',
'ad_text' => 'Старый текст',
'ad_link' => 'https://t.me/old_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 400,
'estimated_cost_rub' => '0.00',
'created_by' => $userId,
], $overrides));
}
/** @return array<string, mixed> валидный payload пересдачи. */
function resubmitPayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Исправленный текст, ООО «Ромашка»',
'ad_link' => 'https://t.me/new_channel',
], $overrides);
}
it('пересдача (песочница): rejected→queued, правки применены, причина и id кабинета очищены, джоб поставлен', function () {
Queue::fake();
$c = rejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertOk()
->assertJsonPath('status', Campaign::STATUS_QUEUED);
$fresh = Campaign::find($c->id);
expect($fresh->status)->toBe(Campaign::STATUS_QUEUED)
->and($fresh->ad_text)->toBe('Исправленный текст, ООО «Ромашка»')
->and($fresh->ad_link)->toBe('https://t.me/new_channel')
->and($fresh->status_reason)->toBeNull()
->and($fresh->mts_campaign_id)->toBeNull();
Queue::assertPushed(RunTelegramCampaignJob::class);
// Песочница — заморозки нет.
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});
it('пересдача с файлом модератору: файл сохранён, путь записан в moderator_file_path', function () {
Queue::fake();
Storage::fake('local');
$c = rejectedCampaign($this->tenant->id, $this->user->id);
$file = UploadedFile::fake()->create('licenziya.pdf', 120, 'application/pdf');
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([
'moderator_file' => $file,
]))->assertOk();
$path = Campaign::find($c->id)->moderator_file_path;
expect($path)->toBeString()->not->toBeEmpty();
Storage::disk('local')->assertExists($path);
});
it('пересдать можно только отклонённую: черновик → 422, джоб не ставится', function () {
Queue::fake();
$c = rejectedCampaign($this->tenant->id, $this->user->id, [
'status' => Campaign::STATUS_DRAFT,
'status_reason' => null,
'mts_campaign_id' => null,
]);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertStatus(422);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_DRAFT);
Queue::assertNothingPushed();
});
it('пересдача (реальный режим, денег не хватает) → 409, кампания остаётся rejected, джоб не ставится', function () {
config(['client_tg.sandbox' => false]);
Queue::fake();
app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test');
// Гейт аудитории считает кандидатов до брони — засеваем с запасом над порогом.
for ($i = 0; $i < 367; $i++) {
Contact::create([
'tenant_id' => $this->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
$c = rejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertStatus(409);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_REJECTED);
Queue::assertNothingPushed();
});
it('валидация: без текста/ссылки — 422', function () {
$c = rejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", [])
->assertStatus(422)
->assertJsonValidationErrors(['ad_text', 'ad_link']);
});
it('неверный формат файла (.exe) → 422', function () {
Storage::fake('local');
$c = rejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([
'moderator_file' => UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'),
]))->assertStatus(422)->assertJsonValidationErrors('moderator_file');
});
it('изоляция тенанта: пересдача чужой кампании → 404', function () {
$tenantB = Tenant::factory()->create();
$foreign = rejectedCampaign($tenantB->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$foreign->id}/resubmit", resubmitPayload())
->assertStatus(404);
});