Files
portal/app/tests/Feature/ClientTg/RobotRunnerTest.php
T
Дмитрий b23d380d69 feat(телеграм-модуль): мост Laravel → Node-робот — статус-машина, обёртка робота, джоб, песочница
Сессия 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>
2026-07-27 16:51:12 +03:00

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));
});