b6c837e28a
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>
355 lines
19 KiB
PHP
355 lines
19 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\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);
|
||
});
|