Files
portal/app/tests/Feature/ClientSms/WindowCampaignTest.php
T
Дмитрий 434d86d5e6 feat(смс-клиент): камчатская часть рассылки уходит сама, а экран объясняет ожидание
Этап 3 «Время и цена», Task 3. Строки листа 3.4 и 3.5.

Ждущая рассылка перестала быть тупиком. Раз в четверть часа команда обходит рассылки,
висящие «ждёт утра», и заново кладёт в очередь те, у которых что-то созрело. Человек не
нажимает ничего: заказал в московский полдень — камчатские номера уйдут в своё утро сами.

Условие «есть что дослать» намеренно строгое: номер созрел И его ещё нет в журнале. Без
второй половины команда дёргала бы одну и ту же рассылку каждые 15 минут до самого конца
ожидания — работы ноль, а журнал шумит.

Команда работает вне запроса пользователя, то есть без tenant-контекста: рассылки
перечисляются служебным соединением (BYPASSRLS), а tenant_id уходит джобу явным аргументом.
Забыть это — получить на бою тихий ноль, тот самый класс поломки «srv_bypass».

Экран под статусом рассылки говорит человеческой фразой: «Отправлено 1 из 7, 1 ждут утра в
своих регионах, ещё 5 — уточняем регион». Два ожидания названы ПО ОТДЕЛЬНОСТИ: утро пройдёт
само, а регион сам не пройдёт, и написать про вторых «ждут утра» значило бы заставить
человека ждать зря.

Счёт ждущих живёт в одном месте — у читателя снимка (один запрос сразу про все рассылки
списка, иначе 50 запросов на открытие страницы). Прежний счёт по одной рассылке зовёт его же:
разъехавшись, экран и джоб рассказали бы про одну рассылку разное.

Прогоны: СМС 195/195 (было 191, 4 новых теста), приём лидов 17/17, фронт 1661 зелёный,
phpstan 0, pint чисто. Вырезанием проверено трижды: убрал «есть что дослать» — покраснел
контрольный тест «пока утро не наступило, не трогаем»; убрал «этого номера ещё нет в журнале»
— он же; убрал из подписи фразу про регион — покраснел фронтовый тест.

Живьём на локальной базе: заказал через браузер рассылку на 7 номеров, ушёл один москвич,
камчатскому проставлено ожидание до 22:00 UTC, пятеро без региона ждут уточнения. Команда
руками до утра — «Дослать: 0 рассылок», после сдвига срока — «1 рассылок» и номер ушёл,
повторный запуск снова 0. Экран сам перестал говорить про утро. Стенд возвращён как был.

🪤 Урок В-95: команда находила НОЛЬ при явно ждущей рассылке — служебное соединение в тестах
не видит незакоммиченных данных (лечится трейтом SharesSupplierPdo). Ловушка врёт в обе
стороны: тест «ничего не ушло» был бы зелёным по неправильной причине. Поймал только потому,
что рядом стоял парный тест «а теперь должно уйти».

⚠️ Ветку по-прежнему нельзя выкатывать до Task 5 (В-93).
2026-07-28 16:44:30 +03:00

309 lines
16 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\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,
));
}
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'); // иначе тест пустой
});