Files
portal/app/tests/Feature/Sales/AdAudienceSyncTest.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

275 lines
13 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 () {
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),
]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тестовая стоматология']);
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
]);
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)->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',
]);
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, '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');
});