Files
portal/app/tests/Feature/ClientTg/LaunchIdempotencyTest.php
T
Дмитрий b3a86e69ad feat(телеграм-реклама): Этап 1 — безопасность и защита входа модуля «по своей базе»
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты):

- #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать
  телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию —
  только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js.
- #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные
  формы вычищаются), сравнение по голому 7XXXXXXXXXX.
- #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days
  max:365; store возвращает dropped_count (сколько номеров не распозналось).
- #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate —
  два воркера сериализуются на блокировке строки (защита от гонки).
- #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги
  под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог —
  config client_tg.auto_batch_threshold (367). В песочнице гейта нет.

TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0.
Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами,
чтобы дойти до проверки денег после нового гейта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 06:37:25 +03:00

103 lines
3.9 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;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
/**
* Задача 1.4 (находка #14) — идемпотентность запуска на уровне БД через lockForUpdate.
* Проблема: статус-гвард («не queued → no-op») защищает от ПОСЛЕДОВАТЕЛЬНОЙ
* переотправки, но НЕ от гонки двух воркеров, читающих queued-строку ОДНОВРЕМЕННО
* до того, как первый переведёт кампанию в running. Фикс — читать строку кампании
* в Фазе A с lockForUpdate внутри существующей tenantTx-транзакции.
*/
beforeEach(function () {
config()->set('client_tg.sandbox', true);
});
/** Кампания-список с двумя фейковыми номерами (ПДн в тестах не используем). */
function idemQueuedCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
/** Подменяет раннер моком, возвращающим $result; захватывает переданные params. */
function idemMockRobot(RobotResult $result, ?array &$captured = null): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use ($result, &$captured) {
$captured = $params;
return $result;
});
app()->instance(TelegramRobotRunner::class, $mock);
}
it('Фаза A читает queued-кампанию с блокировкой строки (SELECT ... FOR UPDATE)', function () {
$tenant = Tenant::factory()->create();
$campaign = idemQueuedCampaign($tenant->id);
idemMockRobot(new RobotResult(ok: true, matched: 2));
DB::enableQueryLog();
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$queries = DB::getQueryLog();
DB::disableQueryLog();
$lockedRead = collect($queries)->contains(function ($q) {
$sql = strtolower($q['query']);
return str_contains($sql, 'client_tg_campaigns')
&& str_contains($sql, 'for update');
});
expect($lockedRead)->toBeTrue();
});
it('повторный запуск (redelivery) не зовёт робота второй раз', function () {
$tenant = Tenant::factory()->create();
$campaign = idemQueuedCampaign($tenant->id);
$runs = 0;
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function () use (&$runs) {
$runs++;
return new RobotResult(ok: true, matched: 2);
});
app()->instance(TelegramRobotRunner::class, $mock);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); // повтор — уже draft_ready → no-op
expect($runs)->toBe(1);
});