cfd26e38ef
Заменяет JivoBotController+JivoBotClient на ChatController (POST /api/chat/message) и ProcessJivoMessageJob на ProcessChatMessageJob. Реплику клиента теперь пишет контроллер и возвращает её номер (message_id); джоба получает этот номер и берёт историю строго до него, не отправляя ответ во внешний Jivo — он ложится в bot_dialogs, откуда его заберёт своё окошко (Задача 3). Мозг бота не менялся. Спека: docs/superpowers/specs/2026-07-13-own-chat-widget-design.md §5
111 lines
4.6 KiB
PHP
111 lines
4.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\Bot\ProcessChatMessageJob;
|
||
use App\Models\BotDialog;
|
||
use App\Models\KnowledgeChunk;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
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=true', function () {
|
||
// Правило владельца 12.07.2026: живого оператора в чате нет — бот собирает контакт
|
||
// сам и отдаёт его поддержке письмом (см. BotContactCaptureTest).
|
||
chatMessage('chat-2', 'какой у меня баланс?');
|
||
|
||
$out = BotDialog::where('direction', 'out')->firstOrFail();
|
||
expect($out->message)->toContain('номер телефона')
|
||
->and($out->escalated)->toBeTrue();
|
||
});
|
||
|
||
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);
|
||
});
|