9cc3f5e950
Хвосты денег Д1-Д6 и робота Р-х1-Р-х6 из приёмочного листа v12. Д1 цена, по которой заморожены деньги, записывается на кампанию. Пока поле было пустым, списание читало глобальную цену — админ менял её, и клиент платил больше обещанного при запуске. Д2 суточное списание берёт кампанию под замком строки. Ключ идемпотентности зависит от числа показов, поэтому два одновременных прогона получали разные ключи и списали бы клиента дважды. Д3 пауза, не дошедшая до Директа, больше не считается паузой: отказ 409, заморозка остаётся. Раньше реклама крутилась дальше, портал показывал паузу, а деньги были уже свободны. У возобновления поведение намеренно прежнее — иначе понадобилось бы пятое место разморозки, а их ровно четыре. Там же убрана мина строгого сравнения рубильника. Д4 не трогали — это вопрос владельца. Д5 рубильник Директа держит и служебный канал робота: выдача задания и приём отчёта ходили в живой кабинет мимо него. Д6 проверка рубильника приведена к общему виду: YANDEX_DIRECT_ENABLED=0 давало строку, которую строгое сравнение читало как включено. Р-х1 настройки читаются из .env робота, а не каталога запуска. Р-х2 файл-замок robot.lock: проход и поддержание входа больше не дерутся за профиль браузера. Занят — уходим молча, задание остаётся в очереди. Брошенный замок перехватывается через полчаса. Р-х3 письмо-алярм честно говорит, залиты ли уже креативы в кабинет. Побочно вскрылось, что тексты писем не проверялись ни одним тестом — транспорт вынесен в src/smtp.js. Р-х4 тест-пустышка про рабочую папку заменён настоящим: запуск из чужого каталога без явной папки. Проверено вырезанием. Р-х6 пустое значение в окружении читается как значение по умолчанию, мусор даёт внятную ошибку вместо тихого NaN. Портал 293/293, робот 57/57. Денежных выходов снятия заморозки по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
7.1 KiB
PHP
163 lines
7.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Events\AdvertisingStopped;
|
||
use App\Jobs\ChargeCampaignSpendJob;
|
||
use App\Models\AdCampaign;
|
||
use App\Models\AdWallet;
|
||
use App\Models\AdWalletTransaction;
|
||
use App\Models\Tenant;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Event;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
|
||
// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
|
||
// второму соединению до commit'а. RefreshDatabase раньше не был подключён в
|
||
// этом файле — подключаем оба вместе (см. общее правило других Advertising-тестов).
|
||
uses(RefreshDatabase::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
function configureYandexDirectForSpend(): void
|
||
{
|
||
config(['services.yandex_direct.enabled' => true]);
|
||
config(['services.yandex_direct.token' => 'DIRTOKEN']);
|
||
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
|
||
}
|
||
|
||
/** TSV: накопительное число показов за всё время кампании (ALL_TIME). */
|
||
function fakeYandexImpressionsReport(string $tsv = '2500'): void
|
||
{
|
||
Http::fake(['*/json/v5/reports' => Http::response($tsv, 200)]);
|
||
}
|
||
|
||
function makeRunningCampaign(int $tenantId): AdCampaign
|
||
{
|
||
return AdCampaign::create([
|
||
'tenant_id' => $tenantId,
|
||
'name' => 'Кампания списания',
|
||
'status' => AdCampaign::STATUS_RUNNING,
|
||
'yandex_campaign_id' => 222,
|
||
'audience_days' => 10,
|
||
'use_uploaded_list' => true,
|
||
'frequency' => 15,
|
||
'estimated_impressions' => 10000,
|
||
'paid_impressions' => 10000,
|
||
]);
|
||
}
|
||
|
||
it('charges the client for delivered impressions at the flat CPM (client_cpm_rub not set → default 120.00 ₽/1000)', function () {
|
||
configureYandexDirectForSpend();
|
||
fakeYandexImpressionsReport();
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
|
||
$campaign = makeRunningCampaign($tenant->id);
|
||
|
||
app(ChargeCampaignSpendJob::class)->handle();
|
||
|
||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||
// 2500 показов × 120.00 ₽/1000 = 300.00 ₽ клиенту.
|
||
expect($wallet->balance_rub)->toBe('700.00'); // 1000.00 − 300.00
|
||
|
||
$tx = AdWalletTransaction::where('tenant_id', $tenant->id)
|
||
->where('external_key', "yandex-imp:{$campaign->id}:2500")
|
||
->first();
|
||
|
||
expect($tx)->not->toBeNull()
|
||
->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE)
|
||
->and($tx->amount_rub)->toBe('-300.00');
|
||
});
|
||
|
||
/**
|
||
* Кампания в суточном списании берётся ПОД ЗАМКОМ строки.
|
||
*
|
||
* Идемпотентность списания держится на ключе `yandex-imp:{кампания}:{показы}`, а число
|
||
* показов приходит из отчёта Директа. Два прогона, начавшихся одновременно (ручной запуск
|
||
* поверх расписания, повтор упавшей задачи), получат чуть разные числа показов — значит
|
||
* разные ключи, и уникальный индекс по ключу дубль уже не остановит: клиент будет списан
|
||
* дважды. Замок строки выстраивает прогоны в очередь: второй увидит уже обновлённый
|
||
* `charged_client_rub` и спишет только настоящую дельту.
|
||
*
|
||
* Настоящую гонку в тесте не поставить, поэтому смотрим на сам запрос: он обязан быть
|
||
* блокирующим. Уберите `lockForUpdate()` — тест покраснеет.
|
||
*/
|
||
it('reads the campaign under a row lock while charging', function () {
|
||
configureYandexDirectForSpend();
|
||
fakeYandexImpressionsReport();
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
|
||
$campaign = makeRunningCampaign($tenant->id);
|
||
|
||
$locked = false;
|
||
DB::listen(function ($query) use (&$locked, $campaign) {
|
||
if (str_contains($query->sql, 'ad_campaigns')
|
||
&& str_contains(strtolower($query->sql), 'for update')
|
||
&& in_array($campaign->id, $query->bindings, true)) {
|
||
$locked = true;
|
||
}
|
||
});
|
||
|
||
app(ChargeCampaignSpendJob::class)->handle();
|
||
|
||
expect($locked)->toBeTrue();
|
||
});
|
||
|
||
it('does not double-charge on a second run (idempotent by external_key)', function () {
|
||
configureYandexDirectForSpend();
|
||
fakeYandexImpressionsReport();
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
|
||
makeRunningCampaign($tenant->id);
|
||
|
||
app(ChargeCampaignSpendJob::class)->handle();
|
||
$balanceAfterFirst = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
|
||
|
||
app(ChargeCampaignSpendJob::class)->handle();
|
||
$balanceAfterSecond = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
|
||
|
||
expect($balanceAfterSecond)->toBe($balanceAfterFirst)
|
||
->and($balanceAfterSecond)->toBe('700.00');
|
||
});
|
||
|
||
it('stops all advertising when the charge leaves the tenant insolvent', function () {
|
||
Event::fake([AdvertisingStopped::class]);
|
||
configureYandexDirectForSpend();
|
||
fakeYandexImpressionsReport();
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
// Заморозка ТАЕТ вместе со списанием по своей кампании, поэтому нехватку денег
|
||
// создаёт ЧУЖОЙ резерв: у клиента запущена вторая кампания с холдом 200.00 ₽,
|
||
// а первая съедает 300.00 ₽ из 400.00 ₽ → на вторую денег уже нет (100 < 200)
|
||
// → стоп всей рекламы тенанта.
|
||
app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест');
|
||
$campaign = makeRunningCampaign($tenant->id);
|
||
app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00');
|
||
|
||
app(ChargeCampaignSpendJob::class)->handle();
|
||
|
||
Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id);
|
||
});
|
||
|
||
it('does nothing and sends no HTTP when the yandex_direct switch is off', function () {
|
||
config(['services.yandex_direct.enabled' => false]);
|
||
fakeYandexImpressionsReport();
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
|
||
makeRunningCampaign($tenant->id);
|
||
|
||
app(ChargeCampaignSpendJob::class)->handle();
|
||
|
||
Http::assertNothingSent();
|
||
|
||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||
expect($wallet->balance_rub)->toBe('1000.00');
|
||
});
|