Files
portal/app/tests/Feature/Sales/VkAudienceSyncTest.php
T
Дмитрий 780d7552b5
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
fix: статус доступа ВК пишем в строку площадки, не в мёртвый синглтон
Тот же класс, что баг значка воронки. Экран ВК показывает status из строки
sales_ad_audience_platforms, а SyncVkAudienceJob обновлял vk_status на старом
синглтоне sales_ad_audience_state. Строку площадки заполнили один раз при
миграции и больше не трогали — статус доступа ВК на экране был заморожен.

SyncVkAudienceJob теперь пишет status, last_synced_at, last_error на СТРОКУ
ПЛОЩАДКИ, по образцу Яндекс-джоба. vk_list_id остаётся в синглтоне —
внутренний хэндл списка ВК, экран его не показывает.

Баг спящий: на проде ВК выключен. Тесты VkAudienceSyncTest переведены со
старого синглтона на строку площадки, добавлен новый тест-регресс в
AdAudienceSyncTest. Sales-группа 413/413, composer stan 0, pint чист.

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

203 lines
12 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\SyncVkAudienceJob;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudiencePlatform;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
// Реклама по умолчанию выключена (enabled=false в схеме). Рубильник и порог
// ВК теперь живут на СТРОКЕ ПЛОЩАДКИ (sales_ad_audience_platforms, vk), а не на
// синглтоне sales_ad_audience_state — каждая площадка независима. Каждый тест
// канала включает ВК явно, иначе джоб честно выходит на своём рубильнике и мы
// бы проверяли не то, что думаем.
SalesAdAudiencePlatform::forCode('vk')->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('Номеров готово к отправке');
});