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:
SitiWeb
2026-04-30 02:10:15 +02:00
parent 39bdba2dfb
commit c94d3f85e8
36 changed files with 7445 additions and 467 deletions

View File

@@ -0,0 +1,17 @@
<?php
namespace Tests\Fakes;
use App\DTOs\ArticleCandidateDTO;
use App\Repositories\Contracts\ArticleRepositoryInterface;
class FakeArticleRepository implements ArticleRepositoryInterface
{
/** @var array<int, ArticleCandidateDTO> */
public array $candidates = [];
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array
{
return array_slice($this->candidates, 0, $limit);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Tests\Fakes;
use App\Services\Tools\DomainInfoTool;
use App\Services\Tools\OxxaClient;
class FakeDomainInfoTool extends DomainInfoTool
{
/** @var array<int, array{parameters:array,credentials:array}> */
public array $calls = [];
public array $response = ['ok' => true, 'data' => ['domain' => 'example.nl']];
public function __construct()
{
parent::__construct(new OxxaClient);
}
public function execute(array $parameters, array $credentials): array
{
$this->calls[] = [
'parameters' => $parameters,
'credentials' => $credentials,
];
return $this->response;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Tests\Fakes;
use App\Services\Llm\LlmClientInterface;
class FakeLlmClient implements LlmClientInterface
{
/** @var array<string, array<int, float>> */
public array $embeddings = [];
/** @var array<int, string> */
public array $responses = [];
/** @var array<int, array{prompt:string, options:array}> */
public array $generatedPrompts = [];
public function embed(string $text): array
{
return $this->embeddings[$text] ?? [0.1, 0.2, 0.3];
}
public function generate(string $prompt, array $options = []): string
{
$this->generatedPrompts[] = [
'prompt' => $prompt,
'options' => $options,
];
return array_shift($this->responses) ?? '{}';
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Tests\Fakes;
use App\Models\Ticket;
use App\Services\TicketProcessingLoggerService;
class FakeTicketProcessingLoggerService extends TicketProcessingLoggerService
{
/** @var array<int, array{step:string,status:string,message:string|null,context:array}> */
public array $logs = [];
public function log(Ticket $ticket, string $step, string $status = 'info', ?string $message = null, array $context = []): void
{
$this->logs[] = [
'step' => $step,
'status' => $status,
'message' => $message,
'context' => $context,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ArticleApiTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_article_with_note_and_allowed_actions(): void
{
$response = $this->postJson('/api/articles', [
'title' => 'DNS instellen',
'content' => 'Stap 1...',
'note' => 'Alleen voor DNS vragen',
'allowed_actions' => ['domain_inf'],
]);
$response->assertStatus(201);
$article = Article::query()->first();
$this->assertNotNull($article);
$this->assertSame('Alleen voor DNS vragen', $article->note);
$this->assertSame(['domain_inf'], $article->allowed_actions);
}
public function test_it_rejects_invalid_allowed_action(): void
{
$response = $this->postJson('/api/articles', [
'title' => 'DNS instellen',
'content' => 'Stap 1...',
'allowed_actions' => ['invalid_action'],
]);
$response->assertStatus(422);
}
public function test_it_lists_articles(): void
{
Article::query()->create(['title' => 'A', 'content' => 'B']);
$response = $this->getJson('/api/articles');
$response->assertOk();
}
}

View File

@@ -0,0 +1,216 @@
<?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']);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Livewire\Admin\QuickReplyManager;
use App\Models\QuickReply;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class QuickReplyAdminTest extends TestCase
{
use RefreshDatabase;
public function test_it_can_create_and_update_quick_reply(): void
{
Livewire::test(QuickReplyManager::class)
->set('title', 'DNS Basis')
->set('content', 'Gebruik deze stappen')
->set('isActive', true)
->call('save');
$reply = QuickReply::query()->first();
$this->assertNotNull($reply);
Livewire::test(QuickReplyManager::class)
->set("editRows.{$reply->id}.title", 'DNS Geupdated')
->set("editRows.{$reply->id}.content", 'Nieuwe content')
->set("editRows.{$reply->id}.is_active", false)
->call('updateQuickReply', $reply->id);
$reply->refresh();
$this->assertSame('DNS Geupdated', $reply->title);
$this->assertFalse($reply->is_active);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\QuickReply;
use App\Models\Ticket;
use App\Models\TicketToolCall;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class TicketAndArticleModelTest extends TestCase
{
use RefreshDatabase;
public function test_article_quick_reply_pivot_and_cascade_delete(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$reply = QuickReply::query()->create(['title' => 'Quick', 'content' => 'y', 'is_active' => true]);
$article->quickReplies()->sync([$reply->id]);
$this->assertCount(1, $article->quickReplies);
$article->delete();
$pivotCount = DB::table('article_quick_reply')->count();
$this->assertSame(0, $pivotCount);
}
public function test_ticket_credentials_are_stored_encrypted_and_decrypted_via_cast(): void
{
$ticket = Ticket::query()->create([
'message' => 'vraag',
'api_credentials' => ['apiuser' => 'demo', 'apipassword' => 'secret'],
]);
$this->assertSame('demo', $ticket->api_credentials['apiuser']);
$raw = DB::table('tickets')->where('id', $ticket->id)->value('api_credentials');
$this->assertIsString($raw);
$this->assertStringNotContainsString('secret', $raw);
}
public function test_ticket_tool_call_casts_arrays(): void
{
$ticket = Ticket::query()->create(['message' => 'vraag']);
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$toolCall = TicketToolCall::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $article->id,
'action' => 'domain_inf',
'status' => 'success',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
'response' => ['ok' => true],
]);
$toolCall->refresh();
$this->assertSame('example', $toolCall->parameters['sld']);
$this->assertTrue($toolCall->response['ok']);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Jobs\ProcessTicketJob;
use App\Models\Ticket;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class TicketIngestionTest extends TestCase
{
use RefreshDatabase;
public function test_it_ingests_ticket_and_dispatches_processing_job(): void
{
Queue::fake();
$response = $this->postJson('/api/tickets', [
'message' => 'Mijn mail werkt niet',
'api_credentials' => [
'apiuser' => 'demo',
'apipassword' => 'secret',
],
]);
$response->assertStatus(202)
->assertJsonPath('status', 'queued');
$ticket = Ticket::query()->latest('id')->first();
$this->assertNotNull($ticket);
$this->assertSame('queued', $ticket->status);
$this->assertSame('demo', $ticket->api_credentials['apiuser']);
$raw = DB::table('tickets')->where('id', $ticket->id)->value('api_credentials');
$this->assertIsString($raw);
$this->assertStringNotContainsString('secret', $raw);
Queue::assertPushed(ProcessTicketJob::class);
}
public function test_it_validates_required_message(): void
{
$response = $this->postJson('/api/tickets', []);
$response->assertStatus(422);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\Ticket;
use App\Models\TicketToolCall;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketShowPageTest extends TestCase
{
use RefreshDatabase;
public function test_ticket_show_renders_quick_reply_and_tool_call_sections(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$ticket = Ticket::query()->create([
'message' => 'vraag',
'status' => 'completed',
'best_article_id' => $article->id,
'support_reply' => 'Gebruik deze stappen',
'result_payload' => [
'quick_reply' => ['id' => 1, 'title' => 'DNS Quick'],
'draft_article_suggestion' => null,
],
]);
TicketToolCall::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $article->id,
'action' => 'domain_inf',
'status' => 'success',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
'response' => ['ok' => true],
]);
$response = $this->get("/admin/tickets/{$ticket->id}");
$response->assertOk();
$response->assertSee('Snelantwoord gebruikt', false);
$response->assertSee('Toolcalls', false);
}
}

View File

@@ -6,5 +6,4 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View File

@@ -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
);
}
}

View 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);
}
}

View 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;
}
};
}
}

View 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'));
}
}

View 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);
}
}

View 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';
}
};
}
}

View 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']);
}
}

View 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);
}
}

View 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'],
]));
}
}