Files
portal/app/tests/Feature/Sales/AdAudienceSyncTest.php
T
Дмитрий c7831e3f14 feat(прогрев ф2): Яндекс-заливка берёт только строки firm_channels warming+active
Фаза 2 Этап A, Task 5. SyncAdAudienceJob выбирает фирмы не по ch_yandex, а по
наличию строки firm_channels(channel=yandex, status=warming), и решает по этой
строке через decideForChannelRow (funnel/flat). Фирма с yandex-строкой loaded
(не греется) в сегмент не попадает, даже если ch_yandex=true. Kill-switch
enabled и token-guard — не тронуты (защита денег владельца). prospectConnection,
client/replace/last_error — без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:19:13 +03:00

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