Files
portal/app/tests/Feature/Bot/ProcessChatMessageJobTest.php
T

224 lines
8.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessChatMessageJob;
use App\Models\BotDialog;
use App\Models\KnowledgeChunk;
use App\Services\Bot\BotAnswer;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\ClientFacts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('services.yandexgpt', [
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => null,
'topics' => 'создать проект', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов.',
]);
});
/**
* Мимикрирует то, что делает ChatController: пишет реплику клиента в журнал и
* запускает джобу с её номером, чтобы история не включала текущий вопрос дважды.
*/
function chatMessage(string $chatId, string $text): void
{
$row = BotDialog::create([
'chat_id' => $chatId,
'direction' => 'in',
'message' => $text,
'created_at' => now(),
]);
(new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle();
}
it('happy path: ответ пишется в журнал (in+out) с latency', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
]);
chatMessage('chat-1', 'что такое проект?');
$out = BotDialog::where('direction', 'out')->firstOrFail();
expect(BotDialog::where('direction', 'in')->count())->toBe(1)
->and($out->latency_ms)->toBeGreaterThanOrEqual(0)
->and($out->escalated)->toBeFalse()
->and($out->matched_chunks)->not->toBeNull();
});
it('личный вопрос без карточки фактов: приглашение в кабинет в чате, журнал escalated=false', function () {
// chatMessage() пишет реплику ГОСТЯ (user_id не задан) — карточку фактов ClientFacts
// джоба спрашивает только у вошедшего клиента (см. тесты ниже про user_id), у гостя
// facts остаётся null и BotAnswerService приглашает войти в кабинет, а не зовёт
// специалиста (см. BotContactCaptureTest, PersonalAnswersTest).
chatMessage('chat-2', 'какой у меня баланс?');
$out = BotDialog::where('direction', 'out')->firstOrFail();
expect($out->message)->toContain('личном кабинете')
->and($out->escalated)->toBeFalse();
});
it('второе сообщение чата уходит в LLM вместе с историей первого', function () {
// Живой урок 12.07.2026: «а сам руками я могу?» без памяти разговора — бессмыслица.
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Конечно.']]]],
]),
]);
chatMessage('chat-h', 'что такое проект?');
chatMessage('chat-h', 'а расскажи про проект подробнее');
$llmCalls = collect(Http::recorded())
->filter(fn ($pair) => str_contains($pair[0]->url(), 'llm.api.cloud.yandex.net'));
$lastMessages = collect($llmCalls->last()[0]['messages']);
expect($llmCalls)->toHaveCount(2)
->and($lastMessages->pluck('text'))->toContain('что такое проект?')
->and($lastMessages->pluck('role'))->toContain('assistant');
});
it('история — только своего чата, чужие диалоги в LLM не утекают', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ок.']]]],
]),
]);
chatMessage('chat-alien', 'что такое проект?');
chatMessage('chat-mine', 'расскажи про проект');
$last = collect(collect(Http::recorded())
->filter(fn ($pair) => str_contains($pair[0]->url(), 'llm.api.cloud.yandex.net'))
->last()[0]['messages']);
expect($last->pluck('text'))->not->toContain('что такое проект?');
});
it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () {
$job = new ProcessChatMessageJob('c', 1, 'q');
expect($job->queue)->toBe('bot')
->and($job->timeout)->toBeLessThanOrEqual(12);
});
it('вошедший клиент (у строки журнала есть user_id): джоба берёт карточку у ClientFacts и передаёт её в BotAnswerService', function () {
$facts = new class
{
/** @var list<int> */
public array $calledWith = [];
public function card(int $userId): ?string
{
$this->calledWith[] = $userId;
return 'ДАННЫЕ ЭТОГО КЛИЕНТА: Баланс: 777 ₽.';
}
};
app()->instance(ClientFacts::class, $facts);
$botAnswer = new class
{
public ?string $receivedFacts = 'UNSET';
public function answer(string $question, array $history = [], bool $fromPortal = true, ?string $facts = null): BotAnswer
{
$this->receivedFacts = $facts;
return new BotAnswer('ответ', escalate: false);
}
};
app()->instance(BotAnswerService::class, $botAnswer);
$row = BotDialog::create([
'chat_id' => 'chat-user',
'direction' => 'in',
'message' => 'какой у меня баланс?',
'user_id' => 777,
'created_at' => now(),
]);
(new ProcessChatMessageJob('chat-user', (int) $row->id, 'какой у меня баланс?'))->handle();
expect($facts->calledWith)->toBe([777])
->and($botAnswer->receivedFacts)->toContain('777 ₽');
});
it('гость (у строки журнала user_id пуст): ClientFacts не вызывается, в BotAnswerService приходит facts=null', function () {
$facts = new class
{
public int $calls = 0;
public function card(int $userId): ?string
{
$this->calls++;
return 'ЭТО НЕ ДОЛЖНО БЫТЬ ВЫЗВАНО У ГОСТЯ';
}
};
app()->instance(ClientFacts::class, $facts);
$botAnswer = new class
{
public ?string $receivedFacts = 'UNSET';
public function answer(string $question, array $history = [], bool $fromPortal = true, ?string $facts = null): BotAnswer
{
$this->receivedFacts = $facts;
return new BotAnswer('ответ', escalate: false);
}
};
app()->instance(BotAnswerService::class, $botAnswer);
chatMessage('chat-guest-facts', 'что такое проект?');
expect($facts->calls)->toBe(0)
->and($botAnswer->receivedFacts)->toBeNull();
});
it('ClientFacts бросил исключение: клиент всё равно получает ответ (журнал out), ошибка уходит в лог', function () {
Log::spy();
app()->instance(ClientFacts::class, new class
{
public function card(int $userId): ?string
{
throw new RuntimeException('база моргнула');
}
});
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ок.']]]],
]),
]);
$row = BotDialog::create([
'chat_id' => 'chat-boom',
'direction' => 'in',
'message' => 'что такое проект?',
'user_id' => 999,
'created_at' => now(),
]);
(new ProcessChatMessageJob('chat-boom', (int) $row->id, 'что такое проект?'))->handle();
expect(BotDialog::where('chat_id', 'chat-boom')->where('direction', 'out')->exists())->toBeTrue();
Log::shouldHaveReceived('error')->once();
});