Files
portal/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php
T
Дмитрий 76e4862f95 fix(реклама): удалённый черновик уносил запертые деньги, отказы говорили по-машинному
Приёмка Яндекса, восемь бесплатных карточек. Две проверены на живых деньгах
боевой кампании #13, шесть — сторожами на тестовой базе.

Нашлась настоящая дыра. Если запуск оборвался на полпути, деньги остаются
заперты нарочно: в Директе кампания уже завелась и может крутить показы,
клиент дожмёт «Запустить». Но выглядит она обычным черновиком, а черновик
разрешено удалить. Кампании после этого нет, а запись о заморозке ссылается
на её номер — возврат ищут по кампании и не находят. Деньги клиента заперты
навсегда, молча, без единой ошибки в журнале. Теперь такой черновик удалить
нельзя, и портал объясняет, что сделать вместо этого.

Второе: портал отказывал на языке разработчика — «Поле file должно быть
файлом одного из типов», «не может быть больше 10240 Кбайт». Клиент не знает,
что такое поле file, и не считает мегабайты в килобайтах. Четыре отказа по
файлам переписаны по образцу, который в этом же модуле уже был: «Нужен ровно
300×250. Вы загрузили 100×100.»

Сторожа приёмки:
- чужая кампания отдаёт 404 на всех двадцати одном пути, ни одного 403;
- сколько раз ни жми «Запустить» — заморозка одна;
- отклонённую в обход «Исправить» не запустить, денег не трогает;
- черновик с запертыми деньгами не уносит их;
- списание посреди запуска другой кампании — касса сходится;
- отказы по файлам сказаны человеческим языком.

Каждый принят красным: изоляция проверена подстановкой хозяина вместо чужого,
защита запуска — вырезанием прямо в коде, гонка — снятием списания из середины.

Прогоны: 413 тестов рекламы зелёные, статанализ 0.

Заодно форматирование теста минимума площадки — форматтер перенёс длинное имя
класса в шапку уже после записи 80987996e.
2026-08-05 07:40:27 +03:00

86 lines
4.3 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\AdCampaign;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\YandexMinimumSpend;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
/**
* Минимум площадки — в мастере, а не на последнем шаге.
*
* 🔴 Живой отказ 30.07.2026: Директ не берёт кампанию, у которой расход за период ниже
* его минимума, и отвечает по-английски — «Budget for this period cannot be less than
* 600 rub.». Клиент видел этот текст ПОСЛЕ того, как собрал аудиторию (15 часов),
* залил пятнадцать картинок и нажал «Запустить». Понять из него, чего от него хотят,
* невозможно.
*
* Минимум считается от длины периода — 300 ₽ за каждый календарный день. Перевести его
* в понятные клиенту величины (сколько показов и сколько рублей) можно только на сервере:
* в расчёте участвует наша наценка, а её клиенту показывать нельзя.
*
* 🔑 Формула ОДНА на портал: и запуск, и мастер спрашивают один и тот же счётчик.
* Две копии одного расчёта однажды разойдутся, и верить будет нечему.
*/
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
config(['services.yandex_direct.min_spend_limit_rub_per_day' => 300]);
config(['services.yandex_direct.spend_limit_guard_multiplier' => '1.2']);
DB::table('ad_settings')->update(['ad_margin_percent' => '40.00']);
});
it('мастер получает минимум площадки в показах и рублях, наценку не раскрывая', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Черновик',
'status' => AdCampaign::STATUS_DRAFT,
'audience_days' => 10,
'mode' => AdCampaign::MODE_AUTO,
'run_days' => 1,
'client_cpm_rub' => '500.00',
]);
$otvet = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?frequency=5&days=10")
->assertOk();
// Срок 1 день → период сегодня…завтра = 2 календарных дня → минимум Яндексу 600 ₽.
// Яндексу уходит 60 % от 500 ₽ = 300 ₽ за тысячу; с запасом 1,2 это 0,36 ₽ за показ.
// 600 / 0,36 = 1 666,67 → 1 667 показов. Клиенту это 1667 × 500 / 1000 = 833,50 ₽.
$otvet->assertJsonPath('min_impressions', 1667)
->assertJsonPath('min_cost_rub', '833.50');
// Наценка, доля Яндекса и его ставка клиенту не видны ни в каком виде.
$telo = $otvet->getContent();
expect($telo)->not->toContain('margin')
->and($telo)->not->toContain('yandex_cpm')
->and($telo)->not->toContain('300.00');
});
it('запуск и мастер считают минимум ОДНИМ счётчиком', function () {
$schetchik = app(YandexMinimumSpend::class);
$itog = $schetchik->minimum(1, '500.00');
expect($itog['impressions'])->toBe(1667)
->and($itog['cost_rub'])->toBe('833.50');
});
it('длиннее срок — выше минимум: неделя стоит семь дней по 300 ₽', function () {
$schetchik = app(YandexMinimumSpend::class);
// Срок 7 дней → период сегодня…+6 = 7 календарных дней → 2100 ₽ Яндексу.
// 2100 / 0,36 = 5 833,33 → 5 834 показа → 2 917,00 ₽ клиенту.
$itog = $schetchik->minimum(7, '500.00');
expect($itog['impressions'])->toBe(5834)
->and($itog['cost_rub'])->toBe('2917.00');
});