Files
portal/app/tests/Feature/Sales/SendSmsCampaignJobTest.php
T
Дмитрий a90d60a351
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(витрина B3): отправка СМС пишет эпизод прогрева
SendSmsCampaignJob после успешной отправки пишет мгновенный эпизод
(started_at = ended_at) в летопись sales_ad_audience_warming_episodes —
один на фирму за кампанию (у фирмы может быть несколько номеров в одной
рассылке, это одна «стрельба»). Повтор в новой кампании = новый эпизод
(«СМС ×N» = число волн). Регистратор внедрён методным DI (nullable + app()).

Отдельным коммитом намеренно: СМС-блок правит и параллельная сессия — правка
изолирована в двух файлах.

Проверено: SendSmsCampaignJobTest 9/9, Sales 427/427, stan 0.

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

193 lines
8.8 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\SendSmsCampaignJob;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudienceWarmingEpisode;
use App\Models\SalesSmsCampaign;
use App\Models\SalesSmsMessage;
use App\Models\SalesSmsOptout;
use App\Models\SalesSmsSender;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Джоб отправки рассылки (Task 8).
*
* SharesSupplierPdo обязателен: модели sales_sms_* и sales_ad_audience_phones
* прибиты к pgsql_supplier (отдельный PDO), без шаринга DatabaseTransactions
* откатывал бы только соединение по умолчанию — номера 7999000000179990000007
* утекали бы из прогона в прогон и второй запуск падал бы на unique violation.
* Образец — app/tests/Feature/Sales/AdAudienceScreenTest.php:27.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Кампания с активным именем отправителя и годными номерами прогрева.
* Функцией с префиксом sms_, а не $this-> — PHPStan не понимает привязку $this
* в Pest (тот же приём, что sms_phone() в SmsRecipientSelectorTest).
*
* @param array<int, string> $phones
*/
function sms_campaign_with_phones(array $phones): SalesSmsCampaign
{
$sender = SalesSmsSender::create([
'name' => 'liderra.ru', 'provider_key' => 'fake', 'status' => 'active',
]);
foreach ($phones as $p) {
SalesAdAudiencePhone::create([
'phone' => $p, 'expires_at' => now()->addDays(30),
'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile',
]);
}
return SalesSmsCampaign::create([
'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1,
'sender_id' => $sender->id, 'status' => SalesSmsCampaign::STATUS_QUEUED,
]);
}
it('отправляет каждому годному номеру и закрывает кампанию', function () {
$campaign = sms_campaign_with_phones(['79990000001', '79990000002']);
(new SendSmsCampaignJob($campaign->id, ['79990000001', '79990000002']))->handle();
$campaign->refresh();
expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(2)
->and($campaign->messages)->toHaveCount(2);
});
it('пропущенные номера попадают в журнал с причиной, но не в отправку', function () {
$campaign = sms_campaign_with_phones(['79990000003', '79990000004']);
SalesSmsOptout::create(['phone' => '79990000004', 'reason' => 'manual', 'created_at' => now()]);
(new SendSmsCampaignJob($campaign->id, ['79990000003', '79990000004']))->handle();
$campaign->refresh();
expect($campaign->sent_count)->toBe(1)
->and($campaign->skipped_count)->toBe(1);
$skipped = SalesSmsMessage::where('campaign_id', $campaign->id)
->where('phone', '79990000004')->first();
expect($skipped->status)->toBe(SalesSmsMessage::SKIP_OPTOUT)
->and($skipped->cost_kopecks)->toBe(0);
});
it('не стартует без согласованного имени отправителя', function () {
$campaign = sms_campaign_with_phones(['79990000005']);
$campaign->sender->update(['status' => 'pending']);
(new SendSmsCampaignJob($campaign->id, ['79990000005']))->handle();
$campaign->refresh();
expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED)
->and($campaign->last_error)->toContain('имя отправителя')
->and($campaign->messages)->toHaveCount(0);
});
it('повторный запуск не создаёт вторых сообщений', function () {
$campaign = sms_campaign_with_phones(['79990000006']);
(new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle();
(new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle();
expect(SalesSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(1);
});
// ── B3: отправка СМС пишет эпизод прогрева (летопись витрины) ────────────────
//
// СМС — разовая акция: один эпизод на ФИРМУ в кампании (у фирмы может быть
// несколько номеров в одной рассылке — это одна «стрельба», не N). Повтор в
// новой кампании = новый эпизод («СМС ×N» = число волн).
function sms_sender_campaign(): SalesSmsCampaign
{
// name — VARCHAR(11) (буквенный sender ID), уникум не влезает; две кампании
// одного теста («СМС ×2») переиспользуют один отправитель через firstOrCreate
// (unique tenant_id+name+provider_key).
$sender = SalesSmsSender::firstOrCreate(
['name' => 'liderra.ru', 'provider_key' => 'fake'],
['status' => 'active'],
);
return SalesSmsCampaign::create([
'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1,
'sender_id' => $sender->id, 'status' => SalesSmsCampaign::STATUS_QUEUED,
]);
}
function sms_firm_phone(SalesAdAudienceFirm $firm, string $phone): void
{
SalesAdAudiencePhone::create([
'phone' => $phone, 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30),
'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile',
]);
}
it('успешная отправка СМС пишет мгновенный эпизод прогрева фирме номера', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
sms_firm_phone($firm, '79990000101');
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000101']))->handle();
$ep = SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->sole();
expect($ep->started_at->timestamp)->toBe($ep->ended_at->timestamp);
});
it('фирма с двумя номерами в ОДНОЙ кампании даёт один эпизод СМС', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
sms_firm_phone($firm, '79990000111');
sms_firm_phone($firm, '79990000112');
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000111', '79990000112']))->handle();
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->count())->toBe(1);
});
it('две кампании той же фирме дают два эпизода (СМС ×2)', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
sms_firm_phone($firm, '79990000121');
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000121']))->handle();
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000121']))->handle();
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->count())->toBe(2);
});
it('номер без фирмы (сирота) эпизод не пишет', function () {
// Номер без firm_id (сирота до v8.77) отправляется, но эпизод фирме писать некому.
SalesAdAudiencePhone::create([
'phone' => '79990000131', 'expires_at' => now()->addDays(30),
'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile',
]);
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000131']))->handle();
expect(SalesAdAudienceWarmingEpisode::where('channel', 'sms')->count())->toBe(0);
});
it('при нехватке денег у клиента не уходит ни одной СМС', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1.00']);
$campaign = sms_campaign_with_phones(['79990000007']);
$campaign->update(['tenant_id' => $tenant->id, 'estimated_cost_kopecks' => 50000]);
(new SendSmsCampaignJob($campaign->id, ['79990000007']))->handle();
$campaign->refresh();
expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED)
->and($campaign->sent_count)->toBe(0)
->and($campaign->last_error)->toContain('Не хватило денег');
$row = SalesSmsMessage::where('campaign_id', $campaign->id)->first();
expect($row->status)->toBe(SalesSmsMessage::SKIP_NO_FUNDS);
// Деньги не тронуты: списания не было, потому что его не хватало.
expect((string) $tenant->refresh()->balance_rub)->toBe('1.00');
});