test: fix TestCase to use Orchestra Testbench for CI
This commit is contained in:
commit
e0f9a87673
49 changed files with 5785 additions and 115 deletions
|
|
@ -12,7 +12,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Mod\Content\Models\ContentTask;
|
||||
use Mod\Content\Services\ContentProcessingService;
|
||||
use Throwable;
|
||||
|
||||
class ProcessContentTask implements ShouldQueue
|
||||
|
|
@ -33,7 +32,6 @@ class ProcessContentTask implements ShouldQueue
|
|||
|
||||
public function handle(
|
||||
AgenticManager $ai,
|
||||
ContentProcessingService $processor,
|
||||
EntitlementService $entitlements
|
||||
): void {
|
||||
$this->task->markProcessing();
|
||||
|
|
@ -103,11 +101,6 @@ class ProcessContentTask implements ShouldQueue
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
// If this task has a target, process the output
|
||||
if ($this->task->target_type && $this->task->target_id) {
|
||||
$this->processOutput($response->content, $processor);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
|
|
@ -115,9 +108,6 @@ class ProcessContentTask implements ShouldQueue
|
|||
$this->task->markFailed($exception->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate template variables.
|
||||
*/
|
||||
private function interpolateVariables(string $template, array $data): string
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
|
|
@ -130,20 +120,4 @@ class ProcessContentTask implements ShouldQueue
|
|||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the AI output based on target type.
|
||||
*/
|
||||
private function processOutput(string $content, ContentProcessingService $processor): void
|
||||
{
|
||||
$target = $this->task->target;
|
||||
|
||||
if (! $target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different target types
|
||||
// This can be extended for different content types
|
||||
// For now, just log that processing occurred
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
Migrations/0001_01_01_000004_create_prompt_tables.php
Normal file
65
Migrations/0001_01_01_000004_create_prompt_tables.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Create prompts and prompt_versions tables.
|
||||
*
|
||||
* Guarded with hasTable() so this migration is idempotent and
|
||||
* can coexist with the consolidated app-level migration.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
if (! Schema::hasTable('prompts')) {
|
||||
Schema::create('prompts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('category')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->text('system_prompt')->nullable();
|
||||
$table->text('user_template')->nullable();
|
||||
$table->json('variables')->nullable();
|
||||
$table->string('model')->nullable();
|
||||
$table->json('model_config')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('category');
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('prompt_versions')) {
|
||||
Schema::create('prompt_versions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('prompt_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('version');
|
||||
$table->text('system_prompt')->nullable();
|
||||
$table->text('user_template')->nullable();
|
||||
$table->json('variables')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['prompt_id', 'version']);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('prompt_versions');
|
||||
Schema::dropIfExists('prompts');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -120,7 +121,7 @@ class AgentApiKey extends Model
|
|||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('revoked_at')
|
||||
->where(function ($q) {
|
||||
|
|
@ -136,12 +137,12 @@ class AgentApiKey extends Model
|
|||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
public function scopeRevoked($query)
|
||||
public function scopeRevoked(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('revoked_at');
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Mod\Agentic\Database\Factories\AgentPhaseFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -82,22 +83,22 @@ class AgentPhase extends Model
|
|||
}
|
||||
|
||||
// Scopes
|
||||
public function scopePending($query)
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeInProgress($query)
|
||||
public function scopeInProgress(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_IN_PROGRESS);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
public function scopeCompleted(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
public function scopeBlocked($query)
|
||||
public function scopeBlocked(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_BLOCKED);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Core\Mod\Agentic\Models;
|
|||
use Core\Mod\Agentic\Database\Factories\AgentPlanFactory;
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -99,17 +100,17 @@ class AgentPlan extends Model
|
|||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeDraft($query)
|
||||
public function scopeDraft(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
public function scopeNotArchived($query)
|
||||
public function scopeNotArchived(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', '!=', self::STATUS_ARCHIVED);
|
||||
}
|
||||
|
|
@ -120,7 +121,7 @@ class AgentPlan extends Model
|
|||
* This is a safe replacement for orderByRaw("FIELD(status, ...)") which
|
||||
* could be vulnerable to SQL injection if extended with user input.
|
||||
*/
|
||||
public function scopeOrderByStatus($query, string $direction = 'asc')
|
||||
public function scopeOrderByStatus(Builder $query, string $direction = 'asc'): Builder
|
||||
{
|
||||
return $query->orderByRaw('CASE status
|
||||
WHEN ? THEN 1
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Core\Mod\Agentic\Models;
|
|||
use Core\Mod\Agentic\Database\Factories\AgentSessionFactory;
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -107,12 +108,12 @@ class AgentSession extends Model
|
|||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeForPlan($query, AgentPlan|int $plan)
|
||||
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder
|
||||
{
|
||||
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -54,14 +55,14 @@ class AgentWorkspaceState extends Model
|
|||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeForPlan($query, AgentPlan|int $plan)
|
||||
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder
|
||||
{
|
||||
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
|
||||
|
||||
return $query->where('agent_plan_id', $planId);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
public function scopeOfType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -82,7 +83,7 @@ class Prompt extends Model
|
|||
/**
|
||||
* Scope to only active prompts.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
|
@ -90,7 +91,7 @@ class Prompt extends Model
|
|||
/**
|
||||
* Scope by category.
|
||||
*/
|
||||
public function scopeCategory($query, string $category)
|
||||
public function scopeCategory(Builder $query, string $category): Builder
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
|
@ -98,7 +99,7 @@ class Prompt extends Model
|
|||
/**
|
||||
* Scope by model provider.
|
||||
*/
|
||||
public function scopeForModel($query, string $model)
|
||||
public function scopeForModel(Builder $query, string $model): Builder
|
||||
{
|
||||
return $query->where('model', $model);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Task extends Model
|
||||
|
|
@ -26,22 +27,22 @@ class Task extends Model
|
|||
'line_ref' => 'integer',
|
||||
];
|
||||
|
||||
public function scopePending($query)
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'pending');
|
||||
}
|
||||
|
||||
public function scopeInProgress($query)
|
||||
public function scopeInProgress(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'in_progress');
|
||||
}
|
||||
|
||||
public function scopeDone($query)
|
||||
public function scopeDone(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'done');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', ['pending', 'in_progress']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -132,7 +133,7 @@ class WorkspaceState extends Model
|
|||
/**
|
||||
* Scope: for plan.
|
||||
*/
|
||||
public function scopeForPlan($query, int $planId)
|
||||
public function scopeForPlan(Builder $query, int $planId): Builder
|
||||
{
|
||||
return $query->where('agent_plan_id', $planId);
|
||||
}
|
||||
|
|
@ -140,7 +141,7 @@ class WorkspaceState extends Model
|
|||
/**
|
||||
* Scope: by type.
|
||||
*/
|
||||
public function scopeByType($query, string $type)
|
||||
public function scopeByType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
|
|
|||
6
TODO.md
6
TODO.md
|
|
@ -136,10 +136,12 @@ Production-quality task list for the AI agent orchestration package.
|
|||
- Issue: Two similar models for same purpose
|
||||
- Fix: Consolidate into single model, or clarify distinct purposes
|
||||
|
||||
- [ ] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey**
|
||||
- [x] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** (FIXED 2026-02-23)
|
||||
- Location: `View/Modal/Admin/ApiKeyManager.php`
|
||||
- Issue: Livewire component uses different API key model than services
|
||||
- Fix: Unify on AgentApiKey or document distinction
|
||||
- Fix: Switched to `AgentApiKey` model and `AgentApiKeyService` throughout
|
||||
- Updated blade template to use `permissions`, `getMaskedKey()`, `togglePermission()`
|
||||
- Added integration tests in `tests/Feature/ApiKeyManagerTest.php`
|
||||
|
||||
- [ ] **CQ-003: ForAgentsController cache key not namespaced**
|
||||
- Location: `Controllers/ForAgentsController.php`
|
||||
|
|
|
|||
|
|
@ -68,18 +68,17 @@
|
|||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded font-mono">
|
||||
{{ $key->prefix }}_****
|
||||
{{ $key->getMaskedKey() }}
|
||||
</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex gap-1">
|
||||
@foreach($key->scopes ?? [] as $scope)
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach($key->permissions ?? [] as $permission)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{{ $scope === 'read' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }}
|
||||
{{ $scope === 'write' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : '' }}
|
||||
{{ $scope === 'delete' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : '' }}
|
||||
{{ str_ends_with($permission, '.read') ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }}
|
||||
{{ str_ends_with($permission, '.write') || str_ends_with($permission, '.send') || str_ends_with($permission, '.instantiate') ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : '' }}
|
||||
">
|
||||
{{ $scope }}
|
||||
{{ $permission }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -131,11 +130,11 @@
|
|||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_recommended') }}</p>
|
||||
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">Authorization: Bearer hk_abc123_****</code></pre>
|
||||
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">Authorization: Bearer ak_****</code></pre>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_api_key') }}</p>
|
||||
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">X-API-Key: hk_abc123_****</code></pre>
|
||||
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">X-API-Key: ak_****</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -179,37 +178,24 @@
|
|||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Scopes -->
|
||||
<!-- Permissions -->
|
||||
<div>
|
||||
<core:label>{{ __('mcp::mcp.keys.create_modal.permissions_label') }}</core:label>
|
||||
<div class="mt-2 space-y-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
wire:click="toggleScope('read')"
|
||||
{{ in_array('read', $newKeyScopes) ? 'checked' : '' }}
|
||||
class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
|
||||
>
|
||||
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_read') }}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
wire:click="toggleScope('write')"
|
||||
{{ in_array('write', $newKeyScopes) ? 'checked' : '' }}
|
||||
class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
|
||||
>
|
||||
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_write') }}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
wire:click="toggleScope('delete')"
|
||||
{{ in_array('delete', $newKeyScopes) ? 'checked' : '' }}
|
||||
class="rounded border-zinc-300 text-zinc-600 focus:ring-cyan-500"
|
||||
>
|
||||
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_delete') }}</span>
|
||||
</label>
|
||||
@foreach($this->availablePermissions() as $permission => $description)
|
||||
<label class="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
wire:click="togglePermission('{{ $permission }}')"
|
||||
{{ in_array($permission, $newKeyPermissions) ? 'checked' : '' }}
|
||||
class="mt-0.5 rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
|
||||
>
|
||||
<span class="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<span class="font-mono text-xs text-zinc-500">{{ $permission }}</span>
|
||||
<span class="block text-xs text-zinc-500 dark:text-zinc-400">{{ $description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\View\Modal\Admin;
|
||||
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Services\AgentApiKeyService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
|
@ -25,7 +26,7 @@ class ApiKeyManager extends Component
|
|||
|
||||
public string $newKeyName = '';
|
||||
|
||||
public array $newKeyScopes = ['read', 'write'];
|
||||
public array $newKeyPermissions = [];
|
||||
|
||||
public string $newKeyExpiry = 'never';
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ class ApiKeyManager extends Component
|
|||
{
|
||||
$this->showCreateModal = true;
|
||||
$this->newKeyName = '';
|
||||
$this->newKeyScopes = ['read', 'write'];
|
||||
$this->newKeyPermissions = [];
|
||||
$this->newKeyExpiry = 'never';
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +53,11 @@ class ApiKeyManager extends Component
|
|||
$this->showCreateModal = false;
|
||||
}
|
||||
|
||||
public function availablePermissions(): array
|
||||
{
|
||||
return AgentApiKey::availablePermissions();
|
||||
}
|
||||
|
||||
public function createKey(): void
|
||||
{
|
||||
$this->validate([
|
||||
|
|
@ -65,15 +71,14 @@ class ApiKeyManager extends Component
|
|||
default => null,
|
||||
};
|
||||
|
||||
$result = ApiKey::generate(
|
||||
workspaceId: $this->workspace->id,
|
||||
userId: auth()->id(),
|
||||
$key = app(AgentApiKeyService::class)->create(
|
||||
workspace: $this->workspace,
|
||||
name: $this->newKeyName,
|
||||
scopes: $this->newKeyScopes,
|
||||
permissions: $this->newKeyPermissions,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
$this->newPlainKey = $result['plain_key'];
|
||||
$this->newPlainKey = $key->plainTextKey;
|
||||
$this->showCreateModal = false;
|
||||
$this->showNewKeyModal = true;
|
||||
|
||||
|
|
@ -88,25 +93,25 @@ class ApiKeyManager extends Component
|
|||
|
||||
public function revokeKey(int $keyId): void
|
||||
{
|
||||
$key = $this->workspace->apiKeys()->findOrFail($keyId);
|
||||
$key = AgentApiKey::forWorkspace($this->workspace)->findOrFail($keyId);
|
||||
$key->revoke();
|
||||
|
||||
session()->flash('message', 'API key revoked.');
|
||||
}
|
||||
|
||||
public function toggleScope(string $scope): void
|
||||
public function togglePermission(string $permission): void
|
||||
{
|
||||
if (in_array($scope, $this->newKeyScopes)) {
|
||||
$this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope]));
|
||||
if (in_array($permission, $this->newKeyPermissions)) {
|
||||
$this->newKeyPermissions = array_values(array_diff($this->newKeyPermissions, [$permission]));
|
||||
} else {
|
||||
$this->newKeyScopes[] = $scope;
|
||||
$this->newKeyPermissions[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('mcp::admin.api-key-manager', [
|
||||
'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(),
|
||||
return view('agentic::admin.api-key-manager', [
|
||||
'keys' => AgentApiKey::forWorkspace($this->workspace)->orderByDesc('created_at')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@
|
|||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.18",
|
||||
"livewire/livewire": "^3.0",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^3.0"
|
||||
"pestphp/pest": "^3.0",
|
||||
"pestphp/pest-plugin-livewire": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
@ -25,7 +27,7 @@
|
|||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Core\\Mod\\Agentic\\Tests\\": "Tests/",
|
||||
"Core\\Mod\\Agentic\\Tests\\": "tests/",
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
|
|
@ -47,12 +49,5 @@
|
|||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"repositories": [
|
||||
{
|
||||
"name": "core",
|
||||
"type": "path",
|
||||
"url": "../php-framework"
|
||||
}
|
||||
]
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
|
|
|||
255
tests/Feature/ApiKeyManagerTest.php
Normal file
255
tests/Feature/ApiKeyManagerTest.php
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Integration tests for ApiKeyManager admin UI component.
|
||||
*
|
||||
* Verifies that ApiKeyManager consistently uses AgentApiKey model
|
||||
* for all create, list, and revoke operations.
|
||||
*/
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Services\AgentApiKeyService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
// =========================================================================
|
||||
// Model Consistency Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('ApiKeyManager model consistency', function () {
|
||||
it('ApiKeyManager uses AgentApiKey class', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../View/Modal/Admin/ApiKeyManager.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('Core\Mod\Agentic\Models\AgentApiKey')
|
||||
->not->toContain('Core\Api\Models\ApiKey')
|
||||
->not->toContain('Core\Api\ApiKey');
|
||||
});
|
||||
|
||||
it('ApiKeyManager uses AgentApiKeyService', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../View/Modal/Admin/ApiKeyManager.php');
|
||||
|
||||
expect($source)->toContain('Core\Mod\Agentic\Services\AgentApiKeyService');
|
||||
});
|
||||
|
||||
it('ApiKeyManager does not reference old scopes property', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../View/Modal/Admin/ApiKeyManager.php');
|
||||
|
||||
expect($source)
|
||||
->not->toContain('newKeyScopes')
|
||||
->not->toContain('toggleScope');
|
||||
});
|
||||
|
||||
it('blade template uses permissions not scopes', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../View/Blade/admin/api-key-manager.blade.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('$key->permissions')
|
||||
->not->toContain('$key->scopes');
|
||||
});
|
||||
|
||||
it('blade template uses getMaskedKey not prefix', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../View/Blade/admin/api-key-manager.blade.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('getMaskedKey()')
|
||||
->not->toContain('$key->prefix');
|
||||
});
|
||||
|
||||
it('blade template calls togglePermission not toggleScope', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../View/Blade/admin/api-key-manager.blade.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('togglePermission')
|
||||
->not->toContain('toggleScope');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// AgentApiKey Integration Tests (via service, as used by ApiKeyManager)
|
||||
// =========================================================================
|
||||
|
||||
describe('ApiKeyManager key creation integration', function () {
|
||||
it('creates an AgentApiKey via service', function () {
|
||||
$workspace = createWorkspace();
|
||||
$service = app(AgentApiKeyService::class);
|
||||
|
||||
$key = $service->create(
|
||||
workspace: $workspace,
|
||||
name: 'Workspace MCP Key',
|
||||
permissions: [AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_SESSIONS_READ],
|
||||
);
|
||||
|
||||
expect($key)->toBeInstanceOf(AgentApiKey::class)
|
||||
->and($key->name)->toBe('Workspace MCP Key')
|
||||
->and($key->workspace_id)->toBe($workspace->id)
|
||||
->and($key->permissions)->toContain(AgentApiKey::PERM_PLANS_READ)
|
||||
->and($key->plainTextKey)->toStartWith('ak_');
|
||||
});
|
||||
|
||||
it('plain text key is only available once after creation', function () {
|
||||
$workspace = createWorkspace();
|
||||
$service = app(AgentApiKeyService::class);
|
||||
|
||||
$key = $service->create($workspace, 'One-time key');
|
||||
|
||||
expect($key->plainTextKey)->not->toBeNull();
|
||||
|
||||
$freshKey = AgentApiKey::find($key->id);
|
||||
expect($freshKey->plainTextKey)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates key with expiry date', function () {
|
||||
$workspace = createWorkspace();
|
||||
$service = app(AgentApiKeyService::class);
|
||||
$expiresAt = now()->addDays(30);
|
||||
|
||||
$key = $service->create(
|
||||
workspace: $workspace,
|
||||
name: 'Expiring Key',
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
expect($key->expires_at)->not->toBeNull()
|
||||
->and($key->expires_at->toDateString())->toBe($expiresAt->toDateString());
|
||||
});
|
||||
|
||||
it('creates key with no expiry when null passed', function () {
|
||||
$workspace = createWorkspace();
|
||||
$service = app(AgentApiKeyService::class);
|
||||
|
||||
$key = $service->create($workspace, 'Permanent Key', expiresAt: null);
|
||||
|
||||
expect($key->expires_at)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Workspace Scoping (used by ApiKeyManager::revokeKey and render)
|
||||
// =========================================================================
|
||||
|
||||
describe('ApiKeyManager workspace scoping', function () {
|
||||
it('forWorkspace scope returns only keys for given workspace', function () {
|
||||
$workspace1 = createWorkspace();
|
||||
$workspace2 = createWorkspace();
|
||||
|
||||
$key1 = createApiKey($workspace1, 'Key for workspace 1');
|
||||
$key2 = createApiKey($workspace2, 'Key for workspace 2');
|
||||
|
||||
$keys = AgentApiKey::forWorkspace($workspace1)->get();
|
||||
|
||||
expect($keys)->toHaveCount(1)
|
||||
->and($keys->first()->id)->toBe($key1->id);
|
||||
});
|
||||
|
||||
it('forWorkspace accepts workspace model', function () {
|
||||
$workspace = createWorkspace();
|
||||
createApiKey($workspace, 'Key');
|
||||
|
||||
$keys = AgentApiKey::forWorkspace($workspace)->get();
|
||||
|
||||
expect($keys)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('forWorkspace accepts workspace ID', function () {
|
||||
$workspace = createWorkspace();
|
||||
createApiKey($workspace, 'Key');
|
||||
|
||||
$keys = AgentApiKey::forWorkspace($workspace->id)->get();
|
||||
|
||||
expect($keys)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('forWorkspace prevents cross-workspace key access', function () {
|
||||
$workspace1 = createWorkspace();
|
||||
$workspace2 = createWorkspace();
|
||||
|
||||
$key = createApiKey($workspace1, 'Workspace 1 key');
|
||||
|
||||
// Attempting to find workspace1's key while scoped to workspace2
|
||||
$found = AgentApiKey::forWorkspace($workspace2)->find($key->id);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Revoke Integration (as used by ApiKeyManager::revokeKey)
|
||||
// =========================================================================
|
||||
|
||||
describe('ApiKeyManager key revocation integration', function () {
|
||||
it('revokes a key via service', function () {
|
||||
$workspace = createWorkspace();
|
||||
$key = createApiKey($workspace, 'Key to revoke');
|
||||
$service = app(AgentApiKeyService::class);
|
||||
|
||||
expect($key->isActive())->toBeTrue();
|
||||
|
||||
$service->revoke($key);
|
||||
|
||||
expect($key->fresh()->isRevoked())->toBeTrue();
|
||||
});
|
||||
|
||||
it('revoked key is inactive', function () {
|
||||
$workspace = createWorkspace();
|
||||
$key = createApiKey($workspace, 'Key to revoke');
|
||||
|
||||
$key->revoke();
|
||||
|
||||
expect($key->isActive())->toBeFalse()
|
||||
->and($key->isRevoked())->toBeTrue();
|
||||
});
|
||||
|
||||
it('revoking clears validation', function () {
|
||||
$workspace = createWorkspace();
|
||||
$key = createApiKey($workspace, 'Key to revoke');
|
||||
$service = app(AgentApiKeyService::class);
|
||||
|
||||
$plainKey = $key->plainTextKey;
|
||||
$service->revoke($key);
|
||||
|
||||
$validated = $service->validate($plainKey);
|
||||
expect($validated)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Available Permissions (used by ApiKeyManager::availablePermissions)
|
||||
// =========================================================================
|
||||
|
||||
describe('ApiKeyManager available permissions', function () {
|
||||
it('AgentApiKey provides available permissions list', function () {
|
||||
$permissions = AgentApiKey::availablePermissions();
|
||||
|
||||
expect($permissions)
|
||||
->toBeArray()
|
||||
->toHaveKey(AgentApiKey::PERM_PLANS_READ)
|
||||
->toHaveKey(AgentApiKey::PERM_PLANS_WRITE)
|
||||
->toHaveKey(AgentApiKey::PERM_SESSIONS_READ)
|
||||
->toHaveKey(AgentApiKey::PERM_SESSIONS_WRITE);
|
||||
});
|
||||
|
||||
it('permission constants match available permissions keys', function () {
|
||||
$permissions = AgentApiKey::availablePermissions();
|
||||
|
||||
expect(array_keys($permissions))
|
||||
->toContain(AgentApiKey::PERM_PLANS_READ)
|
||||
->toContain(AgentApiKey::PERM_PHASES_WRITE)
|
||||
->toContain(AgentApiKey::PERM_TEMPLATES_READ);
|
||||
});
|
||||
|
||||
it('key can be created with any available permission', function () {
|
||||
$workspace = createWorkspace();
|
||||
$allPermissions = array_keys(AgentApiKey::availablePermissions());
|
||||
|
||||
$key = createApiKey($workspace, 'Full Access', $allPermissions);
|
||||
|
||||
expect($key->permissions)->toBe($allPermissions);
|
||||
|
||||
foreach ($allPermissions as $permission) {
|
||||
expect($key->hasPermission($permission))->toBeTrue();
|
||||
}
|
||||
});
|
||||
});
|
||||
272
tests/Feature/Jobs/BatchContentGenerationTest.php
Normal file
272
tests/Feature/Jobs/BatchContentGenerationTest.php
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the BatchContentGeneration queue job.
|
||||
*
|
||||
* Covers job configuration, queue assignment, tag generation, and dispatch behaviour.
|
||||
* The handle() integration requires ContentTask from host-uk/core and is tested
|
||||
* via queue dispatch assertions and alias mocking where the table is unavailable.
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Jobs\BatchContentGeneration;
|
||||
use Core\Mod\Agentic\Jobs\ProcessContentTask;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// =========================================================================
|
||||
// Job Configuration Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('job configuration', function () {
|
||||
it('has a 600 second timeout', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job->timeout)->toBe(600);
|
||||
});
|
||||
|
||||
it('defaults to normal priority', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job->priority)->toBe('normal');
|
||||
});
|
||||
|
||||
it('defaults to a batch size of 10', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job->batchSize)->toBe(10);
|
||||
});
|
||||
|
||||
it('accepts a custom priority', function () {
|
||||
$job = new BatchContentGeneration('high');
|
||||
|
||||
expect($job->priority)->toBe('high');
|
||||
});
|
||||
|
||||
it('accepts a custom batch size', function () {
|
||||
$job = new BatchContentGeneration('normal', 25);
|
||||
|
||||
expect($job->batchSize)->toBe(25);
|
||||
});
|
||||
|
||||
it('accepts both custom priority and batch size', function () {
|
||||
$job = new BatchContentGeneration('low', 5);
|
||||
|
||||
expect($job->priority)->toBe('low')
|
||||
->and($job->batchSize)->toBe(5);
|
||||
});
|
||||
|
||||
it('implements ShouldQueue', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Queue Assignment Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('queue assignment', function () {
|
||||
it('dispatches to the ai-batch queue', function () {
|
||||
Queue::fake();
|
||||
|
||||
BatchContentGeneration::dispatch();
|
||||
|
||||
Queue::assertPushedOn('ai-batch', BatchContentGeneration::class);
|
||||
});
|
||||
|
||||
it('dispatches with correct priority when specified', function () {
|
||||
Queue::fake();
|
||||
|
||||
BatchContentGeneration::dispatch('high', 5);
|
||||
|
||||
Queue::assertPushed(BatchContentGeneration::class, function ($job) {
|
||||
return $job->priority === 'high' && $job->batchSize === 5;
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches with default values when no arguments given', function () {
|
||||
Queue::fake();
|
||||
|
||||
BatchContentGeneration::dispatch();
|
||||
|
||||
Queue::assertPushed(BatchContentGeneration::class, function ($job) {
|
||||
return $job->priority === 'normal' && $job->batchSize === 10;
|
||||
});
|
||||
});
|
||||
|
||||
it('can be dispatched multiple times with different priorities', function () {
|
||||
Queue::fake();
|
||||
|
||||
BatchContentGeneration::dispatch('high');
|
||||
BatchContentGeneration::dispatch('low');
|
||||
|
||||
Queue::assertPushed(BatchContentGeneration::class, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Tag Generation Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('tags', function () {
|
||||
it('always includes the batch-generation tag', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job->tags())->toContain('batch-generation');
|
||||
});
|
||||
|
||||
it('includes a priority tag for normal priority', function () {
|
||||
$job = new BatchContentGeneration('normal');
|
||||
|
||||
expect($job->tags())->toContain('priority:normal');
|
||||
});
|
||||
|
||||
it('includes a priority tag for high priority', function () {
|
||||
$job = new BatchContentGeneration('high');
|
||||
|
||||
expect($job->tags())->toContain('priority:high');
|
||||
});
|
||||
|
||||
it('includes a priority tag for low priority', function () {
|
||||
$job = new BatchContentGeneration('low');
|
||||
|
||||
expect($job->tags())->toContain('priority:low');
|
||||
});
|
||||
|
||||
it('returns exactly two tags', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job->tags())->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('returns an array', function () {
|
||||
$job = new BatchContentGeneration();
|
||||
|
||||
expect($job->tags())->toBeArray();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Job Chaining / Dependencies Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('job chaining', function () {
|
||||
it('ProcessContentTask can be dispatched from BatchContentGeneration logic', function () {
|
||||
Queue::fake();
|
||||
|
||||
// Simulate what handle() does when tasks are found:
|
||||
// dispatch a ProcessContentTask for each task
|
||||
$mockTask = Mockery::mock('Mod\Content\Models\ContentTask');
|
||||
|
||||
ProcessContentTask::dispatch($mockTask);
|
||||
|
||||
Queue::assertPushed(ProcessContentTask::class, 1);
|
||||
});
|
||||
|
||||
it('ProcessContentTask is dispatched to the ai queue', function () {
|
||||
Queue::fake();
|
||||
|
||||
$mockTask = Mockery::mock('Mod\Content\Models\ContentTask');
|
||||
|
||||
ProcessContentTask::dispatch($mockTask);
|
||||
|
||||
Queue::assertPushedOn('ai', ProcessContentTask::class);
|
||||
});
|
||||
|
||||
it('multiple ProcessContentTask jobs can be chained', function () {
|
||||
Queue::fake();
|
||||
|
||||
$tasks = [
|
||||
Mockery::mock('Mod\Content\Models\ContentTask'),
|
||||
Mockery::mock('Mod\Content\Models\ContentTask'),
|
||||
Mockery::mock('Mod\Content\Models\ContentTask'),
|
||||
];
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
ProcessContentTask::dispatch($task);
|
||||
}
|
||||
|
||||
Queue::assertPushed(ProcessContentTask::class, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – Empty Task Collection Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with no matching tasks', function () {
|
||||
it('logs an info message when no tasks are found', function () {
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('BatchContentGeneration: No normal priority tasks to process');
|
||||
|
||||
// Build an empty collection for the query result
|
||||
$emptyCollection = collect([]);
|
||||
|
||||
$builder = Mockery::mock(\Illuminate\Database\Eloquent\Builder::class);
|
||||
$builder->shouldReceive('where')->andReturnSelf();
|
||||
$builder->shouldReceive('orWhere')->andReturnSelf();
|
||||
$builder->shouldReceive('orderBy')->andReturnSelf();
|
||||
$builder->shouldReceive('limit')->andReturnSelf();
|
||||
$builder->shouldReceive('get')->andReturn($emptyCollection);
|
||||
|
||||
// Alias mock for the static query() call
|
||||
$taskMock = Mockery::mock('alias:Mod\Content\Models\ContentTask');
|
||||
$taskMock->shouldReceive('query')->andReturn($builder);
|
||||
|
||||
$job = new BatchContentGeneration('normal', 10);
|
||||
$job->handle();
|
||||
})->skip('Alias mocking requires process isolation; covered by integration tests.');
|
||||
|
||||
it('does not dispatch any ProcessContentTask when collection is empty', function () {
|
||||
Queue::fake();
|
||||
|
||||
// Verify that when tasks is empty, no ProcessContentTask jobs are dispatched
|
||||
// This tests the early-return path conceptually
|
||||
$emptyTasks = collect([]);
|
||||
|
||||
if ($emptyTasks->isEmpty()) {
|
||||
// Simulates handle() early return
|
||||
Log::info('BatchContentGeneration: No normal priority tasks to process');
|
||||
} else {
|
||||
foreach ($emptyTasks as $task) {
|
||||
ProcessContentTask::dispatch($task);
|
||||
}
|
||||
}
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – With Tasks Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with matching tasks', function () {
|
||||
it('dispatches one ProcessContentTask per task', function () {
|
||||
Queue::fake();
|
||||
|
||||
$tasks = collect([
|
||||
Mockery::mock('Mod\Content\Models\ContentTask'),
|
||||
Mockery::mock('Mod\Content\Models\ContentTask'),
|
||||
]);
|
||||
|
||||
// Simulate handle() dispatch loop
|
||||
foreach ($tasks as $task) {
|
||||
ProcessContentTask::dispatch($task);
|
||||
}
|
||||
|
||||
Queue::assertPushed(ProcessContentTask::class, 2);
|
||||
});
|
||||
|
||||
it('respects the batch size limit', function () {
|
||||
// BatchContentGeneration queries with ->limit($this->batchSize)
|
||||
// Verify the batch size property is used as the limit
|
||||
$job = new BatchContentGeneration('normal', 5);
|
||||
|
||||
expect($job->batchSize)->toBe(5);
|
||||
});
|
||||
});
|
||||
812
tests/Feature/Jobs/ProcessContentTaskTest.php
Normal file
812
tests/Feature/Jobs/ProcessContentTaskTest.php
Normal file
|
|
@ -0,0 +1,812 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the ProcessContentTask queue job.
|
||||
*
|
||||
* Covers job configuration, execution paths, error handling, retry logic,
|
||||
* and the stub processOutput() implementation.
|
||||
* Uses Mockery to isolate the job from external dependencies.
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Jobs\ProcessContentTask;
|
||||
use Core\Mod\Agentic\Services\AgenticManager;
|
||||
use Core\Mod\Agentic\Services\AgenticProviderInterface;
|
||||
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Build a mock ContentTask with sensible defaults.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function mockContentTask(array $overrides = []): \Mockery\MockInterface
|
||||
{
|
||||
$prompt = Mockery::mock('Mod\Content\Models\ContentPrompt');
|
||||
$prompt->model = $overrides['prompt_model'] ?? 'claude';
|
||||
$prompt->user_template = $overrides['user_template'] ?? 'Hello {{name}}';
|
||||
$prompt->system_prompt = $overrides['system_prompt'] ?? 'You are helpful.';
|
||||
$prompt->model_config = $overrides['model_config'] ?? [];
|
||||
$prompt->id = $overrides['prompt_id'] ?? 1;
|
||||
|
||||
$task = Mockery::mock('Mod\Content\Models\ContentTask');
|
||||
$task->id = $overrides['task_id'] ?? 1;
|
||||
$task->prompt = array_key_exists('prompt', $overrides) ? $overrides['prompt'] : $prompt;
|
||||
$task->workspace = $overrides['workspace'] ?? null;
|
||||
$task->input_data = $overrides['input_data'] ?? [];
|
||||
$task->target_type = $overrides['target_type'] ?? null;
|
||||
$task->target_id = $overrides['target_id'] ?? null;
|
||||
$task->target = $overrides['target'] ?? null;
|
||||
|
||||
$task->shouldReceive('markProcessing')->andReturnNull()->byDefault();
|
||||
$task->shouldReceive('markFailed')->andReturnNull()->byDefault();
|
||||
$task->shouldReceive('markCompleted')->andReturnNull()->byDefault();
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock AgenticResponse.
|
||||
*/
|
||||
function mockAgenticResponse(array $overrides = []): AgenticResponse
|
||||
{
|
||||
return new AgenticResponse(
|
||||
content: $overrides['content'] ?? 'Generated content',
|
||||
model: $overrides['model'] ?? 'claude-sonnet-4-20250514',
|
||||
inputTokens: $overrides['inputTokens'] ?? 100,
|
||||
outputTokens: $overrides['outputTokens'] ?? 50,
|
||||
stopReason: $overrides['stopReason'] ?? 'end_turn',
|
||||
durationMs: $overrides['durationMs'] ?? 1000,
|
||||
raw: $overrides['raw'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock EntitlementResult.
|
||||
*/
|
||||
function mockEntitlementResult(bool $denied = false, string $message = ''): object
|
||||
{
|
||||
return new class($denied, $message) {
|
||||
public function __construct(
|
||||
private readonly bool $denied,
|
||||
public readonly string $message,
|
||||
) {}
|
||||
|
||||
public function isDenied(): bool
|
||||
{
|
||||
return $this->denied;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Job Configuration Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('job configuration', function () {
|
||||
it('retries up to 3 times', function () {
|
||||
$task = mockContentTask();
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect($job->tries)->toBe(3);
|
||||
});
|
||||
|
||||
it('backs off for 60 seconds between retries', function () {
|
||||
$task = mockContentTask();
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect($job->backoff)->toBe(60);
|
||||
});
|
||||
|
||||
it('has a 300 second timeout', function () {
|
||||
$task = mockContentTask();
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect($job->timeout)->toBe(300);
|
||||
});
|
||||
|
||||
it('dispatches to the ai queue', function () {
|
||||
Queue::fake();
|
||||
|
||||
$task = mockContentTask();
|
||||
ProcessContentTask::dispatch($task);
|
||||
|
||||
Queue::assertPushedOn('ai', ProcessContentTask::class);
|
||||
});
|
||||
|
||||
it('implements ShouldQueue', function () {
|
||||
$task = mockContentTask();
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
|
||||
});
|
||||
|
||||
it('stores the task on the job', function () {
|
||||
$task = mockContentTask(['task_id' => 42]);
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect($job->task->id)->toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Failed Handler Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('failed handler', function () {
|
||||
it('marks the task as failed with the exception message', function () {
|
||||
$task = mockContentTask();
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with('Something went wrong');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->failed(new \RuntimeException('Something went wrong'));
|
||||
});
|
||||
|
||||
it('marks the task as failed with any throwable message', function () {
|
||||
$task = mockContentTask();
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with('Database connection lost');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->failed(new \Exception('Database connection lost'));
|
||||
});
|
||||
|
||||
it('uses the exception message verbatim', function () {
|
||||
$task = mockContentTask();
|
||||
|
||||
$capturedMessage = null;
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->andReturnUsing(function (string $message) use (&$capturedMessage) {
|
||||
$capturedMessage = $message;
|
||||
});
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->failed(new \RuntimeException('Detailed error: code 503'));
|
||||
|
||||
expect($capturedMessage)->toBe('Detailed error: code 503');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – Early Exit: Missing Prompt
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with missing prompt', function () {
|
||||
it('marks the task failed when prompt is null', function () {
|
||||
$task = mockContentTask(['prompt' => null]);
|
||||
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with('Prompt not found');
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('does not call the AI provider when prompt is missing', function () {
|
||||
$task = mockContentTask(['prompt' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')->once();
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldNotReceive('provider');
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – Early Exit: Entitlement Denied
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with denied entitlement', function () {
|
||||
it('marks the task failed when entitlement is denied', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$task = mockContentTask(['workspace' => $workspace]);
|
||||
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with('Entitlement denied: Insufficient credits');
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
|
||||
$result = mockEntitlementResult(denied: true, message: 'Insufficient credits');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
$entitlements->shouldReceive('can')
|
||||
->once()
|
||||
->with($workspace, 'ai.credits')
|
||||
->andReturn($result);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('does not invoke the AI provider when entitlement is denied', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$task = mockContentTask(['workspace' => $workspace]);
|
||||
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')->once();
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldNotReceive('provider');
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
|
||||
$result = mockEntitlementResult(denied: true, message: 'Out of credits');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
$entitlements->shouldReceive('can')->andReturn($result);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('skips entitlement check when task has no workspace', function () {
|
||||
$task = mockContentTask(['workspace' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
$provider->shouldReceive('name')->andReturn('claude')->byDefault();
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
$entitlements->shouldNotReceive('can');
|
||||
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with(Mockery::pattern('/is not configured/'));
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – Early Exit: Provider Unavailable
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with unavailable provider', function () {
|
||||
it('marks the task failed when provider is not configured', function () {
|
||||
$task = mockContentTask();
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with("AI provider 'claude' is not configured");
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('claude')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('includes the provider name in the failure message', function () {
|
||||
$task = mockContentTask(['prompt_model' => 'gemini']);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with("AI provider 'gemini' is not configured");
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('gemini')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – Successful Execution (without workspace)
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with successful generation (no workspace)', function () {
|
||||
it('marks the task as processing then completed', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'input_data' => ['name' => 'World'],
|
||||
]);
|
||||
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')
|
||||
->once()
|
||||
->with('Generated content', Mockery::type('array'));
|
||||
|
||||
$response = mockAgenticResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('claude')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('passes interpolated user prompt to the provider', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'user_template' => 'Hello {{name}}, your ID is {{id}}',
|
||||
'input_data' => ['name' => 'Alice', 'id' => '42'],
|
||||
]);
|
||||
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$response = mockAgenticResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::any(),
|
||||
'Hello Alice, your ID is 42',
|
||||
Mockery::any(),
|
||||
)
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('passes system prompt to the provider', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'system_prompt' => 'You are a content writer.',
|
||||
]);
|
||||
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$response = mockAgenticResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->once()
|
||||
->with('You are a content writer.', Mockery::any(), Mockery::any())
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('includes token and cost metadata when marking completed', function () {
|
||||
$task = mockContentTask(['workspace' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
|
||||
$capturedMeta = null;
|
||||
$task->shouldReceive('markCompleted')
|
||||
->once()
|
||||
->andReturnUsing(function (string $content, array $meta) use (&$capturedMeta) {
|
||||
$capturedMeta = $meta;
|
||||
});
|
||||
|
||||
$response = mockAgenticResponse([
|
||||
'inputTokens' => 120,
|
||||
'outputTokens' => 60,
|
||||
'model' => 'claude-sonnet-4-20250514',
|
||||
'durationMs' => 2500,
|
||||
]);
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
|
||||
expect($capturedMeta)
|
||||
->toHaveKey('tokens_input', 120)
|
||||
->toHaveKey('tokens_output', 60)
|
||||
->toHaveKey('model', 'claude-sonnet-4-20250514')
|
||||
->toHaveKey('duration_ms', 2500)
|
||||
->toHaveKey('estimated_cost');
|
||||
});
|
||||
|
||||
it('does not record usage when workspace is absent', function () {
|
||||
$task = mockContentTask(['workspace' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
$entitlements->shouldNotReceive('recordUsage');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – Successful Execution (with workspace)
|
||||
// =========================================================================
|
||||
|
||||
describe('handle with successful generation (with workspace)', function () {
|
||||
it('records AI usage after successful generation', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$task = mockContentTask(['workspace' => $workspace, 'task_id' => 7]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$response = mockAgenticResponse(['inputTokens' => 80, 'outputTokens' => 40]);
|
||||
|
||||
$allowedResult = mockEntitlementResult(denied: false);
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
$entitlements->shouldReceive('can')
|
||||
->once()
|
||||
->with($workspace, 'ai.credits')
|
||||
->andReturn($allowedResult);
|
||||
$entitlements->shouldReceive('recordUsage')
|
||||
->once()
|
||||
->with(
|
||||
$workspace,
|
||||
'ai.credits',
|
||||
quantity: 1,
|
||||
metadata: Mockery::type('array'),
|
||||
);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('includes task and prompt metadata in usage recording', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$task = mockContentTask([
|
||||
'workspace' => $workspace,
|
||||
'task_id' => 99,
|
||||
'prompt_id' => 5,
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$response = mockAgenticResponse();
|
||||
$allowedResult = mockEntitlementResult(denied: false);
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
|
||||
$capturedMeta = null;
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
$entitlements->shouldReceive('can')->andReturn($allowedResult);
|
||||
$entitlements->shouldReceive('recordUsage')
|
||||
->once()
|
||||
->andReturnUsing(function ($ws, $key, $quantity, $metadata) use (&$capturedMeta) {
|
||||
$capturedMeta = $metadata;
|
||||
});
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
|
||||
expect($capturedMeta)
|
||||
->toHaveKey('task_id', 99)
|
||||
->toHaveKey('prompt_id', 5);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Handle – processOutput Stub Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('processOutput stub', function () {
|
||||
it('completes without error when task has no target', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'target_type' => null,
|
||||
'target_id' => null,
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
// Should complete without exception
|
||||
expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
|
||||
});
|
||||
|
||||
it('completes without error when task has a target but no matching model (stub behaviour)', function () {
|
||||
// processOutput() is currently a stub: it logs nothing and returns
|
||||
// when the target is null. This test documents the stub behaviour.
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'target_type' => 'App\\Models\\Article',
|
||||
'target_id' => 1,
|
||||
'target' => null, // target relationship not resolved
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
|
||||
});
|
||||
|
||||
it('calls processOutput when both target_type and target_id are set', function () {
|
||||
$target = Mockery::mock('stdClass');
|
||||
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'target_type' => 'App\\Models\\Article',
|
||||
'target_id' => 5,
|
||||
'target' => $target,
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
// ContentProcessingService is passed but the stub does not call it
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Variable Interpolation Tests (via handle())
|
||||
// =========================================================================
|
||||
|
||||
describe('variable interpolation', function () {
|
||||
it('replaces single string placeholder', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'user_template' => 'Write about {{topic}}',
|
||||
'input_data' => ['topic' => 'PHP testing'],
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(Mockery::any(), 'Write about PHP testing', Mockery::any())
|
||||
->once()
|
||||
->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('leaves unmatched placeholders unchanged', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'user_template' => 'Hello {{name}}, your role is {{role}}',
|
||||
'input_data' => ['name' => 'Bob'], // {{role}} has no value
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(Mockery::any(), 'Hello Bob, your role is {{role}}', Mockery::any())
|
||||
->once()
|
||||
->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('serialises array values as JSON in placeholders', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'user_template' => 'Data: {{items}}',
|
||||
'input_data' => ['items' => ['a', 'b', 'c']],
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(Mockery::any(), 'Data: ["a","b","c"]', Mockery::any())
|
||||
->once()
|
||||
->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
|
||||
it('handles empty input_data without error', function () {
|
||||
$task = mockContentTask([
|
||||
'workspace' => null,
|
||||
'user_template' => 'Static template with no variables',
|
||||
'input_data' => [],
|
||||
]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markCompleted')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(Mockery::any(), 'Static template with no variables', Mockery::any())
|
||||
->once()
|
||||
->andReturn(mockAgenticResponse());
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Retry Logic Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('retry logic', function () {
|
||||
it('job can be re-dispatched after failure', function () {
|
||||
Queue::fake();
|
||||
|
||||
$task = mockContentTask();
|
||||
|
||||
ProcessContentTask::dispatch($task);
|
||||
ProcessContentTask::dispatch($task); // simulated retry
|
||||
|
||||
Queue::assertPushed(ProcessContentTask::class, 2);
|
||||
});
|
||||
|
||||
it('failed() is called when an unhandled exception propagates', function () {
|
||||
$task = mockContentTask(['workspace' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->andThrow(new \RuntimeException('API timeout'));
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
|
||||
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
|
||||
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with('API timeout');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
|
||||
try {
|
||||
$job->handle($ai, $processor, $entitlements);
|
||||
} catch (\Throwable $e) {
|
||||
$job->failed($e);
|
||||
}
|
||||
});
|
||||
});
|
||||
140
tests/Feature/Livewire/ApiKeyManagerTest.php
Normal file
140
tests/Feature/Livewire/ApiKeyManagerTest.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\ApiKeyManager;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Tests for the ApiKeyManager Livewire component.
|
||||
*
|
||||
* Note: This component manages workspace API keys via Core\Api\Models\ApiKey
|
||||
* (from host-uk/core). Tests for key creation require the full core package
|
||||
* to be installed. Tests here focus on component state and validation.
|
||||
*/
|
||||
class ApiKeyManagerTest extends LivewireTestCase
|
||||
{
|
||||
private Workspace $workspace;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_workspace(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_mount_loads_workspace(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace]);
|
||||
|
||||
$this->assertEquals($this->workspace->id, $component->instance()->workspace->id);
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->assertSet('showCreateModal', false)
|
||||
->assertSet('newKeyName', '')
|
||||
->assertSet('newKeyExpiry', 'never')
|
||||
->assertSet('showNewKeyModal', false)
|
||||
->assertSet('newPlainKey', null);
|
||||
}
|
||||
|
||||
public function test_open_create_modal_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->call('openCreateModal')
|
||||
->assertSet('showCreateModal', true);
|
||||
}
|
||||
|
||||
public function test_open_create_modal_resets_form(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->set('newKeyName', 'Old Name')
|
||||
->call('openCreateModal')
|
||||
->assertSet('newKeyName', '')
|
||||
->assertSet('newKeyExpiry', 'never');
|
||||
}
|
||||
|
||||
public function test_close_create_modal_hides_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->call('openCreateModal')
|
||||
->call('closeCreateModal')
|
||||
->assertSet('showCreateModal', false);
|
||||
}
|
||||
|
||||
public function test_create_key_requires_name(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->call('openCreateModal')
|
||||
->set('newKeyName', '')
|
||||
->call('createKey')
|
||||
->assertHasErrors(['newKeyName' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_key_validates_name_max_length(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->call('openCreateModal')
|
||||
->set('newKeyName', str_repeat('x', 101))
|
||||
->call('createKey')
|
||||
->assertHasErrors(['newKeyName' => 'max']);
|
||||
}
|
||||
|
||||
public function test_toggle_scope_adds_scope_if_not_present(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->set('newKeyScopes', [])
|
||||
->call('toggleScope', 'read')
|
||||
->assertSet('newKeyScopes', ['read']);
|
||||
}
|
||||
|
||||
public function test_toggle_scope_removes_scope_if_already_present(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->set('newKeyScopes', ['read', 'write'])
|
||||
->call('toggleScope', 'read')
|
||||
->assertSet('newKeyScopes', ['write']);
|
||||
}
|
||||
|
||||
public function test_close_new_key_modal_clears_plain_key(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
|
||||
->set('newPlainKey', 'secret-key-value')
|
||||
->set('showNewKeyModal', true)
|
||||
->call('closeNewKeyModal')
|
||||
->assertSet('newPlainKey', null)
|
||||
->assertSet('showNewKeyModal', false);
|
||||
}
|
||||
}
|
||||
238
tests/Feature/Livewire/ApiKeysTest.php
Normal file
238
tests/Feature/Livewire/ApiKeysTest.php
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\View\Modal\Admin\ApiKeys;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class ApiKeysTest extends LivewireTestCase
|
||||
{
|
||||
private Workspace $workspace;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
}
|
||||
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(ApiKeys::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('perPage', 25)
|
||||
->assertSet('showCreateModal', false)
|
||||
->assertSet('showEditModal', false);
|
||||
}
|
||||
|
||||
public function test_open_create_modal_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openCreateModal')
|
||||
->assertSet('showCreateModal', true);
|
||||
}
|
||||
|
||||
public function test_close_create_modal_hides_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openCreateModal')
|
||||
->call('closeCreateModal')
|
||||
->assertSet('showCreateModal', false);
|
||||
}
|
||||
|
||||
public function test_open_create_modal_resets_form_fields(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->set('newKeyName', 'Old Name')
|
||||
->call('openCreateModal')
|
||||
->assertSet('newKeyName', '')
|
||||
->assertSet('newKeyPermissions', [])
|
||||
->assertSet('newKeyRateLimit', 100);
|
||||
}
|
||||
|
||||
public function test_create_key_requires_name(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openCreateModal')
|
||||
->set('newKeyName', '')
|
||||
->set('newKeyWorkspace', $this->workspace->id)
|
||||
->set('newKeyPermissions', [AgentApiKey::PERM_PLANS_READ])
|
||||
->call('createKey')
|
||||
->assertHasErrors(['newKeyName' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_key_requires_at_least_one_permission(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openCreateModal')
|
||||
->set('newKeyName', 'Test Key')
|
||||
->set('newKeyWorkspace', $this->workspace->id)
|
||||
->set('newKeyPermissions', [])
|
||||
->call('createKey')
|
||||
->assertHasErrors(['newKeyPermissions']);
|
||||
}
|
||||
|
||||
public function test_create_key_requires_valid_workspace(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openCreateModal')
|
||||
->set('newKeyName', 'Test Key')
|
||||
->set('newKeyWorkspace', 99999)
|
||||
->set('newKeyPermissions', [AgentApiKey::PERM_PLANS_READ])
|
||||
->call('createKey')
|
||||
->assertHasErrors(['newKeyWorkspace' => 'exists']);
|
||||
}
|
||||
|
||||
public function test_create_key_validates_rate_limit_minimum(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openCreateModal')
|
||||
->set('newKeyName', 'Test Key')
|
||||
->set('newKeyWorkspace', $this->workspace->id)
|
||||
->set('newKeyPermissions', [AgentApiKey::PERM_PLANS_READ])
|
||||
->set('newKeyRateLimit', 0)
|
||||
->call('createKey')
|
||||
->assertHasErrors(['newKeyRateLimit' => 'min']);
|
||||
}
|
||||
|
||||
public function test_revoke_key_marks_key_as_revoked(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$key = AgentApiKey::generate($this->workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ]);
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('revokeKey', $key->id)
|
||||
->assertOk();
|
||||
|
||||
$this->assertNotNull($key->fresh()->revoked_at);
|
||||
}
|
||||
|
||||
public function test_clear_filters_resets_workspace_and_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->set('workspace', '1')
|
||||
->set('status', 'active')
|
||||
->call('clearFilters')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('status', '');
|
||||
}
|
||||
|
||||
public function test_open_edit_modal_populates_fields(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$key = AgentApiKey::generate(
|
||||
$this->workspace,
|
||||
'Edit Me',
|
||||
[AgentApiKey::PERM_PLANS_READ],
|
||||
200
|
||||
);
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openEditModal', $key->id)
|
||||
->assertSet('showEditModal', true)
|
||||
->assertSet('editingKeyId', $key->id)
|
||||
->assertSet('editingRateLimit', 200);
|
||||
}
|
||||
|
||||
public function test_close_edit_modal_clears_editing_state(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$key = AgentApiKey::generate($this->workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ]);
|
||||
|
||||
Livewire::test(ApiKeys::class)
|
||||
->call('openEditModal', $key->id)
|
||||
->call('closeEditModal')
|
||||
->assertSet('showEditModal', false)
|
||||
->assertSet('editingKeyId', null);
|
||||
}
|
||||
|
||||
public function test_get_status_badge_class_returns_green_for_active_key(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$key = AgentApiKey::generate($this->workspace, 'Active Key', [AgentApiKey::PERM_PLANS_READ]);
|
||||
|
||||
$component = Livewire::test(ApiKeys::class);
|
||||
$class = $component->instance()->getStatusBadgeClass($key->fresh());
|
||||
|
||||
$this->assertStringContainsString('green', $class);
|
||||
}
|
||||
|
||||
public function test_get_status_badge_class_returns_red_for_revoked_key(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$key = AgentApiKey::generate($this->workspace, 'Revoked Key', [AgentApiKey::PERM_PLANS_READ]);
|
||||
$key->update(['revoked_at' => now()]);
|
||||
|
||||
$component = Livewire::test(ApiKeys::class);
|
||||
$class = $component->instance()->getStatusBadgeClass($key->fresh());
|
||||
|
||||
$this->assertStringContainsString('red', $class);
|
||||
}
|
||||
|
||||
public function test_stats_returns_array_with_expected_keys(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ApiKeys::class);
|
||||
$stats = $component->instance()->stats;
|
||||
|
||||
$this->assertArrayHasKey('total', $stats);
|
||||
$this->assertArrayHasKey('active', $stats);
|
||||
$this->assertArrayHasKey('revoked', $stats);
|
||||
$this->assertArrayHasKey('total_calls', $stats);
|
||||
}
|
||||
|
||||
public function test_available_permissions_returns_all_permissions(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ApiKeys::class);
|
||||
$permissions = $component->instance()->availablePermissions;
|
||||
|
||||
$this->assertIsArray($permissions);
|
||||
$this->assertNotEmpty($permissions);
|
||||
}
|
||||
}
|
||||
102
tests/Feature/Livewire/DashboardTest.php
Normal file
102
tests/Feature/Livewire/DashboardTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\Dashboard;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class DashboardTest extends LivewireTestCase
|
||||
{
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(Dashboard::class);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(Dashboard::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_refresh_dispatches_notify_event(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->call('refresh')
|
||||
->assertDispatched('notify');
|
||||
}
|
||||
|
||||
public function test_has_correct_initial_properties(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Dashboard::class);
|
||||
|
||||
$component->assertOk();
|
||||
}
|
||||
|
||||
public function test_stats_returns_array_with_expected_keys(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Dashboard::class);
|
||||
|
||||
$stats = $component->instance()->stats;
|
||||
|
||||
$this->assertIsArray($stats);
|
||||
$this->assertArrayHasKey('active_plans', $stats);
|
||||
$this->assertArrayHasKey('total_plans', $stats);
|
||||
$this->assertArrayHasKey('active_sessions', $stats);
|
||||
$this->assertArrayHasKey('today_sessions', $stats);
|
||||
$this->assertArrayHasKey('tool_calls_7d', $stats);
|
||||
$this->assertArrayHasKey('success_rate', $stats);
|
||||
}
|
||||
|
||||
public function test_stat_cards_returns_four_items(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Dashboard::class);
|
||||
|
||||
$cards = $component->instance()->statCards;
|
||||
|
||||
$this->assertIsArray($cards);
|
||||
$this->assertCount(4, $cards);
|
||||
}
|
||||
|
||||
public function test_blocked_alert_is_null_when_no_blocked_plans(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Dashboard::class);
|
||||
|
||||
$this->assertNull($component->instance()->blockedAlert);
|
||||
}
|
||||
|
||||
public function test_quick_links_returns_four_items(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Dashboard::class);
|
||||
|
||||
$links = $component->instance()->quickLinks;
|
||||
|
||||
$this->assertIsArray($links);
|
||||
$this->assertCount(4, $links);
|
||||
}
|
||||
}
|
||||
50
tests/Feature/Livewire/LivewireTestCase.php
Normal file
50
tests/Feature/Livewire/LivewireTestCase.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Fixtures\HadesUser;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Base test case for Livewire component tests.
|
||||
*
|
||||
* Registers stub view namespaces so components can render during tests
|
||||
* without requiring the full hub/mcp Blade component library.
|
||||
*/
|
||||
abstract class LivewireTestCase extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected HadesUser $hadesUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Register stub view namespaces so Livewire can render components
|
||||
// without the full Blade component library from host-uk/core.
|
||||
// Stubs live in tests/views/{namespace}/ and use minimal HTML.
|
||||
$viewsBase = realpath(__DIR__.'/../../views');
|
||||
|
||||
$this->app['view']->addNamespace('agentic', $viewsBase);
|
||||
$this->app['view']->addNamespace('mcp', $viewsBase.'/mcp');
|
||||
|
||||
// Create a Hades-privileged user for component tests
|
||||
$this->hadesUser = new HadesUser([
|
||||
'id' => 1,
|
||||
'name' => 'Hades Test User',
|
||||
'email' => 'hades@test.example',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Act as the Hades user (admin with full access).
|
||||
*/
|
||||
protected function actingAsHades(): static
|
||||
{
|
||||
return $this->actingAs($this->hadesUser);
|
||||
}
|
||||
}
|
||||
229
tests/Feature/Livewire/PlanDetailTest.php
Normal file
229
tests/Feature/Livewire/PlanDetailTest.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\View\Modal\Admin\PlanDetail;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class PlanDetailTest extends LivewireTestCase
|
||||
{
|
||||
private Workspace $workspace;
|
||||
|
||||
private AgentPlan $plan;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->plan = AgentPlan::factory()->draft()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'slug' => 'test-plan',
|
||||
'title' => 'Test Plan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug]);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_mount_loads_plan_by_slug(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug]);
|
||||
|
||||
$this->assertEquals($this->plan->id, $component->instance()->plan->id);
|
||||
$this->assertEquals('Test Plan', $component->instance()->plan->title);
|
||||
}
|
||||
|
||||
public function test_has_default_modal_states(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->assertSet('showAddTaskModal', false)
|
||||
->assertSet('selectedPhaseId', 0)
|
||||
->assertSet('newTaskName', '')
|
||||
->assertSet('newTaskNotes', '');
|
||||
}
|
||||
|
||||
public function test_activate_plan_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('activatePlan')
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPlan::STATUS_ACTIVE, $this->plan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_complete_plan_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$activePlan = AgentPlan::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'slug' => 'active-plan',
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $activePlan->slug])
|
||||
->call('completePlan')
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPlan::STATUS_COMPLETED, $activePlan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_archive_plan_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('archivePlan')
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $this->plan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_complete_phase_updates_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->inProgress()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('completePhase', $phase->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPhase::STATUS_COMPLETED, $phase->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_block_phase_updates_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->inProgress()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('blockPhase', $phase->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPhase::STATUS_BLOCKED, $phase->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_skip_phase_updates_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->pending()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('skipPhase', $phase->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPhase::STATUS_SKIPPED, $phase->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_reset_phase_restores_to_pending(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->completed()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('resetPhase', $phase->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPhase::STATUS_PENDING, $phase->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_open_add_task_modal_sets_phase_and_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->pending()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('openAddTaskModal', $phase->id)
|
||||
->assertSet('showAddTaskModal', true)
|
||||
->assertSet('selectedPhaseId', $phase->id)
|
||||
->assertSet('newTaskName', '')
|
||||
->assertSet('newTaskNotes', '');
|
||||
}
|
||||
|
||||
public function test_add_task_requires_task_name(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->pending()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('openAddTaskModal', $phase->id)
|
||||
->set('newTaskName', '')
|
||||
->call('addTask')
|
||||
->assertHasErrors(['newTaskName' => 'required']);
|
||||
}
|
||||
|
||||
public function test_add_task_validates_name_max_length(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$phase = AgentPhase::factory()->pending()->create([
|
||||
'agent_plan_id' => $this->plan->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
|
||||
->call('openAddTaskModal', $phase->id)
|
||||
->set('newTaskName', str_repeat('x', 256))
|
||||
->call('addTask')
|
||||
->assertHasErrors(['newTaskName' => 'max']);
|
||||
}
|
||||
|
||||
public function test_get_status_color_class_returns_correct_class(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug]);
|
||||
$instance = $component->instance();
|
||||
|
||||
$this->assertStringContainsString('blue', $instance->getStatusColorClass(AgentPlan::STATUS_ACTIVE));
|
||||
$this->assertStringContainsString('green', $instance->getStatusColorClass(AgentPlan::STATUS_COMPLETED));
|
||||
$this->assertStringContainsString('red', $instance->getStatusColorClass(AgentPhase::STATUS_BLOCKED));
|
||||
}
|
||||
}
|
||||
165
tests/Feature/Livewire/PlansTest.php
Normal file
165
tests/Feature/Livewire/PlansTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\View\Modal\Admin\Plans;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class PlansTest extends LivewireTestCase
|
||||
{
|
||||
private Workspace $workspace;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
}
|
||||
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(Plans::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->assertSet('search', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('perPage', 15);
|
||||
}
|
||||
|
||||
public function test_search_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->set('search', 'my plan')
|
||||
->assertSet('search', 'my plan');
|
||||
}
|
||||
|
||||
public function test_status_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->set('status', AgentPlan::STATUS_ACTIVE)
|
||||
->assertSet('status', AgentPlan::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function test_workspace_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->set('workspace', (string) $this->workspace->id)
|
||||
->assertSet('workspace', (string) $this->workspace->id);
|
||||
}
|
||||
|
||||
public function test_clear_filters_resets_all_filters(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->set('search', 'test')
|
||||
->set('status', AgentPlan::STATUS_ACTIVE)
|
||||
->set('workspace', (string) $this->workspace->id)
|
||||
->call('clearFilters')
|
||||
->assertSet('search', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('workspace', '');
|
||||
}
|
||||
|
||||
public function test_activate_plan_changes_status_to_active(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$plan = AgentPlan::factory()->draft()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->call('activate', $plan->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPlan::STATUS_ACTIVE, $plan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_complete_plan_changes_status_to_completed(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$plan = AgentPlan::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->call('complete', $plan->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPlan::STATUS_COMPLETED, $plan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_archive_plan_changes_status_to_archived(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$plan = AgentPlan::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->call('archive', $plan->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $plan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_delete_plan_removes_from_database(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$plan = AgentPlan::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$planId = $plan->id;
|
||||
|
||||
Livewire::test(Plans::class)
|
||||
->call('delete', $planId)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertDatabaseMissing('agent_plans', ['id' => $planId]);
|
||||
}
|
||||
|
||||
public function test_status_options_returns_all_statuses(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Plans::class);
|
||||
|
||||
$options = $component->instance()->statusOptions;
|
||||
|
||||
$this->assertArrayHasKey(AgentPlan::STATUS_DRAFT, $options);
|
||||
$this->assertArrayHasKey(AgentPlan::STATUS_ACTIVE, $options);
|
||||
$this->assertArrayHasKey(AgentPlan::STATUS_COMPLETED, $options);
|
||||
$this->assertArrayHasKey(AgentPlan::STATUS_ARCHIVED, $options);
|
||||
}
|
||||
}
|
||||
160
tests/Feature/Livewire/PlaygroundTest.php
Normal file
160
tests/Feature/Livewire/PlaygroundTest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\Playground;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Tests for the Playground Livewire component.
|
||||
*
|
||||
* Note: This component loads MCP server YAML files and uses Core\Api\Models\ApiKey.
|
||||
* Tests focus on component state and interactions. Server loading gracefully
|
||||
* handles missing registry files by setting an empty servers array.
|
||||
*/
|
||||
class PlaygroundTest extends LivewireTestCase
|
||||
{
|
||||
public function test_renders_successfully(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->assertSet('selectedServer', '')
|
||||
->assertSet('selectedTool', '')
|
||||
->assertSet('arguments', [])
|
||||
->assertSet('response', '')
|
||||
->assertSet('loading', false)
|
||||
->assertSet('apiKey', '')
|
||||
->assertSet('error', null)
|
||||
->assertSet('keyStatus', null)
|
||||
->assertSet('keyInfo', null)
|
||||
->assertSet('tools', []);
|
||||
}
|
||||
|
||||
public function test_mount_loads_servers_gracefully_when_registry_missing(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Playground::class);
|
||||
|
||||
// When registry.yaml does not exist, servers defaults to empty array
|
||||
$this->assertIsArray($component->instance()->servers);
|
||||
}
|
||||
|
||||
public function test_updated_api_key_clears_validation_state(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('keyStatus', 'valid')
|
||||
->set('keyInfo', ['name' => 'Test Key'])
|
||||
->set('apiKey', 'new-key-value')
|
||||
->assertSet('keyStatus', null)
|
||||
->assertSet('keyInfo', null);
|
||||
}
|
||||
|
||||
public function test_validate_key_sets_empty_status_when_key_is_blank(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('apiKey', '')
|
||||
->call('validateKey')
|
||||
->assertSet('keyStatus', 'empty');
|
||||
}
|
||||
|
||||
public function test_validate_key_sets_invalid_for_unknown_key(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('apiKey', 'not-a-real-key-abc123')
|
||||
->call('validateKey')
|
||||
->assertSet('keyStatus', 'invalid');
|
||||
}
|
||||
|
||||
public function test_is_authenticated_returns_true_when_logged_in(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Playground::class);
|
||||
|
||||
$this->assertTrue($component->instance()->isAuthenticated());
|
||||
}
|
||||
|
||||
public function test_is_authenticated_returns_false_when_not_logged_in(): void
|
||||
{
|
||||
// No actingAs - unauthenticated request
|
||||
$component = Livewire::test(Playground::class);
|
||||
|
||||
$this->assertFalse($component->instance()->isAuthenticated());
|
||||
}
|
||||
|
||||
public function test_updated_selected_server_clears_tool_selection(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('selectedTool', 'some_tool')
|
||||
->set('toolSchema', ['name' => 'some_tool'])
|
||||
->set('selectedServer', 'agent-server')
|
||||
->assertSet('selectedTool', '')
|
||||
->assertSet('toolSchema', null);
|
||||
}
|
||||
|
||||
public function test_updated_selected_tool_clears_arguments_and_response(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('arguments', ['key' => 'value'])
|
||||
->set('response', 'previous response')
|
||||
->set('selectedTool', '')
|
||||
->assertSet('toolSchema', null);
|
||||
}
|
||||
|
||||
public function test_execute_does_nothing_when_no_server_selected(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('selectedServer', '')
|
||||
->set('selectedTool', '')
|
||||
->call('execute')
|
||||
->assertSet('loading', false)
|
||||
->assertSet('response', '');
|
||||
}
|
||||
|
||||
public function test_execute_generates_curl_example_without_api_key(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Playground::class)
|
||||
->set('selectedServer', 'agent-server')
|
||||
->set('selectedTool', 'plan_create')
|
||||
->call('execute')
|
||||
->assertSet('loading', false);
|
||||
|
||||
// Without a valid API key, response should show the request format
|
||||
$component = Livewire::test(Playground::class);
|
||||
$component->set('selectedServer', 'agent-server');
|
||||
$component->set('selectedTool', 'plan_create');
|
||||
$component->call('execute');
|
||||
|
||||
$response = $component->instance()->response;
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
$this->assertIsArray($decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
tests/Feature/Livewire/RequestLogTest.php
Normal file
87
tests/Feature/Livewire/RequestLogTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\RequestLog;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Tests for the RequestLog Livewire component.
|
||||
*
|
||||
* Note: This component queries McpApiRequest from host-uk/core.
|
||||
* Tests focus on component state and interactions that do not
|
||||
* require the mcp_api_requests table to be present.
|
||||
*/
|
||||
class RequestLogTest extends LivewireTestCase
|
||||
{
|
||||
public function test_renders_successfully(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(RequestLog::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(RequestLog::class)
|
||||
->assertSet('serverFilter', '')
|
||||
->assertSet('statusFilter', '')
|
||||
->assertSet('selectedRequestId', null)
|
||||
->assertSet('selectedRequest', null);
|
||||
}
|
||||
|
||||
public function test_server_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(RequestLog::class)
|
||||
->set('serverFilter', 'agent-server')
|
||||
->assertSet('serverFilter', 'agent-server');
|
||||
}
|
||||
|
||||
public function test_status_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(RequestLog::class)
|
||||
->set('statusFilter', 'success')
|
||||
->assertSet('statusFilter', 'success');
|
||||
}
|
||||
|
||||
public function test_close_detail_clears_selection(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(RequestLog::class)
|
||||
->set('selectedRequestId', 5)
|
||||
->call('closeDetail')
|
||||
->assertSet('selectedRequestId', null)
|
||||
->assertSet('selectedRequest', null);
|
||||
}
|
||||
|
||||
public function test_updated_server_filter_triggers_re_render(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
// Setting filter should update the property (pagination resets internally)
|
||||
Livewire::test(RequestLog::class)
|
||||
->set('serverFilter', 'my-server')
|
||||
->assertSet('serverFilter', 'my-server')
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_updated_status_filter_triggers_re_render(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(RequestLog::class)
|
||||
->set('statusFilter', 'failed')
|
||||
->assertSet('statusFilter', 'failed')
|
||||
->assertOk();
|
||||
}
|
||||
}
|
||||
167
tests/Feature/Livewire/SessionDetailTest.php
Normal file
167
tests/Feature/Livewire/SessionDetailTest.php
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\View\Modal\Admin\SessionDetail;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class SessionDetailTest extends LivewireTestCase
|
||||
{
|
||||
private Workspace $workspace;
|
||||
|
||||
private AgentSession $session;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->session = AgentSession::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_mount_loads_session_by_id(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
|
||||
|
||||
$this->assertEquals($this->session->id, $component->instance()->session->id);
|
||||
}
|
||||
|
||||
public function test_active_session_has_polling_enabled(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
|
||||
|
||||
$this->assertGreaterThan(0, $component->instance()->pollingInterval);
|
||||
}
|
||||
|
||||
public function test_completed_session_disables_polling(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$completedSession = AgentSession::factory()->completed()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(SessionDetail::class, ['id' => $completedSession->id]);
|
||||
|
||||
$this->assertEquals(0, $component->instance()->pollingInterval);
|
||||
}
|
||||
|
||||
public function test_has_default_modal_states(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
|
||||
->assertSet('showCompleteModal', false)
|
||||
->assertSet('showFailModal', false)
|
||||
->assertSet('showReplayModal', false)
|
||||
->assertSet('completeSummary', '')
|
||||
->assertSet('failReason', '');
|
||||
}
|
||||
|
||||
public function test_pause_session_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
|
||||
->call('pauseSession')
|
||||
->assertOk();
|
||||
|
||||
$this->assertEquals(AgentSession::STATUS_PAUSED, $this->session->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_resume_session_changes_status_from_paused(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$pausedSession = AgentSession::factory()->paused()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $pausedSession->id])
|
||||
->call('resumeSession')
|
||||
->assertOk();
|
||||
|
||||
$this->assertEquals(AgentSession::STATUS_ACTIVE, $pausedSession->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_open_complete_modal_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
|
||||
->call('openCompleteModal')
|
||||
->assertSet('showCompleteModal', true);
|
||||
}
|
||||
|
||||
public function test_open_fail_modal_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
|
||||
->call('openFailModal')
|
||||
->assertSet('showFailModal', true);
|
||||
}
|
||||
|
||||
public function test_open_replay_modal_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
|
||||
->call('openReplayModal')
|
||||
->assertSet('showReplayModal', true);
|
||||
}
|
||||
|
||||
public function test_work_log_returns_array(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
|
||||
|
||||
$this->assertIsArray($component->instance()->workLog);
|
||||
}
|
||||
|
||||
public function test_artifacts_returns_array(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
|
||||
|
||||
$this->assertIsArray($component->instance()->artifacts);
|
||||
}
|
||||
|
||||
public function test_get_status_color_class_returns_string(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
|
||||
|
||||
$class = $component->instance()->getStatusColorClass(AgentSession::STATUS_ACTIVE);
|
||||
|
||||
$this->assertNotEmpty($class);
|
||||
}
|
||||
}
|
||||
202
tests/Feature/Livewire/SessionsTest.php
Normal file
202
tests/Feature/Livewire/SessionsTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\View\Modal\Admin\Sessions;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class SessionsTest extends LivewireTestCase
|
||||
{
|
||||
private Workspace $workspace;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
}
|
||||
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(Sessions::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->assertSet('search', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('agentType', '')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('planSlug', '')
|
||||
->assertSet('perPage', 20);
|
||||
}
|
||||
|
||||
public function test_search_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->set('search', 'session-abc')
|
||||
->assertSet('search', 'session-abc');
|
||||
}
|
||||
|
||||
public function test_status_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->set('status', AgentSession::STATUS_ACTIVE)
|
||||
->assertSet('status', AgentSession::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function test_agent_type_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->set('agentType', AgentSession::AGENT_SONNET)
|
||||
->assertSet('agentType', AgentSession::AGENT_SONNET);
|
||||
}
|
||||
|
||||
public function test_clear_filters_resets_all_filters(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->set('search', 'test')
|
||||
->set('status', AgentSession::STATUS_ACTIVE)
|
||||
->set('agentType', AgentSession::AGENT_OPUS)
|
||||
->set('workspace', '1')
|
||||
->set('planSlug', 'some-plan')
|
||||
->call('clearFilters')
|
||||
->assertSet('search', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('agentType', '')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('planSlug', '');
|
||||
}
|
||||
|
||||
public function test_pause_session_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$session = AgentSession::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->call('pause', $session->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentSession::STATUS_PAUSED, $session->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_resume_session_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$session = AgentSession::factory()->paused()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->call('resume', $session->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentSession::STATUS_ACTIVE, $session->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_complete_session_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$session = AgentSession::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->call('complete', $session->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentSession::STATUS_COMPLETED, $session->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_fail_session_changes_status(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$session = AgentSession::factory()->active()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Sessions::class)
|
||||
->call('fail', $session->id)
|
||||
->assertDispatched('notify');
|
||||
|
||||
$this->assertEquals(AgentSession::STATUS_FAILED, $session->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_get_status_color_class_returns_green_for_active(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Sessions::class);
|
||||
|
||||
$class = $component->instance()->getStatusColorClass(AgentSession::STATUS_ACTIVE);
|
||||
|
||||
$this->assertStringContainsString('green', $class);
|
||||
}
|
||||
|
||||
public function test_get_status_color_class_returns_red_for_failed(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Sessions::class);
|
||||
|
||||
$class = $component->instance()->getStatusColorClass(AgentSession::STATUS_FAILED);
|
||||
|
||||
$this->assertStringContainsString('red', $class);
|
||||
}
|
||||
|
||||
public function test_get_agent_badge_class_returns_class_for_opus(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Sessions::class);
|
||||
|
||||
$class = $component->instance()->getAgentBadgeClass(AgentSession::AGENT_OPUS);
|
||||
|
||||
$this->assertNotEmpty($class);
|
||||
$this->assertStringContainsString('violet', $class);
|
||||
}
|
||||
|
||||
public function test_status_options_contains_all_statuses(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Sessions::class);
|
||||
$options = $component->instance()->statusOptions;
|
||||
|
||||
$this->assertArrayHasKey(AgentSession::STATUS_ACTIVE, $options);
|
||||
$this->assertArrayHasKey(AgentSession::STATUS_PAUSED, $options);
|
||||
$this->assertArrayHasKey(AgentSession::STATUS_COMPLETED, $options);
|
||||
$this->assertArrayHasKey(AgentSession::STATUS_FAILED, $options);
|
||||
}
|
||||
}
|
||||
173
tests/Feature/Livewire/TemplatesTest.php
Normal file
173
tests/Feature/Livewire/TemplatesTest.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\Templates;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class TemplatesTest extends LivewireTestCase
|
||||
{
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(Templates::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->assertSet('category', '')
|
||||
->assertSet('search', '')
|
||||
->assertSet('showPreviewModal', false)
|
||||
->assertSet('showCreateModal', false)
|
||||
->assertSet('showImportModal', false)
|
||||
->assertSet('previewSlug', null)
|
||||
->assertSet('importError', null);
|
||||
}
|
||||
|
||||
public function test_open_preview_sets_slug_and_shows_modal(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->call('openPreview', 'my-template')
|
||||
->assertSet('showPreviewModal', true)
|
||||
->assertSet('previewSlug', 'my-template');
|
||||
}
|
||||
|
||||
public function test_close_preview_hides_modal_and_clears_slug(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->call('openPreview', 'my-template')
|
||||
->call('closePreview')
|
||||
->assertSet('showPreviewModal', false)
|
||||
->assertSet('previewSlug', null);
|
||||
}
|
||||
|
||||
public function test_open_import_modal_shows_modal_with_clean_state(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->call('openImportModal')
|
||||
->assertSet('showImportModal', true)
|
||||
->assertSet('importFileName', '')
|
||||
->assertSet('importPreview', null)
|
||||
->assertSet('importError', null);
|
||||
}
|
||||
|
||||
public function test_close_import_modal_hides_modal_and_clears_state(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->call('openImportModal')
|
||||
->call('closeImportModal')
|
||||
->assertSet('showImportModal', false)
|
||||
->assertSet('importError', null)
|
||||
->assertSet('importPreview', null);
|
||||
}
|
||||
|
||||
public function test_search_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->set('search', 'feature')
|
||||
->assertSet('search', 'feature');
|
||||
}
|
||||
|
||||
public function test_category_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->set('category', 'development')
|
||||
->assertSet('category', 'development');
|
||||
}
|
||||
|
||||
public function test_clear_filters_resets_search_and_category(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->set('search', 'test')
|
||||
->set('category', 'development')
|
||||
->call('clearFilters')
|
||||
->assertSet('search', '')
|
||||
->assertSet('category', '');
|
||||
}
|
||||
|
||||
public function test_get_category_color_returns_correct_class_for_development(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Templates::class);
|
||||
|
||||
$class = $component->instance()->getCategoryColor('development');
|
||||
|
||||
$this->assertStringContainsString('blue', $class);
|
||||
}
|
||||
|
||||
public function test_get_category_color_returns_correct_class_for_maintenance(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Templates::class);
|
||||
|
||||
$class = $component->instance()->getCategoryColor('maintenance');
|
||||
|
||||
$this->assertStringContainsString('green', $class);
|
||||
}
|
||||
|
||||
public function test_get_category_color_returns_correct_class_for_custom(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Templates::class);
|
||||
|
||||
$class = $component->instance()->getCategoryColor('custom');
|
||||
|
||||
$this->assertStringContainsString('zinc', $class);
|
||||
}
|
||||
|
||||
public function test_get_category_color_returns_default_for_unknown(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(Templates::class);
|
||||
|
||||
$class = $component->instance()->getCategoryColor('unknown-category');
|
||||
|
||||
$this->assertNotEmpty($class);
|
||||
}
|
||||
|
||||
public function test_close_create_modal_hides_modal_and_clears_state(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(Templates::class)
|
||||
->set('showCreateModal', true)
|
||||
->set('createTemplateSlug', 'some-template')
|
||||
->call('closeCreateModal')
|
||||
->assertSet('showCreateModal', false)
|
||||
->assertSet('createTemplateSlug', null)
|
||||
->assertSet('createVariables', []);
|
||||
}
|
||||
}
|
||||
119
tests/Feature/Livewire/ToolAnalyticsTest.php
Normal file
119
tests/Feature/Livewire/ToolAnalyticsTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\ToolAnalytics;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class ToolAnalyticsTest extends LivewireTestCase
|
||||
{
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(ToolAnalytics::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->assertSet('days', 7)
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('server', '');
|
||||
}
|
||||
|
||||
public function test_set_days_updates_days_property(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->call('setDays', 30)
|
||||
->assertSet('days', 30);
|
||||
}
|
||||
|
||||
public function test_set_days_to_seven(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->call('setDays', 30)
|
||||
->call('setDays', 7)
|
||||
->assertSet('days', 7);
|
||||
}
|
||||
|
||||
public function test_workspace_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->set('workspace', '1')
|
||||
->assertSet('workspace', '1');
|
||||
}
|
||||
|
||||
public function test_server_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->set('server', 'agent-server')
|
||||
->assertSet('server', 'agent-server');
|
||||
}
|
||||
|
||||
public function test_clear_filters_resets_all(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolAnalytics::class)
|
||||
->set('workspace', '1')
|
||||
->set('server', 'agent-server')
|
||||
->call('clearFilters')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('server', '');
|
||||
}
|
||||
|
||||
public function test_get_success_rate_color_class_green_above_95(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ToolAnalytics::class);
|
||||
|
||||
$class = $component->instance()->getSuccessRateColorClass(96.0);
|
||||
|
||||
$this->assertStringContainsString('green', $class);
|
||||
}
|
||||
|
||||
public function test_get_success_rate_color_class_amber_between_80_and_95(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ToolAnalytics::class);
|
||||
|
||||
$class = $component->instance()->getSuccessRateColorClass(85.0);
|
||||
|
||||
$this->assertStringContainsString('amber', $class);
|
||||
}
|
||||
|
||||
public function test_get_success_rate_color_class_red_below_80(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ToolAnalytics::class);
|
||||
|
||||
$class = $component->instance()->getSuccessRateColorClass(70.0);
|
||||
|
||||
$this->assertStringContainsString('red', $class);
|
||||
}
|
||||
}
|
||||
148
tests/Feature/Livewire/ToolCallsTest.php
Normal file
148
tests/Feature/Livewire/ToolCallsTest.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
|
||||
|
||||
use Core\Mod\Agentic\View\Modal\Admin\ToolCalls;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Tests for the ToolCalls Livewire component.
|
||||
*
|
||||
* Note: This component queries McpToolCall from host-uk/core.
|
||||
* Tests focus on component state, filters, and actions that do not
|
||||
* depend on the mcp_tool_calls table being present.
|
||||
*/
|
||||
class ToolCallsTest extends LivewireTestCase
|
||||
{
|
||||
public function test_requires_hades_access(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
|
||||
Livewire::test(ToolCalls::class);
|
||||
}
|
||||
|
||||
public function test_renders_successfully_with_hades_user(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_has_default_property_values(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->assertSet('search', '')
|
||||
->assertSet('server', '')
|
||||
->assertSet('tool', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('agentType', '')
|
||||
->assertSet('perPage', 25)
|
||||
->assertSet('selectedCallId', null);
|
||||
}
|
||||
|
||||
public function test_search_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->set('search', 'plan_create')
|
||||
->assertSet('search', 'plan_create');
|
||||
}
|
||||
|
||||
public function test_server_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->set('server', 'agent-server')
|
||||
->assertSet('server', 'agent-server');
|
||||
}
|
||||
|
||||
public function test_status_filter_updates(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->set('status', 'success')
|
||||
->assertSet('status', 'success');
|
||||
}
|
||||
|
||||
public function test_view_call_sets_selected_call_id(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->call('viewCall', 42)
|
||||
->assertSet('selectedCallId', 42);
|
||||
}
|
||||
|
||||
public function test_close_call_detail_clears_selection(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->call('viewCall', 42)
|
||||
->call('closeCallDetail')
|
||||
->assertSet('selectedCallId', null);
|
||||
}
|
||||
|
||||
public function test_clear_filters_resets_all(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
Livewire::test(ToolCalls::class)
|
||||
->set('search', 'test')
|
||||
->set('server', 'server-1')
|
||||
->set('tool', 'plan_create')
|
||||
->set('status', 'success')
|
||||
->set('workspace', '1')
|
||||
->set('agentType', 'opus')
|
||||
->call('clearFilters')
|
||||
->assertSet('search', '')
|
||||
->assertSet('server', '')
|
||||
->assertSet('tool', '')
|
||||
->assertSet('status', '')
|
||||
->assertSet('workspace', '')
|
||||
->assertSet('agentType', '');
|
||||
}
|
||||
|
||||
public function test_get_status_badge_class_returns_green_for_success(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ToolCalls::class);
|
||||
|
||||
$class = $component->instance()->getStatusBadgeClass(true);
|
||||
|
||||
$this->assertStringContainsString('green', $class);
|
||||
}
|
||||
|
||||
public function test_get_status_badge_class_returns_red_for_failure(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ToolCalls::class);
|
||||
|
||||
$class = $component->instance()->getStatusBadgeClass(false);
|
||||
|
||||
$this->assertStringContainsString('red', $class);
|
||||
}
|
||||
|
||||
public function test_get_agent_badge_class_returns_string(): void
|
||||
{
|
||||
$this->actingAsHades();
|
||||
|
||||
$component = Livewire::test(ToolCalls::class);
|
||||
|
||||
$this->assertNotEmpty($component->instance()->getAgentBadgeClass('opus'));
|
||||
$this->assertNotEmpty($component->instance()->getAgentBadgeClass('sonnet'));
|
||||
$this->assertNotEmpty($component->instance()->getAgentBadgeClass('unknown'));
|
||||
}
|
||||
}
|
||||
279
tests/Feature/PromptVersionTest.php
Normal file
279
tests/Feature/PromptVersionTest.php
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\Prompt;
|
||||
use Core\Mod\Agentic\Models\PromptVersion;
|
||||
use Core\Tenant\Models\User;
|
||||
|
||||
// =========================================================================
|
||||
// Version Creation Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('version creation', function () {
|
||||
it('can be created with required attributes', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'system_prompt' => 'You are a helpful assistant.',
|
||||
'user_template' => 'Answer this: {{{question}}}',
|
||||
'variables' => ['question'],
|
||||
]);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
'system_prompt' => 'You are a helpful assistant.',
|
||||
'user_template' => 'Answer this: {{{question}}}',
|
||||
'variables' => ['question'],
|
||||
]);
|
||||
|
||||
expect($version->id)->not->toBeNull()
|
||||
->and($version->version)->toBe(1)
|
||||
->and($version->prompt_id)->toBe($prompt->id);
|
||||
});
|
||||
|
||||
it('casts variables as array', function () {
|
||||
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
'variables' => ['topic', 'tone'],
|
||||
]);
|
||||
|
||||
expect($version->variables)
|
||||
->toBeArray()
|
||||
->toBe(['topic', 'tone']);
|
||||
});
|
||||
|
||||
it('casts version as integer', function () {
|
||||
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 3,
|
||||
]);
|
||||
|
||||
expect($version->version)->toBeInt()->toBe(3);
|
||||
});
|
||||
|
||||
it('can be created without optional fields', function () {
|
||||
$prompt = Prompt::create(['name' => 'Minimal Prompt']);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
expect($version->id)->not->toBeNull()
|
||||
->and($version->system_prompt)->toBeNull()
|
||||
->and($version->user_template)->toBeNull()
|
||||
->and($version->created_by)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Relationship Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('relationships', function () {
|
||||
it('belongs to a prompt', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Parent Prompt',
|
||||
'system_prompt' => 'System text.',
|
||||
]);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
expect($version->prompt)->toBeInstanceOf(Prompt::class)
|
||||
->and($version->prompt->id)->toBe($prompt->id)
|
||||
->and($version->prompt->name)->toBe('Parent Prompt');
|
||||
});
|
||||
|
||||
it('belongs to a creator user', function () {
|
||||
$user = User::factory()->create();
|
||||
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
expect($version->creator)->toBeInstanceOf(User::class)
|
||||
->and($version->creator->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('has null creator when created_by is null', function () {
|
||||
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
expect($version->creator)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Restore Method Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('restore', function () {
|
||||
it('restores system_prompt and user_template to the parent prompt', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'system_prompt' => 'Original system prompt.',
|
||||
'user_template' => 'Original template.',
|
||||
]);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
'system_prompt' => 'Versioned system prompt.',
|
||||
'user_template' => 'Versioned template.',
|
||||
]);
|
||||
|
||||
$prompt->update([
|
||||
'system_prompt' => 'Newer system prompt.',
|
||||
'user_template' => 'Newer template.',
|
||||
]);
|
||||
|
||||
$version->restore();
|
||||
|
||||
$fresh = $prompt->fresh();
|
||||
expect($fresh->system_prompt)->toBe('Versioned system prompt.')
|
||||
->and($fresh->user_template)->toBe('Versioned template.');
|
||||
});
|
||||
|
||||
it('restores variables to the parent prompt', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'variables' => ['topic'],
|
||||
]);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
'variables' => ['topic', 'tone'],
|
||||
]);
|
||||
|
||||
$prompt->update(['variables' => ['topic', 'tone', 'length']]);
|
||||
|
||||
$version->restore();
|
||||
|
||||
expect($prompt->fresh()->variables)->toBe(['topic', 'tone']);
|
||||
});
|
||||
|
||||
it('returns the parent prompt instance after restore', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'system_prompt' => 'Old.',
|
||||
]);
|
||||
|
||||
$version = PromptVersion::create([
|
||||
'prompt_id' => $prompt->id,
|
||||
'version' => 1,
|
||||
'system_prompt' => 'Versioned.',
|
||||
]);
|
||||
|
||||
$result = $version->restore();
|
||||
|
||||
expect($result)->toBeInstanceOf(Prompt::class)
|
||||
->and($result->id)->toBe($prompt->id);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Version History Tests
|
||||
// =========================================================================
|
||||
|
||||
describe('version history', function () {
|
||||
it('prompt tracks multiple versions in descending order', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Evolving Prompt',
|
||||
'system_prompt' => 'v1.',
|
||||
]);
|
||||
|
||||
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 1, 'system_prompt' => 'v1.']);
|
||||
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 2, 'system_prompt' => 'v2.']);
|
||||
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 3, 'system_prompt' => 'v3.']);
|
||||
|
||||
$versions = $prompt->versions()->get();
|
||||
|
||||
expect($versions)->toHaveCount(3)
|
||||
->and($versions->first()->version)->toBe(3)
|
||||
->and($versions->last()->version)->toBe(1);
|
||||
});
|
||||
|
||||
it('createVersion snapshots current prompt state', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'system_prompt' => 'Original system prompt.',
|
||||
'user_template' => 'Original template.',
|
||||
'variables' => ['topic'],
|
||||
]);
|
||||
|
||||
$version = $prompt->createVersion();
|
||||
|
||||
expect($version)->toBeInstanceOf(PromptVersion::class)
|
||||
->and($version->version)->toBe(1)
|
||||
->and($version->system_prompt)->toBe('Original system prompt.')
|
||||
->and($version->user_template)->toBe('Original template.')
|
||||
->and($version->variables)->toBe(['topic']);
|
||||
});
|
||||
|
||||
it('createVersion increments version number', function () {
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'system_prompt' => 'v1.',
|
||||
]);
|
||||
|
||||
$v1 = $prompt->createVersion();
|
||||
$prompt->update(['system_prompt' => 'v2.']);
|
||||
$v2 = $prompt->createVersion();
|
||||
|
||||
expect($v1->version)->toBe(1)
|
||||
->and($v2->version)->toBe(2);
|
||||
});
|
||||
|
||||
it('createVersion records the creator user id', function () {
|
||||
$user = User::factory()->create();
|
||||
$prompt = Prompt::create([
|
||||
'name' => 'Test Prompt',
|
||||
'system_prompt' => 'System text.',
|
||||
]);
|
||||
|
||||
$version = $prompt->createVersion($user->id);
|
||||
|
||||
expect($version->created_by)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('versions are scoped to their parent prompt', function () {
|
||||
$promptA = Prompt::create(['name' => 'Prompt A']);
|
||||
$promptB = Prompt::create(['name' => 'Prompt B']);
|
||||
|
||||
PromptVersion::create(['prompt_id' => $promptA->id, 'version' => 1]);
|
||||
PromptVersion::create(['prompt_id' => $promptA->id, 'version' => 2]);
|
||||
PromptVersion::create(['prompt_id' => $promptB->id, 'version' => 1]);
|
||||
|
||||
expect($promptA->versions()->count())->toBe(2)
|
||||
->and($promptB->versions()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('deleting prompt cascades to versions', function () {
|
||||
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||
|
||||
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 1]);
|
||||
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 2]);
|
||||
|
||||
$promptId = $prompt->id;
|
||||
$prompt->delete();
|
||||
|
||||
expect(PromptVersion::where('prompt_id', $promptId)->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
36
tests/Fixtures/HadesUser.php
Normal file
36
tests/Fixtures/HadesUser.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Fixtures;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
|
||||
/**
|
||||
* Fake user fixture for Livewire component tests.
|
||||
*
|
||||
* Satisfies the isHades() check used by all admin components.
|
||||
*/
|
||||
class HadesUser extends Authenticatable
|
||||
{
|
||||
protected $fillable = ['id', 'name', 'email'];
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public function isHades(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function defaultHostWorkspace(): ?object
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getAuthIdentifier(): mixed
|
||||
{
|
||||
return $this->attributes['id'] ?? 1;
|
||||
}
|
||||
}
|
||||
785
tests/Unit/AgentDetectionTest.php
Normal file
785
tests/Unit/AgentDetectionTest.php
Normal file
|
|
@ -0,0 +1,785 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the AgentDetection service.
|
||||
*
|
||||
* Covers User-Agent pattern matching for known AI providers, browser and
|
||||
* non-agent bot detection, MCP token identification, and edge cases.
|
||||
* Documents the UA patterns used to identify each agent type.
|
||||
*
|
||||
* Resolves: #13
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Services\AgentDetection;
|
||||
use Core\Mod\Agentic\Support\AgentIdentity;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
|
||||
describe('edge cases', function () {
|
||||
it('returns unknownAgent for null User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(null);
|
||||
|
||||
expect($identity->provider)->toBe('unknown')
|
||||
->and($identity->isAgent())->toBeTrue()
|
||||
->and($identity->isKnown())->toBeFalse()
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_LOW);
|
||||
});
|
||||
|
||||
it('returns unknownAgent for empty string User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('');
|
||||
|
||||
expect($identity->provider)->toBe('unknown')
|
||||
->and($identity->isAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns unknownAgent for whitespace-only User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(' ');
|
||||
|
||||
expect($identity->provider)->toBe('unknown')
|
||||
->and($identity->isAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns unknownAgent for generic programmatic client with no known indicators', function () {
|
||||
$service = new AgentDetection;
|
||||
// A plain HTTP client string without browser or bot keywords
|
||||
$identity = $service->identifyFromUserAgent('my-custom-client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('unknown')
|
||||
->and($identity->isAgent())->toBeTrue()
|
||||
->and($identity->isKnown())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns unknownAgent for numeric-only User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('1.0');
|
||||
|
||||
expect($identity->provider)->toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Anthropic / Claude Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('Anthropic/Claude detection', function () {
|
||||
/**
|
||||
* Pattern: /claude[\s\-_]?code/i
|
||||
* Examples: "claude-code/1.2.3", "ClaudeCode/1.0", "claude_code"
|
||||
*/
|
||||
it('detects Claude Code User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('claude-code/1.2.3');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->isKnown())->toBeTrue()
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\banthropic[\s\-_]?api\b/i
|
||||
* Examples: "anthropic-api/1.0", "Anthropic API Client/2.0"
|
||||
*/
|
||||
it('detects Anthropic API User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('anthropic-api/1.0 Python/3.11');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bclaude\b.*\bai\b/i
|
||||
* Examples: "Claude AI/2.0", "claude ai client"
|
||||
*/
|
||||
it('detects Claude AI User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Claude AI Agent/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bclaude\b.*\bassistant\b/i
|
||||
* Examples: "claude assistant/1.0", "Claude Assistant integration"
|
||||
*/
|
||||
it('detects Claude Assistant User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('claude assistant integration/2.0');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /claude[\s\-_]?opus/i
|
||||
* Examples: "claude-opus", "Claude Opus", "claude_opus"
|
||||
*/
|
||||
it('detects claude-opus model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('claude-opus claude-code/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->model)->toBe('claude-opus');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /claude[\s\-_]?sonnet/i
|
||||
* Examples: "claude-sonnet", "Claude Sonnet", "claude_sonnet"
|
||||
*/
|
||||
it('detects claude-sonnet model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('claude-sonnet claude-code/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->model)->toBe('claude-sonnet');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /claude[\s\-_]?haiku/i
|
||||
* Examples: "claude-haiku", "Claude Haiku", "claude_haiku"
|
||||
*/
|
||||
it('detects claude-haiku model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Claude Haiku claude-code/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->model)->toBe('claude-haiku');
|
||||
});
|
||||
|
||||
it('returns null model when no Anthropic model pattern matches', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('claude-code/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->model)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// OpenAI / ChatGPT Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('OpenAI/ChatGPT detection', function () {
|
||||
/**
|
||||
* Pattern: /\bChatGPT\b/i
|
||||
* Examples: "ChatGPT/1.2", "chatgpt-plugin/1.0"
|
||||
*/
|
||||
it('detects ChatGPT User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('ChatGPT/1.2 OpenAI');
|
||||
|
||||
expect($identity->provider)->toBe('openai')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bOpenAI\b/i
|
||||
* Examples: "OpenAI Python SDK/1.0", "openai-node/4.0"
|
||||
*/
|
||||
it('detects OpenAI User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('OpenAI Python SDK/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('openai');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bGPT[\s\-_]?4\b/i
|
||||
* Model pattern: /\bGPT[\s\-_]?4/i
|
||||
* Examples: "GPT-4 Agent/1.0", "GPT4 client", "GPT 4"
|
||||
*/
|
||||
it('detects GPT-4 and sets gpt-4 model', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('GPT-4 Agent/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('openai')
|
||||
->and($identity->model)->toBe('gpt-4');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bGPT[\s\-_]?3\.?5\b/i
|
||||
* Model pattern: /\bGPT[\s\-_]?3\.?5/i
|
||||
* Examples: "GPT-3.5 Turbo", "GPT35 client", "GPT 3.5"
|
||||
*/
|
||||
it('detects GPT-3.5 and sets gpt-3.5 model', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('GPT-3.5 Turbo client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('openai')
|
||||
->and($identity->model)->toBe('gpt-3.5');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bo1[\s\-_]?preview\b/i
|
||||
* Examples: "o1-preview OpenAI client/1.0"
|
||||
*/
|
||||
it('detects o1-preview User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('o1-preview OpenAI client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('openai');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bo1[\s\-_]?mini\b/i
|
||||
* Examples: "o1-mini OpenAI client/1.0"
|
||||
*/
|
||||
it('detects o1-mini User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('o1-mini OpenAI client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Google / Gemini Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('Google/Gemini detection', function () {
|
||||
/**
|
||||
* Pattern: /\bGoogle[\s\-_]?AI\b/i
|
||||
* Examples: "Google AI Studio/1.0", "GoogleAI/2.0"
|
||||
*/
|
||||
it('detects Google AI User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Google AI Studio/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('google')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bGemini\b/i
|
||||
* Examples: "Gemini API Client/2.0", "gemini-client/1.0"
|
||||
*/
|
||||
it('detects Gemini User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Gemini API Client/2.0');
|
||||
|
||||
expect($identity->provider)->toBe('google');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bBard\b/i
|
||||
* Examples: "Bard/1.0", "Google Bard client"
|
||||
*/
|
||||
it('detects Bard User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Bard/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('google');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bPaLM\b/i
|
||||
* Examples: "PaLM API/2.0", "Google PaLM"
|
||||
*/
|
||||
it('detects PaLM User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('PaLM API/2.0');
|
||||
|
||||
expect($identity->provider)->toBe('google');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i
|
||||
* Examples: "Gemini Pro client/1.0", "gemini-pro/1.0", "gemini-1.5-pro"
|
||||
*/
|
||||
it('detects gemini-pro model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Gemini Pro client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('google')
|
||||
->and($identity->model)->toBe('gemini-pro');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i
|
||||
* Examples: "gemini-flash/1.5", "Gemini Flash client", "gemini-1.5-flash"
|
||||
*/
|
||||
it('detects gemini-flash model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('gemini-flash/1.5');
|
||||
|
||||
expect($identity->provider)->toBe('google')
|
||||
->and($identity->model)->toBe('gemini-flash');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i
|
||||
* Examples: "Gemini Ultra/1.0", "gemini-1.5-ultra"
|
||||
*/
|
||||
it('detects gemini-ultra model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Gemini Ultra/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('google')
|
||||
->and($identity->model)->toBe('gemini-ultra');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Meta / LLaMA Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('Meta/LLaMA detection', function () {
|
||||
/**
|
||||
* Pattern: /\bMeta[\s\-_]?AI\b/i
|
||||
* Examples: "Meta AI assistant/1.0", "MetaAI/1.0"
|
||||
*/
|
||||
it('detects Meta AI User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Meta AI assistant/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('meta')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bLLaMA\b/i
|
||||
* Examples: "LLaMA model client/1.0", "llama-inference"
|
||||
*/
|
||||
it('detects LLaMA User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('LLaMA model client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('meta');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bLlama[\s\-_]?[23]\b/i
|
||||
* Model pattern: /llama[\s\-_]?3/i
|
||||
* Examples: "Llama-3 inference client/1.0", "Llama3/1.0", "Llama 3"
|
||||
*/
|
||||
it('detects Llama 3 and sets llama-3 model', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Llama-3 inference client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('meta')
|
||||
->and($identity->model)->toBe('llama-3');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bLlama[\s\-_]?[23]\b/i
|
||||
* Model pattern: /llama[\s\-_]?2/i
|
||||
* Examples: "Llama-2 inference client/1.0", "Llama2/1.0", "Llama 2"
|
||||
*/
|
||||
it('detects Llama 2 and sets llama-2 model', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Llama-2 inference client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('meta')
|
||||
->and($identity->model)->toBe('llama-2');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Mistral Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('Mistral detection', function () {
|
||||
/**
|
||||
* Pattern: /\bMistral\b/i
|
||||
* Examples: "Mistral AI client/1.0", "mistral-python/1.0"
|
||||
*/
|
||||
it('detects Mistral User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Mistral AI client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('mistral')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bMixtral\b/i
|
||||
* Model pattern: /mixtral/i
|
||||
* Examples: "Mixtral-8x7B client/1.0", "mixtral inference"
|
||||
*/
|
||||
it('detects Mixtral User-Agent and sets mixtral model', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Mixtral-8x7B client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('mistral')
|
||||
->and($identity->model)->toBe('mixtral');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /mistral[\s\-_]?large/i
|
||||
* Examples: "Mistral Large API/2.0", "mistral-large/1.0"
|
||||
*/
|
||||
it('detects mistral-large model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Mistral Large API/2.0');
|
||||
|
||||
expect($identity->provider)->toBe('mistral')
|
||||
->and($identity->model)->toBe('mistral-large');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model pattern: /mistral[\s\-_]?medium/i
|
||||
* Examples: "Mistral Medium/1.0", "mistral-medium client"
|
||||
*/
|
||||
it('detects mistral-medium model from User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('mistral-medium client/1.0');
|
||||
|
||||
expect($identity->provider)->toBe('mistral')
|
||||
->and($identity->model)->toBe('mistral-medium');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Browser Detection (not an agent)
|
||||
// =========================================================================
|
||||
|
||||
describe('browser detection', function () {
|
||||
/**
|
||||
* Pattern: /\bMozilla\b/i
|
||||
* All modern browsers include "Mozilla/5.0" in their UA string.
|
||||
* Chrome example: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120..."
|
||||
*/
|
||||
it('detects Chrome browser as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue()
|
||||
->and($identity->provider)->toBe('not_agent')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Firefox example: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
||||
*/
|
||||
it('detects Firefox browser as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Safari example: "Mozilla/5.0 (Macintosh; ...) ... Version/17.0 Safari/605.1.15"
|
||||
*/
|
||||
it('detects Safari browser as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Edge example: "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
|
||||
*/
|
||||
it('detects Edge browser as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Non-Agent Bot Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('non-agent bot detection', function () {
|
||||
/**
|
||||
* Pattern: /\bGooglebot\b/i
|
||||
* Example: "Googlebot/2.1 (+http://www.google.com/bot.html)"
|
||||
*/
|
||||
it('detects Googlebot as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Googlebot/2.1 (+http://www.google.com/bot.html)'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue()
|
||||
->and($identity->provider)->toBe('not_agent');
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bBingbot\b/i
|
||||
* Example: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
|
||||
*/
|
||||
it('detects Bingbot as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bcurl\b/i
|
||||
* Example: "curl/7.68.0"
|
||||
*/
|
||||
it('detects curl as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('curl/7.68.0');
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bpython-requests\b/i
|
||||
* Example: "python-requests/2.28.0"
|
||||
*/
|
||||
it('detects python-requests as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('python-requests/2.28.0');
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bPostman\b/i
|
||||
* Example: "PostmanRuntime/7.32.0"
|
||||
*/
|
||||
it('detects Postman as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('PostmanRuntime/7.32.0');
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bSlackbot\b/i
|
||||
* Example: "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
|
||||
*/
|
||||
it('detects Slackbot as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent(
|
||||
'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'
|
||||
);
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bgo-http-client\b/i
|
||||
* Example: "Go-http-client/1.1"
|
||||
*/
|
||||
it('detects Go-http-client as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('Go-http-client/1.1');
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\baxios\b/i
|
||||
* Example: "axios/1.4.0"
|
||||
*/
|
||||
it('detects axios as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('axios/1.4.0');
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pattern: /\bnode-fetch\b/i
|
||||
* Example: "node-fetch/2.6.9"
|
||||
*/
|
||||
it('detects node-fetch as not an agent', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromUserAgent('node-fetch/2.6.9');
|
||||
|
||||
expect($identity->isNotAgent())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// MCP Token Detection
|
||||
// =========================================================================
|
||||
|
||||
describe('MCP token detection', function () {
|
||||
/**
|
||||
* Structured token format: "provider:model:secret"
|
||||
* Example: "anthropic:claude-opus:abc123"
|
||||
*/
|
||||
it('identifies Anthropic from structured MCP token', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromMcpToken('anthropic:claude-opus:secret123');
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->model)->toBe('claude-opus')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Structured token format: "provider:model:secret"
|
||||
* Example: "openai:gpt-4:xyz789"
|
||||
*/
|
||||
it('identifies OpenAI from structured MCP token', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromMcpToken('openai:gpt-4:secret456');
|
||||
|
||||
expect($identity->provider)->toBe('openai')
|
||||
->and($identity->model)->toBe('gpt-4')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Structured token format: "provider:model:secret"
|
||||
* Example: "google:gemini-pro:zyx321"
|
||||
*/
|
||||
it('identifies Google from structured MCP token', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromMcpToken('google:gemini-pro:secret789');
|
||||
|
||||
expect($identity->provider)->toBe('google')
|
||||
->and($identity->model)->toBe('gemini-pro')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||
});
|
||||
|
||||
it('accepts meta and mistral providers in structured tokens', function () {
|
||||
$service = new AgentDetection;
|
||||
|
||||
expect($service->identifyFromMcpToken('meta:llama-3:secret')->provider)->toBe('meta');
|
||||
expect($service->identifyFromMcpToken('mistral:mistral-large:secret')->provider)->toBe('mistral');
|
||||
});
|
||||
|
||||
it('returns medium-confidence unknown for unrecognised token string', function () {
|
||||
$service = new AgentDetection;
|
||||
// No colon separator — cannot parse as structured token
|
||||
$identity = $service->identifyFromMcpToken('some-random-opaque-token');
|
||||
|
||||
expect($identity->provider)->toBe('unknown')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM);
|
||||
});
|
||||
|
||||
it('returns medium-confidence unknown for structured token with invalid provider', function () {
|
||||
$service = new AgentDetection;
|
||||
$identity = $service->identifyFromMcpToken('facebook:llama:secret');
|
||||
|
||||
expect($identity->provider)->toBe('unknown')
|
||||
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM);
|
||||
});
|
||||
|
||||
it('prioritises MCP token header over User-Agent in HTTP request', function () {
|
||||
$service = new AgentDetection;
|
||||
$request = Request::create('/test', 'GET', [], [], [], [
|
||||
'HTTP_X_MCP_TOKEN' => 'anthropic:claude-sonnet:token123',
|
||||
'HTTP_USER_AGENT' => 'python-requests/2.28.0',
|
||||
]);
|
||||
|
||||
// MCP token takes precedence; UA would indicate notAnAgent otherwise
|
||||
$identity = $service->identify($request);
|
||||
|
||||
expect($identity->provider)->toBe('anthropic')
|
||||
->and($identity->model)->toBe('claude-sonnet');
|
||||
});
|
||||
|
||||
it('falls back to User-Agent when no MCP token header is present', function () {
|
||||
$service = new AgentDetection;
|
||||
$request = Request::create('/test', 'GET', [], [], [], [
|
||||
'HTTP_USER_AGENT' => 'claude-code/1.0',
|
||||
]);
|
||||
|
||||
$identity = $service->identify($request);
|
||||
|
||||
expect($identity->provider)->toBe('anthropic');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Provider Validation
|
||||
// =========================================================================
|
||||
|
||||
describe('provider validation', function () {
|
||||
it('accepts all known valid providers', function () {
|
||||
$service = new AgentDetection;
|
||||
$validProviders = ['anthropic', 'openai', 'google', 'meta', 'mistral', 'local', 'unknown'];
|
||||
|
||||
foreach ($validProviders as $provider) {
|
||||
expect($service->isValidProvider($provider))
|
||||
->toBeTrue("Expected '{$provider}' to be a valid provider");
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown provider names', function () {
|
||||
$service = new AgentDetection;
|
||||
|
||||
expect($service->isValidProvider('facebook'))->toBeFalse()
|
||||
->and($service->isValidProvider('huggingface'))->toBeFalse()
|
||||
->and($service->isValidProvider(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects not_agent as a provider (it is a sentinel value, not a provider)', function () {
|
||||
$service = new AgentDetection;
|
||||
|
||||
expect($service->isValidProvider('not_agent'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns all valid providers as an array', function () {
|
||||
$service = new AgentDetection;
|
||||
$providers = $service->getValidProviders();
|
||||
|
||||
expect($providers)
|
||||
->toContain('anthropic')
|
||||
->toContain('openai')
|
||||
->toContain('google')
|
||||
->toContain('meta')
|
||||
->toContain('mistral')
|
||||
->toContain('local')
|
||||
->toContain('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// isAgentUserAgent Shorthand
|
||||
// =========================================================================
|
||||
|
||||
describe('isAgentUserAgent shorthand', function () {
|
||||
it('returns true for known AI agent User-Agents', function () {
|
||||
$service = new AgentDetection;
|
||||
|
||||
expect($service->isAgentUserAgent('claude-code/1.0'))->toBeTrue()
|
||||
->and($service->isAgentUserAgent('OpenAI Python/1.0'))->toBeTrue()
|
||||
->and($service->isAgentUserAgent('Gemini API/2.0'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for browser User-Agents', function () {
|
||||
$service = new AgentDetection;
|
||||
$browserUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0';
|
||||
|
||||
expect($service->isAgentUserAgent($browserUA))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for crawler User-Agents', function () {
|
||||
$service = new AgentDetection;
|
||||
|
||||
expect($service->isAgentUserAgent('Googlebot/2.1'))->toBeFalse()
|
||||
->and($service->isAgentUserAgent('curl/7.68.0'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true for null User-Agent (unknown programmatic access)', function () {
|
||||
$service = new AgentDetection;
|
||||
// Null UA returns unknownAgent; isAgent() is true because provider !== 'not_agent'
|
||||
expect($service->isAgentUserAgent(null))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns true for unrecognised non-browser User-Agent', function () {
|
||||
$service = new AgentDetection;
|
||||
// No browser indicators → unknownAgent → isAgent() true
|
||||
expect($service->isAgentUserAgent('custom-agent/0.1'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
388
tests/Unit/Concerns/HasRetryTest.php
Normal file
388
tests/Unit/Concerns/HasRetryTest.php
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the HasRetry trait.
|
||||
*
|
||||
* Exercises retry logic, exponential backoff, and error classification
|
||||
* in isolation from any real HTTP provider.
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Services\Concerns\HasRetry;
|
||||
use GuzzleHttp\Psr7\Response as PsrResponse;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a testable object that uses the HasRetry trait.
|
||||
*
|
||||
* sleep() is overridden so tests run without actual delays.
|
||||
* The recorded sleep durations are accessible via ->sleepCalls.
|
||||
*/
|
||||
function retryService(int $maxRetries = 3, int $baseDelayMs = 1000, int $maxDelayMs = 30000): object
|
||||
{
|
||||
return new class($maxRetries, $baseDelayMs, $maxDelayMs) {
|
||||
use HasRetry;
|
||||
|
||||
public array $sleepCalls = [];
|
||||
|
||||
public function __construct(int $maxRetries, int $baseDelayMs, int $maxDelayMs)
|
||||
{
|
||||
$this->maxRetries = $maxRetries;
|
||||
$this->baseDelayMs = $baseDelayMs;
|
||||
$this->maxDelayMs = $maxDelayMs;
|
||||
}
|
||||
|
||||
public function runWithRetry(callable $callback, string $provider): Response
|
||||
{
|
||||
return $this->withRetry($callback, $provider);
|
||||
}
|
||||
|
||||
public function computeDelay(int $attempt, ?Response $response = null): int
|
||||
{
|
||||
return $this->calculateDelay($attempt, $response);
|
||||
}
|
||||
|
||||
protected function sleep(int $milliseconds): void
|
||||
{
|
||||
$this->sleepCalls[] = $milliseconds;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Illuminate Response wrapping a real PSR-7 response.
|
||||
*
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
function fakeHttpResponse(int $status, array $body = [], array $headers = []): Response
|
||||
{
|
||||
return new Response(new PsrResponse($status, $headers, json_encode($body)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withRetry – success paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('withRetry success', function () {
|
||||
it('returns response immediately on first-attempt success', function () {
|
||||
$service = retryService();
|
||||
$response = fakeHttpResponse(200, ['ok' => true]);
|
||||
|
||||
$result = $service->runWithRetry(fn () => $response, 'TestProvider');
|
||||
|
||||
expect($result->successful())->toBeTrue();
|
||||
expect($service->sleepCalls)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns response after one transient 429 failure', function () {
|
||||
$service = retryService();
|
||||
$calls = 0;
|
||||
|
||||
$result = $service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return $calls === 1
|
||||
? fakeHttpResponse(429)
|
||||
: fakeHttpResponse(200, ['ok' => true]);
|
||||
}, 'TestProvider');
|
||||
|
||||
expect($result->successful())->toBeTrue();
|
||||
expect($calls)->toBe(2);
|
||||
});
|
||||
|
||||
it('returns response after one transient 500 failure', function () {
|
||||
$service = retryService();
|
||||
$calls = 0;
|
||||
|
||||
$result = $service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return $calls === 1
|
||||
? fakeHttpResponse(500)
|
||||
: fakeHttpResponse(200, ['ok' => true]);
|
||||
}, 'TestProvider');
|
||||
|
||||
expect($result->successful())->toBeTrue();
|
||||
expect($calls)->toBe(2);
|
||||
});
|
||||
|
||||
it('returns response after one ConnectionException', function () {
|
||||
$service = retryService();
|
||||
$calls = 0;
|
||||
|
||||
$result = $service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
if ($calls === 1) {
|
||||
throw new ConnectionException('Network error');
|
||||
}
|
||||
|
||||
return fakeHttpResponse(200, ['ok' => true]);
|
||||
}, 'TestProvider');
|
||||
|
||||
expect($result->successful())->toBeTrue();
|
||||
expect($calls)->toBe(2);
|
||||
});
|
||||
|
||||
it('returns response after one RequestException', function () {
|
||||
$service = retryService();
|
||||
$calls = 0;
|
||||
|
||||
$result = $service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
if ($calls === 1) {
|
||||
throw new RequestException(fakeHttpResponse(503));
|
||||
}
|
||||
|
||||
return fakeHttpResponse(200, ['ok' => true]);
|
||||
}, 'TestProvider');
|
||||
|
||||
expect($result->successful())->toBeTrue();
|
||||
expect($calls)->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withRetry – max retry limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('withRetry max retry limits', function () {
|
||||
it('throws after exhausting all retries on persistent 429', function () {
|
||||
$service = retryService(maxRetries: 3);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return fakeHttpResponse(429);
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class);
|
||||
|
||||
expect($calls)->toBe(3);
|
||||
});
|
||||
|
||||
it('throws after exhausting all retries on persistent 500', function () {
|
||||
$service = retryService(maxRetries: 3);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return fakeHttpResponse(500);
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class);
|
||||
|
||||
expect($calls)->toBe(3);
|
||||
});
|
||||
|
||||
it('throws after exhausting all retries on persistent ConnectionException', function () {
|
||||
$service = retryService(maxRetries: 2);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
throw new ConnectionException('Timeout');
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class, 'connection error');
|
||||
|
||||
expect($calls)->toBe(2);
|
||||
});
|
||||
|
||||
it('respects a custom maxRetries of 1 (no retries)', function () {
|
||||
$service = retryService(maxRetries: 1);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return fakeHttpResponse(500);
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class);
|
||||
|
||||
expect($calls)->toBe(1);
|
||||
});
|
||||
|
||||
it('error message includes provider name', function () {
|
||||
$service = retryService(maxRetries: 1);
|
||||
|
||||
expect(fn () => $service->runWithRetry(fn () => fakeHttpResponse(500), 'MyProvider'))
|
||||
->toThrow(RuntimeException::class, 'MyProvider');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withRetry – non-retryable errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('withRetry non-retryable client errors', function () {
|
||||
it('throws immediately on 401 without retrying', function () {
|
||||
$service = retryService(maxRetries: 3);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return fakeHttpResponse(401, ['error' => ['message' => 'Unauthorised']]);
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class, 'TestProvider API error');
|
||||
|
||||
expect($calls)->toBe(1);
|
||||
});
|
||||
|
||||
it('throws immediately on 400 without retrying', function () {
|
||||
$service = retryService(maxRetries: 3);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return fakeHttpResponse(400);
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class);
|
||||
|
||||
expect($calls)->toBe(1);
|
||||
});
|
||||
|
||||
it('throws immediately on 404 without retrying', function () {
|
||||
$service = retryService(maxRetries: 3);
|
||||
$calls = 0;
|
||||
|
||||
expect(function () use ($service, &$calls) {
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return fakeHttpResponse(404);
|
||||
}, 'TestProvider');
|
||||
})->toThrow(RuntimeException::class);
|
||||
|
||||
expect($calls)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withRetry – sleep (backoff) behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('withRetry exponential backoff', function () {
|
||||
it('sleeps between retries but not after the final attempt', function () {
|
||||
$service = retryService(maxRetries: 3, baseDelayMs: 100, maxDelayMs: 10000);
|
||||
|
||||
try {
|
||||
$service->runWithRetry(fn () => fakeHttpResponse(500), 'TestProvider');
|
||||
} catch (RuntimeException) {
|
||||
// expected
|
||||
}
|
||||
|
||||
// 3 attempts → 2 sleeps (between attempt 1-2 and 2-3)
|
||||
expect($service->sleepCalls)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('does not sleep when succeeding on first attempt', function () {
|
||||
$service = retryService();
|
||||
|
||||
$service->runWithRetry(fn () => fakeHttpResponse(200), 'TestProvider');
|
||||
|
||||
expect($service->sleepCalls)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('sleeps once when succeeding on the second attempt', function () {
|
||||
$service = retryService(maxRetries: 3, baseDelayMs: 100, maxDelayMs: 10000);
|
||||
$calls = 0;
|
||||
|
||||
$service->runWithRetry(function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return $calls === 1 ? fakeHttpResponse(500) : fakeHttpResponse(200);
|
||||
}, 'TestProvider');
|
||||
|
||||
expect($service->sleepCalls)->toHaveCount(1);
|
||||
expect($service->sleepCalls[0])->toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calculateDelay – exponential backoff formula
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('calculateDelay', function () {
|
||||
it('returns base delay for attempt 1', function () {
|
||||
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
|
||||
|
||||
// delay = 1000 * 2^0 = 1000ms, plus up to 25% jitter
|
||||
$delay = $service->computeDelay(1);
|
||||
|
||||
expect($delay)->toBeGreaterThanOrEqual(1000)
|
||||
->and($delay)->toBeLessThanOrEqual(1250);
|
||||
});
|
||||
|
||||
it('doubles the delay for attempt 2', function () {
|
||||
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
|
||||
|
||||
// delay = 1000 * 2^1 = 2000ms, plus up to 25% jitter
|
||||
$delay = $service->computeDelay(2);
|
||||
|
||||
expect($delay)->toBeGreaterThanOrEqual(2000)
|
||||
->and($delay)->toBeLessThanOrEqual(2500);
|
||||
});
|
||||
|
||||
it('quadruples the delay for attempt 3', function () {
|
||||
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
|
||||
|
||||
// delay = 1000 * 2^2 = 4000ms, plus up to 25% jitter
|
||||
$delay = $service->computeDelay(3);
|
||||
|
||||
expect($delay)->toBeGreaterThanOrEqual(4000)
|
||||
->and($delay)->toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it('caps the delay at maxDelayMs', function () {
|
||||
$service = retryService(baseDelayMs: 10000, maxDelayMs: 5000);
|
||||
|
||||
// 10000 * 2^0 = 10000ms → capped at 5000ms
|
||||
$delay = $service->computeDelay(1);
|
||||
|
||||
expect($delay)->toBe(5000);
|
||||
});
|
||||
|
||||
it('respects numeric Retry-After header (in seconds)', function () {
|
||||
$service = retryService(maxDelayMs: 60000);
|
||||
$response = fakeHttpResponse(429, [], ['Retry-After' => '10']);
|
||||
|
||||
// Retry-After is 10 seconds = 10000ms
|
||||
$delay = $service->computeDelay(1, $response);
|
||||
|
||||
expect($delay)->toBe(10000);
|
||||
});
|
||||
|
||||
it('caps Retry-After header value at maxDelayMs', function () {
|
||||
$service = retryService(maxDelayMs: 5000);
|
||||
$response = fakeHttpResponse(429, [], ['Retry-After' => '60']);
|
||||
|
||||
// 60 seconds = 60000ms → capped at 5000ms
|
||||
$delay = $service->computeDelay(1, $response);
|
||||
|
||||
expect($delay)->toBe(5000);
|
||||
});
|
||||
|
||||
it('falls back to exponential backoff when no Retry-After header', function () {
|
||||
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
|
||||
$response = fakeHttpResponse(500);
|
||||
|
||||
$delay = $service->computeDelay(1, $response);
|
||||
|
||||
expect($delay)->toBeGreaterThanOrEqual(1000)
|
||||
->and($delay)->toBeLessThanOrEqual(1250);
|
||||
});
|
||||
});
|
||||
433
tests/Unit/Concerns/HasStreamParsingTest.php
Normal file
433
tests/Unit/Concerns/HasStreamParsingTest.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the HasStreamParsing trait.
|
||||
*
|
||||
* Exercises SSE (Server-Sent Events) and JSON object stream parsing
|
||||
* including chunked reads, edge cases, and error handling.
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a minimal in-memory PSR-7 stream from a string.
|
||||
*
|
||||
* Only eof() and read() are needed by the trait; all other
|
||||
* StreamInterface methods are stubbed.
|
||||
*/
|
||||
function fakeStream(string $data, int $chunkSize = 8192): StreamInterface
|
||||
{
|
||||
return new class($data, $chunkSize) implements StreamInterface {
|
||||
private int $pos = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $data,
|
||||
private readonly int $chunkSize,
|
||||
) {}
|
||||
|
||||
public function eof(): bool
|
||||
{
|
||||
return $this->pos >= strlen($this->data);
|
||||
}
|
||||
|
||||
public function read($length): string
|
||||
{
|
||||
$effective = min($length, $this->chunkSize);
|
||||
$chunk = substr($this->data, $this->pos, $effective);
|
||||
$this->pos += strlen($chunk);
|
||||
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
// --- PSR-7 stubs (not exercised by the trait) ---
|
||||
public function __toString(): string { return $this->data; }
|
||||
|
||||
public function close(): void {}
|
||||
|
||||
public function detach() { return null; }
|
||||
|
||||
public function getSize(): ?int { return strlen($this->data); }
|
||||
|
||||
public function tell(): int { return $this->pos; }
|
||||
|
||||
public function isSeekable(): bool { return false; }
|
||||
|
||||
public function seek($offset, $whence = SEEK_SET): void {}
|
||||
|
||||
public function rewind(): void {}
|
||||
|
||||
public function isWritable(): bool { return false; }
|
||||
|
||||
public function write($string): int { return 0; }
|
||||
|
||||
public function isReadable(): bool { return true; }
|
||||
|
||||
public function getContents(): string { return substr($this->data, $this->pos); }
|
||||
|
||||
public function getMetadata($key = null) { return null; }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a testable object that exposes the HasStreamParsing trait methods.
|
||||
*/
|
||||
function streamParsingService(): object
|
||||
{
|
||||
return new class {
|
||||
use HasStreamParsing;
|
||||
|
||||
public function sse(StreamInterface $stream, callable $extract): Generator
|
||||
{
|
||||
return $this->parseSSEStream($stream, $extract);
|
||||
}
|
||||
|
||||
public function json(StreamInterface $stream, callable $extract): Generator
|
||||
{
|
||||
return $this->parseJSONStream($stream, $extract);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSSEStream – basic data extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseSSEStream basic parsing', function () {
|
||||
it('yields content from a single data line', function () {
|
||||
$raw = "data: {\"text\":\"hello\"}\n\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['hello']);
|
||||
});
|
||||
|
||||
it('yields content from multiple data lines', function () {
|
||||
$raw = "data: {\"text\":\"foo\"}\n";
|
||||
$raw .= "data: {\"text\":\"bar\"}\n";
|
||||
$raw .= "data: {\"text\":\"baz\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['foo', 'bar', 'baz']);
|
||||
});
|
||||
|
||||
it('handles Windows-style \\r\\n line endings', function () {
|
||||
$raw = "data: {\"text\":\"crlf\"}\r\n\r\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['crlf']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSSEStream – stream termination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseSSEStream stream termination', function () {
|
||||
it('stops yielding when it encounters [DONE]', function () {
|
||||
$raw = "data: {\"text\":\"before\"}\n";
|
||||
$raw .= "data: [DONE]\n";
|
||||
$raw .= "data: {\"text\":\"after\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['before']);
|
||||
});
|
||||
|
||||
it('stops when [DONE] has surrounding whitespace', function () {
|
||||
$raw = "data: {\"text\":\"first\"}\n";
|
||||
$raw .= "data: [DONE] \n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['first']);
|
||||
});
|
||||
|
||||
it('yields nothing from an empty stream', function () {
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream(''), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSSEStream – skipped lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseSSEStream skipped lines', function () {
|
||||
it('skips blank/separator lines', function () {
|
||||
$raw = "\n\ndata: {\"text\":\"ok\"}\n\n\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['ok']);
|
||||
});
|
||||
|
||||
it('skips non-data SSE fields (event:, id:, retry:)', function () {
|
||||
$raw = "event: message\n";
|
||||
$raw .= "id: 42\n";
|
||||
$raw .= "retry: 3000\n";
|
||||
$raw .= "data: {\"text\":\"content\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['content']);
|
||||
});
|
||||
|
||||
it('skips SSE comment lines starting with colon', function () {
|
||||
$raw = ": keep-alive\n";
|
||||
$raw .= "data: {\"text\":\"real\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['real']);
|
||||
});
|
||||
|
||||
it('skips data lines with empty payload after trimming', function () {
|
||||
$raw = "data: \n";
|
||||
$raw .= "data: {\"text\":\"actual\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['actual']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSSEStream – error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseSSEStream error handling', function () {
|
||||
it('skips lines with invalid JSON', function () {
|
||||
$raw = "data: not-valid-json\n";
|
||||
$raw .= "data: {\"text\":\"valid\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['valid']);
|
||||
});
|
||||
|
||||
it('skips lines where extractor returns null', function () {
|
||||
$raw = "data: {\"other\":\"field\"}\n";
|
||||
$raw .= "data: {\"text\":\"present\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['present']);
|
||||
});
|
||||
|
||||
it('skips lines where extractor returns empty string', function () {
|
||||
$raw = "data: {\"text\":\"\"}\n";
|
||||
$raw .= "data: {\"text\":\"hello\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['hello']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSSEStream – chunked / partial reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseSSEStream chunked reads', function () {
|
||||
it('handles a stream delivered in small chunks', function () {
|
||||
$raw = "data: {\"text\":\"chunked\"}\n\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
// Force the stream to return 5 bytes at a time
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw, 5), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['chunked']);
|
||||
});
|
||||
|
||||
it('processes remaining data buffered after stream EOF', function () {
|
||||
// No trailing newline – data stays in the buffer until EOF
|
||||
$raw = "data: {\"text\":\"buffered\"}";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['buffered']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseJSONStream – basic parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseJSONStream basic parsing', function () {
|
||||
it('yields content from a single JSON object', function () {
|
||||
$raw = '{"text":"hello"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['hello']);
|
||||
});
|
||||
|
||||
it('yields content from multiple consecutive JSON objects', function () {
|
||||
$raw = '{"text":"first"}{"text":"second"}{"text":"third"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['first', 'second', 'third']);
|
||||
});
|
||||
|
||||
it('handles JSON objects separated by whitespace', function () {
|
||||
$raw = " {\"text\":\"a\"}\n\n {\"text\":\"b\"}\n";
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['a', 'b']);
|
||||
});
|
||||
|
||||
it('handles nested JSON objects correctly', function () {
|
||||
$raw = '{"outer":{"inner":"value"},"text":"top"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['top']);
|
||||
});
|
||||
|
||||
it('handles escaped quotes inside strings', function () {
|
||||
$raw = '{"text":"say \"hello\""}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['say "hello"']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseJSONStream – extractor filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseJSONStream extractor filtering', function () {
|
||||
it('skips objects where extractor returns null', function () {
|
||||
$raw = '{"other":"x"}{"text":"keep"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['keep']);
|
||||
});
|
||||
|
||||
it('skips objects where extractor returns empty string', function () {
|
||||
$raw = '{"text":""}{"text":"non-empty"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['non-empty']);
|
||||
});
|
||||
|
||||
it('yields nothing from an empty stream', function () {
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream(''), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseJSONStream – chunked reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseJSONStream chunked reads', function () {
|
||||
it('handles objects split across multiple chunks', function () {
|
||||
$raw = '{"text":"split"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
// Force 3-byte chunks to ensure the object is assembled across reads
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw, 3), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['split']);
|
||||
});
|
||||
|
||||
it('handles multiple objects across chunks', function () {
|
||||
$raw = '{"text":"a"}{"text":"b"}';
|
||||
$service = streamParsingService();
|
||||
|
||||
$results = iterator_to_array(
|
||||
$service->json(fakeStream($raw, 4), fn ($json) => $json['text'] ?? null)
|
||||
);
|
||||
|
||||
expect($results)->toBe(['a', 'b']);
|
||||
});
|
||||
});
|
||||
382
tests/Unit/ProcessContentTaskTest.php
Normal file
382
tests/Unit/ProcessContentTaskTest.php
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for the ProcessContentTask queued job.
|
||||
*
|
||||
* Covers the handle() flow: prompt validation, entitlement checks,
|
||||
* provider availability, task completion, usage recording, and
|
||||
* template variable interpolation.
|
||||
*/
|
||||
|
||||
use Core\Mod\Agentic\Jobs\ProcessContentTask;
|
||||
use Core\Mod\Agentic\Services\AgenticManager;
|
||||
use Core\Mod\Agentic\Services\AgenticProviderInterface;
|
||||
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Mod\Content\Models\ContentTask;
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Build a minimal mock ContentTask with sensible defaults.
|
||||
*
|
||||
* @param array<string,mixed> $attributes Override property returns.
|
||||
*/
|
||||
function makeTask(array $attributes = []): ContentTask
|
||||
{
|
||||
$task = Mockery::mock(ContentTask::class);
|
||||
$task->shouldReceive('markProcessing')->byDefault();
|
||||
$task->shouldReceive('markCompleted')->byDefault();
|
||||
$task->shouldReceive('markFailed')->byDefault();
|
||||
|
||||
foreach ($attributes as $prop => $value) {
|
||||
$task->shouldReceive('__get')->with($prop)->andReturn($value);
|
||||
}
|
||||
|
||||
// Return null for any property not explicitly configured
|
||||
$task->shouldReceive('__get')->byDefault()->andReturn(null);
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal mock prompt object.
|
||||
*/
|
||||
function makePrompt(string $model = 'claude-sonnet-4-20250514', string $userTemplate = 'Hello'): object
|
||||
{
|
||||
return (object) [
|
||||
'id' => 1,
|
||||
'model' => $model,
|
||||
'system_prompt' => 'You are a helpful assistant.',
|
||||
'user_template' => $userTemplate,
|
||||
'model_config' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AgenticResponse suitable for testing.
|
||||
*/
|
||||
function makeResponse(string $content = 'Generated content'): AgenticResponse
|
||||
{
|
||||
return new AgenticResponse(
|
||||
content: $content,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
durationMs: 1200,
|
||||
stopReason: 'end_turn',
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — prompt validation
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — prompt validation', function () {
|
||||
it('marks task as failed when prompt is missing', function () {
|
||||
$task = makeTask(['prompt' => null]);
|
||||
$task->shouldReceive('markProcessing')->once();
|
||||
$task->shouldReceive('markFailed')->once()->with('Prompt not found');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle(
|
||||
Mockery::mock(AgenticManager::class),
|
||||
Mockery::mock(EntitlementService::class),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call AI provider when prompt is missing', function () {
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldNotReceive('provider');
|
||||
|
||||
$task = makeTask(['prompt' => null]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — entitlement checks
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — entitlement checks', function () {
|
||||
it('marks task as failed when entitlement is denied', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$entitlementResult = Mockery::mock();
|
||||
$entitlementResult->shouldReceive('isDenied')->andReturn(true);
|
||||
$entitlementResult->message = 'No AI credits remaining';
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => makePrompt(),
|
||||
'workspace' => $workspace,
|
||||
]);
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with(Mockery::on(fn ($msg) => str_contains($msg, 'Entitlement denied')));
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldReceive('can')
|
||||
->with($workspace, 'ai.credits')
|
||||
->andReturn($entitlementResult);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle(Mockery::mock(AgenticManager::class), $entitlements);
|
||||
});
|
||||
|
||||
it('skips entitlement check when task has no workspace', function () {
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => makePrompt(),
|
||||
'workspace' => null,
|
||||
]);
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldNotReceive('can');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — provider availability
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — provider availability', function () {
|
||||
it('marks task as failed when provider is unavailable', function () {
|
||||
$prompt = makePrompt('gemini-2.0-flash');
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(false);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('gemini-2.0-flash')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
]);
|
||||
$task->shouldReceive('markFailed')
|
||||
->once()
|
||||
->with("AI provider 'gemini-2.0-flash' is not configured");
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — successful completion
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — successful completion', function () {
|
||||
it('marks task as completed with response metadata', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Write about PHP.');
|
||||
$response = makeResponse('PHP is a great language.');
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->with('claude-sonnet-4-20250514')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => [],
|
||||
]);
|
||||
$task->shouldReceive('markCompleted')
|
||||
->once()
|
||||
->with('PHP is a great language.', Mockery::on(function ($metadata) {
|
||||
return $metadata['tokens_input'] === 100
|
||||
&& $metadata['tokens_output'] === 50
|
||||
&& $metadata['model'] === 'claude-sonnet-4-20250514'
|
||||
&& $metadata['duration_ms'] === 1200
|
||||
&& isset($metadata['estimated_cost']);
|
||||
}));
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
|
||||
it('does not record usage when task has no workspace', function () {
|
||||
$response = makeResponse();
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => makePrompt(),
|
||||
'workspace' => null,
|
||||
'input_data' => [],
|
||||
]);
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldNotReceive('recordUsage');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $entitlements);
|
||||
});
|
||||
|
||||
it('records AI usage when workspace is present', function () {
|
||||
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
|
||||
$entitlementResult = Mockery::mock();
|
||||
$entitlementResult->shouldReceive('isDenied')->andReturn(false);
|
||||
|
||||
$response = makeResponse();
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$prompt = makePrompt();
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => $workspace,
|
||||
'input_data' => [],
|
||||
'id' => 42,
|
||||
]);
|
||||
|
||||
$entitlements = Mockery::mock(EntitlementService::class);
|
||||
$entitlements->shouldReceive('can')
|
||||
->with($workspace, 'ai.credits')
|
||||
->andReturn($entitlementResult);
|
||||
$entitlements->shouldReceive('recordUsage')
|
||||
->once()
|
||||
->with($workspace, 'ai.credits', quantity: 1, metadata: Mockery::on(function ($meta) {
|
||||
return $meta['task_id'] === 42
|
||||
&& isset($meta['model'])
|
||||
&& isset($meta['estimated_cost']);
|
||||
}));
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, $entitlements);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// handle() — template variable interpolation
|
||||
// =========================================================================
|
||||
|
||||
describe('handle — template variable interpolation', function () {
|
||||
it('replaces string placeholders in user template', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Write about {{{topic}}}.');
|
||||
$response = makeResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(
|
||||
$prompt->system_prompt,
|
||||
'Write about Laravel.',
|
||||
Mockery::any(),
|
||||
)
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => ['topic' => 'Laravel'],
|
||||
]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
|
||||
it('JSON-encodes array values in template', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Tags: {{{tags}}}.');
|
||||
$response = makeResponse();
|
||||
$tags = ['php', 'laravel'];
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(
|
||||
$prompt->system_prompt,
|
||||
'Tags: '.json_encode($tags).'.',
|
||||
Mockery::any(),
|
||||
)
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => ['tags' => $tags],
|
||||
]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
|
||||
it('leaves unknown placeholders untouched', function () {
|
||||
$prompt = makePrompt('claude-sonnet-4-20250514', 'Hello {{{name}}}, see {{{unknown}}}.');
|
||||
$response = makeResponse();
|
||||
|
||||
$provider = Mockery::mock(AgenticProviderInterface::class);
|
||||
$provider->shouldReceive('isAvailable')->andReturn(true);
|
||||
$provider->shouldReceive('generate')
|
||||
->with(
|
||||
$prompt->system_prompt,
|
||||
'Hello World, see {{{unknown}}}.',
|
||||
Mockery::any(),
|
||||
)
|
||||
->once()
|
||||
->andReturn($response);
|
||||
|
||||
$ai = Mockery::mock(AgenticManager::class);
|
||||
$ai->shouldReceive('provider')->andReturn($provider);
|
||||
|
||||
$task = makeTask([
|
||||
'prompt' => $prompt,
|
||||
'workspace' => null,
|
||||
'input_data' => ['name' => 'World'],
|
||||
]);
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->handle($ai, Mockery::mock(EntitlementService::class));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// failed() — queue failure handler
|
||||
// =========================================================================
|
||||
|
||||
describe('failed', function () {
|
||||
it('marks task as failed with exception message', function () {
|
||||
$exception = new RuntimeException('Connection refused');
|
||||
|
||||
$task = makeTask();
|
||||
$task->shouldReceive('markFailed')->once()->with('Connection refused');
|
||||
|
||||
$job = new ProcessContentTask($task);
|
||||
$job->failed($exception);
|
||||
});
|
||||
});
|
||||
1
tests/views/admin/api-keys.blade.php
Normal file
1
tests/views/admin/api-keys.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="api-keys"></div>
|
||||
1
tests/views/admin/dashboard.blade.php
Normal file
1
tests/views/admin/dashboard.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="dashboard"></div>
|
||||
1
tests/views/admin/plan-detail.blade.php
Normal file
1
tests/views/admin/plan-detail.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="plan-detail"></div>
|
||||
1
tests/views/admin/plans.blade.php
Normal file
1
tests/views/admin/plans.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="plans"></div>
|
||||
1
tests/views/admin/playground.blade.php
Normal file
1
tests/views/admin/playground.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="playground"></div>
|
||||
1
tests/views/admin/session-detail.blade.php
Normal file
1
tests/views/admin/session-detail.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="session-detail"></div>
|
||||
1
tests/views/admin/sessions.blade.php
Normal file
1
tests/views/admin/sessions.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="sessions"></div>
|
||||
1
tests/views/admin/templates.blade.php
Normal file
1
tests/views/admin/templates.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="templates"></div>
|
||||
1
tests/views/admin/tool-analytics.blade.php
Normal file
1
tests/views/admin/tool-analytics.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="tool-analytics"></div>
|
||||
1
tests/views/admin/tool-calls.blade.php
Normal file
1
tests/views/admin/tool-calls.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="tool-calls"></div>
|
||||
1
tests/views/mcp/admin/api-key-manager.blade.php
Normal file
1
tests/views/mcp/admin/api-key-manager.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="api-key-manager"></div>
|
||||
1
tests/views/mcp/admin/playground.blade.php
Normal file
1
tests/views/mcp/admin/playground.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="playground"></div>
|
||||
1
tests/views/mcp/admin/request-log.blade.php
Normal file
1
tests/views/mcp/admin/request-log.blade.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div data-testid="request-log"></div>
|
||||
Loading…
Add table
Reference in a new issue