b23d380d69
Сессия 2 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Сердце модуля: по кампании запускаем браузер-робота кабинета МТС (у МТС нет API),
парсим его JSON и ведём статус-машину. В тестах робот замокан node-скриптами.
- Статус-машина Campaign: draft → queued → running → (draft_ready | launched |
failed | rejected); недопустимый переход — DomainException, статус не меняется
(глобальное исключение, а не доменный класс: deptrac Model: [], ADR-005).
Терминальные статусы без исходящих (resubmit — Сессия 5).
- TelegramRobotRunner: формирует task.json и запускает `node bin/run.js --task <file>`
через Symfony Process, разбирает {ok,matched,launched,campaignId}. Таймаут и
непарсабельный вывод → аккуратный RobotResult::failed, воркер не падает.
- RunTelegramCampaignJob: queued → running, собирает кандидатов, пишет временный
файл номеров (ПДн, чистится в finally), зовёт робота ВНЕ транзакции, итог пишет
под tenant-контекстом (SET LOCAL). ok+draft → draft_ready + matched; ошибка → failed
(причина в журнал — колонки нет, как у СМС-близнеца; показ клиенту — Сессия 5).
Идемпотентно: работает только со статусом queued. Денег в песочнице не трогает.
- Песочница config/client_tg.php + .env.example: TG_SANDBOX (по умолчанию ВКЛ) —
робот только draft, деньги/запуск выключены. Живой режим — осознанно в Сессии 6.
Реальная точка входа робота — bin/run.js --task (в плане текст «src/runner.js» —
вольная стенография). Живое списание отложено на Сессию 6: робот пока не возвращает
фактическую стоимость.
Проверки: 50 из 50 Pest зелёные (24 новых), phpstan 0 по своему коду, робот в тестах
замокан (кабинет МТС не тронут).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
4.3 KiB
PHP
123 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Services\ClientTg\RobotResult;
|
|
use App\Services\ClientTg\TelegramRobotRunner;
|
|
|
|
/**
|
|
* Обёртка вызова робота (план §Сессия 2, задача 2.2). Робот ЗАМОКАН node-скриптами
|
|
* из tests/Fixtures/tg-robot, печатающими известный JSON. Проверяем: формирование
|
|
* task.json, разбор {ok,matched,launched,campaignId}, и что таймаут/мусорный вывод
|
|
* дают аккуратный ok:false, а не падение воркера.
|
|
*/
|
|
|
|
/** Указывает обёртку на конкретный fixture-скрипт вместо реального робота. */
|
|
function useRobotFixture(string $script, float $timeout = 30.0): void
|
|
{
|
|
config()->set('client_tg.robot', [
|
|
'node' => 'node',
|
|
'script' => base_path('tests/fixtures/tg-robot/'.$script),
|
|
'cwd' => base_path('tests/fixtures/tg-robot'),
|
|
'timeout' => $timeout,
|
|
]);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
function sampleTaskParams(): array
|
|
{
|
|
return [
|
|
'mode' => 'draft',
|
|
'phonesFile' => '/tmp/phones-tg-test.txt',
|
|
'adText' => 'Приходите к нам',
|
|
'budgetRub' => '1000.00',
|
|
'buttonUrl' => 'https://example.test',
|
|
'ordCategory' => 'Размещение рекламы',
|
|
];
|
|
}
|
|
|
|
it('строит task.json со всеми полями задания робота', function () {
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$payload = $runner->taskPayload(sampleTaskParams());
|
|
|
|
expect($payload)->toMatchArray([
|
|
'mode' => 'draft',
|
|
'phonesFile' => '/tmp/phones-tg-test.txt',
|
|
'adText' => 'Приходите к нам',
|
|
'budgetRub' => '1000.00',
|
|
'buttonUrl' => 'https://example.test',
|
|
'ordCategory' => 'Размещение рекламы',
|
|
]);
|
|
});
|
|
|
|
it('подставляет дефолты mode=draft и категорию ОРД', function () {
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$payload = $runner->taskPayload([
|
|
'phonesFile' => '/tmp/x.txt',
|
|
'adText' => 'текст',
|
|
'budgetRub' => '500.00',
|
|
'buttonUrl' => 'https://a.test',
|
|
]);
|
|
|
|
expect($payload['mode'])->toBe('draft')
|
|
->and($payload['ordCategory'])->toBe('Размещение рекламы')
|
|
->and($payload['mediaFile'])->toBeNull();
|
|
});
|
|
|
|
it('запускает робота и разбирает успешный JSON', function () {
|
|
useRobotFixture('print-ok.js');
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$res = $runner->run(sampleTaskParams());
|
|
|
|
expect($res)->toBeInstanceOf(RobotResult::class)
|
|
->and($res->ok)->toBeTrue()
|
|
->and($res->matched)->toBe(512)
|
|
->and($res->launched)->toBeFalse()
|
|
->and($res->campaignId)->toBe('draft');
|
|
});
|
|
|
|
it('штатный отказ робота (exit 1) → ok:false с причиной, без исключения', function () {
|
|
useRobotFixture('print-fail.js');
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$res = $runner->run(sampleTaskParams());
|
|
|
|
expect($res->ok)->toBeFalse()
|
|
->and($res->step)->toBe('upload')
|
|
->and($res->reason)->toContain('Недостаточно номеров');
|
|
});
|
|
|
|
it('непарсабельный вывод → аккуратный ok:false, воркер не падает', function () {
|
|
useRobotFixture('print-garbage.js');
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$res = $runner->run(sampleTaskParams());
|
|
|
|
expect($res->ok)->toBeFalse()
|
|
->and($res->reason)->not->toBeNull();
|
|
});
|
|
|
|
it('таймаут робота → аккуратный ok:false, воркер не падает', function () {
|
|
useRobotFixture('sleep.js', timeout: 1.0);
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$res = $runner->run(sampleTaskParams());
|
|
|
|
expect($res->ok)->toBeFalse()
|
|
->and($res->reason)->not->toBeNull();
|
|
});
|
|
|
|
it('НЕ оставляет временный task-файл после запуска (чистка)', function () {
|
|
useRobotFixture('print-ok.js');
|
|
$runner = app(TelegramRobotRunner::class);
|
|
|
|
$before = glob(sys_get_temp_dir().'/tg-task-*.json') ?: [];
|
|
$runner->run(sampleTaskParams());
|
|
$after = glob(sys_get_temp_dir().'/tg-task-*.json') ?: [];
|
|
|
|
expect(count($after))->toBeLessThanOrEqual(count($before));
|
|
});
|