Files
portal/app/tests/Feature/Sales/AdAudienceIntakeTest.php
T
Дмитрий 6e771636ef feat(смс): модуль «Прогрев СМС» с заделом под мультиклиентность
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл).

Что сделано:
- разъём провайдера SmsProvider: новый оператор подключается одним файлом
- заглушка FakeSmsProvider — модуль работает и проверяется ДО согласования
  имени отправителя у операторов (это недели), иначе разработку не закончить
- маршрутизация по оператору: билайновский номер уходит через Билайн за
  4,75 ₽, прочие через МТС — без ручного выбора канала
- стоп-лист: кто отписался, тому не шлём никогда, проверка перед списанием
- отбор получателей с шестью причинами пропуска, все ДО траты денег
- списание скопировано с AutopodborChargeService; пока клиента нет
  (tenant_id пуст) с баланса не берём — платим оператору напрямую
- оператор номера доезжает из «Поиска клиентов» в прогрев (был известен
  и оплачен ДаДате, но терялся при передаче)

Мультиклиентность в костях: колонка tenant_id во всех четырёх таблицах
СМС с первого дня, NULL = «Лидерра сама». Клиент добавляется строкой,
а не переделкой модуля.

Найдено и закрыто при исполнении:
- замок от двойного списания стоял не на том соединении: кампания на
  pgsql_supplier, деньги на pgsql, lockForUpdate по кампании отпускался
  сразу. На бою два запуска списали бы дважды, обрыв — оставил бы пометку
  «оплачено» при неушедших деньгах. Источник правды перенесён в
  balance_transactions под замок по тенанту. Доказано тестом: старый код
  списывал 700 вместо 850
- приём в портал требовал phones строкой по regex — словарь с оператором
  получал 422, в базу не доезжало ничего. Тесты были зелёные, потому что
  звали сервис МИМО контроллера. Проверка теперь принимает оба формата,
  тест идёт через HTTP
- телефоны директоров в contacts остаются строками (договор
  SalesProspectController), словари — только в верхнем phones

Заодно вылечена мигающая поломка 48 тестов доставки лидов: помощник
createRoutingSnapshotFromProject клал снимок на сегодня, а LeadRouter
после 21:00 МСК ищет завтрашний (вечерний переворот заливки) — вечерние
прогоны падали, дневные проходили. Помощник теперь зеркалит активную дату
роутера в любой час. Регрессия SnapshotHelperTimeOfDayTest замораживает
22:00 МСК и пинит инвариант. Боевой LeadRouter не тронут.

Тесты: 84 бэкенд + фронт по экрану + 397 поисковика, весь набор 3226
зелёный, статанализ чист. Все защиты проверены вырезанием.

План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md
Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 06:35:43 +03:00

244 lines
10 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\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Приём фирм в прогрев рекламой сервис-каналом «Поиск → Портал» (контракт v2).
*
* Контракт сменился: приходит фирма со снимком и списком номеров, а не голый
* номер (v1). Фирма опознаётся по ИНН — повторная отправка не плодит дубль,
* а начинает прогрев заново (начальник осознанно вернул фирму в рекламу).
*
* Токен канала — 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('повторная отправка не плодит дубль, а начинает прогрев заново', 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(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)->toBeNull()
->and($firm->warmup_started_at->isToday())->toBeTrue();
});
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 включает обе галочки ch_yandex и ch_vk', function () {
$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->ch_yandex)->toBeTrue()
->and($firm->ch_vk)->toBeTrue()
->and($firm->ch_mts)->toBeFalse();
});
it('без указания площадки греет в Яндексе (ch_yandex=true по умолчанию)', 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->ch_yandex)->toBeTrue()
->and($firm->ch_vk)->toBeFalse()
->and($firm->ch_mts)->toBeFalse();
});
it('новый формат ch_yandex/ch_vk/ch_mts проставляет площадки явно', 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->ch_yandex)->toBeFalse()
->and($firm->ch_vk)->toBeTrue()
->and($firm->ch_mts)->toBeTrue();
});
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);
});