Files
portal/app/tests/Feature/ClientTg/AudienceServiceTest.php
T
Дмитрий 298afb835a feat(телеграм-модуль): ядро клиентского модуля Telegram-рекламы — таблицы, модели, цена, аудитория, кошелёк
Сессия 1 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Бэкенд-ядро — зеркало готового СМС-модуля. Робота ещё нет — он в Сессии 2.

- 8 таблиц client_tg_* с RLS tenant_isolation и GRANT crm_app_user; справочные
  tariffs/settings без RLS с guarded-GRANT. rls-reviewer PASS 8 из 8.
- 7 моделей ClientTg + связи campaign->phones.
- Ступенчатая цена TelegramTariffService — ₽ за показ по объёму; тариф = потолок,
  точную стоимость считает МТС.
- Сборка аудитории TelegramAudienceService — сделки за период / своя база / свой
  список, нормализация телефона, стоп-лист и дедуп; отдаёт кандидатов, реальный
  охват узнаёт робот после загрузки в МТС.
- Канал кошелька telegram: перенесён общий AdWallet со свежей версии портала
  — модели, сервис, миграции; списание и заморозка по каналу telegram под тестами,
  charge не бросает и не уходит в минус, нехватка средств ловится freeze до списания.

Проверки: 26 из 26 Pest зелёные, larastan 0, gitleaks чисто.
db/CHANGELOG_schema.md v8.86 — номер предварительный, ветка отстаёт от main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 16:15:17 +03:00

148 lines
6.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAudienceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeTgCampaign(int $tenantId, string $kind, ?array $params = null): Campaign
{
return Campaign::create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'Объявление',
'ad_link' => 'https://liderra.ru',
'media_path' => null,
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => $kind,
'audience_params' => $params,
'created_by' => null,
]);
}
it('собирает кандидатов из сделок (окно, мягкое удаление, мастер-лид)', 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();
// дубликат другого лида
Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '79990000004',
'phone_operator' => 'МТС', 'duplicate_of_id' => 999999, 'received_at' => now()->subDays(1),
]);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_DEALS, ['days' => 5]);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toBe(['79990000001'])
->and($audience->candidatesCount)->toBe(1);
});
it('собирает кандидатов из своей базы (base)', function () {
$tenant = Tenant::factory()->create();
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000011', 'name' => 'Иван', 'operator' => 'Билайн']);
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000012', 'name' => null, 'operator' => null]);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_BASE);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toEqualCanonicalizing(['79990000011', '79990000012'])
->and($audience->candidatesCount)->toBe(2);
});
it('собирает кандидатов из своего списка с учётом срока (list)', function () {
$tenant = Tenant::factory()->create();
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_LIST);
CampaignPhone::create(['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000021', 'expires_at' => null]);
CampaignPhone::create(['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000022', 'expires_at' => now()->addDays(3)]);
// истёкший — не должен попасть
CampaignPhone::create(['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000023', 'expires_at' => now()->subDay()]);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toEqualCanonicalizing(['79990000021', '79990000022'])
->and($audience->candidatesCount)->toBe(2);
});
it('нормализует форматы и схлопывает дубли до голых 11 цифр', 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' => 'МегаФон', 'received_at' => now()->subDays(2),
]);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_DEALS, ['days' => 5]);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toBe(['79991112233'])
->and($audience->candidatesCount)->toBe(1);
});
it('применяет стоп-лист (optout)', function () {
$tenant = Tenant::factory()->create();
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000041', 'name' => null, 'operator' => null]);
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000042', 'name' => null, 'operator' => null]);
Optout::create(['tenant_id' => $tenant->id, 'phone' => '79990000042']);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_BASE);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toBe(['79990000041'])
->and($audience->candidatesCount)->toBe(1);
});
it('никогда не смешивает номера разных тенантов', 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),
]);
Contact::create(['tenant_id' => $tenantB->id, 'phone' => '79990000033', 'name' => null, 'operator' => null]);
$deals = app(TelegramAudienceService::class)->build(makeTgCampaign($tenantA->id, Campaign::AUDIENCE_DEALS, ['days' => 5]));
$base = app(TelegramAudienceService::class)->build(makeTgCampaign($tenantA->id, Campaign::AUDIENCE_BASE));
expect($deals->phones)->toBe(['79990000031'])
->and($base->phones)->toBe([]);
});