update(['enabled' => true]); }); it('когда реклама выключена рубильником, джоб вообще не работает', function () { // 🔴 Рубильник ВК (строка площадки vk.enabled) обязан гасить площадку. Он // теперь свой у каждой площадки — можно погасить ВК, не трогая Яндекс, — но // защитный смысл ТОТ ЖЕ: выключатель ВК в ВЫКЛ ⇒ джоб не делает ни одного // обращения к ВК и статус канала остаётся нетронутым. // // 🪤 Проверять здесь Http::assertNothingSent() БЕСПОЛЕЗНО: при трёх номерах // джоб и без рубильника не дошёл бы до обращения к ВК, и тест был бы зелёным // в любом случае (проверено 19.07 — первая редакция теста проходила и с // вырезанной защитой). Наблюдаемая разница одна: с рубильником джоб выходит // сразу и статус канала остаётся нетронутым, без рубильника — он успевает // проставить 'waiting_volume'. SalesAdAudiencePlatform::forCode('vk')->update(['enabled' => false, 'status' => 'working']); config(['services.vk_ads.token' => 'test-token']); vkFirmWithPhones(3); SyncVkAudienceJob::dispatchSync(); // Статус канала живёт на СТРОКЕ ПЛОЩАДКИ (её читает экран). Выключенная // площадка — джоб выходит сразу и статус не трогает (был 'working', остался). expect(SalesAdAudiencePlatform::forCode('vk')->status)->toBe('working'); }); /** Заводит фирму для ВК с нужным числом номеров. */ function vkFirmWithPhones(int $count): SalesAdAudienceFirm { $firm = SalesAdAudienceFirm::create([ 'firm_name' => 'Тестовая стоматология', 'warmup_started_at' => now(), 'ch_vk' => true, ]); // Фаза 2: источник состава — строка канала firm_channels(vk, warming), а не // флаг ch_vk. Заводим её тем же заездом, что и warmup фирмы. $firm->firmChannels()->create([ 'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at ?? now(), 'warmed_times' => 1, ]); for ($i = 1; $i <= $count; $i++) { SalesAdAudiencePhone::create([ 'phone' => '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT), 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); } return $firm; } it('без ключей доступа не трогает ВК и пишет «нет доступа»', function () { config(['services.vk_ads.token' => '']); vkFirmWithPhones(3); Http::fake(); SyncVkAudienceJob::dispatchSync(); Http::assertNothingSent(); expect(SalesAdAudiencePlatform::forCode('vk')->status)->toBe('no_access'); }); it('пока номеров меньше двух тысяч, не делает ни одного обращения к ВК', function () { // 🔴 Порог 2000 — требование самого ВК к загрузке списка программой. Теперь // берётся из строки площадки (vk.min_phones), дефолт 2000. Обращаться раньше // бессмысленно и опасно: ВК считает попытки. config(['services.vk_ads.token' => 'test-token']); vkFirmWithPhones(3); Http::fake(); SyncVkAudienceJob::dispatchSync(); Http::assertNothingSent(); $vk = SalesAdAudiencePlatform::forCode('vk'); expect($vk->status)->toBe('waiting_volume') ->and($vk->last_error)->toBeNull(); }); it('фирмы, помеченные только Яндексом, в счёт для ВК не идут', function () { config(['services.vk_ads.token' => 'test-token']); $yandexOnly = SalesAdAudienceFirm::create([ 'firm_name' => 'Только Яндекс', 'warmup_started_at' => now(), 'ch_yandex' => true, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000001', 'firm_id' => $yandexOnly->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); Http::fake(); SyncVkAudienceJob::dispatchSync(); Http::assertNothingSent(); expect(SalesAdAudiencePlatform::forCode('vk')->status)->toBe('waiting_volume'); }); it('состав ВК считается по срокам ВК, независимо от Яндекса', function () { // 🔴 Смысл раздельных сроков: состав ВК считается ПОФИРМЕННО по срокам ВК, а // не по сводному phone.state и не по срокам Яндекса. Фирма, погасшая по ВК, // в состав ВК не входит — даже если по срокам Яндекса была бы ещё жива. // // 🪤 VkAudienceClient::replaceList — заглушка: программного доступа к ВК ещё // нет, метод бросает исключение с ЧИСЛОМ готовых к отправке номеров в тексте // (см. сам клиент). Джоб ловит его и кладёт в state.vk_last_error. Http сюда // не задействован (в отличие от Яндекса), поэтому наблюдаем состав через это // число: 1 = в состав попала только живая по ВК фирма; 2 = решение сломано и // погасшая по ВК тоже просочилась. Порог опускаем до 1, чтобы джоб дошёл до // попытки отправки и число стало наблюдаемым. config(['services.vk_ads.token' => 'test-token']); SalesAdAudiencePlatform::forCode('vk')->update(['enabled' => true, 'min_phones' => 1, 'warmup_days' => 3]); SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 30]); // active по ВК (warmup 3, начат 1 день назад): $live = SalesAdAudienceFirm::create(['firm_name' => 'Живая по ВК', 'warmup_started_at' => now()->subDay(), 'ch_vk' => true]); // Фаза 2: срок ВК считается от строки канала — её заезд = warmup фирмы. $live->firmChannels()->create(['channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $live->warmup_started_at, 'warmed_times' => 1]); SalesAdAudiencePhone::create(['phone' => '79990000011', 'firm_id' => $live->id, 'expires_at' => now()->addDays(30), 'state' => 'active']); // погасла по ВК (warmup 3, начат 10 дней назад) — но по Яндексу (warmup 30) была бы жива: $dead = SalesAdAudienceFirm::create(['firm_name' => 'Погасла по ВК', 'warmup_started_at' => now()->subDays(10), 'ch_vk' => true]); $dead->firmChannels()->create(['channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $dead->warmup_started_at, 'warmed_times' => 1]); SalesAdAudiencePhone::create(['phone' => '79990000022', 'firm_id' => $dead->id, 'expires_at' => now()->addDays(30), 'state' => 'active']); SyncVkAudienceJob::dispatchSync(); // В состав ушла только живая по ВК фирма → к отправке готов ровно 1 номер. $error = (string) SalesAdAudiencePlatform::forCode('vk')->last_error; expect($error)->toContain('Номеров готово к отправке: 1') ->and($error)->not->toContain('Номеров готово к отправке: 2'); }); it('фирма со строкой ВК warming+active идёт в состав по строке канала, а не по флагу', function () { // 🔴 Фаза 2: источник состава — именно строка firm_channels(vk, warming), // а НЕ флаг ch_vk. Заводим фирму БЕЗ ch_vk, но со строкой канала vk-warming — // и она обязана попасть в состав. Порог опускаем до 1, чтобы дойти до заглушки // отправки и увидеть число готовых номеров. config(['services.vk_ads.token' => 'test-token']); SalesAdAudiencePlatform::forCode('vk')->update(['enabled' => true, 'min_phones' => 1, 'warmup_days' => 3]); $firm = SalesAdAudienceFirm::create(['firm_name' => 'ВК по строке канала', 'warmup_started_at' => now()->subDay()]); $firm->firmChannels()->create(['channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1]); SalesAdAudiencePhone::create(['phone' => '79990000033', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active']); SyncVkAudienceJob::dispatchSync(); expect((string) SalesAdAudiencePlatform::forCode('vk')->last_error)->toContain('Номеров готово к отправке: 1'); }); it('фирма со строкой ВК loaded (ещё не греется) в состав не идёт', function () { // 🔴 Фаза 2: строка канала со статусом loaded — фирма загружена, но НЕ греется. // Такой канал в состав ВК не берём (whereHas ищет строго status=warming), даже // если у фирмы стоит старый флаг ch_vk. При пороге 1 это видно так: номер в // состав не попал → номеров 0 < 1 → канал уходит в ожидание объёма, до заглушки // отправки не доходит. config(['services.vk_ads.token' => 'test-token']); SalesAdAudiencePlatform::forCode('vk')->update(['enabled' => true, 'min_phones' => 1]); $firm = SalesAdAudienceFirm::create(['firm_name' => 'Загружена, но не греется', 'warmup_started_at' => now(), 'ch_vk' => true]); $firm->firmChannels()->create(['channel' => 'vk', 'status' => 'loaded', 'mode' => 'funnel', 'warming_started_at' => null, 'warmed_times' => 0]); SalesAdAudiencePhone::create(['phone' => '79990000044', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30), 'state' => 'active']); Http::fake(); SyncVkAudienceJob::dispatchSync(); $vk = SalesAdAudiencePlatform::forCode('vk'); expect($vk->status)->toBe('waiting_volume') ->and((string) $vk->last_error)->not->toContain('Номеров готово к отправке'); });