Files
portal/app/tests/Feature/ClientSms/AudienceBuilderTest.php
T
Дмитрий c516e73255 refactor(смс-клиент): убран мёртвый путь сборщика аудитории и фильтр по колонке, в которую никто не пишет
Найдено при чтении кода перед Task 11, вычищено по указанию владельца
«не оставляй хвостов» (журнал В-183).

Что было. Ветка `ClientSmsAudienceBuilder::fromManual` читала снимок
получателей и отсеивала строки по `expires_at`. При этом:
· колонку `expires_at` не заполняет НИКТО — ни контроллер, ни джобы, ни
  обогащение ДаДатой; на стенде она пуста во всех строках;
· саму ветку не зовёт НИКТО: номера, вписанные руками, контроллер берёт
  прямо из запроса и разбирает единственным домом
  (`normalizeManualPhones`, строка листа 4.8).
То есть код обещал «у номера в списке есть срок годности» — поведение,
которого в портале нет. Опасность не теоретическая: я сам, начиная Task 11,
чуть не стал считать возраст снимка по этой колонке.

Что стало. Ветка убрана, на её месте ГРОМКИЙ отказ с объяснением, а не
пустой массив: пустая аудитория читалась бы как «рассылка честно ушла на
ноль номеров» — тот самый молчаливый сбой (В-121). Тест переписан на новое
поведение, мёртвый импорт убран.

🔴 Саму колонку `expires_at` НЕ удалял, и это осознанно: удаление
необратимо, а что лежит в ней на БОЕВОЙ базе, проверить нельзя — она по
умолчанию только для чтения и только с разрешения владельца. Оставлено
названным хвостом в приёмочном листе (п.30 «Чего эта работа НЕ делает»):
удаление — отдельная миграция со своим доказательством.

Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов
17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 08:13:07 +03:00

193 lines
7.5 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\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsContact;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeClientSmsCampaign(int $tenantId, string $source, ?int $audienceDays = null): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Т',
'body' => 'Здравствуйте!',
'sender_name' => 'LIDERRA',
'source' => $source,
'audience_days' => $audienceDays,
'status' => ClientSmsCampaign::STATUS_DRAFT,
'segments' => 1,
'planned_count' => 0,
'sent_count' => 0,
'total_sms' => 0,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '0.00',
'actual_cost_rub' => null,
'created_by' => null,
]);
}
it('builds audience from deals honoring window, soft-delete and duplicate master', function () {
$tenant = Tenant::factory()->create();
// в окне, мастер-лид — должен попасть
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000001',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(2),
]);
// вне окна — не должен попасть
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000002',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(30),
]);
// мягко удалён — не должен попасть
$deleted = Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000003',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(1),
]);
$deleted->delete();
// дубликат другого лида (duplicate_of_id не null) — не должен попасть
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000004',
'phone_operator' => 'МТС',
'duplicate_of_id' => 999999,
'received_at' => now()->subDays(1),
]);
$campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_DEALS, 5);
$result = app(ClientSmsAudienceBuilder::class)->build($campaign);
// Этап 3: вместе с номером аудитория отдаёт его часовой пояс. У этой сделки региона
// нет, поэтому пусто — «не знаем, где человек живёт» (В-85).
expect($result)->toBe([
['phone' => '79990000001', 'operator' => 'МТС', 'tz' => null],
]);
});
it('builds audience from own contact base (source=base)', function () {
$tenant = Tenant::factory()->create();
ClientSmsContact::create([
'tenant_id' => $tenant->id,
'phone' => '79990000011',
'name' => 'Иван',
'operator' => 'Билайн',
// Этап 3: пояс своей базы проставляет ДаДата; здесь он уже известен.
'tz_offset_minutes' => 180,
]);
ClientSmsContact::create([
'tenant_id' => $tenant->id,
'phone' => '79990000012',
'name' => 'Пётр',
'operator' => null,
]);
$campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_BASE);
$result = app(ClientSmsAudienceBuilder::class)->build($campaign);
expect($result)->toEqualCanonicalizing([
['phone' => '79990000011', 'operator' => 'Билайн', 'tz' => 180],
['phone' => '79990000012', 'operator' => null, 'tz' => null],
]);
});
it('источник «список руками» этот сборщик не строит и говорит об этом ГРОМКО, а не пустым списком', function () {
// Раньше здесь была ветка, читавшая снимок и отсеивавшая строки по `expires_at` —
// колонке, в которую не пишет никто. Вызовов у неё не было ни одного (проверено при
// Task 11): номера, вписанные руками, контроллер берёт прямо из запроса и разбирает
// сам (`normalizeManualPhones`, строка листа 4.8). Ветку убрал, но поставил громкий
// отказ вместо пустого массива: пустая аудитория читалась бы как «рассылка честно
// ушла на ноль номеров» — тот самый молчаливый сбой (В-121).
$tenant = Tenant::factory()->create();
$campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_MANUAL);
ClientSmsCampaignPhone::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000021',
]);
expect(fn () => app(ClientSmsAudienceBuilder::class)->build($campaign))
->toThrow(LogicException::class);
});
it('never leaks phones across tenants', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
Deal::factory()->create([
'tenant_id' => $tenantA->id,
'phone' => '79990000031',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(1),
]);
Deal::factory()->create([
'tenant_id' => $tenantB->id,
'phone' => '79990000032',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(1),
]);
ClientSmsContact::create([
'tenant_id' => $tenantB->id,
'phone' => '79990000033',
'name' => null,
'operator' => null,
]);
$campaignDeals = makeClientSmsCampaign($tenantA->id, ClientSmsCampaign::SOURCE_DEALS, 5);
$campaignBase = makeClientSmsCampaign($tenantA->id, ClientSmsCampaign::SOURCE_BASE);
$resultDeals = app(ClientSmsAudienceBuilder::class)->build($campaignDeals);
$resultBase = app(ClientSmsAudienceBuilder::class)->build($campaignBase);
expect($resultDeals)->toBe([
['phone' => '79990000031', 'operator' => 'МТС', 'tz' => null],
])
->and($resultBase)->toBe([]);
});
it('dedups phones across formats preferring a non-null operator, normalized to bare 11 digits', function () {
$tenant = Tenant::factory()->create();
// тот же телефон в разных форматах ввода, разные сделки
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '89991112233',
'phone_operator' => null,
// У этой сделки региона нет…
'received_at' => now()->subDays(1),
]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '+79991112233',
'phone_operator' => 'МегаФон',
// …а у этой есть: 27 = Камчатский край, UTC+12.
'subject_code' => 27,
'received_at' => now()->subDays(2),
]);
$campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_DEALS, 5);
$result = app(ClientSmsAudienceBuilder::class)->build($campaign);
// При схлопывании дублей знание побеждает незнание — и про оператора, и про пояс.
expect($result)->toBe([
['phone' => '79991112233', 'operator' => 'МегаФон', 'tz' => 720],
]);
});