Files
portal/app/tests/Feature/Sales/AdAudienceIntakeTest.php
T
Дмитрий ce48d911d6 feat(прогрев ф2 этап B): заезд = только загрузка во все 4 канала (loaded), без автостарта
Фаза 2 Этап B, Task B1 (+ план этапа B). AdAudienceIntake::ingest больше не
запускает прогрев: грузит фирму во все 4 канала (yandex/vk/mts/sms) строками
firm_channels status='loaded' (firstOrCreate — не понижает уже греющийся канал).
ch_* на заезде не пишутся (источник членства — строки firm_channels, resolveChannels
удалён). Повторный заезд обновляет только снимок-поля и НЕ сбрасывает
warmup_started_at/ready_at/stopped_at/stop_reason (заезд ≠ рестарт прогрева).
Фирма без warming-строк ни в одну заливку не идёт ⇒ автостарта нет по построению.
Запуск прогрева — кнопкой «Греть» на портале (этап C). Тесты intake мигрированы
на новую семантику. Bump baseline (+6 postJson-ложняков).

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

324 lines
15 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)]);
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, '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);
});