Files
portal/app/tests/Feature/ClientSms/CancelCampaignTest.php
T
Дмитрий f5483332e9 feat(смс-клиент): кнопка «Остановить» — после нажатия ни одного нового СМС
Клиент может остановить рассылку в очереди, на отправке и в ожидании утреннего окна.
Нажатие — это отметка «попросил остановить», а не мгновенный обрыв: сообщение, начатое
в этот момент, доводится до конца, сеть на полпути не рвём. Джоб читает отметку свежим
запросом перед каждым следующим номером.

Итог честный: статус «остановлена», причина «клиент», ушло столько, сколько в журнале,
списано ровно за это, заморозка снята полностью. Номера, до которых не дошли, в журнал
не пишутся — с ними ничего не произошло (В-27).

Строки приёмочного листа 1.14-1.17 (серверная часть; кнопка на экране — Task 10).
Защита проверена вырезанием: убрать выход из цикла — 2 красных теста.

ClientSms 151/151, приём лидов 17/17, phpstan по своим файлам чисто.
2026-07-27 18:28:58 +03:00

206 lines
7.4 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\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\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use App\Services\Sms\SmsSendResult;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строки листа 1.14–1.17: кнопка «Остановить».
*
* Здесь важнее всего не «остановилось», а «остановилось ЧЕСТНО»: ни одного нового
* СМС после нажатия, начатое доведено, итог и деньги сходятся с журналом, заморозка
* снята. Поэтому проверяется не статус кампании, а число обращений к провайдеру и
* кошелёк.
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Провайдер-счётчик: на N-й отправке дёргает переданный обработчик. */
function cancelSpyProvider(?Closure $onSend = null): SmsProvider
{
return new class($onSend) implements SmsProvider
{
public int $calls = 0;
public function __construct(private readonly ?Closure $onSend) {}
public function key(): string
{
return 'mts';
}
public function servesOperators(): array
{
return ['mts'];
}
public function priceKopecks(string $operator): int
{
return 0;
}
public function send(SmsOutgoing $message): SmsSendResult
{
$this->calls++;
if ($this->onSend !== null) {
($this->onSend)($this->calls);
}
return new SmsSendResult('MSG-CANCEL-1', $message->segments, 0, CarbonImmutable::now());
}
};
}
function cancelCampaign(int $tenantId, int $count): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'with_optout_link' => false,
'segments' => 1,
'planned_count' => $count,
'total_sms' => $count,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => bcmul('8.50', (string) $count, 2),
'created_by' => null,
]);
}
function cancelContacts(int $tenantId, int $count): void
{
foreach (range(1, $count) as $i) {
ClientSmsContact::create([
'tenant_id' => $tenantId,
'phone' => '7999000010'.$i,
'name' => 'Контакт '.$i,
'operator' => 'МТС',
]);
}
}
it('после остановки провайдер больше не вызывается ни разу', function () {
config(['services.sms.sandbox' => true]);
cancelContacts($this->tenant->id, 5);
$campaign = cancelCampaign($this->tenant->id, 5);
// Клиент нажимает «Остановить» ровно в тот момент, когда ушло второе сообщение.
$spy = cancelSpyProvider(function (int $n) use ($campaign) {
if ($n === 2) {
$campaign->newQuery()->whereKey($campaign->id)->update(['cancel_requested_at' => now()]);
}
});
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
expect($spy->calls)->toBe(2);
});
it('итог остановленной рассылки честный и деньги сходятся', function () {
config(['services.sms.sandbox' => false]);
$wallet = app(AdWalletService::class);
$wallet->topup($this->tenant->id, '1000.00', null, 'test');
cancelContacts($this->tenant->id, 5);
$campaign = cancelCampaign($this->tenant->id, 5);
$wallet->freeze($this->tenant->id, 'sms', 'campaign', $campaign->id, '42.50');
$spy = cancelSpyProvider(function (int $n) use ($campaign) {
if ($n === 2) {
$campaign->newQuery()->whereKey($campaign->id)->update(['cancel_requested_at' => now()]);
}
});
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
app(ClientSmsPricing::class),
$wallet,
app(ClientSmsUnsubscribeLinkService::class),
);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_CLIENT)
->and($campaign->sent_count)->toBe(2)
->and((string) $campaign->actual_cost_rub)->toBe('17.00'); // 2 × 8.50, не 42.50
// Номера, до которых не дошли, в журнале отсутствуют (В-27).
expect(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
$after = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $after->balance_rub)->toBe('983.00') // 1000 17
->and((string) $after->frozen_rub)->toBe('0.00'); // заморозка снята полностью
});
it('остановить можно в очереди, в отправке и в ожидании окна', function () {
foreach ([
ClientSmsCampaign::STATUS_QUEUED,
ClientSmsCampaign::STATUS_SENDING,
ClientSmsCampaign::STATUS_WAITING_WINDOW,
] as $status) {
$campaign = cancelCampaign($this->tenant->id, 1);
$campaign->update(['status' => $status]);
$this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertOk();
expect($campaign->fresh()->cancel_requested_at)->not->toBeNull();
}
});
it('законченную рассылку остановить нельзя', function () {
$campaign = cancelCampaign($this->tenant->id, 1);
$campaign->update(['status' => ClientSmsCampaign::STATUS_DONE]);
$this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertStatus(409);
expect($campaign->fresh()->cancel_requested_at)->toBeNull();
});
it('чужую рассылку остановить нельзя', function () {
$stranger = Tenant::factory()->create();
$campaign = cancelCampaign($stranger->id, 1);
$this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertNotFound();
expect($campaign->fresh()->cancel_requested_at)->toBeNull();
});