Files
portal/app/tests/Feature/ClientSms/SnapshotTest.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

219 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsPricing;
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;
/**
* Строки листа 2.1–2.2: снимок получателей.
*
* Смысл: список получателей фиксируется ОДИН раз — в момент создания рассылки, —
* и после этого не пересматривается ничем (решение владельца В-39). Раньше
* аудитория собиралась дважды: в контроллере для сметы и заново в джобе для
* отправки. Между двумя сборками приходят новые лиды, и факт расходился со сметой.
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::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);
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем —
// иначе тест зависел бы от часа, в который его запустили (вечером все номера
// ждали бы утра). 09:00 UTC = полдень в Москве, окно открыто.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
});
/** Заказ рассылки по своей базе. Порядок слагаемых: переданное побеждает (оператор «+»). */
function snapshotPayload(array $extra = []): array
{
return $extra + [
'title' => 'Снимок',
'source' => 'base',
'body' => 'Здравствуйте!',
];
}
function snapshotContact(int $tenantId, string $phone): ClientSmsContact
{
return ClientSmsContact::create([
'tenant_id' => $tenantId,
'phone' => $phone,
'name' => 'Кто-то',
'operator' => 'МТС',
// Этап 3: без известного региона номер не уходит вовсе (В-85).
'tz_offset_minutes' => 180,
]);
}
/**
* Маршрут МТС существует — иначе в реальном режиме получателей ноль и тест пустой.
*
* 🪤 Подменять ОДИН отборщик (как в тестах Этапа 1) теперь мало: со снимком канал
* выбирается дважды — при создании рассылки отборщиком и при отправке читателем
* снимка. Читатель берёт роутер из контейнера, поэтому подменять надо именно
* роутер: иначе смета посчитается, а отправится ноль — и денежный тест окажется
* пустым, оставаясь зелёным.
*/
function snapshotRealRoute(): void
{
$router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
app()->instance(SmsRouter::class, $router);
app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
$router,
new OperatorNormalizer,
));
}
function snapshotRunJob(int $campaignId, int $tenantId): void
{
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
);
}
it('фиксирует получателей в момент создания рассылки', function () {
snapshotContact($this->tenant->id, '79990000001');
snapshotContact($this->tenant->id, '79990000002');
// Этот в стоп-листе — в снимке он тоже есть, но с причиной «не уйдёт».
snapshotContact($this->tenant->id, '79990000009');
ClientSmsOptout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000009']);
$response = $this->postJson('/api/sms/campaigns', snapshotPayload())->assertCreated();
$campaignId = (int) $response->json('id');
$snapshot = ClientSmsCampaignPhone::where('campaign_id', $campaignId)->get();
expect($snapshot)->toHaveCount(3)
->and($snapshot->whereNull('skip_reason')->pluck('phone')->sort()->values()->all())
->toBe(['79990000001', '79990000002'])
->and($snapshot->firstWhere('phone', '79990000009')->skip_reason)
->toBe(ClientSmsMessage::SKIP_OPTOUT);
});
it('не шлёт тому, кто появился уже ПОСЛЕ создания рассылки', function () {
snapshotContact($this->tenant->id, '79990000001');
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
->assertCreated()->json('id');
// Контакт приходит между созданием рассылки и её отправкой: в снимке его нет,
// значит и СМС ему не уйдёт — считали одно, отправляем ровно то же.
snapshotContact($this->tenant->id, '79990000777');
snapshotRunJob($campaignId, $this->tenant->id);
expect(ClientSmsCampaign::find($campaignId)->sent_count)->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000777')->exists())
->toBeFalse();
});
it('шлёт тому, кого внесли в стоп-лист уже ПОСЛЕ запуска — решение владельца В-39', function () {
snapshotContact($this->tenant->id, '79990000001');
snapshotContact($this->tenant->id, '79990000002');
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
->assertCreated()->json('id');
// Клиент передумал уже после запуска. Владелец 27.07: «написать всё равно» —
// список зафиксирован, смета и факт обязаны сойтись копейка в копейку.
ClientSmsOptout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002']);
snapshotRunJob($campaignId, $this->tenant->id);
expect(ClientSmsCampaign::find($campaignId)->sent_count)->toBe(2)
->and(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000002')
->where('status', ClientSmsMessage::STATUS_FAKE_SENT)->exists())
->toBeTrue();
});
it('2.3 — канал исчез после снимка: в журнале «не определён оператор», как и в предпросмотре', function () {
// Номер без оператора: так приходят номера, вписанные руками, и номера «своей
// базы», которым ДаДата ещё не проставила оператора. В песочнице заглушка
// универсальная, поэтому в снимок он попадает как «уйдёт».
ClientSmsContact::create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000123',
'name' => 'Без оператора',
'operator' => null,
// Регион известен, а оператор — нет: тест ровно про оператора. Без региона
// номер вообще не дошёл бы до вопроса о маршруте и просто ждал бы (Этап 3, В-85).
'tz_offset_minutes' => 180,
]);
// 🪤 Без этого джоб выполняется ВНУТРИ запроса на создание (В-61) — то есть ещё
// со старым, универсальным каналом: номер уходит, и мой прогон оказывается
// вторым, где он уже «обработан». Отправку выполняем сами, после подмены канала.
Queue::fake();
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
->assertCreated()->json('id');
expect(ClientSmsCampaignPhone::where('campaign_id', $campaignId)->whereNull('skip_reason')->count())
->toBe(1); // иначе тест проверял бы не то
// Универсальный канал выключили уже ПОСЛЕ создания рассылки — маршрута больше нет.
// Причину в журнале ставит читатель снимка, и она обязана совпадать с той, что
// человек видел в предпросмотре: «не определён оператор», а не «не из МТС» (В-70).
snapshotRealRoute();
snapshotRunJob($campaignId, $this->tenant->id);
expect(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000123')->value('status'))
->toBe(ClientSmsMessage::SKIP_UNKNOWN_OPERATOR);
});
it('списывает ровно смету — до копейки', function () {
config(['services.sms.sandbox' => false]);
snapshotRealRoute();
Http::fake(['*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200)]);
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
snapshotContact($this->tenant->id, '79990000001');
snapshotContact($this->tenant->id, '79990000002');
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
->assertCreated()->json('id');
// Мир меняется между сметой и отправкой — на деньги это влиять не должно.
snapshotContact($this->tenant->id, '79990000778');
snapshotRunJob($campaignId, $this->tenant->id);
$campaign = ClientSmsCampaign::find($campaignId);
expect((string) $campaign->actual_cost_rub)->toBe((string) $campaign->estimated_cost_rub)
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00'); // иначе тест пустой
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe(bcsub('1000.00', (string) $campaign->actual_cost_rub, 2))
->and((string) $wallet->frozen_rub)->toBe('0.00');
});