diff --git a/Jobs/ProcessContentTask.php b/Jobs/ProcessContentTask.php
index b3e137f..ddb4b17 100644
--- a/Jobs/ProcessContentTask.php
+++ b/Jobs/ProcessContentTask.php
@@ -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
- }
}
diff --git a/Migrations/0001_01_01_000004_create_prompt_tables.php b/Migrations/0001_01_01_000004_create_prompt_tables.php
new file mode 100644
index 0000000..f5eac73
--- /dev/null
+++ b/Migrations/0001_01_01_000004_create_prompt_tables.php
@@ -0,0 +1,65 @@
+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();
+ }
+};
diff --git a/Models/AgentApiKey.php b/Models/AgentApiKey.php
index f959fc3..e6b41cc 100644
--- a/Models/AgentApiKey.php
+++ b/Models/AgentApiKey.php
@@ -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());
diff --git a/Models/AgentPhase.php b/Models/AgentPhase.php
index b249793..d4c8549 100644
--- a/Models/AgentPhase.php
+++ b/Models/AgentPhase.php
@@ -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);
}
diff --git a/Models/AgentPlan.php b/Models/AgentPlan.php
index 9a675d8..fc071c7 100644
--- a/Models/AgentPlan.php
+++ b/Models/AgentPlan.php
@@ -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
diff --git a/Models/AgentSession.php b/Models/AgentSession.php
index b399bc7..cf9cfce 100644
--- a/Models/AgentSession.php
+++ b/Models/AgentSession.php
@@ -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;
diff --git a/Models/AgentWorkspaceState.php b/Models/AgentWorkspaceState.php
index ba6bebe..d1db1ce 100644
--- a/Models/AgentWorkspaceState.php
+++ b/Models/AgentWorkspaceState.php
@@ -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);
}
diff --git a/Models/Prompt.php b/Models/Prompt.php
index b1e948b..2c1ee42 100644
--- a/Models/Prompt.php
+++ b/Models/Prompt.php
@@ -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);
}
diff --git a/Models/Task.php b/Models/Task.php
index 9293e87..908d892 100644
--- a/Models/Task.php
+++ b/Models/Task.php
@@ -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']);
}
diff --git a/Models/WorkspaceState.php b/Models/WorkspaceState.php
index c3f7267..1000dd8 100644
--- a/Models/WorkspaceState.php
+++ b/Models/WorkspaceState.php
@@ -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);
}
diff --git a/TODO.md b/TODO.md
index 9be662d..c69793b 100644
--- a/TODO.md
+++ b/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`
diff --git a/View/Blade/admin/api-key-manager.blade.php b/View/Blade/admin/api-key-manager.blade.php
index 7226a73..33a8d23 100644
--- a/View/Blade/admin/api-key-manager.blade.php
+++ b/View/Blade/admin/api-key-manager.blade.php
@@ -68,18 +68,17 @@
- {{ $key->prefix }}_****
+ {{ $key->getMaskedKey() }}
|
-
-
- @foreach($key->scopes ?? [] as $scope)
+
+
+ @foreach($key->permissions ?? [] as $permission)
- {{ $scope }}
+ {{ $permission }}
@endforeach
@@ -131,11 +130,11 @@
{{ __('mcp::mcp.keys.auth.header_recommended') }}
- Authorization: Bearer hk_abc123_****
+ Authorization: Bearer ak_****
{{ __('mcp::mcp.keys.auth.header_api_key') }}
- X-API-Key: hk_abc123_****
+ X-API-Key: ak_****
@@ -179,37 +178,24 @@
@enderror
-
+
diff --git a/View/Modal/Admin/ApiKeyManager.php b/View/Modal/Admin/ApiKeyManager.php
index 88a8663..c5e9718 100644
--- a/View/Modal/Admin/ApiKeyManager.php
+++ b/View/Modal/Admin/ApiKeyManager.php
@@ -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(),
]);
}
}
diff --git a/composer.json b/composer.json
index 13ec2cd..0f2faac 100644
--- a/composer.json
+++ b/composer.json
@@ -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
}
diff --git a/tests/Feature/ApiKeyManagerTest.php b/tests/Feature/ApiKeyManagerTest.php
new file mode 100644
index 0000000..9123e11
--- /dev/null
+++ b/tests/Feature/ApiKeyManagerTest.php
@@ -0,0 +1,255 @@
+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();
+ }
+ });
+});
diff --git a/tests/Feature/Jobs/BatchContentGenerationTest.php b/tests/Feature/Jobs/BatchContentGenerationTest.php
new file mode 100644
index 0000000..527d1a2
--- /dev/null
+++ b/tests/Feature/Jobs/BatchContentGenerationTest.php
@@ -0,0 +1,272 @@
+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);
+ });
+});
diff --git a/tests/Feature/Jobs/ProcessContentTaskTest.php b/tests/Feature/Jobs/ProcessContentTaskTest.php
new file mode 100644
index 0000000..2b6badb
--- /dev/null
+++ b/tests/Feature/Jobs/ProcessContentTaskTest.php
@@ -0,0 +1,812 @@
+ $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);
+ }
+ });
+});
diff --git a/tests/Feature/Livewire/ApiKeyManagerTest.php b/tests/Feature/Livewire/ApiKeyManagerTest.php
new file mode 100644
index 0000000..795ef0d
--- /dev/null
+++ b/tests/Feature/Livewire/ApiKeyManagerTest.php
@@ -0,0 +1,140 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/ApiKeysTest.php b/tests/Feature/Livewire/ApiKeysTest.php
new file mode 100644
index 0000000..b07e82a
--- /dev/null
+++ b/tests/Feature/Livewire/ApiKeysTest.php
@@ -0,0 +1,238 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php
new file mode 100644
index 0000000..9c3019c
--- /dev/null
+++ b/tests/Feature/Livewire/DashboardTest.php
@@ -0,0 +1,102 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/LivewireTestCase.php b/tests/Feature/Livewire/LivewireTestCase.php
new file mode 100644
index 0000000..32fab3e
--- /dev/null
+++ b/tests/Feature/Livewire/LivewireTestCase.php
@@ -0,0 +1,50 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/PlanDetailTest.php b/tests/Feature/Livewire/PlanDetailTest.php
new file mode 100644
index 0000000..058b1d7
--- /dev/null
+++ b/tests/Feature/Livewire/PlanDetailTest.php
@@ -0,0 +1,229 @@
+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));
+ }
+}
diff --git a/tests/Feature/Livewire/PlansTest.php b/tests/Feature/Livewire/PlansTest.php
new file mode 100644
index 0000000..b4cfb69
--- /dev/null
+++ b/tests/Feature/Livewire/PlansTest.php
@@ -0,0 +1,165 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/PlaygroundTest.php b/tests/Feature/Livewire/PlaygroundTest.php
new file mode 100644
index 0000000..af9944d
--- /dev/null
+++ b/tests/Feature/Livewire/PlaygroundTest.php
@@ -0,0 +1,160 @@
+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);
+ }
+ }
+}
diff --git a/tests/Feature/Livewire/RequestLogTest.php b/tests/Feature/Livewire/RequestLogTest.php
new file mode 100644
index 0000000..4fcf3b8
--- /dev/null
+++ b/tests/Feature/Livewire/RequestLogTest.php
@@ -0,0 +1,87 @@
+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();
+ }
+}
diff --git a/tests/Feature/Livewire/SessionDetailTest.php b/tests/Feature/Livewire/SessionDetailTest.php
new file mode 100644
index 0000000..4d2f52f
--- /dev/null
+++ b/tests/Feature/Livewire/SessionDetailTest.php
@@ -0,0 +1,167 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/SessionsTest.php b/tests/Feature/Livewire/SessionsTest.php
new file mode 100644
index 0000000..7efadd8
--- /dev/null
+++ b/tests/Feature/Livewire/SessionsTest.php
@@ -0,0 +1,202 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/TemplatesTest.php b/tests/Feature/Livewire/TemplatesTest.php
new file mode 100644
index 0000000..847ac9a
--- /dev/null
+++ b/tests/Feature/Livewire/TemplatesTest.php
@@ -0,0 +1,173 @@
+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', []);
+ }
+}
diff --git a/tests/Feature/Livewire/ToolAnalyticsTest.php b/tests/Feature/Livewire/ToolAnalyticsTest.php
new file mode 100644
index 0000000..9185bd2
--- /dev/null
+++ b/tests/Feature/Livewire/ToolAnalyticsTest.php
@@ -0,0 +1,119 @@
+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);
+ }
+}
diff --git a/tests/Feature/Livewire/ToolCallsTest.php b/tests/Feature/Livewire/ToolCallsTest.php
new file mode 100644
index 0000000..422d077
--- /dev/null
+++ b/tests/Feature/Livewire/ToolCallsTest.php
@@ -0,0 +1,148 @@
+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'));
+ }
+}
diff --git a/tests/Feature/PromptVersionTest.php b/tests/Feature/PromptVersionTest.php
new file mode 100644
index 0000000..6e4fb96
--- /dev/null
+++ b/tests/Feature/PromptVersionTest.php
@@ -0,0 +1,279 @@
+ '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);
+ });
+});
diff --git a/tests/Fixtures/HadesUser.php b/tests/Fixtures/HadesUser.php
new file mode 100644
index 0000000..c1207c7
--- /dev/null
+++ b/tests/Fixtures/HadesUser.php
@@ -0,0 +1,36 @@
+attributes['id'] ?? 1;
+ }
+}
diff --git a/tests/Unit/AgentDetectionTest.php b/tests/Unit/AgentDetectionTest.php
new file mode 100644
index 0000000..069468b
--- /dev/null
+++ b/tests/Unit/AgentDetectionTest.php
@@ -0,0 +1,785 @@
+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();
+ });
+});
diff --git a/tests/Unit/Concerns/HasRetryTest.php b/tests/Unit/Concerns/HasRetryTest.php
new file mode 100644
index 0000000..2102aa5
--- /dev/null
+++ b/tests/Unit/Concerns/HasRetryTest.php
@@ -0,0 +1,388 @@
+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 $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);
+ });
+});
diff --git a/tests/Unit/Concerns/HasStreamParsingTest.php b/tests/Unit/Concerns/HasStreamParsingTest.php
new file mode 100644
index 0000000..8197863
--- /dev/null
+++ b/tests/Unit/Concerns/HasStreamParsingTest.php
@@ -0,0 +1,433 @@
+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']);
+ });
+});
diff --git a/tests/Unit/ProcessContentTaskTest.php b/tests/Unit/ProcessContentTaskTest.php
new file mode 100644
index 0000000..9b785c0
--- /dev/null
+++ b/tests/Unit/ProcessContentTaskTest.php
@@ -0,0 +1,382 @@
+ $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);
+ });
+});
diff --git a/tests/views/admin/api-keys.blade.php b/tests/views/admin/api-keys.blade.php
new file mode 100644
index 0000000..b162e5e
--- /dev/null
+++ b/tests/views/admin/api-keys.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/dashboard.blade.php b/tests/views/admin/dashboard.blade.php
new file mode 100644
index 0000000..d0ef063
--- /dev/null
+++ b/tests/views/admin/dashboard.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/plan-detail.blade.php b/tests/views/admin/plan-detail.blade.php
new file mode 100644
index 0000000..bfa75a0
--- /dev/null
+++ b/tests/views/admin/plan-detail.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/plans.blade.php b/tests/views/admin/plans.blade.php
new file mode 100644
index 0000000..27351f8
--- /dev/null
+++ b/tests/views/admin/plans.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/playground.blade.php b/tests/views/admin/playground.blade.php
new file mode 100644
index 0000000..f261550
--- /dev/null
+++ b/tests/views/admin/playground.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/session-detail.blade.php b/tests/views/admin/session-detail.blade.php
new file mode 100644
index 0000000..67676f0
--- /dev/null
+++ b/tests/views/admin/session-detail.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/sessions.blade.php b/tests/views/admin/sessions.blade.php
new file mode 100644
index 0000000..234a7ab
--- /dev/null
+++ b/tests/views/admin/sessions.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/templates.blade.php b/tests/views/admin/templates.blade.php
new file mode 100644
index 0000000..c2dcc20
--- /dev/null
+++ b/tests/views/admin/templates.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/tool-analytics.blade.php b/tests/views/admin/tool-analytics.blade.php
new file mode 100644
index 0000000..35587d0
--- /dev/null
+++ b/tests/views/admin/tool-analytics.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/admin/tool-calls.blade.php b/tests/views/admin/tool-calls.blade.php
new file mode 100644
index 0000000..c0d7f13
--- /dev/null
+++ b/tests/views/admin/tool-calls.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/mcp/admin/api-key-manager.blade.php b/tests/views/mcp/admin/api-key-manager.blade.php
new file mode 100644
index 0000000..7a3abb3
--- /dev/null
+++ b/tests/views/mcp/admin/api-key-manager.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/mcp/admin/playground.blade.php b/tests/views/mcp/admin/playground.blade.php
new file mode 100644
index 0000000..f261550
--- /dev/null
+++ b/tests/views/mcp/admin/playground.blade.php
@@ -0,0 +1 @@
+
diff --git a/tests/views/mcp/admin/request-log.blade.php b/tests/views/mcp/admin/request-log.blade.php
new file mode 100644
index 0000000..0999e49
--- /dev/null
+++ b/tests/views/mcp/admin/request-log.blade.php
@@ -0,0 +1 @@
+
| |