Files
portal/app/tests/Feature/ClientSms/AudienceBuilderTest.php
T

193 lines
7.5 KiB
PHP
Raw Normal View History

<?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],
]);
});