2026-07-13 07:29:47 +03:00
|
|
|
<?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('подождите');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-14 13:46:15 +03:00
|
|
|
it('гостю в первый час разговора даёт шестьдесят вопросов', function () {
|
2026-07-13 07:29:47 +03:00
|
|
|
$limiter = app(ChatRateLimiter::class);
|
|
|
|
|
$chat = str_repeat('b', 32);
|
|
|
|
|
|
2026-07-14 13:46:15 +03:00
|
|
|
for ($i = 0; $i < ChatRateLimiter::GUEST_FIRST_HOUR; $i++) {
|
2026-07-13 07:47:20 +03:00
|
|
|
Cache::forget('chat:pause:'.$chat);
|
2026-07-14 13:46:15 +03:00
|
|
|
Cache::forget('chat:ip:1.2.3.4'); // адресный счётчик проверяется отдельным тестом
|
2026-07-13 07:29:47 +03:00
|
|
|
expect($limiter->check($chat, '1.2.3.4', false))->toBeNull();
|
|
|
|
|
}
|
2026-07-13 07:47:20 +03:00
|
|
|
Cache::forget('chat:pause:'.$chat);
|
2026-07-14 13:46:15 +03:00
|
|
|
Cache::forget('chat:ip:1.2.3.4');
|
2026-07-13 07:29:47 +03:00
|
|
|
expect($limiter->check($chat, '1.2.3.4', false))->toContain('несколько минут');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-14 13:46:15 +03:00
|
|
|
// Разговорился — пусть говорит: первый час щедрый. Но если человек «завис» в чате на
|
|
|
|
|
// весь день, дальше притормаживаем, иначе один посетитель съест дневной бюджет.
|
|
|
|
|
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('несколько минут');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-13 07:29:47 +03:00
|
|
|
it('вошедшему клиенту даёт больше — шестьдесят в час', function () {
|
|
|
|
|
$limiter = app(ChatRateLimiter::class);
|
|
|
|
|
$chat = str_repeat('c', 32);
|
|
|
|
|
|
2026-07-14 13:46:15 +03:00
|
|
|
for ($i = 0; $i < ChatRateLimiter::USER_PER_HOUR; $i++) {
|
2026-07-13 07:47:20 +03:00
|
|
|
Cache::forget('chat:pause:'.$chat);
|
2026-07-14 13:46:15 +03:00
|
|
|
Cache::forget('chat:ip:1.2.3.4');
|
2026-07-13 07:29:47 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 13:46:15 +03:00
|
|
|
expect($limiter->check(str_repeat('e', 32), '9.9.9.9', false))->toContain('несколько минут');
|
2026-07-13 07:29:47 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-07-13 07:47:20 +03:00
|
|
|
|
|
|
|
|
it('счётчики чата живут в атомарном хранилище, а не в файловом кэше', function () {
|
|
|
|
|
// Файловый кэш под параллельной нагрузкой теряет прибавки (проверено: 300 → 77),
|
|
|
|
|
// и защита от накрутки счёта становится дырявой. Хранилище счётчиков должно быть
|
2026-07-13 07:49:20 +03:00
|
|
|
// атомарным (Redis INCR). Сторож ловит и подмену на 'file', и пропажу самого ключа:
|
|
|
|
|
// без ключа config() вернул бы null, и проверка «не file» прошла бы впустую.
|
2026-07-13 08:32:21 +03:00
|
|
|
expect(config('services.bot.limiter_store'))->toBeIn(['redis', 'array']);
|
2026-07-13 07:49:20 +03:00
|
|
|
|
|
|
|
|
// И сам класс обязан брать это хранилище, а не общий кэш приложения.
|
|
|
|
|
expect(file_get_contents(app_path('Services/Bot/ChatRateLimiter.php')))
|
2026-07-13 08:32:21 +03:00
|
|
|
->toContain("Cache::store((string) config('services.bot.limiter_store'))");
|
2026-07-13 07:47:20 +03:00
|
|
|
});
|