Files
portal/app/tests/Feature/Advertising/NacenkaNeVidnaNigdeTest.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

135 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignMessage;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* Я-Д2 приёмочного листа — наша наценка не видна клиенту НИГДЕ.
*
* Клиент платит 500 ₽ за тысячу показов, Яндексу из них уходит 300 ₽, остальное — наше.
* Модель эта скрытая: клиент видит одну цену и не должен знать ни размера наценки, ни
* того, сколько на самом деле стоит показ у площадки.
*
* 🔴 Утечь оно может из любой щели: поле в ответе, служебное число в смете, строка в
* летописи кошелька, текст в ленте сообщений. Прежний сторож проверял ОДИН путь — карточку
* кампании. Остальные шесть путей не смотрел никто, а данные там те же самые.
*
* Сторож сплошной: обходит все читающие пути рекламного модуля и ищет в ответах и наши
* внутренние имена, и само число нашей доли.
*
* 🔑 Вторая половина — Я-Д1: заморозка равна смете копейка в копейку. Проверяется на трёх
* разных сметах, включая некруглую: округление «в свою пользу» на копейку — это тихая
* кража, которую заметят не скоро.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
DB::table('ad_settings')->update(['ad_margin_percent' => '40.00', 'client_cpm_rub' => '500.00']);
app(AdWalletService::class)->topup($this->tenant->id, '50000.00', 'yandex', 'приёмка');
});
it('Я-Д2: ни на одном читающем пути не видно ни наценки, ни доли Яндекса', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания',
'status' => AdCampaign::STATUS_RUNNING,
'audience_days' => 10,
'use_uploaded_list' => false,
'run_days' => 7,
'client_cpm_rub' => '500.00',
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'delivered_impressions' => 2000,
'charged_client_rub' => '1000.00',
// Наше: Яндексу ушло 60 % от списанного клиенту.
'yandex_cost_rub' => '600.00',
'yandex_campaign_id' => 777,
]);
app(AdWalletService::class)->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '5000.00');
AdCampaignMessage::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'author' => AdCampaignMessage::AUTHOR_YANDEX,
'body' => 'Изображение не подошло',
]);
$puti = [
'/api/advertising/wallet',
'/api/advertising/wallet/transactions',
'/api/advertising/campaigns',
"/api/advertising/campaigns/{$campaign->id}",
"/api/advertising/campaigns/{$campaign->id}/audience-size",
"/api/advertising/campaigns/{$campaign->id}/banners",
"/api/advertising/campaigns/{$campaign->id}/messages",
];
// Наши внутренние имена и само число нашей доли (600.00 — то, что ушло Яндексу).
$zapreshcheno = ['margin', 'yandex_cost', 'direct_cost', 'micros', 'spend_limit', '600.00'];
$techet = [];
foreach ($puti as $put) {
$telo = $this->getJson($put)->getContent();
foreach ($zapreshcheno as $slovo) {
if (str_contains($telo, $slovo)) {
$techet[] = $put.' → «'.$slovo.'»';
}
}
}
expect($techet)->toBe([]);
});
it('Я-Д2: пути отвечают содержимым — иначе сторож выше искал бы в пустоте', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания для проверки',
'status' => AdCampaign::STATUS_RUNNING,
'audience_days' => 10,
'use_uploaded_list' => false,
'client_cpm_rub' => '500.00',
]);
// Достаточно одного пути: если ответы пустые, искать в них запрещённые слова бессмысленно.
// 🪤 Сверяем по разобранному ответу, а не по сырой строке: русский текст уезжает в
// JSON \u-последовательностями, и поиск по строке его не находит — сторож покраснел бы
// на ровном месте.
$otvet = $this->getJson("/api/advertising/campaigns/{$campaign->id}");
expect($otvet->json('campaign.name'))->toBe('Кампания для проверки')
->and($otvet->json('campaign.client_cpm_rub'))->toBe('500.00');
});
it('Я-Д1: заморозка равна смете копейка в копейку, включая некруглую', function () {
$svc = app(AdWalletService::class);
foreach (['3333.36', '833.50', '12000.00'] as $smeta) {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Смета '.$smeta,
'status' => AdCampaign::STATUS_DRAFT,
'audience_days' => 10,
'use_uploaded_list' => false,
]);
$do = AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub;
$svc->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, $smeta);
$posle = AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub;
expect(bcsub((string) $posle, (string) $do, 2))->toBe($smeta);
}
});