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

86 lines
3.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Песочница и конфиг (план §Сессия 2, задача 2.4). Флаг TG_SANDBOX управляет
* режимом робота (draft/live) и читается через config('client_tg.sandbox').
*/
function sandboxQueuedCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Текст объявления',
'ad_link' => 'https://example.test',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 1,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => '79990000009',
]);
return $campaign;
}
/** @return array<string, mixed> захваченные параметры робота */
function runWithSandbox(bool $sandbox): array
{
config()->set('client_tg.sandbox', $sandbox);
$tenant = Tenant::factory()->create();
$campaign = sandboxQueuedCampaign($tenant->id);
$captured = [];
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use (&$captured) {
$captured = $params;
return new RobotResult(ok: true, matched: 400);
});
app()->instance(TelegramRobotRunner::class, $mock);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
return $captured;
}
it('по умолчанию песочница включена (TG_SANDBOX=true)', function () {
// Конфиг грузится из config/client_tg.php с дефолтом env('TG_SANDBOX', true).
expect(config('client_tg.sandbox'))->toBeTrue();
});
it('конфиг робота имеет дефолты (script → bin/run.js, node, cwd, timeout)', function () {
// base_path() склеивает через '/', поэтому дефолт всегда оканчивается 'bin/run.js'.
expect(config('client_tg.robot.script'))->toEndWith('bin/run.js')
->and(config('client_tg.robot.node'))->toBe('node')
->and(config('client_tg.robot.cwd'))->toContain('mts-telegram-ads')
->and(config('client_tg.robot.timeout'))->toBeGreaterThan(0);
});
it('песочница вкл → робот вызывается в mode=draft', function () {
$captured = runWithSandbox(true);
expect($captured['mode'])->toBe('draft');
});
it('песочница выкл → mode=live (флаг реально управляет режимом)', function () {
$captured = runWithSandbox(false);
expect($captured['mode'])->toBe('live');
});