Files
portal/app/tests/Feature/ClientSms/CancelCampaignTest.php
T
Дмитрий 1d8723f285 feat(смс-клиент): Калининград и Камчатка получают СМС каждый в своё утро
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.

У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.

Три состояния строки, и их важно не путать:
  — пояс известен, ждать нечего  → отдаём прямо сейчас;
  — пояс известен, время не пришло → ждёт своего утра;
  — пояса нет                     → ждёт уточнения региона и НЕ уходит вовсе (В-85).

Пустой пояс больше не означает «шлём по Москве». Он означает «мы не знаем, где человек
живёт», и такому номеру СМС не уходит. Про него при этом НЕ пишется в журнал рассылки
«нет маршрута»: мы его даже не пробовали отправить, и врать про него нельзя.

Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).

Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).

Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.

В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.

Запись схемы v9.13.
2026-07-28 15:46:53 +03:00

233 lines
8.9 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);
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем —
// иначе тест зависел бы от часа, в который его запустили (вечером все номера
// ждали бы утра). 09:00 UTC = полдень в Москве, окно открыто.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
});
/** Провайдер-счётчик: на 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' => 'МТС',
'tz_offset_minutes' => 180,
// Этап 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))
->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();
});