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

239 lines
9.3 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\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);
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем —
// иначе тест зависел бы от часа, в который его запустили (вечером все номера
// ждали бы утра). 09:00 UTC = полдень в Москве, окно открыто.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
});
/**
* Провайдер-счётчик: на N-й отправке дёргает переданный обработчик.
*
* Тип возврата НЕ сужен до SmsProvider намеренно: тест читает счётчик `$calls`,
* а интерфейс его не обещает — под интерфейсным типом анализатор счётчика не видит
* и краснеет, хотя свойство у класса есть.
*/
function cancelSpyProvider(?Closure $onSend = null)
{
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' => 'МТС',
// Этап 3: без известного региона номер не уходит вовсе (В-85).
'tz_offset_minutes' => 180,
]);
}
}
/**
* Снимок получателей + канал, которым пойдут сообщения — то, что в бою делает
* контроллер при создании рассылки (строки листа 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, new AllowedSmsOperators(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();
});