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