Files
portal/app/tests/Feature/Sales/AdAudienceSyncTest.php
T

324 lines
16 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\SyncAdAudienceJob;
use App\Jobs\SyncVkAudienceJob;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudiencePlatform;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
/**
* Ночная заливка состава аудитории в Яндекс (Task 6).
*
* SyncAdAudienceJob использует уже готовый YandexAudienceClient (тесты — в
* tests/Unit/YandexAudienceClientTest.php, здесь его не трогаем) и режим
* modify_data(..., 'replace'): заливает весь активный список целиком.
*
* Ключевое правило владельца: пока рубильник `enabled` выключен — в Яндекс
* не уходит НИЧЕГО (защита от случайной траты денег).
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.yandex_audience.token' => 'test-token']);
SalesAdAudiencePlatform::forCode('yandex')->update([
'enabled' => true,
'external_id' => 58029600,
]);
});
it('номер без хозяина в рекламу не попадает', function () {
// Сирота из схемы до v8.77: фирмы у него нет, значит ночной пересчёт его
// никогда не тронет. Попади он в сегмент — крутился бы вечно.
Http::fake([
'api-audience.yandex.ru/*' => Http::response(['segment' => ['id' => 58029600]], 200),
]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тестовая стоматология', 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at ?? now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create(['phone' => '79990000001', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(3), 'state' => 'active']);
SalesAdAudiencePhone::create(['phone' => '79990000009', 'firm_id' => null, 'expires_at' => now()->addDays(3), 'state' => 'active']);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $request) {
$body = (string) $request->body();
return str_contains($body, '79990000001') && ! str_contains($body, '79990000009');
});
});
it('при нуле активных номеров не дёргает Яндекс и гасит прошлую ошибку', function () {
// После чистки данных активных номеров ноль. Пустую замену Яндекс отвергает
// 400-м «Нет ни одного корректного элемента», и на экране начальника повисает
// красная строчка. Слать нечего — джоб обязан тихо выйти, погасив прошлую
// ошибку. Сам сегмент в Яндексе при этом сохраняет прежний состав.
Http::fake();
SalesAdAudiencePlatform::forCode('yandex')->update([
'last_error' => 'Яндекс не принял обновление: Нет ни одного корректного элемента',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertNothingSent();
expect(SalesAdAudiencePlatform::forCode('yandex')->last_error)->toBeNull();
});
it('пока рубильник выключен, в Яндекс ничего не уходит', function () {
Http::fake();
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false]);
SyncAdAudienceJob::dispatchSync();
Http::assertNothingSent();
});
it('заливает номера фирмы, активной по срокам Яндекса, и пропускает погасшую', function () {
// Гранулярность теперь ПОФИРМЕННАЯ: решение движка прогрева считается на
// фирму по срокам Яндекса, а не по сводному phone.state (пересчёт пишет всем
// номерам фирмы один статус — разных статусов на одной фирме в жизни не бывает).
Http::fake([
'api-audience.yandex.ru/*' => Http::response(['segment' => ['id' => 58029600]], 200),
]);
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 3]);
// Активна по Яндексу: прогрев только начат (warmup_started_at = сейчас).
$fresh = SalesAdAudienceFirm::create([
'firm_name' => 'Свежая', 'warmup_started_at' => now(), 'ch_yandex' => true,
]);
$fresh->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000001', 'firm_id' => $fresh->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
// Погасла по Яндексу: прогрев начат 10 дней назад > warmup_days=3.
$gone = SalesAdAudienceFirm::create([
'firm_name' => 'Погасшая', 'warmup_started_at' => now()->subDays(10), 'ch_yandex' => true,
]);
$gone->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(10), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000002', 'firm_id' => $gone->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $request) {
$file = collect($request->data())->firstWhere('name', 'file');
$csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $request->body();
return str_contains($request->url(), 'modify_data')
&& str_contains($csv, '79990000001')
&& ! str_contains($csv, '79990000002');
});
expect(SalesAdAudiencePlatform::forCode('yandex')->last_synced_at)->not->toBeNull();
});
it('ошибку Яндекса показывает начальнику, а не прячет', function () {
Http::fake([
'api-audience.yandex.ru/*' => Http::response(['error' => 'quota exceeded'], 429),
]);
// Фирма реально греется по Яндексу (строка warming) — иначе номеров ноль,
// джоб тихо выйдет и до обращения к Яндексу не дойдёт (пустую заливку не шлём).
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тестовая стоматология', 'warmup_started_at' => now(), 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create(['phone' => '79990000001', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(3), 'state' => 'active']);
SyncAdAudienceJob::dispatchSync();
expect(SalesAdAudiencePlatform::forCode('yandex')->last_error)->toContain('quota');
});
it('фирму, помеченную только для ВК, в Яндекс не льёт', function () {
// 🔴 Смысл фичи: начальник увёл фирму в ВК — из яндексового сегмента она
// должна уйти сама при ближайшей заливке, без ручной чистки.
config(['services.yandex_audience.token' => 'test-token']);
SalesAdAudiencePlatform::forCode('yandex')->update([
'enabled' => true,
'external_id' => 58029600,
]);
$yandexFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Греем в Яндексе', 'warmup_started_at' => now(), 'ch_yandex' => true,
]);
$yandexFirm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000001', 'firm_id' => $yandexFirm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
$vkFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Греем в ВК', 'warmup_started_at' => now(), 'ch_vk' => true,
]);
$vkFirm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000002', 'firm_id' => $vkFirm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $r) {
$body = collect($r->data())->firstWhere('name', 'file');
$csv = is_array($body) && is_string($body['contents'] ?? null) ? $body['contents'] : '';
expect($csv)->toContain('79990000001')
->and($csv)->not->toContain('79990000002');
return true;
});
});
it('фирма, погасшая по срокам Яндекса, в сегмент не входит — даже если сводка active из-за ВК', function () {
config(['services.yandex_audience.token' => 'test-token']);
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'external_id' => 58029600, 'warmup_days' => 3]);
SalesAdAudiencePlatform::forCode('vk')->update(['warmup_days' => 30]);
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
// Фирма на ОБЕИХ площадках, прогрев начат 5 дней назад:
// по Яндексу (warmup 3) уже погасла, по ВК (warmup 30) ещё active.
// Сводный phone.state будет 'active' (из-за ВК) — но в ЯНДЕКС номер не должен уйти.
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'На обеих', 'warmup_started_at' => now()->subDays(5),
'ch_yandex' => true, 'ch_vk' => true,
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(5), 'warmed_times' => 1,
]);
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(5), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000077', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active', // сводка active
]);
// Живая по Яндексу фирма рядом — чтобы заливка реально ушла (пустую не шлём),
// и исключение погасшей проверялось на настоящем составе, а не на тишине.
$live = SalesAdAudienceFirm::create(['firm_name' => 'Живая', 'warmup_started_at' => now(), 'ch_yandex' => true]);
$live->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000078', 'firm_id' => $live->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $r) {
$file = collect($r->data())->firstWhere('name', 'file');
$csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body();
expect($csv)->toContain('79990000078')
->and($csv)->not->toContain('79990000077');
return true;
});
});
it('фирма со строкой yandex warming+active — номер попадает в сегмент (Task 5)', function () {
// Источник Task 5: не forYandex()/ch_yandex, а строка firm_channels
// (channel=yandex, status=warming). Решение по ней же — decideForChannelRow.
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тёплая по Яндексу', 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000031', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $r) {
$file = collect($r->data())->firstWhere('name', 'file');
$csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body();
return str_contains($r->url(), 'modify_data') && str_contains($csv, '79990000031');
});
});
it('фирма со строкой yandex loaded (не греется) — номер НЕ в сегменте, даже с ch_yandex=true (Task 5)', function () {
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Уже загруженная', 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(20), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000032', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
// Живая (warming) фирма рядом — чтобы заливка ушла и «loaded» проверялся на
// настоящем составе: пустую заливку джоб больше не шлёт.
$warm = SalesAdAudienceFirm::create(['firm_name' => 'Греется', 'ch_yandex' => true]);
$warm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000033', 'firm_id' => $warm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $r) {
$file = collect($r->data())->firstWhere('name', 'file');
$csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body();
return str_contains($r->url(), 'modify_data')
&& str_contains($csv, '79990000033')
&& ! str_contains($csv, '79990000032');
});
});
it('VK-джоб пишет статус доступа в СТРОКУ ПЛОЩАДКИ, а не в мёртвый синглтон (экран читает строку)', function () {
// Экран ВК показывает status из строки sales_ad_audience_platforms (platformState),
// поэтому джоб обязан обновлять именно её. До Фазы 2 статус уходил в SalesAdAudienceState.
config(['services.vk_ads.token' => 'test-token']);
SalesAdAudiencePlatform::forCode('vk')->update(['enabled' => true, 'min_phones' => 2000]);
// Номеров нет → путь waiting_volume (без обращения к API ВК).
SyncVkAudienceJob::dispatchSync();
expect(SalesAdAudiencePlatform::forCode('vk')->status)->toBe('waiting_volume');
});