Files
portal/app/tests/Feature/ClientSms/CancelCampaignTest.php
T
Дмитрий c5943ebb4e feat(смс-клиент): снимок получателей — считаем смету и шлём по одному списку
Аудитория собиралась дважды: в контроллере для сметы и заново в джобе для
отправки. Между двумя сборками приходят новые лиды, и «посчитали 900,
отправили 917» было физически возможно.

Теперь контроллер, посчитав смету, кладёт получателей в
client_sms_campaign_phones (две новые колонки: operator, skip_reason), а джоб
читает этот снимок пачками по 500 и ничего не пересобирает.

Решение владельца В-39: после запуска список не пересматривается ничем,
включая стоп-листы — смета и факт сходятся копейка в копейку. Человек,
внесённый в «Не писать этим» уже после запуска, эту рассылку получит;
следующую — нет. Следствие названо и владельцем принято.

Попутно убран квадрат: проверка «номер уже отправлен» шла через in_array по
массиву — на 20 000 номеров это 400 млн сравнений.

Строки приёмочного листа 2.1 и 2.2.

Проверено: ClientSms 144/144, приём лидов 17/17, phpstan по своим файлам 0,
вырезание записи снимка красит все 4 новых теста, живой прогон — контакт,
добавленный между созданием и отправкой, СМС не получил.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 05:37:16 +03:00

225 lines
8.3 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\ClientSmsSnapshotReader;
use App\Services\ClientSms\ClientSmsSnapshotWriter;
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,
'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' => 'МТС',
]);
}
}
/**
* Снимок получателей + канал, которым пойдут сообщения — то, что в бою делает
* контроллер при создании рассылки (строки листа 2.1–2.2).
*
* 🪤 Роутер ставится В КОНТЕЙНЕР: канал выбирается дважды — отборщиком при снимке
* и читателем снимка при отправке. Подменить один отборщик теперь мало.
*/
function cancelSnapshot(ClientSmsCampaign $campaign, SmsProvider $provider): void
{
$router = new SmsRouter([$provider]);
app()->instance(SmsRouter::class, $router);
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
$plan = (new ClientSmsRecipientSelector($router, new OperatorNormalizer))
->build($recipients, (int) $campaign->tenant_id);
app(ClientSmsSnapshotWriter::class)->write((int) $campaign->tenant_id, (int) $campaign->id, $plan);
}
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()]);
}
});
cancelSnapshot($campaign, $spy);
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
app(AdWalletService::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()]);
}
});
cancelSnapshot($campaign, $spy);
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
$wallet,
);
$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();
});