Files
portal/app/tests/Feature/ClientSms/QuietHoursTest.php
T

202 lines
10 KiB
PHP
Raw Normal View History

<?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 МСК — уже поздно
});