Files
portal/app/tests/Feature/Advertising/PoddelannyyZaprosTest.php
T
Дмитрий 652b708203 fix(реклама): остановка рекламы делалась дважды; добраны все бесплатные карточки приёмки
Из 29 карточек приёмки Яндекса закрыты 27. Остались две, и обе ждут не работы,
а события: живого отказа всех объявлений разом и платных комбинаций Я1-Я9.

Нашлась вторая за день тихая беда. Слушатель остановки рекламы был подписан на
событие ДВАЖДЫ: Laravel 11 сам находит слушателей в app/Listeners по типу
параметра handle(), а в провайдере он вдобавок прописан руками. Остановка не
бесплатная: по каждой кампании клиента она идёт в Яндекс за числом показов,
платит за показанное и глушит кампанию, - и всё это делалось по два раза.
Деньги уцелели по случайности: списание идемпотентно по ключу показов, снятие
заморозки тоже, а второй заход не находил кампаний, первый уже увёл их из
работы. Ручная подписка убрана.

Найдено не глазами, а вырезанием: новый сквозной сторож Я-Б3 не покраснел,
когда подписку отключили. Если бы регистрация была одна, он обязан был.

Замерено на живой кампании #13, без единого рубля сверху:
- Я-Д3: показано 21, списано 10,50 руб = 21 x 500/1000; по смете было бы 850;
- Я-М2: кампания running, заморозка 839,50 = 850 - 10,50, показы идут;
- Я-Х4: задача из очереди под боевой ролью без контекста клиента видит
  1 кампанию служебным путём и 0 обычным - тот самый молчаливый ноль,
  ради которого служебный путь и заведён.

Новые сторожа, каждый принят красным:
- Я-Б2: денег ровно на смету - запуск проходит, вторая кампания уже нет;
- Я-Б3: деньги кончились - кампания РЕАЛЬНО встала и владельцу ушло письмо;
- Я-М4: пока вердикта нет, не двигаются ни заморозка, ни списание;
- Я-М5: после «Исправить» и повторного запуска заморозка ОДНА;
- Я-Х2: десять подделок отбиты, чужой номер клиента ничего не меняет;
- Я-Д1: заморозка равна смете копейка в копейку, включая некруглую;
- Я-Д2: сплошной обход всех семи читающих путей - наценка не видна нигде
  (прежний сторож проверял один путь из семи);
- остановка рекламы подписана ровно один раз.

Прогоны: 424 теста рекламы зелёные, статанализ 0, формат чистый.

Журнал результатов приёмки дописан разделом на 05.08 - что закрыто, чем
закрыто и чего ждём.
2026-08-05 09:52:10 +03:00

106 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* Я-Х2 приёмочного листа — запрос мимо экрана.
*
* Экран мастера не даёт ввести чушь: поля с ограничениями, кнопки блокируются. Но экран —
* это вежливость, а не защита: запрос к порталу можно отправить и без него, руками. Всё,
* что мастер не пускает, обязан не пускать и сам портал.
*
* 🔴 Цена ошибки здесь денежная. Дробное или отрицательное число показов, нулевая цена,
* срок в тысячу лет — любое из этих значений, попав в расчёт, испортит смету, а по смете
* запираются настоящие деньги клиента. Отдельная строка — ЧУЖОЙ кошелёк: если бы номер
* клиента можно было передать в запросе, посторонний тратил бы чужие деньги.
*
* Сторож проверяет две вещи разом: каждая подделка отбита, и кошелёк после всех попыток
* остался ровно таким, каким был.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
app(AdWalletService::class)->topup($this->tenant->id, '50000.00', 'yandex', 'приёмка');
$this->campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Черновик',
'status' => AdCampaign::STATUS_DRAFT,
'audience_days' => 10,
'use_uploaded_list' => false,
]);
});
it('Я-Х2: подделанные числа отбиваются все до одного, кошелёк не тронут', function () {
$doBalance = AdWallet::where('tenant_id', $this->tenant->id)->first();
$poddelki = [
'дробное число показов' => ['estimated_impressions' => 10.5],
'отрицательное число показов' => ['estimated_impressions' => -100],
'отрицательная смета' => ['budget_rub' => -1000],
'цена ноль' => ['client_cpm_rub' => 0],
'отрицательная цена' => ['client_cpm_rub' => -5],
'срок ноль дней' => ['run_days' => 0],
'срок тысяча лет' => ['run_days' => 999999],
'частота ноль' => ['frequency' => 0],
'глубина сбора в сто лет' => ['audience_days' => 36500],
'адрес сайта не адрес' => ['landing_url' => 'не-адрес-вовсе'],
];
$proshli = [];
foreach ($poddelki as $imya => $telo) {
$otvet = $this->patchJson("/api/advertising/campaigns/{$this->campaign->id}", $telo);
if ($otvet->getStatusCode() < 400) {
$proshli[] = $imya.' → '.$otvet->getStatusCode();
}
}
expect($proshli)->toBe([]);
$posle = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect($posle->balance_rub)->toBe($doBalance->balance_rub)
->and($posle->frozen_rub)->toBe($doBalance->frozen_rub);
});
it('Я-Х2: честные числа проходят — иначе сторож выше отбивал бы вообще всё', function () {
$otvet = $this->patchJson("/api/advertising/campaigns/{$this->campaign->id}", [
'estimated_impressions' => 5000,
'client_cpm_rub' => 500,
'run_days' => 7,
'frequency' => 5,
'audience_days' => 30,
'landing_url' => 'https://liderra.ru/promo',
]);
expect($otvet->getStatusCode())->toBeLessThan(400);
});
it('Я-Х2: чужой номер клиента в запросе ничего не меняет — деньги остаются своими', function () {
$chuzhoy = Tenant::factory()->create();
app(AdWalletService::class)->topup($chuzhoy->id, '77777.00', 'yandex', 'чужие деньги');
// Подделка: пробуем приписать свою кампанию чужому клиенту.
$this->patchJson("/api/advertising/campaigns/{$this->campaign->id}", [
'name' => 'Переписанная',
'tenant_id' => $chuzhoy->id,
]);
expect((int) $this->campaign->fresh()->tenant_id)->toBe((int) $this->tenant->id);
// И чужой кошелёк не шелохнулся.
$chuzhoyKoshelek = AdWallet::where('tenant_id', $chuzhoy->id)->first();
expect($chuzhoyKoshelek->balance_rub)->toBe('77777.00')
->and($chuzhoyKoshelek->frozen_rub)->toBe('0.00');
});