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

257 lines
13 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsSnapshotReader;
use App\Services\ClientSms\ClientSmsSnapshotWriter;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* Строка листа 4.13: кнопка «Продолжить рассылку» (решение владельца В-49 «надо обязательно»).
*
* Рассылка встала из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт
* дальше С ТОГО МЕСТА: уже отправленным повторно не шлём, а тому, на кого денег не хватило,
* шлём обязательно.
*
* 🔴 Мина, ради которой этот тест главный (В-151, подтверждена чтением кода): джоб отправки
* считает обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, БЕЗ
* разбора статуса. А номер, на котором кончились деньги, записан как `skipped_no_funds`.
* Значит простой повторный запуск молча пропустил бы его: человек заплатил бы за продолжение,
* а получатель не получил бы ничего. Поэтому продолжение эти строки УДАЛЯЕТ (В-184 — иначе
* не даёт уникальный ключ по клиенту+рассылке+номеру).
*
* Обстановка не изобретена: она взята из `NoFundsStopTest` (там рассылка доводится ровно до
* состояния «встала из-за денег»), помощники переименованы с префиксом resume* — имена
* функций в Pest ГЛОБАЛЬНЫЕ.
*
* Номера только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
// Окно 10–20 по местному времени: 09:00 UTC = полдень в Москве, окно открыто.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
});
/** Кампания со снимком цены 8.50 за СМС — как её создаёт контроллер. */
function resumeCampaign(int $tenantId, int $totalSms): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1,
'planned_count' => $totalSms,
'sent_count' => 0,
'total_sms' => $totalSms,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => bcmul('8.50', (string) $totalSms, 2),
'actual_cost_rub' => null,
]);
}
/** 🪤 В-46: канал выбирается ДВАЖДЫ — подменять надо роутер в контейнере. */
function resumeBindRoute(): void
{
app()->instance(SmsRouter::class, new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]));
}
function resumeSnapshot(ClientSmsCampaign $campaign): void
{
$selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
app(ClientSmsSnapshotWriter::class)->write(
(int) $campaign->tenant_id,
(int) $campaign->id,
$selector->build($recipients, (int) $campaign->tenant_id),
);
}
function resumeRunJob(int $campaignId, int $tenantId, AdWalletService $wallet): void
{
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
$wallet,
);
}
/**
* Доводит рассылку на 3 номера до состояния «встала из-за денег»: одно СМС ушло,
* на втором деньги кончились. Возвращает клиента, рассылку и кошелёк.
*/
function resumeStuckByMoney(): array
{
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK']]],
], 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
foreach (['79995550001', '79995550002', '79995550003'] as $i => $phone) {
ClientSmsContact::create([
'tenant_id' => $tenant->id,
'phone' => $phone,
'name' => 'К'.$i,
'operator' => 'МТС',
'tz_offset_minutes' => 180,
]);
}
resumeBindRoute();
$campaign = resumeCampaign($tenant->id, 3);
resumeSnapshot($campaign);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '25.50');
// Осталось 12.00 — хватает ровно на одно СМС, на второе уже нет.
DB::table('ad_wallets')->where('tenant_id', $tenant->id)->update(['balance_rub' => '12.00']);
resumeRunJob($campaign->id, $tenant->id, $wallet);
return [$tenant, $campaign->fresh(), $wallet];
}
/** Клиентский пользователь для запросов к адресам кабинета. */
function resumeActor(int $tenantId): User
{
return User::factory()->create(['tenant_id' => $tenantId]);
}
it('4.13 — продолжение шлёт тому, на ком кончились деньги, и не шлёт повторно уже отправленным', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
// Убеждаемся, что обстановка та: встала по деньгам, ушло одно, один номер с отказом.
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS)
->and($campaign->sent_count)->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
// Клиент пополнил кошелёк и нажал «Продолжить».
$wallet->topup($tenant->id, '100.00', null, 'пополнение перед продолжением');
$this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertOk();
resumeRunJob($campaign->id, $tenant->id, $wallet);
$campaign->refresh();
// Ушло всем трём, и у КАЖДОГО номера ровно одна запись в журнале.
expect($campaign->sent_count)->toBe(3)
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(3)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(0)
->and((string) $campaign->actual_cost_rub)->toBe('25.50');
});
it('4.13 — рассылку, остановленную клиентом, продолжить нельзя', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
// Та же рассылка, но причина остановки — решение человека.
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_CLIENT]);
$response = $this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertStatus(422);
// Текст объясняет, ЧЬЁ это было решение: человеку важно понимать, что остановил он сам.
expect($response->json('message'))->toContain('остановили сами');
// Ничего не изменилось: ни статус, ни журнал.
expect(ClientSmsCampaign::whereKey($campaign->id)->value('stop_reason'))->toBe(ClientSmsCampaign::STOP_CLIENT)
->and(ClientSmsCampaign::whereKey($campaign->id)->value('status'))->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
});
it('4.13 — рассылку, сорванную сторожем, продолжить нельзя', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_WATCHDOG]);
$response = $this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertStatus(422);
// Здесь причина другая, и текст обязан быть другим: остановила Лидерра из-за сбоя.
expect($response->json('message'))->toContain('из-за сбоя');
expect(ClientSmsCampaign::whereKey($campaign->id)->value('status'))->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
});
it('4.13 — при пустом кошельке продолжение не трогает ни рассылку, ни деньги', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
// Денег нет вовсе: продолжать нечем, и врать про это нельзя.
DB::table('ad_wallets')->where('tenant_id', $tenant->id)->update(['balance_rub' => '0.00']);
$movesBefore = DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count();
$response = $this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertStatus(422);
// Отказ называет ДЕНЬГИ, а не «нельзя продолжить».
expect($response->json('message'))->toContain('₽');
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS)
// Строку «не хватило денег» не тронули: продолжения не было.
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1)
->and(DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count())->toBe($movesBefore)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('0.00');
});
it('4.13 — список рассылок сам говорит, какую можно продолжить и сколько осталось', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
$rows = $this->actingAs(resumeActor($tenant->id))
->getJson('/api/sms/campaigns')
->assertOk()
->json('campaigns');
$row = collect($rows)->firstWhere('id', $campaign->id);
// Правило живёт на СЕРВЕРЕ (урок В-146): экран не разбирает причины сам.
// Осталось двое: тот, на ком встали, и тот, до кого не дошли.
expect($row['can_resume'])->toBeTrue()
->and($row['resume_pending_count'])->toBe(2);
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_CLIENT]);
$rows = $this->actingAs(resumeActor($tenant->id))->getJson('/api/sms/campaigns')->json('campaigns');
expect(collect($rows)->firstWhere('id', $campaign->id)['can_resume'])->toBeFalse();
});