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 */ 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(); });