check($chat, '1.2.3.4', false))->toBeNull(); expect($limiter->check($chat, '1.2.3.4', false))->toContain('подождите'); }); it('гостю в первый час разговора даёт шестьдесят вопросов', function () { $limiter = app(ChatRateLimiter::class); $chat = str_repeat('b', 32); for ($i = 0; $i < ChatRateLimiter::GUEST_FIRST_HOUR; $i++) { Cache::forget('chat:pause:'.$chat); Cache::forget('chat:ip:1.2.3.4'); // адресный счётчик проверяется отдельным тестом expect($limiter->check($chat, '1.2.3.4', false))->toBeNull(); } Cache::forget('chat:pause:'.$chat); Cache::forget('chat:ip:1.2.3.4'); expect($limiter->check($chat, '1.2.3.4', false))->toContain('несколько минут'); }); // Разговорился — пусть говорит: первый час щедрый. Но если человек «завис» в чате на // весь день, дальше притормаживаем, иначе один посетитель съест дневной бюджет. it('после первого часа гостю даёт уже тридцать вопросов в час', function () { $limiter = app(ChatRateLimiter::class); $chat = str_repeat('d', 32); Cache::forget('chat:pause:'.$chat); expect($limiter->check($chat, '9.9.9.9', false))->toBeNull(); // разговор начался $this->travel(70)->minutes(); // прошёл час с хвостиком for ($i = 0; $i < ChatRateLimiter::GUEST_PER_HOUR_AFTER; $i++) { Cache::forget('chat:pause:'.$chat); Cache::forget('chat:ip:9.9.9.9'); expect($limiter->check($chat, '9.9.9.9', false))->toBeNull(); } Cache::forget('chat:pause:'.$chat); Cache::forget('chat:ip:9.9.9.9'); expect($limiter->check($chat, '9.9.9.9', false))->toContain('несколько минут'); }); it('вошедшему клиенту даёт больше — шестьдесят в час', function () { $limiter = app(ChatRateLimiter::class); $chat = str_repeat('c', 32); for ($i = 0; $i < ChatRateLimiter::USER_PER_HOUR; $i++) { Cache::forget('chat:pause:'.$chat); Cache::forget('chat:ip:1.2.3.4'); expect($limiter->check($chat, '1.2.3.4', true))->toBeNull(); } }); it('режет по адресу в интернете, даже если открывать новые разговоры', function () { $limiter = app(ChatRateLimiter::class); for ($i = 0; $i < ChatRateLimiter::IP_PER_HOUR; $i++) { // STR_PAD_LEFT — с дефолтным (правым) паддингом "1" и "10" дают ОДНУ и ту же // строку ("1" + нули), разговоры схлопывались и тест не набирал нужный счётчик. $limiter->check(str_pad((string) $i, 32, '0', STR_PAD_LEFT), '9.9.9.9', false); } expect($limiter->check(str_repeat('e', 32), '9.9.9.9', false))->toContain('несколько минут'); }); it('обрывает бесконечный разговор', function () { $limiter = app(ChatRateLimiter::class); $chat = str_repeat('e', 32); for ($i = 0; $i < ChatRateLimiter::PER_CHAT_TOTAL; $i++) { BotDialog::create(['chat_id' => $chat, 'direction' => 'in', 'message' => 'вопрос', 'created_at' => now()]); } expect($limiter->check($chat, '1.2.3.4', true))->toContain('несколько минут'); }); it('пробитый суточный потолок гасит чат и шлёт владельцу тревогу один раз', function () { Mail::fake(); config(['services.support.email' => 'support@liderra.ru']); $limiter = app(ChatRateLimiter::class); Cache::put('chat:day:'.now()->toDateString(), ChatRateLimiter::GLOBAL_PER_DAY, now()->addDay()); expect($limiter->check(str_repeat('f', 32), '1.2.3.4', false))->toContain('support@liderra.ru'); expect($limiter->check(str_repeat('9', 32), '5.6.7.8', false))->toContain('support@liderra.ru'); Mail::assertQueued(ChatBudgetAlertMail::class, 1); }); it('счётчики чата живут в атомарном хранилище, а не в файловом кэше', function () { // Файловый кэш под параллельной нагрузкой теряет прибавки (проверено: 300 → 77), // и защита от накрутки счёта становится дырявой. Хранилище счётчиков должно быть // атомарным (Redis INCR). Сторож ловит и подмену на 'file', и пропажу самого ключа: // без ключа config() вернул бы null, и проверка «не file» прошла бы впустую. expect(config('services.bot.limiter_store'))->toBeIn(['redis', 'array']); // И сам класс обязан брать это хранилище, а не общий кэш приложения. expect(file_get_contents(app_path('Services/Bot/ChatRateLimiter.php'))) ->toContain("Cache::store((string) config('services.bot.limiter_store'))"); });