652b708203
Из 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 - что закрыто, чем закрыто и чего ждём.
135 lines
6.3 KiB
PHP
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);
|
|
}
|
|
});
|