Files
portal/app/tests/Feature/ClientTg/ConservativeReleaseTest.php
T
Дмитрий 6f469fb13d feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления
Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере)
оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске,
возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund).
Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены).
Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён
(сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом.

Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы,
а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС:
- «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет):
  ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting,
  фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee.
  Таблицы client_tg_senders/_settings дропнуты.
- мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны
  сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены).
- client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»).
- лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс».

Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media
(картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле
загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile
→ cabinet.js fillAdMedia) — не хватало только приёма файла от клиента.

Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп
СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному):
Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view
19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот
bots/mts-telegram-ads НЕ трогали.

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

142 lines
7.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\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Ревью-фикс F5 (Мост #2/#3): единое ОСТОРОЖНОЕ правило возврата денег — как уборщик.
* Если у кампании есть mts_campaign_id (черновик в кабинете реально создан), она МОГЛА
* уйти на модерацию → деньги вслепую НЕ возвращаем (иначе оплатим показы из своего
* кармана), помечаем needs_review — списание с ОБЩЕГО баланса тенанта держится, refund
* НЕ происходит. Без id — чистый провал: failed + возврат списания на баланс.
* Всё в боевом режиме (sandbox=false). Синтетические номера 7999… — реальных ПДн нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
function crCampaign(int $tenantId, string $status, ?string $mtsId = null): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '500.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create(['tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'phone' => $phone]);
}
return $campaign;
}
function crMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
/** Сколько возвратов сметы прошло по кампании (сальдо-источник правды — проводки). */
function crRefundCount(int $campaignId): int
{
return BalanceTransaction::query()
->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $campaignId)
->count();
}
it('finalize: провал робота с id кабинета → needs_review, списание держится, refund НЕ произошёл', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
crMockRobot(RobotResult::failed('Оборвалось на подтверждении', 'confirm', '2231200'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->mts_campaign_id)->toBe('2231200')
->and(crRefundCount($campaign->id))->toBe(0) // возврата не было
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00'); // списание держится
});
it('finalize: провал робота БЕЗ id кабинета → failed, refund произошёл, баланс восстановлен', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
crMockRobot(RobotResult::failed('Мало номеров «не МТС»', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and(crRefundCount($campaign->id))->toBe(1) // возврат произошёл
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00'); // баланс восстановлен
});
it('failed(): running с id кабинета → needs_review, списание держится, refund НЕ произошёл', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING, mtsId: '2231201');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and(crRefundCount($campaign->id))->toBe(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
});
it('failed(): running без id кабинета → failed, refund произошёл, баланс восстановлен', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING);
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and(crRefundCount($campaign->id))->toBe(1)
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00');
});
it('failed(): уже moderating → статус не трогаем, refund НЕ произошёл', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_MODERATING, mtsId: '2231202');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and(crRefundCount($campaign->id))->toBe(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
});