c7bcd7943a
При нуле активных номеров (например, после чистки данных) джоб слал в Яндекс пустую замену состава, Яндекс отвечал 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>
324 lines
16 KiB
PHP
324 lines
16 KiB
PHP
<?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');
|
||
});
|