Files
portal/app/tests/Feature/ClientTg/EstimateApiTest.php
T
Дмитрий 76cb44b91d feat(телеграм-реклама): пачка 4б — мастер шагов, живой счётчик охвата и серверная ручка оценки
Замечание владельца: «мастер шагов вместо простыни» и «живой счётчик охвата
вместо кнопки Рассчитать». Форма была одна длинная, охват узнавался только по
нажатию кнопки.

Экран:
- мастер из четырёх шагов: Кому показываем → Объявление → Деньги → Проверка;
- живой счётчик охвата на первом шаге, пересчитывается сам при смене источника
  людей, периода или списка номеров (с задержкой, чтобы не дёргать сервер
  на каждую букву);
- кнопки «Рассчитать» больше нет: «Запустить» создаёт кампанию, прикладывает
  картинку и отправляет в кабинет одним нажатием;
- на шаге «Деньги» рядом смета и остаток баланса — нехватка денег видна ДО
  запуска, а не отказом после;
- сводка на последнем шаге повторяет всё выбранное.

Сервер — новая ручка POST /api/telegram/campaigns/estimate:
🔴 Живой счётчик обязан считать на каждое изменение поля, а охват до сих пор
возвращала только POST /campaigns — и она СОЗДАЁТ черновик в базе. Через неё
счётчик наплодил бы десятки кампаний-призраков в списке клиента. Новая ручка
только читает: ни записи, ни денег, ни робота. На это стоит отдельный тест.

Ручка считает ТЕМ ЖЕ кодом, что и создание кампании (общее тело выборки из
сделок вынесено на оба входа TelegramAudienceService). Отдельный тест сверяет
два ответа: разойдись они — клиент видел бы на экране одно число, а платил
по другому.

🔴 Запуск запрещён, когда людей меньше 367 — правило площадки, не наше. Тот же
предохранитель стоит на сервере (AudienceGateTest); на экране он объясняет
заранее, вместо отказа после оплаты.

Новых прав в базе ручка не требует: читает строго то же, что уже читает
создание кампании, и не пишет никуда.

Тесты: +EstimateApiTest (11 на PHP), +telegram-master-shagov (27 на экранах).
14 прежних проверок формы переехали в набор мастера вместе с кодом — сперва
переписаны на новом месте и там проверены, потом убраны со старого. Устарели
по смыслу только две («Рассчитать создаёт черновик», «правка сбрасывает
смету»): считать вручную больше нечего, устареть расчёту негде.

Замер: экраны 240 файлов, 1829 тестов, 0 падений (было 239/1813); телеграм-
модуль на PHP 292 теста, 0 падений (было 281); статанализ 0 (базовая линия
пересобрана — добавлен только новый Pest-файл, удалений нет); типы 6 чужих
ошибок вместо 7; pint чист.

На боевой НЕ выкачено. Глазами не принято — приёмка в пачке 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:04:07 +03:00

184 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Ручка оценки охвата (пачка 4б): «сколько людей увидит рекламу и во сколько это
* обойдётся» — БЕЗ создания кампании.
*
* 🔴 Зачем она понадобилась. Живой счётчик охвата на экране должен пересчитываться
* на каждое изменение поля. До сих пор охват возвращала ТОЛЬКО `POST /campaigns` —
* а она создаёт черновик в базе. Дёргать её на каждую букву значило бы засорить
* `client_tg_campaigns` мусорными черновиками (и у клиента в списке кампаний
* появлялись бы призраки). Отсюда отдельная ручка, которая считает и НИЧЕГО не пишет.
*
* 🔴 Ручка обязана считать ТЕМИ ЖЕ правилами, что и создание черновика. Иначе клиент
* увидит на экране одно число, а заплатит по другому — это ровно тот класс вранья,
* из-за которого владелец писал замечание З-6.
*
* Синтетические номера 7999… — реальные НИКОГДА (это ПДн).
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Столько контактов в своей базе тенанта. */
function zavestiKontakty(int $tenantId, int $skolko, int $ot = 0): void
{
for ($i = $ot; $i < $ot + $skolko; $i++) {
Contact::create([
'tenant_id' => $tenantId,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
}
it('🔴 считает охват и смету, НЕ создавая кампанию в базе', function () {
zavestiKontakty($this->tenant->id, 5);
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
]);
$otvet->assertOk()->assertJsonStructure(['planned_count', 'estimated_cost_rub', 'min_count', 'enough']);
expect($otvet->json('planned_count'))->toBe(5);
// Главное: в базе не осталось ни одного черновика. Иначе живой счётчик за минуту
// работы клиента наплодил бы десятки кампаний-призраков.
expect(Campaign::count())->toBe(0);
});
it('смета совпадает с той, что даёт создание черновика — иначе счётчик врёт перед оплатой', function () {
zavestiKontakty($this->tenant->id, 7);
$ocenka = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
$chernovik = $this->postJson('/api/telegram/campaigns', [
'ad_text' => 'Заходите',
'ad_link' => 'https://t.me/example',
'audience_kind' => Campaign::AUDIENCE_BASE,
'budget_cap_rub' => '1000.00',
])->assertCreated();
expect($ocenka->json('planned_count'))->toBe($chernovik->json('planned_count'));
expect($ocenka->json('estimated_cost_rub'))->toBe($chernovik->json('estimated_cost_rub'));
});
it('сделки за период: то, что старше окна, в охват не идёт', function () {
Deal::factory()->create([
'tenant_id' => $this->tenant->id, 'phone' => '79990000001',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(2),
]);
Deal::factory()->create([
'tenant_id' => $this->tenant->id, 'phone' => '79990000002',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(30),
]);
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_DEALS,
'audience_days' => 5,
])->assertOk();
expect($otvet->json('planned_count'))->toBe(1);
});
it('свой список: считает по присланным номерам и схлопывает дубли', function () {
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_LIST,
'phones' => ['79990000001', '8 999 000 00 01', '79990000002', 'ерунда'],
])->assertOk();
// Первые два — один и тот же человек в разных записях, третий отдельный, четвёртый не номер.
expect($otvet->json('planned_count'))->toBe(2);
// И тут тоже ничего не записалось — ни кампании, ни номеров.
expect(Campaign::count())->toBe(0);
});
it('отписавшихся в охват не считает — как и создание черновика', function () {
zavestiKontakty($this->tenant->id, 3);
Optout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000000']);
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
expect($otvet->json('planned_count'))->toBe(2);
});
it('чужих контактов не видит', function () {
$chuzhoy = Tenant::factory()->create();
zavestiKontakty($chuzhoy->id, 40, 500);
zavestiKontakty($this->tenant->id, 3);
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
expect($otvet->json('planned_count'))->toBe(3);
});
it('говорит, хватает ли людей для площадки — 367 это её правило, не наше', function () {
zavestiKontakty($this->tenant->id, 5);
$malo = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
expect($malo->json('min_count'))->toBe(367);
expect($malo->json('enough'))->toBeFalse();
zavestiKontakty($this->tenant->id, 400, 1000);
$hvatit = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
expect($hvatit->json('enough'))->toBeTrue();
});
it('пустая своя база — честный ноль, а не отказ', function () {
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
expect($otvet->json('planned_count'))->toBe(0);
expect($otvet->json('enough'))->toBeFalse();
});
it('незнакомый вид аудитории отвергается, а не считается нулём молча', function () {
$this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => 'chto-to-svoyo',
])->assertStatus(422);
});
it('сделки без числа дней отвергаются — иначе молча посчитаем за всё время', function () {
$this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_DEALS,
])->assertStatus(422);
});
it('без входа в кабинет ручка недоступна', function () {
app('auth')->forgetGuards();
$this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertStatus(401);
});