Files
portal/app/tests/Feature/ClientTg/EstimateApiTest.php
T
Дмитрий f2ac3f4c7f fix,телеграм: подсказка обещала цену вдвое ниже настоящей + образец файла для базы номеров
Приёмка владельца на боевом, два замечания из трёх (третье — про рекламный
кошелёк и заморозку — отложено, кусок большой).

1. Подсказка «?» у поля медиа обещала «600 ₽ за тысячу с картинкой, 680 ₽ с
   видео». Клиент платит 1008 и 1142,40 ₽. Числа были вбиты в текст руками и
   протухли в ту минуту, когда миграция client_tg_cena_po_media поменяла тариф.

   Лечение в корень, а не подстановкой верных чисел: цену называет тот, кто её
   считает. Ручка оценки отдаёт ceny_za_tysyachu по каждому виду медиа
   (себестоимость × наценка), подсказка собирается из них функцией
   podskazkaProMedia. Пока сервер не ответил — текст без единой цифры: подставить
   «примерные» числа значило бы вернуть ровно эту беду.

   Прайс держим отдельно от охвата: охват на ошибке гасим (устаревший хуже
   никакого), а цены от настроек аудитории не зависят — иначе подсказка мигала бы
   на каждой опечатке в поле.

2. Замечание дословно: «нету скачать файл с примером как надо заполнить для нас
   файл». Кнопка «Скачать образец» рядом с полем загрузки — подпись клиент читает
   уже ПОСЛЕ того, как файл отклонили. Пять строк, написания разные (с плюсом, с
   восьмёркой, со скобками), номера синтетические 7999.

   Заголовка-строки в образце намеренно нет: разборщик нормализует первый столбец
   КАЖДОЙ строки, и слово «Телефон» попало бы в «не похоже на номер» — наш
   собственный образец показал бы клиенту ошибку.

Проверено вырезанием, а не только зелёным:
- вернул в подсказку вбитые 600/680 — покраснели 4 датчика, включая тот, что
  прямо запрещает эти два числа;
- вставил в образец строку-заголовок — покраснели 3.

Полный прогон поймал две мои же поломки, обе настоящие:
- значок mdi-file-download-outline на новой кнопке ОТСУТСТВОВАЛ в карте Lucide —
  на экране стал бы вопросом в кружке. Поймал сторож значков, у которого вчера
  опустошили список поблажек. Добавлен (Download — точного «файла со стрелкой» в
  Lucide нет);
- датчик подсказок ждал PODSKAZKI.media строкой, а её больше нет.

Замеры: экраны 245 файлов / 1870 тестов / 0 падений; телеграм-модуль 327 / 0;
статанализ 0; формат чисто; типы 6 — все в чужих файлах, столько же было до;
сборка 3,80 с. Счётчик в phpstan-baseline сдвинут 13→15 (новые тесты на Pest),
diff проверен глазами: изменилась ровно эта строка.

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

220 lines
10 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\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\ClientTg\Tariff;
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);
});
/**
* Цена за тысячу показов — ОТ СЕРВЕРА, а не вбитая в текст экрана.
*
* 🔴 Откуда взялось (приёмка владельца 03.08.2026). Подсказка «?» у поля медиа обещала
* «600 ₽ за тысячу с картинкой, 680 ₽ с видео». Настоящие клиентские цены на тот момент —
* 1008 и 1142,40 ₽: числа были вбиты в текст руками и протухли в ту же минуту, когда
* миграция client_tg_cena_po_media поменяла тариф. Обещать клиенту цену вдвое ниже
* настоящей — это не опечатка, это неверная цена в кабинете.
*
* Лечение: цену называет тот же, кто её считает. Экран не имеет права знать числа.
*/
it('оценка отдаёт клиентскую цену за тысячу по каждому виду медиа', function () {
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
// Себестоимость 0.480 / 0.720 / 0.816 ₽ за показ × наценка 1.40 × 1000 показов.
expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1008.00');
expect($otvet->json('ceny_za_tysyachu.video'))->toBe('1142.40');
});
it('цена за тысячу едет из таблицы тарифа — поменяли тариф, поменялся ответ', function () {
Tariff::where('media_kind', Campaign::MEDIA_IMAGE)->update(['price_rub' => '1.000']);
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertOk();
// 1.000 × 1.40 × 1000 = 1400. Если бы число было вбито в код, ответ не сдвинулся бы.
expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1400.00');
// Соседние виды медиа при этом не поехали.
expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
});