php-agentic/tests/Feature/PlanTemplateServiceTest.php
Snider c432a45ca9 feat(security): switch API key to Argon2id with comprehensive tests
P2 Items Completed (P2-062 to P2-068):
- Switch AgentApiKey from SHA-256 to Argon2id hashing
- Add 200+ tests for models, services, and AI providers
- Create agent_plans migration with phases and workspace states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:36:53 +00:00

700 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Services\PlanTemplateService;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* Tests for the PlanTemplateService.
*
* Covers template loading, variable substitution, and plan creation.
*/
class PlanTemplateServiceTest extends TestCase
{
use RefreshDatabase;
private Workspace $workspace;
private PlanTemplateService $service;
private string $testTemplatesPath;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
$this->service = app(PlanTemplateService::class);
// Create test templates directory
$this->testTemplatesPath = resource_path('plan-templates');
if (! File::isDirectory($this->testTemplatesPath)) {
File::makeDirectory($this->testTemplatesPath, 0755, true);
}
}
protected function tearDown(): void
{
// Clean up test templates
if (File::isDirectory($this->testTemplatesPath)) {
File::deleteDirectory($this->testTemplatesPath);
}
parent::tearDown();
}
/**
* Create a test template file.
*/
private function createTestTemplate(string $slug, array $content): void
{
$yaml = \Symfony\Component\Yaml\Yaml::dump($content, 10);
File::put($this->testTemplatesPath.'/'.$slug.'.yaml', $yaml);
}
// =========================================================================
// Template Listing Tests
// =========================================================================
public function test_list_returns_empty_collection_when_no_templates(): void
{
// Ensure directory is empty
File::cleanDirectory($this->testTemplatesPath);
$result = $this->service->list();
$this->assertTrue($result->isEmpty());
}
public function test_list_returns_templates_sorted_by_name(): void
{
$this->createTestTemplate('zebra-template', ['name' => 'Zebra Template', 'phases' => []]);
$this->createTestTemplate('alpha-template', ['name' => 'Alpha Template', 'phases' => []]);
$this->createTestTemplate('middle-template', ['name' => 'Middle Template', 'phases' => []]);
$result = $this->service->list();
$this->assertCount(3, $result);
$this->assertEquals('Alpha Template', $result[0]['name']);
$this->assertEquals('Middle Template', $result[1]['name']);
$this->assertEquals('Zebra Template', $result[2]['name']);
}
public function test_list_includes_template_metadata(): void
{
$this->createTestTemplate('test-template', [
'name' => 'Test Template',
'description' => 'A test description',
'category' => 'testing',
'phases' => [
['name' => 'Phase 1', 'tasks' => ['Task 1']],
['name' => 'Phase 2', 'tasks' => ['Task 2']],
],
]);
$result = $this->service->list();
$this->assertCount(1, $result);
$template = $result[0];
$this->assertEquals('test-template', $template['slug']);
$this->assertEquals('Test Template', $template['name']);
$this->assertEquals('A test description', $template['description']);
$this->assertEquals('testing', $template['category']);
$this->assertEquals(2, $template['phases_count']);
}
public function test_list_extracts_variables(): void
{
$this->createTestTemplate('with-vars', [
'name' => 'Template with Variables',
'variables' => [
'project_name' => [
'description' => 'The project name',
'required' => true,
],
'author' => [
'description' => 'Author name',
'default' => 'Anonymous',
'required' => false,
],
],
'phases' => [],
]);
$result = $this->service->list();
$template = $result[0];
$this->assertCount(2, $template['variables']);
$this->assertEquals('project_name', $template['variables'][0]['name']);
$this->assertTrue($template['variables'][0]['required']);
$this->assertEquals('author', $template['variables'][1]['name']);
$this->assertEquals('Anonymous', $template['variables'][1]['default']);
}
public function test_list_ignores_non_yaml_files(): void
{
$this->createTestTemplate('valid-template', ['name' => 'Valid', 'phases' => []]);
File::put($this->testTemplatesPath.'/readme.txt', 'Not a template');
File::put($this->testTemplatesPath.'/config.json', '{}');
$result = $this->service->list();
$this->assertCount(1, $result);
$this->assertEquals('valid-template', $result[0]['slug']);
}
public function test_list_templates_returns_array(): void
{
$this->createTestTemplate('test-template', ['name' => 'Test', 'phases' => []]);
$result = $this->service->listTemplates();
$this->assertIsArray($result);
$this->assertCount(1, $result);
}
// =========================================================================
// Get Template Tests
// =========================================================================
public function test_get_returns_template_content(): void
{
$this->createTestTemplate('my-template', [
'name' => 'My Template',
'description' => 'Test description',
'phases' => [
['name' => 'Phase 1', 'tasks' => ['Task A']],
],
]);
$result = $this->service->get('my-template');
$this->assertNotNull($result);
$this->assertEquals('my-template', $result['slug']);
$this->assertEquals('My Template', $result['name']);
$this->assertEquals('Test description', $result['description']);
}
public function test_get_returns_null_for_nonexistent(): void
{
$result = $this->service->get('nonexistent-template');
$this->assertNull($result);
}
public function test_get_supports_yml_extension(): void
{
$yaml = \Symfony\Component\Yaml\Yaml::dump(['name' => 'YML Template', 'phases' => []], 10);
File::put($this->testTemplatesPath.'/yml-template.yml', $yaml);
$result = $this->service->get('yml-template');
$this->assertNotNull($result);
$this->assertEquals('YML Template', $result['name']);
}
// =========================================================================
// Preview Template Tests
// =========================================================================
public function test_preview_template_returns_structure(): void
{
$this->createTestTemplate('preview-test', [
'name' => 'Preview Test',
'description' => 'Testing preview',
'category' => 'test',
'phases' => [
['name' => 'Setup', 'description' => 'Initial setup', 'tasks' => ['Install deps']],
],
'guidelines' => ['Be thorough', 'Test everything'],
]);
$result = $this->service->previewTemplate('preview-test');
$this->assertNotNull($result);
$this->assertEquals('preview-test', $result['slug']);
$this->assertEquals('Preview Test', $result['name']);
$this->assertEquals('Testing preview', $result['description']);
$this->assertEquals('test', $result['category']);
$this->assertCount(1, $result['phases']);
$this->assertEquals(1, $result['phases'][0]['order']);
$this->assertEquals('Setup', $result['phases'][0]['name']);
$this->assertCount(2, $result['guidelines']);
}
public function test_preview_template_returns_null_for_nonexistent(): void
{
$result = $this->service->previewTemplate('nonexistent');
$this->assertNull($result);
}
public function test_preview_template_applies_variables(): void
{
$this->createTestTemplate('var-preview', [
'name' => '{{ project_name }} Plan',
'description' => 'Plan for {{ project_name }}',
'phases' => [
['name' => 'Work on {{ project_name }}', 'tasks' => ['{{ task_type }}']],
],
]);
$result = $this->service->previewTemplate('var-preview', [
'project_name' => 'MyProject',
'task_type' => 'Build feature',
]);
$this->assertStringContainsString('MyProject', $result['name']);
$this->assertStringContainsString('MyProject', $result['description']);
$this->assertStringContainsString('MyProject', $result['phases'][0]['name']);
}
public function test_preview_includes_applied_variables(): void
{
$this->createTestTemplate('track-vars', [
'name' => '{{ name }} Template',
'phases' => [],
]);
$result = $this->service->previewTemplate('track-vars', ['name' => 'Test']);
$this->assertArrayHasKey('variables_applied', $result);
$this->assertEquals(['name' => 'Test'], $result['variables_applied']);
}
// =========================================================================
// Variable Substitution Tests
// =========================================================================
public function test_substitutes_simple_variables(): void
{
$this->createTestTemplate('simple-vars', [
'name' => '{{ project }} Project',
'phases' => [],
]);
$result = $this->service->previewTemplate('simple-vars', ['project' => 'Alpha']);
$this->assertEquals('Alpha Project', $result['name']);
}
public function test_substitutes_variables_with_whitespace(): void
{
$this->createTestTemplate('whitespace-vars', [
'name' => '{{ project }} Project',
'phases' => [],
]);
$result = $this->service->previewTemplate('whitespace-vars', ['project' => 'Beta']);
$this->assertEquals('Beta Project', $result['name']);
}
public function test_applies_default_values(): void
{
$this->createTestTemplate('default-vars', [
'name' => '{{ project }} by {{ author }}',
'variables' => [
'project' => ['required' => true],
'author' => ['default' => 'Unknown'],
],
'phases' => [],
]);
$result = $this->service->previewTemplate('default-vars', ['project' => 'Gamma']);
$this->assertEquals('Gamma by Unknown', $result['name']);
}
public function test_handles_special_characters_in_variables(): void
{
$this->createTestTemplate('special-chars', [
'name' => '{{ title }}',
'description' => '{{ desc }}',
'phases' => [],
]);
$result = $this->service->previewTemplate('special-chars', [
'title' => 'Test "quotes" and \\backslashes\\',
'desc' => 'Has <html> & "quotes"',
]);
// Should handle without corrupting JSON structure
$this->assertNotNull($result);
$this->assertStringContainsString('quotes', $result['name']);
}
public function test_ignores_non_scalar_variable_values(): void
{
$this->createTestTemplate('scalar-only', [
'name' => '{{ project }}',
'phases' => [],
]);
$result = $this->service->previewTemplate('scalar-only', [
'project' => ['array' => 'value'],
]);
// Variable should not be substituted
$this->assertStringContainsString('{{ project }}', $result['name']);
}
// =========================================================================
// Create Plan Tests
// =========================================================================
public function test_create_plan_from_template(): void
{
$this->createTestTemplate('create-test', [
'name' => 'Test Template',
'description' => 'Template description',
'phases' => [
['name' => 'Phase 1', 'tasks' => ['Task 1', 'Task 2']],
['name' => 'Phase 2', 'tasks' => ['Task 3']],
],
]);
$plan = $this->service->createPlan('create-test', [], [], $this->workspace);
$this->assertNotNull($plan);
$this->assertInstanceOf(AgentPlan::class, $plan);
$this->assertEquals('Test Template', $plan->title);
$this->assertEquals('Template description', $plan->description);
$this->assertEquals($this->workspace->id, $plan->workspace_id);
$this->assertCount(2, $plan->agentPhases);
}
public function test_create_plan_returns_null_for_nonexistent_template(): void
{
$result = $this->service->createPlan('nonexistent', [], [], $this->workspace);
$this->assertNull($result);
}
public function test_create_plan_with_custom_title(): void
{
$this->createTestTemplate('custom-title', [
'name' => 'Template Name',
'phases' => [],
]);
$plan = $this->service->createPlan(
'custom-title',
[],
['title' => 'My Custom Title'],
$this->workspace
);
$this->assertEquals('My Custom Title', $plan->title);
}
public function test_create_plan_with_custom_slug(): void
{
$this->createTestTemplate('custom-slug', [
'name' => 'Template',
'phases' => [],
]);
$plan = $this->service->createPlan(
'custom-slug',
[],
['slug' => 'my-custom-slug'],
$this->workspace
);
$this->assertEquals('my-custom-slug', $plan->slug);
}
public function test_create_plan_applies_variables(): void
{
$this->createTestTemplate('var-plan', [
'name' => '{{ project }} Plan',
'description' => 'Plan for {{ project }}',
'phases' => [
['name' => '{{ project }} Setup', 'tasks' => ['Configure {{ project }}']],
],
]);
$plan = $this->service->createPlan(
'var-plan',
['project' => 'MyApp'],
[],
$this->workspace
);
$this->assertStringContainsString('MyApp', $plan->title);
$this->assertStringContainsString('MyApp', $plan->description);
$this->assertStringContainsString('MyApp', $plan->agentPhases[0]->name);
}
public function test_create_plan_activates_when_requested(): void
{
$this->createTestTemplate('activate-plan', [
'name' => 'Activatable',
'phases' => [],
]);
$plan = $this->service->createPlan(
'activate-plan',
[],
['activate' => true],
$this->workspace
);
$this->assertEquals(AgentPlan::STATUS_ACTIVE, $plan->status);
}
public function test_create_plan_defaults_to_draft(): void
{
$this->createTestTemplate('draft-plan', [
'name' => 'Draft Plan',
'phases' => [],
]);
$plan = $this->service->createPlan('draft-plan', [], [], $this->workspace);
$this->assertEquals(AgentPlan::STATUS_DRAFT, $plan->status);
}
public function test_create_plan_stores_template_metadata(): void
{
$this->createTestTemplate('metadata-plan', [
'name' => 'Metadata Template',
'phases' => [],
]);
$plan = $this->service->createPlan(
'metadata-plan',
['var1' => 'value1'],
[],
$this->workspace
);
$this->assertEquals('template', $plan->metadata['source']);
$this->assertEquals('metadata-plan', $plan->metadata['template_slug']);
$this->assertEquals(['var1' => 'value1'], $plan->metadata['variables']);
}
public function test_create_plan_creates_phases_in_order(): void
{
$this->createTestTemplate('ordered-phases', [
'name' => 'Ordered',
'phases' => [
['name' => 'First'],
['name' => 'Second'],
['name' => 'Third'],
],
]);
$plan = $this->service->createPlan('ordered-phases', [], [], $this->workspace);
$this->assertEquals(1, $plan->agentPhases[0]->order);
$this->assertEquals('First', $plan->agentPhases[0]->name);
$this->assertEquals(2, $plan->agentPhases[1]->order);
$this->assertEquals('Second', $plan->agentPhases[1]->name);
$this->assertEquals(3, $plan->agentPhases[2]->order);
$this->assertEquals('Third', $plan->agentPhases[2]->name);
}
public function test_create_plan_creates_tasks_as_pending(): void
{
$this->createTestTemplate('task-status', [
'name' => 'Task Status',
'phases' => [
['name' => 'Phase', 'tasks' => ['Task 1', 'Task 2']],
],
]);
$plan = $this->service->createPlan('task-status', [], [], $this->workspace);
$tasks = $plan->agentPhases[0]->tasks;
$this->assertEquals('pending', $tasks[0]['status']);
$this->assertEquals('pending', $tasks[1]['status']);
}
public function test_create_plan_handles_complex_task_definitions(): void
{
$this->createTestTemplate('complex-tasks', [
'name' => 'Complex Tasks',
'phases' => [
[
'name' => 'Phase',
'tasks' => [
['name' => 'Simple task'],
['name' => 'Task with metadata', 'priority' => 'high'],
],
],
],
]);
$plan = $this->service->createPlan('complex-tasks', [], [], $this->workspace);
$tasks = $plan->agentPhases[0]->tasks;
$this->assertEquals('Simple task', $tasks[0]['name']);
$this->assertEquals('Task with metadata', $tasks[1]['name']);
$this->assertEquals('high', $tasks[1]['priority']);
}
public function test_create_plan_with_workspace_id_option(): void
{
$this->createTestTemplate('workspace-id-option', [
'name' => 'Test',
'phases' => [],
]);
$plan = $this->service->createPlan(
'workspace-id-option',
[],
['workspace_id' => $this->workspace->id]
);
$this->assertEquals($this->workspace->id, $plan->workspace_id);
}
// =========================================================================
// Validate Variables Tests
// =========================================================================
public function test_validate_variables_returns_valid_when_all_provided(): void
{
$this->createTestTemplate('validate-vars', [
'name' => 'Test',
'variables' => [
'required_var' => ['required' => true],
],
'phases' => [],
]);
$result = $this->service->validateVariables('validate-vars', ['required_var' => 'value']);
$this->assertTrue($result['valid']);
$this->assertEmpty($result['errors']);
}
public function test_validate_variables_returns_error_when_missing_required(): void
{
$this->createTestTemplate('missing-required', [
'name' => 'Test',
'variables' => [
'required_var' => ['required' => true],
],
'phases' => [],
]);
$result = $this->service->validateVariables('missing-required', []);
$this->assertFalse($result['valid']);
$this->assertNotEmpty($result['errors']);
$this->assertStringContainsString('required_var', $result['errors'][0]);
}
public function test_validate_variables_accepts_default_for_required(): void
{
$this->createTestTemplate('default-required', [
'name' => 'Test',
'variables' => [
'optional_with_default' => ['required' => true, 'default' => 'default value'],
],
'phases' => [],
]);
$result = $this->service->validateVariables('default-required', []);
$this->assertTrue($result['valid']);
}
public function test_validate_variables_returns_error_for_nonexistent_template(): void
{
$result = $this->service->validateVariables('nonexistent', []);
$this->assertFalse($result['valid']);
$this->assertStringContainsString('Template not found', $result['errors'][0]);
}
// =========================================================================
// Category Tests
// =========================================================================
public function test_get_by_category_filters_templates(): void
{
$this->createTestTemplate('dev-1', ['name' => 'Dev 1', 'category' => 'development', 'phases' => []]);
$this->createTestTemplate('dev-2', ['name' => 'Dev 2', 'category' => 'development', 'phases' => []]);
$this->createTestTemplate('ops-1', ['name' => 'Ops 1', 'category' => 'operations', 'phases' => []]);
$devTemplates = $this->service->getByCategory('development');
$this->assertCount(2, $devTemplates);
foreach ($devTemplates as $template) {
$this->assertEquals('development', $template['category']);
}
}
public function test_get_categories_returns_unique_categories(): void
{
$this->createTestTemplate('t1', ['name' => 'T1', 'category' => 'alpha', 'phases' => []]);
$this->createTestTemplate('t2', ['name' => 'T2', 'category' => 'beta', 'phases' => []]);
$this->createTestTemplate('t3', ['name' => 'T3', 'category' => 'alpha', 'phases' => []]);
$categories = $this->service->getCategories();
$this->assertCount(2, $categories);
$this->assertContains('alpha', $categories->toArray());
$this->assertContains('beta', $categories->toArray());
}
public function test_get_categories_returns_sorted(): void
{
$this->createTestTemplate('t1', ['name' => 'T1', 'category' => 'zebra', 'phases' => []]);
$this->createTestTemplate('t2', ['name' => 'T2', 'category' => 'alpha', 'phases' => []]);
$categories = $this->service->getCategories();
$this->assertEquals('alpha', $categories[0]);
$this->assertEquals('zebra', $categories[1]);
}
// =========================================================================
// Context Building Tests
// =========================================================================
public function test_creates_context_from_template_data(): void
{
$this->createTestTemplate('with-context', [
'name' => 'Context Test',
'description' => 'Testing context generation',
'guidelines' => ['Guideline 1', 'Guideline 2'],
'phases' => [],
]);
$plan = $this->service->createPlan(
'with-context',
['project' => 'TestProject'],
[],
$this->workspace
);
$this->assertNotNull($plan->context);
$this->assertStringContainsString('Context Test', $plan->context);
$this->assertStringContainsString('Testing context generation', $plan->context);
$this->assertStringContainsString('Guideline 1', $plan->context);
}
public function test_uses_explicit_context_when_provided(): void
{
$this->createTestTemplate('explicit-context', [
'name' => 'Test',
'context' => 'This is the explicit context.',
'phases' => [],
]);
$plan = $this->service->createPlan('explicit-context', [], [], $this->workspace);
$this->assertEquals('This is the explicit context.', $plan->context);
}
}