Add unit and feature tests for ticket processing and article management
- 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.
This commit is contained in:
@@ -41,22 +41,9 @@ class AIClassifierServiceTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$settings = new class extends AppSettingsService
|
||||
{
|
||||
public function getPrompt(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return 'Select best article.';
|
||||
}
|
||||
|
||||
public function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return $default;
|
||||
}
|
||||
};
|
||||
|
||||
$service = new AIClassifierService(
|
||||
$client,
|
||||
$settings,
|
||||
$this->fakeSettings(),
|
||||
new ClassifierPromptBuilder,
|
||||
new LlmJsonDecoder,
|
||||
new ToolCallRequestValidator
|
||||
@@ -81,4 +68,122 @@ class AIClassifierServiceTest extends TestCase
|
||||
$this->assertStringContainsString('Allowed actions: ["domain_inf"]', $client->prompt);
|
||||
$this->assertStringContainsString('Internal note for support assistant', $client->prompt);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_when_ranking_disabled(): void
|
||||
{
|
||||
config()->set('services.llm.ranking_enabled', false);
|
||||
|
||||
$client = new class implements LlmClientInterface
|
||||
{
|
||||
public function embed(string $text): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function generate(string $prompt, array $options = []): string
|
||||
{
|
||||
return '{}';
|
||||
}
|
||||
};
|
||||
|
||||
$service = new AIClassifierService(
|
||||
$client,
|
||||
$this->fakeSettings(),
|
||||
new ClassifierPromptBuilder,
|
||||
new LlmJsonDecoder,
|
||||
new ToolCallRequestValidator
|
||||
);
|
||||
|
||||
$result = $service->rank('vraag', [$this->candidate(15)]);
|
||||
$this->assertSame(15, $result->articleId);
|
||||
$this->assertSame(0.2, $result->confidence);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_when_llm_json_is_invalid(): void
|
||||
{
|
||||
config()->set('services.llm.ranking_enabled', true);
|
||||
|
||||
$client = new class implements LlmClientInterface
|
||||
{
|
||||
public function embed(string $text): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function generate(string $prompt, array $options = []): string
|
||||
{
|
||||
return 'not-json';
|
||||
}
|
||||
};
|
||||
|
||||
$service = new AIClassifierService(
|
||||
$client,
|
||||
$this->fakeSettings(),
|
||||
new ClassifierPromptBuilder,
|
||||
new LlmJsonDecoder,
|
||||
new ToolCallRequestValidator
|
||||
);
|
||||
|
||||
$result = $service->rank('vraag', [$this->candidate(21)]);
|
||||
$this->assertSame(21, $result->articleId);
|
||||
$this->assertStringContainsString('invalid JSON', $result->explanation);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_when_article_id_not_in_candidates(): void
|
||||
{
|
||||
$client = new class implements LlmClientInterface
|
||||
{
|
||||
public function embed(string $text): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function generate(string $prompt, array $options = []): string
|
||||
{
|
||||
return json_encode([
|
||||
'article_id' => 999,
|
||||
'confidence' => 0.9,
|
||||
'explanation' => 'x',
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
$service = new AIClassifierService(
|
||||
$client,
|
||||
$this->fakeSettings(),
|
||||
new ClassifierPromptBuilder,
|
||||
new LlmJsonDecoder,
|
||||
new ToolCallRequestValidator
|
||||
);
|
||||
|
||||
$result = $service->rank('vraag', [$this->candidate(33)]);
|
||||
$this->assertSame(33, $result->articleId);
|
||||
$this->assertStringContainsString('schema invalid', $result->explanation);
|
||||
}
|
||||
|
||||
private function fakeSettings(): AppSettingsService
|
||||
{
|
||||
return new class extends AppSettingsService
|
||||
{
|
||||
public function getPrompt(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return 'Select best article.';
|
||||
}
|
||||
|
||||
public function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return $default;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function candidate(int $id): ArticleCandidateDTO
|
||||
{
|
||||
return new ArticleCandidateDTO(
|
||||
articleId: $id,
|
||||
title: 'A',
|
||||
content: 'B',
|
||||
distance: 0.2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
37
tests/Unit/ClassifierPromptBuilderTest.php
Normal file
37
tests/Unit/ClassifierPromptBuilderTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\DTOs\ArticleCandidateDTO;
|
||||
use App\Services\ClassifierPromptBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ClassifierPromptBuilderTest extends TestCase
|
||||
{
|
||||
public function test_it_builds_prompt_with_articles_notes_and_actions(): void
|
||||
{
|
||||
$builder = new ClassifierPromptBuilder;
|
||||
$prompt = $builder->build(
|
||||
'Base prompt',
|
||||
'Hoe stel ik DNS in?',
|
||||
[
|
||||
new ArticleCandidateDTO(
|
||||
articleId: 10,
|
||||
title: 'DNS',
|
||||
content: 'Stappen voor DNS.',
|
||||
distance: 0.12,
|
||||
sourceUrl: 'https://example.test/article',
|
||||
note: 'Alleen gebruiken voor DNS.',
|
||||
allowedActions: ['domain_inf']
|
||||
),
|
||||
],
|
||||
'nl'
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('Base prompt', $prompt);
|
||||
$this->assertStringContainsString('User language: nl', $prompt);
|
||||
$this->assertStringContainsString('Allowed actions: ["domain_inf"]', $prompt);
|
||||
$this->assertStringContainsString('Internal note for support assistant', $prompt);
|
||||
$this->assertStringContainsString('"tool_call"', $prompt);
|
||||
}
|
||||
}
|
||||
70
tests/Unit/EmbeddingServiceTest.php
Normal file
70
tests/Unit/EmbeddingServiceTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Exceptions\OllamaUnavailableException;
|
||||
use App\Models\EmbeddingCache;
|
||||
use App\Services\AppSettingsService;
|
||||
use App\Services\EmbeddingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Fakes\FakeLlmClient;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmbeddingServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_cached_embedding_without_new_llm_call(): void
|
||||
{
|
||||
$settings = $this->fakeSettings();
|
||||
$llm = new FakeLlmClient;
|
||||
$service = new EmbeddingService($llm, $settings);
|
||||
|
||||
EmbeddingCache::query()->create([
|
||||
'provider_instance_id' => 'instance-1',
|
||||
'embedding_model' => 'embed-model',
|
||||
'text_hash' => hash('sha256', 'abc'),
|
||||
'text' => 'abc',
|
||||
'embedding' => [0.9, 0.8],
|
||||
]);
|
||||
|
||||
$embedding = $service->embed('abc');
|
||||
$this->assertSame([0.9, 0.8], $embedding);
|
||||
$this->assertCount(0, $llm->generatedPrompts);
|
||||
}
|
||||
|
||||
public function test_it_throws_when_embedding_is_empty(): void
|
||||
{
|
||||
$settings = $this->fakeSettings();
|
||||
$llm = new FakeLlmClient;
|
||||
$llm->embeddings['abc'] = [];
|
||||
$service = new EmbeddingService($llm, $settings);
|
||||
|
||||
$this->expectException(OllamaUnavailableException::class);
|
||||
$service->embed('abc');
|
||||
}
|
||||
|
||||
private function fakeSettings(): AppSettingsService
|
||||
{
|
||||
return new class extends AppSettingsService {
|
||||
public function activeProviderInstance(): array
|
||||
{
|
||||
return ['id' => 'instance-1', 'embedding_model' => 'embed-model'];
|
||||
}
|
||||
|
||||
public function activeProviderInstanceId(): string
|
||||
{
|
||||
return 'instance-1';
|
||||
}
|
||||
|
||||
public function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
if ($key === 'llm.models.embedding') {
|
||||
return 'embed-model';
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
40
tests/Unit/LlmJsonDecoderTest.php
Normal file
40
tests/Unit/LlmJsonDecoderTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\LlmJsonDecoder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class LlmJsonDecoderTest extends TestCase
|
||||
{
|
||||
public function test_it_decodes_plain_json(): void
|
||||
{
|
||||
$decoder = new LlmJsonDecoder;
|
||||
$decoded = $decoder->decode('{"a":1}');
|
||||
|
||||
$this->assertSame(['a' => 1], $decoded);
|
||||
}
|
||||
|
||||
public function test_it_decodes_fenced_json(): void
|
||||
{
|
||||
$decoder = new LlmJsonDecoder;
|
||||
$decoded = $decoder->decode("```json\n{\"a\":2}\n```");
|
||||
|
||||
$this->assertSame(['a' => 2], $decoded);
|
||||
}
|
||||
|
||||
public function test_it_extracts_json_from_mixed_text(): void
|
||||
{
|
||||
$decoder = new LlmJsonDecoder;
|
||||
$decoded = $decoder->decode("noise before {\"a\":3} noise after");
|
||||
|
||||
$this->assertSame(['a' => 3], $decoded);
|
||||
}
|
||||
|
||||
public function test_it_returns_null_on_invalid_json(): void
|
||||
{
|
||||
$decoder = new LlmJsonDecoder;
|
||||
|
||||
$this->assertNull($decoder->decode('no json here'));
|
||||
}
|
||||
}
|
||||
40
tests/Unit/QuickReplyResolverTest.php
Normal file
40
tests/Unit/QuickReplyResolverTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\QuickReply;
|
||||
use App\Services\QuickReplyResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class QuickReplyResolverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_null_without_article(): void
|
||||
{
|
||||
$resolver = new QuickReplyResolver;
|
||||
$this->assertNull($resolver->resolveForArticle(null));
|
||||
}
|
||||
|
||||
public function test_it_returns_first_active_quick_reply(): void
|
||||
{
|
||||
$article = Article::query()->create([
|
||||
'title' => 'DNS',
|
||||
'content' => 'content',
|
||||
]);
|
||||
|
||||
$inactive = QuickReply::query()->create(['title' => 'B Reply', 'content' => 'B', 'is_active' => false]);
|
||||
$activeB = QuickReply::query()->create(['title' => 'Z Reply', 'content' => 'Z', 'is_active' => true]);
|
||||
$activeA = QuickReply::query()->create(['title' => 'A Reply', 'content' => 'A', 'is_active' => true]);
|
||||
|
||||
$article->quickReplies()->sync([$inactive->id, $activeB->id, $activeA->id]);
|
||||
|
||||
$resolver = new QuickReplyResolver;
|
||||
$resolved = $resolver->resolveForArticle($article);
|
||||
|
||||
$this->assertInstanceOf(QuickReply::class, $resolved);
|
||||
$this->assertSame($activeA->id, $resolved->id);
|
||||
}
|
||||
}
|
||||
68
tests/Unit/SupportReplyServiceTest.php
Normal file
68
tests/Unit/SupportReplyServiceTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\AppSettingsService;
|
||||
use App\Services\Llm\LlmClientInterface;
|
||||
use App\Services\SupportReplyService;
|
||||
use App\Services\TicketProcessingLoggerService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Fakes\FakeLlmClient;
|
||||
use Tests\Fakes\FakeTicketProcessingLoggerService;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SupportReplyServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_llm_response_when_available(): void
|
||||
{
|
||||
$llm = new FakeLlmClient;
|
||||
$llm->responses = ['1. Doe X'];
|
||||
|
||||
$service = new SupportReplyService(
|
||||
$this->fakeSettings(),
|
||||
$llm,
|
||||
new FakeTicketProcessingLoggerService
|
||||
);
|
||||
|
||||
$ticket = Ticket::query()->create(['message' => 'vraag', 'normalized_message' => 'vraag']);
|
||||
$article = Article::query()->create(['title' => 'DNS', 'content' => 'steps']);
|
||||
|
||||
$reply = $service->build($ticket, $article, 'relevant');
|
||||
|
||||
$this->assertSame('1. Doe X', $reply);
|
||||
$this->assertStringContainsString('Gebruikersvraag (genormaliseerd): vraag', $llm->generatedPrompts[0]['prompt']);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_when_llm_returns_empty(): void
|
||||
{
|
||||
$llm = new FakeLlmClient;
|
||||
$llm->responses = [''];
|
||||
|
||||
$service = new SupportReplyService(
|
||||
$this->fakeSettings(),
|
||||
$llm,
|
||||
new FakeTicketProcessingLoggerService
|
||||
);
|
||||
|
||||
$ticket = Ticket::query()->create(['message' => 'vraag']);
|
||||
$article = Article::query()->create(['title' => 'DNS', 'content' => 'steps']);
|
||||
|
||||
$reply = $service->build($ticket, $article, 'relevant');
|
||||
|
||||
$this->assertStringContainsString('Gebruik het kennisbankartikel', $reply);
|
||||
}
|
||||
|
||||
private function fakeSettings(): AppSettingsService
|
||||
{
|
||||
return new class extends AppSettingsService {
|
||||
public function getPrompt(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return 'Prompt';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
36
tests/Unit/TicketResultPayloadBuilderTest.php
Normal file
36
tests/Unit/TicketResultPayloadBuilderTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\QuickReply;
|
||||
use App\Models\TicketToolCall;
|
||||
use App\Services\TicketResultPayloadBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class TicketResultPayloadBuilderTest extends TestCase
|
||||
{
|
||||
public function test_it_builds_payload_with_optional_fields(): void
|
||||
{
|
||||
$builder = new TicketResultPayloadBuilder;
|
||||
$quickReply = new QuickReply(['id' => 7, 'title' => 'Quick']);
|
||||
$quickReply->id = 7;
|
||||
$toolCall = new TicketToolCall(['action' => 'domain_inf', 'status' => 'success']);
|
||||
|
||||
$payload = $builder->build(
|
||||
[
|
||||
'top_3_candidates' => [['article_id' => 1]],
|
||||
'top_5_candidates' => [['article_id' => 1], ['article_id' => 2]],
|
||||
'classifier_raw_response' => ['mode' => 'llm'],
|
||||
'requested_tool_call' => ['action' => 'domain_inf'],
|
||||
],
|
||||
$toolCall,
|
||||
$quickReply,
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
$this->assertSame(7, $payload['quick_reply']['id']);
|
||||
$this->assertSame('domain_inf', $payload['requested_tool_call']['action']);
|
||||
$this->assertFalse($payload['knowledge_gap']);
|
||||
}
|
||||
}
|
||||
53
tests/Unit/TicketToolCallServiceTest.php
Normal file
53
tests/Unit/TicketToolCallServiceTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\Tools\TicketToolCallService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Fakes\FakeDomainInfoTool;
|
||||
use Tests\Fakes\FakeTicketProcessingLoggerService;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketToolCallServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_skips_when_action_not_allowed(): void
|
||||
{
|
||||
$service = new TicketToolCallService(new FakeDomainInfoTool, new FakeTicketProcessingLoggerService);
|
||||
|
||||
$article = Article::query()->create(['title' => 'A', 'content' => 'B', 'allowed_actions' => []]);
|
||||
$ticket = Ticket::query()->create(['message' => 'vraag']);
|
||||
|
||||
$record = $service->executeRequestedTool($ticket, $article, [
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
|
||||
]);
|
||||
|
||||
$this->assertNotNull($record);
|
||||
$this->assertSame('skipped', $record->status);
|
||||
}
|
||||
|
||||
public function test_it_executes_domain_info_when_allowed_and_credentials_present(): void
|
||||
{
|
||||
$fakeTool = new FakeDomainInfoTool;
|
||||
$service = new TicketToolCallService($fakeTool, new FakeTicketProcessingLoggerService);
|
||||
|
||||
$article = Article::query()->create(['title' => 'A', 'content' => 'B', 'allowed_actions' => ['domain_inf']]);
|
||||
$ticket = Ticket::query()->create([
|
||||
'message' => 'vraag',
|
||||
'api_credentials' => ['apiuser' => 'u', 'apipassword' => 'p'],
|
||||
]);
|
||||
|
||||
$record = $service->executeRequestedTool($ticket, $article, [
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
|
||||
]);
|
||||
|
||||
$this->assertNotNull($record);
|
||||
$this->assertSame('success', $record->status);
|
||||
$this->assertCount(1, $fakeTool->calls);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/ToolCallRequestValidatorTest.php
Normal file
45
tests/Unit/ToolCallRequestValidatorTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ToolCallRequestValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ToolCallRequestValidatorTest extends TestCase
|
||||
{
|
||||
public function test_it_validates_domain_inf_tool_call(): void
|
||||
{
|
||||
$validator = new ToolCallRequestValidator;
|
||||
$validated = $validator->validate([
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'Example', 'tld' => 'NL'],
|
||||
'reason' => 'Needed',
|
||||
]);
|
||||
|
||||
$this->assertSame([
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
|
||||
'reason' => 'Needed',
|
||||
], $validated);
|
||||
}
|
||||
|
||||
public function test_it_rejects_missing_parameters(): void
|
||||
{
|
||||
$validator = new ToolCallRequestValidator;
|
||||
|
||||
$this->assertNull($validator->validate([
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'example'],
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_it_rejects_unknown_action(): void
|
||||
{
|
||||
$validator = new ToolCallRequestValidator;
|
||||
|
||||
$this->assertNull($validator->validate([
|
||||
'action' => 'dns_update',
|
||||
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user