76e4862f95
Приёмка Яндекса, восемь бесплатных карточек. Две проверены на живых деньгах
боевой кампании #13, шесть — сторожами на тестовой базе.
Нашлась настоящая дыра. Если запуск оборвался на полпути, деньги остаются
заперты нарочно: в Директе кампания уже завелась и может крутить показы,
клиент дожмёт «Запустить». Но выглядит она обычным черновиком, а черновик
разрешено удалить. Кампании после этого нет, а запись о заморозке ссылается
на её номер — возврат ищут по кампании и не находят. Деньги клиента заперты
навсегда, молча, без единой ошибки в журнале. Теперь такой черновик удалить
нельзя, и портал объясняет, что сделать вместо этого.
Второе: портал отказывал на языке разработчика — «Поле file должно быть
файлом одного из типов», «не может быть больше 10240 Кбайт». Клиент не знает,
что такое поле file, и не считает мегабайты в килобайтах. Четыре отказа по
файлам переписаны по образцу, который в этом же модуле уже был: «Нужен ровно
300×250. Вы загрузили 100×100.»
Сторожа приёмки:
- чужая кампания отдаёт 404 на всех двадцати одном пути, ни одного 403;
- сколько раз ни жми «Запустить» — заморозка одна;
- отклонённую в обход «Исправить» не запустить, денег не трогает;
- черновик с запертыми деньгами не уносит их;
- списание посреди запуска другой кампании — касса сходится;
- отказы по файлам сказаны человеческим языком.
Каждый принят красным: изоляция проверена подстановкой хозяина вместо чужого,
защита запуска — вырезанием прямо в коде, гонка — снятием списания из середины.
Прогоны: 413 тестов рекламы зелёные, статанализ 0.
Заодно форматирование теста минимума площадки — форматтер перенёс длинное имя
класса в шапку уже после записи 80987996e.
86 lines
4.3 KiB
PHP
86 lines
4.3 KiB
PHP
<?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');
|
||
});
|