Files
portal/app/tests/Feature/ClientSms/QuietHoursTest.php
T
Дмитрий 86d974330b feat(смс-клиент): правило «с 10 до 20 по местному» живёт в одном месте, границы правит владелец
Этап 3 «Время и цена», Task 1. Закрыты строки листа Н.3 и 3.7.
Поведение рассылки ещё НЕ меняется — этим займётся Task 2. Сейчас заведено то,
на чём оно будет стоять, и заведено так, чтобы правило нельзя было размножить.

1. Справочник часовых поясов (RegionTimezoneMap). 89 субъектов РФ в том же порядке,
   что и справочник имён; сторож-тест сверяет составы, чтобы справочники не разъехались.
   Отдельный тест на ловушку: код субъекта у нас НЕ автомобильный — 77 это Тюменская
   область, а Москва 82. Неизвестный код и непонятная строка от ДаДаты дают «не знаю»,
   а не ноль: ноль означал бы Гринвич, то есть тихую подмену Камчатки Лондоном.

2. Единственный дом правила 10–20 (SmsQuietHours): можно ли отдавать сейчас, когда
   откроется окно, осмысленно ли такое окно. Границы читаются из настроек один раз
   на объект — на 20 000 номеров иначе был бы 20 000-й запрос к базе.

3. Границы окна в общих настройках: миграция добавляет две колонки со значениями 10 и 20.
   Защита от повторного запуска пошаговая — прерванная ручная подача SQL на бою не должна
   оставить вторую колонку несозданной (урок В-80). Прав не требует: колонки наследуют
   привилегии таблицы. Запись схемы v9.12.

4. Админка «СМС»: два поля «Отправляем с / по» и объяснение, что часы — по местному времени
   получателя и клиент их не настраивает. Окно наизнанку «с 20 до 10» это отправка всю ночь,
   поэтому сервер его не принимает и говорит человеку почему. Проверяется ПОЛУЧИВШЕЕСЯ окно,
   а не присланные поля: правка одной границы тоже могла его вывернуть — журнал В-91.

Прогоны: СМС 186/186 (было 171, 15 новых тестов), приём лидов 17/17, фронт 1658 зелёных
и 3 намеренно пропущенных, phpstan по своим файлам 0, pint чисто, проверка типов без новых ошибок.
Вырезанием проверено дважды: убрал проверку окна — покраснели два теста; убрал чтение границ
с сервера на экране — покраснел фронтовый тест. Живьём: окно 11–19 сохранилось и пережило
перезагрузку, «с 20 до 10» отклонено с человеческим текстом, вернул 10–20.

Попутный урок В-92: два моих же новых теста сперва зеленели по неверной причине — ругань
приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо
и проверяют, за какое поле ругаются.
2026-07-28 15:20:13 +03:00

202 lines
10 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\Models\ClientSmsSettings;
use App\Services\ClientSms\SmsQuietHours;
use App\Support\RegionTimezoneMap;
use App\Support\RussianRegions;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строки листа Н.3 и 3.7: справочник «субъект РФ → часовой пояс» и ЕДИНСТВЕННОЕ место,
* где живёт правило «отдаём сообщение только с 10:00 до 20:00 по местному времени».
*
* Почему это важно проверять отдельно и заранее: ошибка на час здесь не видна ни в одном
* прогоне рассылки — она видна только человеку, которому СМС придёт в 4 утра.
*/
uses(RefreshDatabase::class);
/** Момент UTC по человеческой записи — чтобы в тесте было видно, который час. */
function quietUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
it('справочник поясов накрывает ровно те же 89 субъектов, что и справочник имён', function () {
expect(array_keys(RegionTimezoneMap::CODE_TO_UTC_HOURS))
->toBe(array_keys(RussianRegions::CODE_TO_NAME));
});
it('знает пояс Москвы, Камчатки и Калининграда по коду субъекта', function () {
expect(RegionTimezoneMap::offsetMinutes(82))->toBe(180); // Москва, UTC+3
expect(RegionTimezoneMap::offsetMinutes(27))->toBe(720); // Камчатский край, UTC+12
expect(RegionTimezoneMap::offsetMinutes(46))->toBe(120); // Калининградская область, UTC+2
});
it('код 77 — это Тюменская область, а не Москва', function () {
// 🪤 Ловушка В-82: автомобильный код 77 = Москва, а наш subject_code 77 = Тюмень (UTC+5).
// Спутать их — значит будить Тюмень на два часа раньше и молча.
expect(RussianRegions::CODE_TO_NAME[77])->toBe('Тюменская область');
expect(RegionTimezoneMap::offsetMinutes(77))->toBe(300);
});
it('незнакомый код субъекта даёт «не знаю», а не Москву', function () {
expect(RegionTimezoneMap::offsetMinutes(null))->toBeNull();
expect(RegionTimezoneMap::offsetMinutes(0))->toBeNull();
expect(RegionTimezoneMap::offsetMinutes(90))->toBeNull();
});
it('разбирает пояс из ответа ДаДаты и не принимает мусор за Гринвич', function () {
expect(RegionTimezoneMap::fromDaDataTimezone('UTC+3'))->toBe(180);
expect(RegionTimezoneMap::fromDaDataTimezone('UTC+9'))->toBe(540);
expect(RegionTimezoneMap::fromDaDataTimezone('UTC+12'))->toBe(720);
expect(RegionTimezoneMap::fromDaDataTimezone('UTC-3'))->toBe(-180);
expect(RegionTimezoneMap::fromDaDataTimezone('UTC+5:30'))->toBe(330);
// Непонятная строка — это «не знаю», а не ноль: ноль означал бы Лондон.
expect(RegionTimezoneMap::fromDaDataTimezone(null))->toBeNull();
expect(RegionTimezoneMap::fromDaDataTimezone(''))->toBeNull();
expect(RegionTimezoneMap::fromDaDataTimezone('МСК+3'))->toBeNull();
expect(RegionTimezoneMap::fromDaDataTimezone('UTC'))->toBeNull();
});
it('в окно попадает 10:00 по местному, а 09:59 — нет', function () {
$rule = app(SmsQuietHours::class);
$msk = RegionTimezoneMap::MOSCOW_OFFSET_MINUTES;
expect($rule->canSendNow($msk, quietUtc('2026-08-03 07:00')))->toBeTrue(); // 10:00 МСК
expect($rule->canSendNow($msk, quietUtc('2026-08-03 06:59')))->toBeFalse(); // 09:59 МСК
expect($rule->canSendNow($msk, quietUtc('2026-08-03 16:59')))->toBeTrue(); // 19:59 МСК
expect($rule->canSendNow($msk, quietUtc('2026-08-03 17:00')))->toBeFalse(); // 20:00 МСК
});
it('Камчатка и Калининград просыпаются в разное время', function () {
$rule = app(SmsQuietHours::class);
$kamchatka = 12 * 60;
$kaliningrad = 2 * 60;
// 22:00 UTC = 10:00 утра на Камчатке и полночь в Калининграде.
expect($rule->canSendNow($kamchatka, quietUtc('2026-08-02 22:00')))->toBeTrue();
expect($rule->canSendNow($kaliningrad, quietUtc('2026-08-02 22:00')))->toBeFalse();
});
it('говорит, когда окно откроется в следующий раз', function () {
$rule = app(SmsQuietHours::class);
$msk = RegionTimezoneMap::MOSCOW_OFFSET_MINUTES;
// 03:00 МСК того же дня → ждём до 10:00 МСК = 07:00 UTC сегодня.
expect($rule->nextWindowOpensAt($msk, quietUtc('2026-08-03 00:00'))->toIso8601String())
->toBe(quietUtc('2026-08-03 07:00')->toIso8601String());
// 23:00 МСК → ждём до 10:00 МСК ЗАВТРА.
expect($rule->nextWindowOpensAt($msk, quietUtc('2026-08-03 20:00'))->toIso8601String())
->toBe(quietUtc('2026-08-04 07:00')->toIso8601String());
// Окно открыто прямо сейчас — ждать нечего.
expect($rule->nextWindowOpensAt($msk, quietUtc('2026-08-03 09:00'))->toIso8601String())
->toBe(quietUtc('2026-08-03 09:00')->toIso8601String());
});
it('Камчатке говорит про её утро, а не про московское', function () {
$rule = app(SmsQuietHours::class);
$kamchatka = 12 * 60;
// 03:00 UTC = 15:00 на Камчатке… нет, 15:00 — это ещё окно. Возьмём ночь Камчатки:
// 18:00 UTC = 06:00 утра на Камчатке → ждать до 10:00 камчатских = 22:00 UTC того же дня.
expect($rule->nextWindowOpensAt($kamchatka, quietUtc('2026-08-03 18:00'))->toIso8601String())
->toBe(quietUtc('2026-08-03 22:00')->toIso8601String());
});
it('владелец меняет границы окна в админке, и они сохраняются', function () {
// Строка листа 3.7: без выпуска новой версии.
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 29,
'quiet_from_hour' => 11,
'quiet_to_hour' => 19,
]);
$r->assertStatus(200);
expect($r->json('quiet_from_hour'))->toBe(11);
expect($r->json('quiet_to_hour'))->toBe(19);
$settings = ClientSmsSettings::first();
expect($settings->quiet_from_hour)->toBe(11);
expect($settings->quiet_to_hour)->toBe(19);
});
it('окно наизнанку («с 20 до 10») админка не принимает', function () {
// Окно «с 20 до 10» означало бы отправку всю ночь — ровно то, что этап запрещает.
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 29,
'quiet_from_hour' => 20,
'quiet_to_hour' => 10,
]);
$r->assertStatus(422);
expect(implode(' ', $r->json('errors.quiet_to_hour') ?? []))->toContain('раньше');
// И ничего не сохранилось: границы остались прежними.
$settings = ClientSmsSettings::first();
expect($settings->quiet_from_hour)->toBe(10);
expect($settings->quiet_to_hour)->toBe(20);
});
it('окно нельзя вывернуть и правкой одной границы', function () {
// Хранится 10–20. Присылаем ТОЛЬКО конец = 9 — по отдельности значение допустимое,
// а получившееся окно «с 10 до 9» такое же ночное. Проверять надо результат, а не письмо.
// 🪤 Поля имени шлём обязательно: без них 422 придёт за них, и тест позеленеет впустую (В-46).
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 29,
'quiet_to_hour' => 9,
]);
$r->assertStatus(422);
expect($r->json('errors.quiet_to_hour'))->not->toBeNull();
$settings = ClientSmsSettings::first();
expect($settings->quiet_to_hour)->toBe(20);
});
it('час вне суток админка не принимает', function () {
$base = ['name_fee_rub_per_operator' => '2500.00', 'name_debt_grace_days' => 29];
$r1 = $this->putJson('/api/admin/sms/settings', $base + ['quiet_from_hour' => 25]);
$r1->assertStatus(422);
expect($r1->json('errors.quiet_from_hour'))->not->toBeNull();
$r2 = $this->putJson('/api/admin/sms/settings', $base + ['quiet_to_hour' => 0]);
$r2->assertStatus(422);
expect($r2->json('errors.quiet_to_hour'))->not->toBeNull();
});
it('старый способ сохранить только настройки имени продолжает работать', function () {
// Границы окна необязательны: экран шлёт всё, но контракт менять права не имеем.
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '3000.00',
'name_debt_grace_days' => 14,
]);
$r->assertStatus(200);
$settings = ClientSmsSettings::first();
expect($settings->name_debt_grace_days)->toBe(14);
expect($settings->quiet_from_hour)->toBe(10);
expect($settings->quiet_to_hour)->toBe(20);
});
it('слушается границ, выставленных владельцем в админке', function () {
ClientSmsSettings::query()->update(['quiet_from_hour' => 12, 'quiet_to_hour' => 18]);
$rule = app(SmsQuietHours::class);
$msk = RegionTimezoneMap::MOSCOW_OFFSET_MINUTES;
expect($rule->canSendNow($msk, quietUtc('2026-08-03 07:00')))->toBeFalse(); // 10:00 МСК — уже рано
expect($rule->canSendNow($msk, quietUtc('2026-08-03 09:00')))->toBeTrue(); // 12:00 МСК
expect($rule->canSendNow($msk, quietUtc('2026-08-03 15:00')))->toBeFalse(); // 18:00 МСК — уже поздно
});