2026-07-24 23:27:50 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Events\AdvertisingStopped;
|
|
|
|
|
|
use App\Jobs\ChargeCampaignSpendJob;
|
2026-08-05 09:52:10 +03:00
|
|
|
|
use App\Mail\AdWalletLowBalanceNotification;
|
2026-07-24 23:27:50 +03:00
|
|
|
|
use App\Models\AdCampaign;
|
|
|
|
|
|
use App\Models\AdWallet;
|
|
|
|
|
|
use App\Models\AdWalletTransaction;
|
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use App\Services\Advertising\AdWalletService;
|
2026-07-25 00:07:45 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
2026-07-28 06:36:11 +03:00
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-07-24 23:27:50 +03:00
|
|
|
|
use Illuminate\Support\Facades\Event;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
2026-08-05 09:52:10 +03:00
|
|
|
|
use Illuminate\Support\Facades\Mail;
|
2026-07-25 00:07:45 +03:00
|
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
|
|
|
|
|
|
|
|
// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
|
|
|
|
|
|
// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
|
|
|
|
|
|
// второму соединению до commit'а. RefreshDatabase раньше не был подключён в
|
|
|
|
|
|
// этом файле — подключаем оба вместе (см. общее правило других Advertising-тестов).
|
|
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
|
|
uses(SharesSupplierPdo::class);
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
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']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 00:38:18 +03:00
|
|
|
|
/** TSV: накопительное число показов за всё время кампании (ALL_TIME). */
|
|
|
|
|
|
function fakeYandexImpressionsReport(string $tsv = '2500'): void
|
2026-07-24 23:27:50 +03:00
|
|
|
|
{
|
|
|
|
|
|
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,
|
2026-07-27 00:38:18 +03:00
|
|
|
|
'use_uploaded_list' => true,
|
|
|
|
|
|
'frequency' => 15,
|
|
|
|
|
|
'estimated_impressions' => 10000,
|
|
|
|
|
|
'paid_impressions' => 10000,
|
2026-07-24 23:27:50 +03:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 00:38:18 +03:00
|
|
|
|
it('charges the client for delivered impressions at the flat CPM (client_cpm_rub not set → default 120.00 ₽/1000)', function () {
|
2026-07-24 23:27:50 +03:00
|
|
|
|
configureYandexDirectForSpend();
|
2026-07-27 00:38:18 +03:00
|
|
|
|
fakeYandexImpressionsReport();
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
$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();
|
2026-07-27 00:38:18 +03:00
|
|
|
|
// 2500 показов × 120.00 ₽/1000 = 300.00 ₽ клиенту.
|
|
|
|
|
|
expect($wallet->balance_rub)->toBe('700.00'); // 1000.00 − 300.00
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
$tx = AdWalletTransaction::where('tenant_id', $tenant->id)
|
2026-07-27 00:38:18 +03:00
|
|
|
|
->where('external_key', "yandex-imp:{$campaign->id}:2500")
|
2026-07-24 23:27:50 +03:00
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
expect($tx)->not->toBeNull()
|
|
|
|
|
|
->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE)
|
2026-07-27 00:38:18 +03:00
|
|
|
|
->and($tx->amount_rub)->toBe('-300.00');
|
2026-07-24 23:27:50 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-28 06:36:11 +03: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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-24 23:27:50 +03:00
|
|
|
|
it('does not double-charge on a second run (idempotent by external_key)', function () {
|
|
|
|
|
|
configureYandexDirectForSpend();
|
2026-07-27 00:38:18 +03:00
|
|
|
|
fakeYandexImpressionsReport();
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
$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)
|
2026-07-27 00:38:18 +03:00
|
|
|
|
->and($balanceAfterSecond)->toBe('700.00');
|
2026-07-24 23:27:50 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('stops all advertising when the charge leaves the tenant insolvent', function () {
|
|
|
|
|
|
Event::fake([AdvertisingStopped::class]);
|
|
|
|
|
|
configureYandexDirectForSpend();
|
2026-07-27 00:38:18 +03:00
|
|
|
|
fakeYandexImpressionsReport();
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
2026-07-27 09:44:12 +03:00
|
|
|
|
// Заморозка ТАЕТ вместе со списанием по своей кампании, поэтому нехватку денег
|
|
|
|
|
|
// создаёт ЧУЖОЙ резерв: у клиента запущена вторая кампания с холдом 200.00 ₽,
|
|
|
|
|
|
// а первая съедает 300.00 ₽ из 400.00 ₽ → на вторую денег уже нет (100 < 200)
|
|
|
|
|
|
// → стоп всей рекламы тенанта.
|
2026-07-27 00:38:18 +03:00
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест');
|
2026-07-24 23:27:50 +03:00
|
|
|
|
$campaign = makeRunningCampaign($tenant->id);
|
2026-07-27 09:44:12 +03:00
|
|
|
|
app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00');
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
app(ChargeCampaignSpendJob::class)->handle();
|
|
|
|
|
|
|
|
|
|
|
|
Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 04:26:27 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 Остановка «нет денег» ходит в Яндекс: глушит каждую кампанию и спрашивает у неё
|
|
|
|
|
|
* число показов, чтобы заплатить за них перед возвратом заморозки. Пока это делалось
|
|
|
|
|
|
* ВНУТРИ денежной транзакции, замок строки кампании держался всё время сетевых
|
|
|
|
|
|
* запросов — по два на каждую кампанию тенанта. Один зависший запрос к Директу
|
|
|
|
|
|
* запирал деньги клиента на минуты и тормозил всё, что к ним обращается.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Сторож смотрит на глубину транзакции в момент остановки: она обязана совпадать с
|
|
|
|
|
|
* той, что была до запуска задачи, — то есть своей транзакции задача здесь не держит.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('останавливает рекламу ВНЕ денежной транзакции — замок не висит на сетевом запросе', function () {
|
|
|
|
|
|
configureYandexDirectForSpend();
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'*/json/v5/reports' => Http::response('2500', 200),
|
|
|
|
|
|
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 222]]]]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
// 400 ₽ на счету, 200 ₽ заперты под ЧУЖУЮ кампанию; списание 300 ₽ уводит
|
|
|
|
|
|
// свободный остаток в минус → стоп всей рекламы тенанта.
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
|
|
$urovenVMomentOstanovki = null;
|
|
|
|
|
|
Event::listen(AdvertisingStopped::class, function () use (&$urovenVMomentOstanovki): void {
|
|
|
|
|
|
$urovenVMomentOstanovki = DB::transactionLevel();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$urovenDo = DB::transactionLevel();
|
|
|
|
|
|
app(ChargeCampaignSpendJob::class)->handle();
|
|
|
|
|
|
|
|
|
|
|
|
expect($urovenVMomentOstanovki)->not->toBeNull()
|
|
|
|
|
|
->and($urovenVMomentOstanovki)->toBe($urovenDo);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 09:52:10 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Я-Б3 приёмочного листа — деньги кончились на ходу, вся реклама встаёт.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Сторож выше проверяет, что часовой обход ВЫПУСКАЕТ событие «стоп рекламе», и на этом
|
|
|
|
|
|
* останавливается: событие там подменено (`Event::fake`), дальше него ничего не идёт.
|
|
|
|
|
|
* А клиенту важно не событие, а последствия — встала ли кампания на самом деле и узнал
|
|
|
|
|
|
* ли он об этом.
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 Между «событие выпущено» и «кампания встала» лежит вся цепочка: слушатель, поход в
|
|
|
|
|
|
* Яндекс за числом показов, плата за показанное, возврат остатка и письмо владельцу.
|
|
|
|
|
|
* Порвись она в любом месте — сторож с подменённым событием остался бы зелёным, а
|
|
|
|
|
|
* кампания продолжала бы крутиться за деньги, которых у клиента нет.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Здесь событие НЕ подменяется: проверяется конец цепочки — статус «остановлено, нет
|
|
|
|
|
|
* денег» на самой кампании и письмо, поставленное в очередь владельцу.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('Я-Б3: деньги кончились — кампания РЕАЛЬНО встала и владельцу ушло письмо', function () {
|
|
|
|
|
|
Mail::fake();
|
|
|
|
|
|
configureYandexDirectForSpend();
|
|
|
|
|
|
// Часовой обход спрашивает показы, и остановка спрашивает их же — оба ответа отсюда.
|
|
|
|
|
|
// 3000 показов × 120 ₽ / 1000 = 360 ₽ списания.
|
|
|
|
|
|
fakeYandexImpressionsReport('3000');
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'приёмка');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeRunningCampaign($tenant->id);
|
|
|
|
|
|
// Чужой резерв: 200 ₽ заперты под вторую кампанию клиента. После списания 360 ₽ на
|
|
|
|
|
|
// счету останется 40 ₽ — меньше запертого, и клиент неплатёжеспособен.
|
|
|
|
|
|
app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00');
|
|
|
|
|
|
|
|
|
|
|
|
app(ChargeCampaignSpendJob::class)->handle();
|
|
|
|
|
|
|
|
|
|
|
|
// Конец цепочки: кампания встала именно с причиной «нет денег».
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
|
|
|
|
|
|
|
|
|
|
|
|
// И клиент об этом узнал — письмо поставлено в очередь.
|
|
|
|
|
|
Mail::assertQueued(AdWalletLowBalanceNotification::class);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-24 23:27:50 +03:00
|
|
|
|
it('does nothing and sends no HTTP when the yandex_direct switch is off', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => false]);
|
2026-07-27 00:38:18 +03:00
|
|
|
|
fakeYandexImpressionsReport();
|
2026-07-24 23:27:50 +03:00
|
|
|
|
|
|
|
|
|
|
$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');
|
|
|
|
|
|
});
|