Files
portal/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
T
Дмитрий 3b55c96a85 feat(реклама показы): суточное списание по ПОКАЗАМ через счётчик показов вместо кликов
Задача 8a. Джоб ChargeCampaignSpendJob переключён с модели за клики и наценки
30% делением на модель за показы и наценку 40% вычитанием через готовый
CampaignImpressionCharger. YandexDirectClient.getCampaignSpend заменён на
getCampaignImpressions за всё время кампании ALL_TIME, счётчик сам считает
дельту от уже списанного и идемпотентен по external_key yandex-imp.

Всё за рубильником yandex_direct.enabled ВЫКЛ, на боевых деньгах ничего не
крутится. Реклама-модуль 190/190 зелёный. Отчёт показов помечен «проверить
при go-live» ReportName без даты, ALL_TIME, формат TSV.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:38:18 +03:00

124 lines
4.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\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');
});
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();
// Малый баланс, крупная заморозка → после списания 300.00 баланс (100.00) уйдёт ниже frozen (200.00).
app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест');
$campaign = makeRunningCampaign($tenant->id);
app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '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');
});