Files
portal/app/tests/Feature/Bot/ChatRateLimiterTest.php
T
Дмитрий 9e0614c8a5 fix(chat): счётчики переезжают на атомарный Redis — файловый кэш терял прибавки
Файловый Cache::increment() под параллельной нагрузкой (10 x 30) досчитал 77 из 300 —
защита от накрутки счёта (пауза/час/IP/сутки) продавливалась параллельным скриптом.
ChatRateLimiter теперь берёт своё хранилище (services.jivo_bot.limiter_store, по
умолчанию redis, в тестах array) и считает через атомарные add()+increment(), а не
через фасад RateLimiter на общем кэше. Ручная проверка на живом Redis: 300 из 300.
2026-07-13 07:47:20 +03:00

89 lines
3.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Mail\ChatBudgetAlertMail;
use App\Models\BotDialog;
use App\Services\Bot\ChatRateLimiter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
});
it('не даёт строчить сообщения чаще раза в две секунды', function () {
$limiter = app(ChatRateLimiter::class);
$chat = str_repeat('a', 32);
expect($limiter->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_PER_HOUR; $i++) {
Cache::forget('chat:pause:'.$chat);
expect($limiter->check($chat, '1.2.3.4', false))->toBeNull();
}
Cache::forget('chat:pause:'.$chat);
expect($limiter->check($chat, '1.2.3.4', false))->toContain('несколько минут');
});
it('вошедшему клиенту даёт больше — шестьдесят в час', function () {
$limiter = app(ChatRateLimiter::class);
$chat = str_repeat('c', 32);
for ($i = 0; $i < ChatRateLimiter::GUEST_PER_HOUR + 1; $i++) {
Cache::forget('chat:pause:'.$chat);
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('d', 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' незаметно.
expect(config('services.jivo_bot.limiter_store'))->not->toBe('file');
});