set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t'); 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' => 'Проект — это заявка на поток клиентов.', ]); }); it('happy path: ответ уходит в Jivo, журнал пишет in+out с latency', function () { Http::fake([ 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]], ]), 'bot.jivosite.com/*' => Http::response(['ok' => true]), ]); (new ProcessJivoMessageJob('chat-1', 'client-1', 'что такое проект?'))->handle(); Http::assertSent(fn ($r) => str_contains($r->url(), 'bot.jivosite.com') && $r['event'] === 'BOT_MESSAGE'); $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('эскалация: BOT_MESSAGE-прощание + INVITE_AGENT, журнал escalated=true', function () { Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); (new ProcessJivoMessageJob('chat-2', 'client-2', 'какой у меня баланс?'))->handle(); Http::assertSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT'); expect(BotDialog::where('direction', 'out')->firstOrFail()->escalated)->toBeTrue(); }); it('второе сообщение чата уходит в LLM вместе с историей первого', function () { // Живой урок 12.07.2026: «а сам руками я могу?» без памяти разговора — бессмыслица. Http::fake([ 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Конечно.']]]], ]), 'bot.jivosite.com/*' => Http::response(['ok' => true]), ]); (new ProcessJivoMessageJob('chat-h', 'client-1', 'что такое проект?'))->handle(); (new ProcessJivoMessageJob('chat-h', 'client-1', 'а расскажи про проект подробнее'))->handle(); $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' => 'Ок.']]]], ]), 'bot.jivosite.com/*' => Http::response(['ok' => true]), ]); (new ProcessJivoMessageJob('chat-alien', 'client-9', 'что такое проект?'))->handle(); (new ProcessJivoMessageJob('chat-mine', 'client-1', 'расскажи про проект'))->handle(); $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 ProcessJivoMessageJob('c', 'c', 'q'); expect($job->queue)->toBe('bot') ->and($job->timeout)->toBeLessThanOrEqual(12); });