Files
portal/app/tests/Feature/ClientSms/WindowCampaignTest.php
T
Дмитрий 65e270583c feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или
виртуального оператора в рассылку не берётся, и человек видит честную причину,
а не молчаливую пропажу.

Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный
канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём.

Список — настройкой, а не в коде: client_sms_settings.allowed_operators
(схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию.
Правило живёт в одном месте — AllowedSmsOperators.

Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок
и своей базы оператор известен в момент заказа, у номеров, вписанных руками,
его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится
уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали».

Плата за имя не тронута (В-150): в коде два похожих списка операторов, и
связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей.

Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём
только по МТС)» — универсальный канал возит всех.

Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка
24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент
ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн».
ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние,
vue-tsc 5 чужих давних, pint чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:25:37 +03:00

355 lines
19 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\ClientSmsCampaignPhone;
use App\Models\ClientSmsMessage;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsSnapshotReader;
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\Http;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строки листа 3.1–3.3: сообщение отдаётся оператору только в окно 10:00–20:00
* по МЕСТНОМУ времени получателя, и в одной рассылке Калининград с Камчаткой
* уходят каждый в своё утро.
*
* Почему тесты парные (урок В-54): прогон, доказывающий «не ушло», сам по себе
* ничего не доказывает — ноль мог получиться от поломки. Рядом обязан стоять
* прогон «а вот теперь ушло», где всё то же самое, кроме времени суток.
*
* Помощники с префиксом window* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Синтетические номера 7999… — реальные НИКОГДА.
*/
// 🪤 SharesSupplierPdo — команда добора перечисляет рассылки служебным соединением
// (BYPASSRLS): без общего PDO оно не видит незакоммиченные данные теста и находит
// НОЛЬ. Прогон при этом зелёный там, где ждёшь ноль, — то есть врёт в обе стороны.
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Момент UTC по человеческой записи. */
function windowUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
/** Сделка с регионом. 🪤 Регион живёт в subject_code (1..89), НЕ в region_code (В-82). */
function windowDeal(int $tenantId, string $phone, int $subjectCode): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => 'МТС',
'subject_code' => $subjectCode,
'received_at' => now()->subDay(),
]));
}
/** Заказ рассылки по сделкам. */
function windowPayload(array $extra = []): array
{
return $extra + [
'title' => 'Окно',
'source' => 'deals',
'body' => 'Здравствуйте!',
'audience_days' => 30,
];
}
/**
* Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги.
* 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при
* создании рассылки и читателем снимка при отправке (В-46).
*/
function windowRealRoute(): void
{
$router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
app()->instance(SmsRouter::class, $router);
app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
$router,
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
));
}
function windowRunJob(int $campaignId, int $tenantId): void
{
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
);
}
it('в московский полдень камчатский номер ждёт своего утра', function () {
$this->travelTo(windowUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке
windowDeal($this->tenant->id, '79995550001', 82); // Москва, UTC+3
windowDeal($this->tenant->id, '79995550002', 27); // Камчатский край, UTC+12
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
expect($rows['79995550001']->tz_offset_minutes)->toBe(180)
->and($rows['79995550001']->send_after)->toBeNull() // Москве — прямо сейчас
->and($rows['79995550002']->tz_offset_minutes)->toBe(720)
->and($rows['79995550002']->send_after)->not->toBeNull(); // Камчатке — её утром
// Ушёл ровно один номер, и рассылка честно висит «ждёт утра», а не «готово».
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
});
it('контрольный: камчатским утром уходит камчатский, а Москва ждёт', function () {
// Тот же расклад, что выше, но время другое: 22:00 UTC = 10:00 утра на Камчатке
// и полночь в Москве. Без этого прогона «ушёл один» ничего не доказывает (В-54).
$this->travelTo(windowUtc('2026-08-02 22:00'));
windowDeal($this->tenant->id, '79995550001', 82); // Москва
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550002')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
});
it('днём в рабочее время уходят оба — окно никого не задерживает зря', function () {
// 07:00 UTC = 10:00 в Москве и… 19:00 на Камчатке: окно открыто у обоих.
$this->travelTo(windowUtc('2026-08-03 07:00'));
windowDeal($this->tenant->id, '79995550001', 82);
windowDeal($this->tenant->id, '79995550002', 27);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('номер без региона не уходит вовсе и ждёт уточнения — решение владельца В-85', function () {
$this->travelTo(windowUtc('2026-08-03 09:00')); // рабочий день в Москве
// Сделка, заведённая в кабинете руками: региона у неё нет вообще.
windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]);
windowDeal($this->tenant->id, '79995550001', 82);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
// Он не пропущен (причины нет) и не отправлен — он ждёт, когда узнаем регион.
expect($rows['79995550003']->tz_offset_minutes)->toBeNull()
->and($rows['79995550003']->skip_reason)->toBeNull();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001');
// И в журнале рассылки про него НЕ написано «нет маршрута»: мы его даже не пробовали.
expect(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550003')->exists())
->toBeFalse();
});
it('ждущий утра номер не съедает заморозку и не считается отправленным', function () {
$this->travelTo(windowUtc('2026-08-03 09:00'));
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт
Queue::fake(); // отправку запускаем сами, чтобы посмотреть снимок до неё
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$campaign = ClientSmsCampaign::find($id);
expect($campaign->planned_count)->toBe(2); // смета считалась на ОБОИХ
windowRunJob($id, $this->tenant->id);
$campaign->refresh();
expect($campaign->sent_count)->toBe(1)
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW)
->and($campaign->stop_reason)->toBeNull();
});
/*
|--------------------------------------------------------------------------
| Строки листа 3.4–3.5: созревшие дошлются САМИ, без человека.
|--------------------------------------------------------------------------
| Раз в четверть часа команда `client-sms:resume-waiting` обходит рассылки,
| висящие «ждёт утра», и заново кладёт в очередь те, у которых что-то созрело.
| Повторный запуск безопасен: джоб пропускает номера, по которым уже есть запись
| в журнале, а списание идёт по ключу на номер.
*/
it('созревший номер дошлётся сам — команда добора доводит рассылку до конца', function () {
$this->travelTo(windowUtc('2026-08-03 09:00')); // полдень в Москве, 21:00 на Камчатке
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт до своего утра
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// Наступило камчатское утро. Человек ничего не нажимает — работает расписание.
$this->travelTo(windowUtc('2026-08-03 22:00')); // 10:00 на Камчатке
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$campaign = ClientSmsCampaign::find($id);
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
->and(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550002')->exists())->toBeTrue()
->and($campaign->sent_count)->toBe(2)
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('контрольный: пока утро не наступило, команда добора рассылку не трогает', function () {
// Без этого прогона предыдущий ничего не доказывает: «дошлось» могло бы означать
// «команда шлёт всем подряд, не глядя на часы» (урок В-54).
$this->travelTo(windowUtc('2026-08-03 09:00'));
windowDeal($this->tenant->id, '79995550001', 82);
windowDeal($this->tenant->id, '79995550002', 27);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
// Очередь глушим ПОСЛЕ создания: сама рассылка уже отработала и висит «ждёт утра»,
// а вот тронет ли её команда — как раз то, что проверяем.
Queue::fake();
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
Queue::assertNothingPushed();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
});
it('список рассылок различает «ждут утра» и «ждут уточнения региона»', function () {
// Строка листа 3.4. Свалить два ожидания в одно число нельзя: первое пройдёт само
// к утру, второе может кончиться тем, что номер не уйдёт вовсе (следствие В-85).
$this->travelTo(windowUtc('2026-08-03 09:00'));
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — ждёт утра
windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]); // ждёт региона
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$row = collect($this->getJson('/api/sms/campaigns')->assertOk()->json('campaigns'))
->firstWhere('id', $id);
expect($row['status'])->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW)
->and($row['sent_count'])->toBe(1)
->and($row['planned_count'])->toBe(3)
->and($row['waiting_window_count'])->toBe(1)
->and($row['waiting_region_count'])->toBe(1);
});
it('деньги: заморозка держится всё ожидание и снимается, когда дошёл последний номер', function () {
config(['services.sms.sandbox' => false]);
windowRealRoute();
Http::fake(['*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200)]);
$this->travelTo(windowUtc('2026-08-03 09:00'));
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
windowDeal($this->tenant->id, '79995550001', 82);
windowDeal($this->tenant->id, '79995550002', 27);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
// Пока ждём камчатское утро, смета остаётся замороженной целиком: деньги на
// оставшийся номер ещё понадобятся, и отдавать их клиенту обратно нельзя.
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->not->toBe('0.00');
$this->travelTo(windowUtc('2026-08-03 22:00'));
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$campaign = ClientSmsCampaign::find($id);
$wallet->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and((string) $wallet->frozen_rub)->toBe('0.00')
// Факт — сумма по журналу, а не смета: «записано» ⟺ «оплачено».
->and((string) $campaign->actual_cost_rub)->toBe(
bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2)
)
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00'); // иначе тест пустой
});
it('3.14 — цена зафиксирована при создании: объём вырос, а списано по старой цене', function () {
// Рассылка растянулась на сутки, за это время клиент отправил ещё 5 000 СМС и
// перешёл на дешёвую ступень. Досланному номеру списываем ТУ ЖЕ цену, которую
// клиент видел при заказе, — пересчёт задним числом был бы обманом в обе стороны.
config(['services.sms.sandbox' => false]);
windowRealRoute();
Http::fake(['*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200)]);
$this->travelTo(windowUtc('2026-08-03 09:00'));
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — дождётся утра
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$campaign = ClientSmsCampaign::find($id);
$fixedPrice = (string) $campaign->price_rub_per_sms;
expect($fixedPrice)->toBe('9.00'); // объём заказа 2 СМС — самая дорогая ступень
// Пока камчатский номер ждёт утра, клиент отправил ещё 5 000 СМС другой рассылкой.
ClientSmsMessage::create([
'tenant_id' => $this->tenant->id,
'phone' => '79995559999',
'status' => ClientSmsMessage::STATUS_SENT,
'segments' => 5000,
'cost_rub' => '0.00',
]);
// Новый заказ уже пошёл бы по дешёвой ступени — иначе тест ничего не доказывает.
expect(app(ClientSmsPricing::class)->pricePerSmsRubFor((int) $this->tenant->id, 1))->toBe('8.00');
$this->travelTo(windowUtc('2026-08-03 22:00'));
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$kamchatka = ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550002')->firstOrFail();
expect($kamchatka->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and((string) $kamchatka->cost_rub)->toBe($fixedPrice);
});