- Implement Fake repositories and services for testing purposes. - Create tests for Article API including creation, validation, and listing. - Develop ProcessTicketJobFlowTest to validate ticket processing logic. - Add QuickReplyAdminTest for creating and updating quick replies. - Implement TicketAndArticleModelTest to ensure proper cascading deletes and credential encryption. - Create TicketIngestionTest for ticket creation and job dispatching. - Add TicketShowPageTest to verify rendering of quick replies and tool calls. - Implement unit tests for ClassifierPromptBuilder, EmbeddingService, LlmJsonDecoder, QuickReplyResolver, SupportReplyService, TicketResultPayloadBuilder, TicketToolCallService, and ToolCallRequestValidator.
217 lines
8.8 KiB
PHP
217 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Jobs\ProcessTicketJob;
|
|
use App\Models\Article;
|
|
use App\Models\QuickReply;
|
|
use App\Models\Ticket;
|
|
use App\Services\EmbeddingService;
|
|
use App\Services\KnowledgeGapService;
|
|
use App\Services\QuickReplyResolver;
|
|
use App\Services\SemanticSearchService;
|
|
use App\Services\SupportReplyService;
|
|
use App\Services\TicketNormalizationService;
|
|
use App\Services\TicketProcessingLoggerService;
|
|
use App\Services\TicketResultPayloadBuilder;
|
|
use App\Services\Tools\TicketToolCallService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class ProcessTicketJobFlowTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_it_uses_quick_reply_and_skips_tool_call(): void
|
|
{
|
|
$article = Article::query()->create(['title' => 'DNS', 'content' => 'stappen']);
|
|
$quickReply = QuickReply::query()->create(['title' => 'DNS Quick', 'content' => 'Gebruik DNS quick antwoord', 'is_active' => true]);
|
|
$article->quickReplies()->sync([$quickReply->id]);
|
|
|
|
$ticket = Ticket::query()->create(['message' => 'DNS vraag', 'status' => 'queued']);
|
|
|
|
$embedding = $this->createMock(EmbeddingService::class);
|
|
$embedding->method('embed')->willReturn(array_fill(0, 768, 0.1));
|
|
$embedding->method('context')->willReturn(['provider_instance_id' => 'p1', 'embedding_model' => 'm1']);
|
|
|
|
$semantic = $this->createMock(SemanticSearchService::class);
|
|
$semantic->method('findBestArticle')->willReturn([
|
|
'best_article' => $article,
|
|
'confidence' => 0.9,
|
|
'explanation' => 'match',
|
|
'top_3_candidates' => [],
|
|
'top_5_candidates' => [],
|
|
'retrieval_meta' => [],
|
|
'requested_tool_call' => ['action' => 'domain_inf', 'parameters' => ['sld' => 'example', 'tld' => 'nl']],
|
|
'classifier_raw_response' => ['mode' => 'llm'],
|
|
]);
|
|
|
|
$normalizer = $this->createMock(TicketNormalizationService::class);
|
|
$normalizer->method('normalize')->willReturn([
|
|
'normalized_message' => 'dns vraag',
|
|
'redaction_report' => ['language' => 'nl'],
|
|
]);
|
|
|
|
$logger = app(TicketProcessingLoggerService::class);
|
|
|
|
$knowledgeGap = $this->createMock(KnowledgeGapService::class);
|
|
$knowledgeGap->method('shouldCreateDraft')->willReturn(false);
|
|
|
|
$toolCallService = $this->createMock(TicketToolCallService::class);
|
|
$toolCallService->expects($this->never())->method('executeRequestedTool');
|
|
|
|
$quickReplyResolver = $this->createMock(QuickReplyResolver::class);
|
|
$quickReplyResolver->method('resolveForArticle')->willReturn($quickReply);
|
|
|
|
$supportReply = $this->createMock(SupportReplyService::class);
|
|
$supportReply->expects($this->never())->method('build');
|
|
|
|
$job = new ProcessTicketJob($ticket->id);
|
|
$job->handle(
|
|
$embedding,
|
|
$semantic,
|
|
$normalizer,
|
|
$logger,
|
|
$knowledgeGap,
|
|
$toolCallService,
|
|
$quickReplyResolver,
|
|
$supportReply,
|
|
new TicketResultPayloadBuilder
|
|
);
|
|
|
|
$ticket->refresh();
|
|
$this->assertSame('completed', $ticket->status);
|
|
$this->assertSame('Gebruik DNS quick antwoord', $ticket->support_reply);
|
|
$this->assertSame($quickReply->id, $ticket->result_payload['quick_reply']['id']);
|
|
}
|
|
|
|
public function test_it_executes_tool_call_when_no_quick_reply_exists(): void
|
|
{
|
|
$article = Article::query()->create(['title' => 'DNS', 'content' => 'stappen', 'allowed_actions' => ['domain_inf']]);
|
|
$ticket = Ticket::query()->create([
|
|
'message' => 'DNS vraag',
|
|
'status' => 'queued',
|
|
'api_credentials' => ['apiuser' => 'u', 'apipassword' => 'p'],
|
|
]);
|
|
|
|
$embedding = $this->createMock(EmbeddingService::class);
|
|
$embedding->method('embed')->willReturn(array_fill(0, 768, 0.1));
|
|
$embedding->method('context')->willReturn(['provider_instance_id' => 'p1', 'embedding_model' => 'm1']);
|
|
|
|
$semantic = $this->createMock(SemanticSearchService::class);
|
|
$semantic->method('findBestArticle')->willReturn([
|
|
'best_article' => $article,
|
|
'confidence' => 0.9,
|
|
'explanation' => 'match',
|
|
'top_3_candidates' => [],
|
|
'top_5_candidates' => [],
|
|
'retrieval_meta' => [],
|
|
'requested_tool_call' => ['action' => 'domain_inf', 'parameters' => ['sld' => 'example', 'tld' => 'nl']],
|
|
'classifier_raw_response' => ['mode' => 'llm'],
|
|
]);
|
|
|
|
$normalizer = $this->createMock(TicketNormalizationService::class);
|
|
$normalizer->method('normalize')->willReturn([
|
|
'normalized_message' => 'dns vraag',
|
|
'redaction_report' => ['language' => 'nl'],
|
|
]);
|
|
|
|
$knowledgeGap = $this->createMock(KnowledgeGapService::class);
|
|
$knowledgeGap->method('shouldCreateDraft')->willReturn(false);
|
|
|
|
$quickReplyResolver = $this->createMock(QuickReplyResolver::class);
|
|
$quickReplyResolver->method('resolveForArticle')->willReturn(null);
|
|
|
|
$toolRecord = new \App\Models\TicketToolCall([
|
|
'action' => 'domain_inf',
|
|
'status' => 'success',
|
|
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
|
|
]);
|
|
$toolCallService = $this->createMock(TicketToolCallService::class);
|
|
$toolCallService->method('executeRequestedTool')->willReturn($toolRecord);
|
|
|
|
$supportReply = $this->createMock(SupportReplyService::class);
|
|
$supportReply->method('build')->willReturn('1. Doe stap 1');
|
|
|
|
$job = new ProcessTicketJob($ticket->id);
|
|
$job->handle(
|
|
$embedding,
|
|
$semantic,
|
|
$normalizer,
|
|
app(TicketProcessingLoggerService::class),
|
|
$knowledgeGap,
|
|
$toolCallService,
|
|
$quickReplyResolver,
|
|
$supportReply,
|
|
new TicketResultPayloadBuilder
|
|
);
|
|
|
|
$ticket->refresh();
|
|
$this->assertSame('completed', $ticket->status);
|
|
$this->assertSame('1. Doe stap 1', $ticket->support_reply);
|
|
$this->assertSame('domain_inf', $ticket->result_payload['requested_tool_call']['action']);
|
|
}
|
|
|
|
public function test_it_marks_knowledge_gap_and_skips_customer_reply(): void
|
|
{
|
|
$article = Article::query()->create(['title' => 'DNS', 'content' => 'stappen']);
|
|
$ticket = Ticket::query()->create(['message' => 'DNS vraag', 'status' => 'queued']);
|
|
|
|
$embedding = $this->createMock(EmbeddingService::class);
|
|
$embedding->method('embed')->willReturn(array_fill(0, 768, 0.1));
|
|
$embedding->method('context')->willReturn(['provider_instance_id' => 'p1', 'embedding_model' => 'm1']);
|
|
|
|
$semantic = $this->createMock(SemanticSearchService::class);
|
|
$semantic->method('findBestArticle')->willReturn([
|
|
'best_article' => $article,
|
|
'confidence' => 0.2,
|
|
'explanation' => 'low confidence',
|
|
'top_3_candidates' => [],
|
|
'top_5_candidates' => [],
|
|
'retrieval_meta' => [],
|
|
'requested_tool_call' => null,
|
|
'classifier_raw_response' => ['mode' => 'llm'],
|
|
]);
|
|
|
|
$normalizer = $this->createMock(TicketNormalizationService::class);
|
|
$normalizer->method('normalize')->willReturn([
|
|
'normalized_message' => 'dns vraag',
|
|
'redaction_report' => ['language' => 'nl'],
|
|
]);
|
|
|
|
$knowledgeGap = $this->createMock(KnowledgeGapService::class);
|
|
$knowledgeGap->method('shouldCreateDraft')->willReturn(true);
|
|
$knowledgeGap->method('suggestArticleDraft')->willReturn([
|
|
'title' => 'Nieuwe DNS handleiding',
|
|
'content' => 'Nog aan te vullen',
|
|
]);
|
|
|
|
$toolCallService = $this->createMock(TicketToolCallService::class);
|
|
$toolCallService->expects($this->never())->method('executeRequestedTool');
|
|
|
|
$quickReplyResolver = $this->createMock(QuickReplyResolver::class);
|
|
$quickReplyResolver->expects($this->never())->method('resolveForArticle');
|
|
|
|
$supportReply = $this->createMock(SupportReplyService::class);
|
|
$supportReply->expects($this->never())->method('build');
|
|
|
|
$job = new ProcessTicketJob($ticket->id);
|
|
$job->handle(
|
|
$embedding,
|
|
$semantic,
|
|
$normalizer,
|
|
app(TicketProcessingLoggerService::class),
|
|
$knowledgeGap,
|
|
$toolCallService,
|
|
$quickReplyResolver,
|
|
$supportReply,
|
|
new TicketResultPayloadBuilder
|
|
);
|
|
|
|
$ticket->refresh();
|
|
$this->assertTrue($ticket->needs_article_draft);
|
|
$this->assertNull($ticket->support_reply);
|
|
$this->assertSame('Nieuwe DNS handleiding', $ticket->result_payload['draft_article_suggestion']['title']);
|
|
}
|
|
}
|