86d974330b
Этап 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: два моих же новых теста сперва зеленели по неверной причине — ругань приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо и проверяют, за какое поле ругаются.
202 lines
10 KiB
PHP
202 lines
10 KiB
PHP
<?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 МСК — уже поздно
|
||
});
|