18135b47b4
Закрывает Этап 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>
61 lines
2.2 KiB
JavaScript
61 lines
2.2 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { parseTask } from '../src/task.js';
|
|
|
|
const base = {
|
|
phonesFile: 'phones.txt', adText: 'Короткий текст',
|
|
budgetRub: 1000, buttonUrl: 'https://site.ru',
|
|
mode: 'draft',
|
|
};
|
|
|
|
test('parseTask принимает корректное задание', () => {
|
|
const t = parseTask(base);
|
|
assert.equal(t.mode, 'draft');
|
|
assert.equal(t.budgetRub, 1000);
|
|
});
|
|
|
|
test('parseTask отвергает неизвестный режим', () => {
|
|
assert.throws(() => parseTask({ ...base, mode: 'xxx' }), /mode/);
|
|
});
|
|
|
|
test('parseTask требует phonesFile', () => {
|
|
const { phonesFile, ...rest } = base;
|
|
assert.throws(() => parseTask(rest), /phonesFile/);
|
|
});
|
|
|
|
test('parseTask отвергает пустой текст', () => {
|
|
assert.throws(() => parseTask({ ...base, adText: '' }), /adText/);
|
|
});
|
|
|
|
test('parseTask требует budgetRub > 0', () => {
|
|
assert.throws(() => parseTask({ ...base, budgetRub: 0 }), /budgetRub/);
|
|
});
|
|
|
|
test('parseTask подставляет дефолт ordCategory', () => {
|
|
const t = parseTask(base);
|
|
assert.equal(t.ordCategory, 'Размещение рекламы');
|
|
});
|
|
|
|
test('parseTask пробрасывает заданную ordCategory', () => {
|
|
const t = parseTask({ ...base, ordCategory: 'Другая категория' });
|
|
assert.equal(t.ordCategory, 'Другая категория');
|
|
});
|
|
|
|
test('parseTask НЕ требует adTitle/ordCompanyName (полей в кабинете нет)', () => {
|
|
// Задание без adTitle/ordCompanyName/cpmRub должно проходить.
|
|
const t = parseTask(base);
|
|
assert.equal(t.adTitle, undefined);
|
|
assert.equal(t.ordCompanyName, undefined);
|
|
assert.equal(t.cpmRub, undefined);
|
|
});
|
|
|
|
test('parseTask пробрасывает moderatorFile (документ модератору, пересдача 3.6)', () => {
|
|
const t = parseTask({ ...base, moderatorFile: 'C:/tmp/licenziya.pdf' });
|
|
assert.equal(t.moderatorFile, 'C:/tmp/licenziya.pdf');
|
|
});
|
|
|
|
test('parseTask: без moderatorFile → null (документ не приложили)', () => {
|
|
const t = parseTask(base);
|
|
assert.equal(t.moderatorFile, null);
|
|
});
|