Files
portal/app/tests/Feature/ClientSms/WindowCampaignTest.php
T
Дмитрий b6c837e28a chore(смс-клиент): приёмка Этапа 4 — формат по всей области модуля, а не по свежим файлам
Task 8, приёмка Этапа 4. Кода она не приносит по замыслу: все двенадцать строк
листа (4.1–4.8, 4.11–4.14) закрыты ДВУМЯ доказательствами каждая — тест И живой
прогон, — экранные строки отдельно посмотрены глазами в браузере. Итоговая
таблица Этапа 4 заполнена в приёмочном листе (лист в git не лежит).

В git уходит ровно одна правка — порядок импортов в шести файлах тестов (В-192).
Причина, по которой она вообще нашлась: три этапа подряд `pint` гонялся ТОЛЬКО
по свежим файлам, а по всей области модуля не гонялся ни разу. Заодно выяснено,
что жалобы `line_ending` чинить не надо — это виндовые переводы строк рабочей
копии, в git их нет вовсе (pint «исправил», git не увидел ни одного изменения).
Перегнаны тесты этих шести файлов: 41/41 зелено.

🔴 ДЕНЕЖНАЯ МИНА СВЕРХ ПОСТРОЕННОГО (В-190). Права спросил у самой базы матрицей
по всем таблицам модуля — и увидел, что у рабочей роли crm_app_user есть право
писать в таблицу заморозок, а на её счётчик номеров ad_wallet_holds_id_seq права
нет. Живой прогон парно: без права заморозка денег под боевой ролью ПАДАЕТ («нет
доступа к последовательности»), с правом идёт. Заморозка делается при КАЖДОМ
заказе рассылки, при заказе имени и при запуске рекламной кампании Яндекса.
Промах был мой: в В-142 я привёл стенд к эталону двумя точечными командами по
именам из плана вместо `ON ALL SEQUENCES`, как делает сам db/02_grants.sql.
Памятка выката переписана: вместо списка имён счётчиков — запрос, который САМ
находит все счётчики без права. На бою проверить (косвенно там всё в порядке —
рекламные кампании запускаются тем же кодом, но довод косвенный).

🔴 И ПЕРВЫЙ ЗАХОД ПРИЁМКИ ПРОШЁЛ «ЗЕЛЕНО», НЕ ДОКАЗАВ НИ ОДНОЙ ДЕНЕЖНОЙ СТРОКИ
(В-191): песочница гасит и возврат заморозки при срыве, и ночного работника
целиком. Датчик на будущее — не сдвинулась ни одна копейка, значит прогон не
доказал ничего, даже когда всё зелено.

Ещё три промаха своих же приборов: В-193 и В-194 (прогон не доходил до
состояния — сторож законно даёт попытке дожать её срок, а имя без отметки
согласования кнопка законно не включает: правда была в коде), В-195 (браузерный
замер читал таблицу рассылок вместо базы и «доказал» поломку, которой нет —
класс В-121, соврал прибор).

Живьём под боевой ролью crm_app_user: сторож зависших тремя заходами (пометка →
выдержка срока → срыв, заморозка 17.00 → 0.00); имя за долг вернулось и списало
ровно 100 ₽, отключённое владельцем осталось выключенным при 5 000 ₽; кнопка
включения отказала числами, без пометки клиента 404, с пометкой списала 600 ₽;
номера руками легли пятью видами записи; продолжение довело 5 из 5 за 42.50 ₽.
Под служебной ролью — чистка снимка тройкой (без права падение, без политики
srv_bypass «успешный ноль», с обоими удалено 3).

Прогоны: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов 17/17,
фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan ровно 2
чужие давние, vue-tsc ровно 5 чужих давних в 5 файлах (git blame: от 25.07),
pint чисто.

Стенд сверен со снимком «до» поле за полем и совпал. Намеренное изменение одно:
рабочей роли выданы права на ВСЕ счётчики схемы, как в эталоне db/02_grants.sql.

🟡 Открытый вопрос владельцу — В-182: номера в журнале сообщений живут без срока,
обязательство «90 дней» закрывает только снимок получателей.

Ветка НЕ влита в main и НЕ выкачена. Порядок выката: миграции →
db/03_service_bypass_policies.sql → подсчёт политик srv_bypass (+8) → права →
контрольный запуск чистки снимка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:05:08 +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\AllowedSmsOperators;
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,
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);
});