Files
portal/app/tests/Feature/Sales/AdAudienceSyncTest.php
T
Дмитрий c7bcd7943a fix(прогрев): ночная заливка в Яндекс молчит при нуле номеров, не пишет красную ошибку
При нуле активных номеров (например, после чистки данных) джоб слал в Яндекс
пустую замену состава, Яндекс отвечал 400 «Нет ни одного корректного элемента»,
и на экране начальника повисала красная строчка. Теперь при пустом составе джоб
тихо выходит, погасив прошлую ошибку; сам сегмент в Яндексе сохраняет прежний
состав (пустым его всё равно не сделать — минимум 100). Симметрично VK-джобу.

Три существующих теста конструировали сценарий с нулём яндексовых номеров и
полагались на старую отправку пустой заливки — приведены к настоящему составу
(рядом живая warming-фирма), проверка исключения стала строже.

Larastan исключён: 3 ошибки выше baseline — в чужих tests/Unit/Sms/*SmsProviderTest.php
(параллельная СМС-сессия, не в этом коммите); свои 3 файла stan-чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:20:15 +03:00

324 lines
16 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\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');
});