'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'); });