Files
portal/app/tests/Feature/Sales/AdAudienceIntakeTest.php
T
Дмитрий c7bcd7943a fix(прогрев): ночная заливка в Яндекс молчит при нуле номеров, не пишет красную ошибку
При нуле активных номеров (например, после чистки данных) джоб слал в Яндекс
пустую замену состава, Яндекс отвечал 400 «Нет ни одного корректного элемента»,
и на экране начальника повисала красная строчка. Теперь при пустом составе джоб
тихо выходит, погасив прошлую ошибку; сам сегмент в Яндексе сохраняет прежний
состав (пустым его всё равно не сделать — минимум 100). Симметрично VK-джобу.

Три существующих теста конструировали сценарий с нулём яндексовых номеров и
полагались на старую отправку пустой заливки — приведены к настоящему составу
(рядом живая warming-фирма), проверка исключения стала строже.

Larastan исключён: 3 ошибки выше baseline — в чужих tests/Unit/Sms/*SmsProviderTest.php
(параллельная СМС-сессия, не в этом коммите); свои 3 файла stan-чисты.

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

337 lines
16 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;
/**
* Приём фирм в прогрев рекламой сервис-каналом «Поиск → Портал» (контракт v2).
*
* Фаза 2 этап B: заезд БОЛЬШЕ НЕ запускает прогрев. Он только ГРУЗИТ фирму во
* все 4 канала (yandex/vk/mts/sms) в состоянии «загружена, выключена»
* (firm_channels.status='loaded'). Сам прогрев запускается отдельно кнопкой
* «Греть» на портале (этап C). Поэтому:
* • заезд новой фирмы создаёт 4 строки firm_channels со status='loaded'
* (ни одной warming — фирма в заливки/сегменты пока не идёт);
* • повторный заезд НЕ рестартует прогрев (не сбрасывает warmup_started_at/
* ready_at/stopped_at/stop_reason) и НЕ понижает уже греющийся канал в loaded;
* • поля channels/ch_* в payload на членство больше не влияют — источник
* членства это строки firm_channels, а не булевы ch_* на фирме.
*
* Токен канала — config('sales.integration_token') (middleware SalesIntegrationToken).
* В тестовом окружении env пуст, поэтому задаём его здесь, как в SalesIntegrationApiTest.
*
* SharesSupplierPdo обязателен: модели прибиты к соединению pgsql_supplier
* (отдельный PDO), и без шаринга DatabaseTransactions откатывал бы только
* соединение по умолчанию — записи текли бы из теста в тест.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['sales.integration_token' => 'test-token']);
});
function warmupPayload(array $override = []): array
{
return ['firms' => [array_merge([
'firm_name' => 'Тестовая стоматология',
'firm_inn' => '7700000001',
'city' => 'Москва',
'rubric' => 'Стоматологии',
'phones' => ['79990000001', '79990000002'],
'payload' => ['site' => 'example.test'],
'contacts' => [['name' => 'Директор', 'phones' => ['79990000001']]],
], $override)]];
}
it('без токена канал закрыт', function () {
$this->postJson('/api/sales/integration/ad-audience', warmupPayload())
->assertStatus(401);
});
it('заводит фирму на прогрев вместе с её номерами', function () {
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token'])
->assertOk()
->assertJson(['added' => 1]);
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect($firm->phones()->count())->toBe(2)
->and($firm->prospect_id)->toBeNull()
->and($firm->warmup_started_at)->not->toBeNull();
});
it('заезд грузит фирму во все 4 канала как loaded, прогрев не стартует', function () {
// Смысл этапа B: заезд = загрузка, не старт. Четыре строки firm_channels
// (yandex/vk/mts/sms) заводятся сразу, но ВСЕ в loaded — ни одной warming.
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token'])
->assertOk()
->assertJson(['added' => 1]);
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect($firm->firmChannels()->count())->toBe(4)
->and($firm->firmChannels()->orderBy('channel')->pluck('channel')->all())
->toBe(['mts', 'sms', 'vk', 'yandex'])
->and($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4)
->and($firm->firmChannels()->where('status', 'warming')->count())->toBe(0);
});
it('загруженная (не греется) фирма не попадает в состав Яндекс-заливки', function () {
// Фирма заехала (loaded), но прогрев не запускали — значит в ночной сегмент
// Яндекса она входить НЕ должна: греется только warming.
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token'])->assertOk();
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'external_id' => 777]);
config(['services.yandex_audience.token' => 't']);
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
// Живая (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' => '79990000009', '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($csv, '79990000009') && ! str_contains($csv, '79990000001');
});
});
it('повторный заезд не понижает уже греющийся канал и не сбивает его срок', function () {
// Начальник нажал «Греть» по Яндексу (loaded → warming). Повторный заезд той
// же фирмы из поиска НЕ должен погасить прогрев обратно в loaded или сбить срок.
$headers = ['X-Sales-Token' => 'test-token'];
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
$startedAt = now()->subDays(4);
$firm->firmChannels()->where('channel', 'yandex')->update([
'status' => 'warming', 'warming_started_at' => $startedAt,
]);
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
$row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail();
expect($row->status)->toBe('warming')
->and($row->warming_started_at->timestamp)->toBe($startedAt->timestamp);
});
it('повторный заезд не сбрасывает warmup_started_at фирмы на сегодня', function () {
// Заезд ≠ рестарт прогрева: срок фирмы, выставленный ранее, остаётся как был.
$headers = ['X-Sales-Token' => 'test-token'];
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
SalesAdAudienceFirm::where('firm_inn', '7700000001')
->update(['warmup_started_at' => now()->subDays(3)]);
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect($firm->warmup_started_at->isToday())->toBeFalse()
->and($firm->warmup_started_at->lessThan(now()->subDays(2)))->toBeTrue();
});
it('повторная отправка не плодит дубль и не рестартует прогрев', function () {
// Фаза 2 этап B: повторный заезд НЕ трогает warmup_started_at/stopped_at —
// заезд грузит, а не перезапускает прогрев (раньше сбрасывал, теперь нет).
$headers = ['X-Sales-Token' => 'test-token'];
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
SalesAdAudienceFirm::where('firm_inn', '7700000001')
->update(['warmup_started_at' => now()->subDays(10), 'stopped_at' => now()]);
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)
->assertOk()
->assertJson(['added' => 0, 'renewed' => 1]);
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect(SalesAdAudienceFirm::count())->toBe(1)
->and($firm->stopped_at)->not->toBeNull()
->and($firm->warmup_started_at->isToday())->toBeFalse();
});
it('добавляет новый номер к уже заведённой фирме', function () {
$headers = ['X-Sales-Token' => 'test-token'];
$this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk();
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => ['79990000001', '79990000003']]),
$headers
)->assertOk();
expect(SalesAdAudiencePhone::count())->toBe(3);
});
it('отвергает номер не в формате Яндекса', function () {
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => ['+7 (999) 000-00-01']]),
['X-Sales-Token' => 'test-token']
)->assertStatus(422);
});
it('фирму без единого номера в прогрев не берём', function () {
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => []]),
['X-Sales-Token' => 'test-token']
)->assertStatus(422);
});
it('старый формат channels=both больше не задаёт членство — грузятся все 4 канала loaded', function () {
// Фаза 2: channels/ch_* в payload на членство не влияют. Заезд грузит ВСЕ 4
// канала в loaded вне зависимости от подсказки channels.
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['channels' => 'both']),
['X-Sales-Token' => 'test-token']
)->assertOk();
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4);
});
it('без указания площадки заезд всё равно грузит все 4 канала loaded', function () {
$payload = warmupPayload();
unset($payload['firms'][0]['channels']);
$this->postJson('/api/sales/integration/ad-audience', $payload, ['X-Sales-Token' => 'test-token'])
->assertOk();
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4);
});
it('ch_yandex/ch_vk/ch_mts в payload больше не влияют на членство — все 4 канала loaded', function () {
$payload = warmupPayload();
unset($payload['firms'][0]['channels']);
$payload['firms'][0]['ch_yandex'] = false;
$payload['firms'][0]['ch_vk'] = true;
$payload['firms'][0]['ch_mts'] = true;
$this->postJson('/api/sales/integration/ad-audience', $payload, ['X-Sales-Token' => 'test-token'])
->assertOk();
$firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail();
expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4);
});
it('отвергает выдуманную площадку', function () {
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['channels' => 'telegram']),
['X-Sales-Token' => 'test-token']
)->assertStatus(422);
});
// ── Task 11: оператор номера доезжает из «Поиска клиентов» ───────────────────
//
// 🔴 Эти тесты идут ЧЕРЕЗ HTTP намеренно. Проверка формата номера живёт в
// SalesIntegrationController, а не в AdAudienceIntake, поэтому тест, дёргающий
// сервис напрямую, дыру в валидации НЕ УВИДИТ: он будет зелёным, пока живой путь
// отдаёт 422 и не пишет ни строки. Ровно так эта дыра и пряталась.
it('принимает номера со словарём через HTTP и сохраняет оператора', function () {
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => [
['phone' => '79990000021', 'operator' => 'ВымпелКом', 'phone_type' => 'mobile'],
]]),
['X-Sales-Token' => 'test-token']
)->assertOk();
$row = SalesAdAudiencePhone::where('phone', '79990000021')->first();
expect($row)->not->toBeNull()
->and($row->operator)->toBe('ВымпелКом')
->and($row->phone_type)->toBe('mobile');
});
it('по-прежнему принимает старый формат — голые строки через HTTP', function () {
// Python-служба переезжает не мгновенно: пока часть посылок идёт старым
// форматом, приём обязан их брать, а не отбивать 422.
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => ['79990000022']]),
['X-Sales-Token' => 'test-token']
)->assertOk();
$row = SalesAdAudiencePhone::where('phone', '79990000022')->first();
expect($row)->not->toBeNull()
->and($row->operator)->toBeNull();
});
it('отвергает мусор вместо номера в обоих форматах', function () {
$headers = ['X-Sales-Token' => 'test-token'];
// старый формат — голая строка не по формату Яндекса
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => ['не-телефон']]),
$headers
)->assertStatus(422);
// новый формат — строгость к САМОМУ номеру теряться не должна оттого,
// что он приехал завёрнутым в словарь
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => [['phone' => 'не-телефон', 'operator' => 'ВымпелКом']]]),
$headers
)->assertStatus(422);
// словарь вообще без номера
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => [['operator' => 'ВымпелКом']]]),
$headers
)->assertStatus(422);
});
it('отвергает оператора длиннее колонки в базе', function () {
// operator VARCHAR(30) / phone_type VARCHAR(12): без ограничения в проверке
// длинное значение упало бы уже на записи в БД, а не на входе.
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => [
['phone' => '79990000023', 'operator' => str_repeat('я', 31)],
]]),
['X-Sales-Token' => 'test-token']
)->assertStatus(422);
$this->postJson(
'/api/sales/integration/ad-audience',
warmupPayload(['phones' => [
['phone' => '79990000023', 'phone_type' => str_repeat('m', 13)],
]]),
['X-Sales-Token' => 'test-token']
)->assertStatus(422);
});