From 0004e597c3c974db787661bc56537b99b930b669 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 10 Feb 2026 18:24:28 +0000 Subject: [PATCH] fix: restore service commands with Termwind-compatible styling (#84) Replace unsupported flexbox CSS classes (flex, justify-between, items-center, space-y-*, text-right) with stacked div layouts using supported Termwind classes (mx-*, my-*, px-*, py-*, mb-*, mt-*, ml-*). Restored: - Service commands: up, down, status, logs - HealthCheckInterface and HealthCheckService - HealthCheckInterface binding in AppServiceProvider - Full test coverage for all service commands - Parallel-safe tests (temp dir instead of file renames) --- app/Commands/Service/DownCommand.php | 147 +++++++++++ app/Commands/Service/LogsCommand.php | 143 ++++++++++ app/Commands/Service/StatusCommand.php | 199 ++++++++++++++ app/Commands/Service/UpCommand.php | 124 +++++++++ app/Contracts/HealthCheckInterface.php | 29 ++ app/Providers/AppServiceProvider.php | 5 + app/Services/HealthCheckService.php | 142 ++++++++++ tests/Feature/AppServiceProviderTest.php | 8 + .../Commands/Service/DownCommandTest.php | 112 ++++++++ .../Commands/Service/LogsCommandTest.php | 148 +++++++++++ .../Commands/Service/StatusCommandTest.php | 248 ++++++++++++++++++ .../Commands/Service/UpCommandTest.php | 111 ++++++++ .../Unit/Services/HealthCheckServiceTest.php | 137 ++++++++++ 13 files changed, 1553 insertions(+) create mode 100644 app/Commands/Service/DownCommand.php create mode 100644 app/Commands/Service/LogsCommand.php create mode 100644 app/Commands/Service/StatusCommand.php create mode 100644 app/Commands/Service/UpCommand.php create mode 100644 app/Contracts/HealthCheckInterface.php create mode 100644 app/Services/HealthCheckService.php create mode 100644 tests/Feature/Commands/Service/DownCommandTest.php create mode 100644 tests/Feature/Commands/Service/LogsCommandTest.php create mode 100644 tests/Feature/Commands/Service/StatusCommandTest.php create mode 100644 tests/Feature/Commands/Service/UpCommandTest.php create mode 100644 tests/Unit/Services/HealthCheckServiceTest.php diff --git a/app/Commands/Service/DownCommand.php b/app/Commands/Service/DownCommand.php new file mode 100644 index 0000000..f24d4c7 --- /dev/null +++ b/app/Commands/Service/DownCommand.php @@ -0,0 +1,147 @@ +option('odin') === true + ? 'docker-compose.odin.yml' + : 'docker-compose.yml'; + + $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + + if (! file_exists(base_path($composeFile))) { + render(<< +
+
✗ Configuration Error
+
Docker Compose file not found: {$composeFile}
+
+ + HTML); + + return self::FAILURE; + } + + // Show warning if removing volumes + if ($this->option('volumes') === true && $this->option('force') !== true) { + render(<<<'HTML' +
+
+
+ + Warning: Volume Removal +
+
This will permanently delete all data stored in volumes
+
+
+ HTML); + + $confirmed = confirm( + label: 'Are you sure you want to remove volumes?', + default: false, + hint: 'This action cannot be undone' + ); + + if (! $confirmed) { + render(<<<'HTML' +
+
+
Operation cancelled
+
+
+ HTML); + + return self::SUCCESS; + } + } + + // Display shutdown banner + render(<< +
+
+ + Stopping Knowledge Services +
+
Environment: {$environment}
+
+ + HTML); + + $args = ['docker', 'compose', '-f', $composeFile, 'down']; + + if ($this->option('volumes') === true) { + $args[] = '-v'; + } + + $result = Process::forever() + ->path(base_path()) + ->run($args); + + $exitCode = $result->exitCode(); + + if ($exitCode === 0) { + $volumeText = $this->option('volumes') === true ? ' and volumes removed' : ''; + + render(<< +
+
+ + Services Stopped Successfully +
+
All containers have been stopped{$volumeText}
+
+ + HTML); + + if ($this->option('volumes') !== true) { + render(<<<'HTML' +
+
+
+ Tip: Use + know service:down --volumes + to remove data volumes +
+
+
+ HTML); + } + + return self::SUCCESS; + } + + render(<<<'HTML' +
+
+
+ + Failed to Stop Services +
+
Check the error output above for details
+
+
+ HTML); + + return self::FAILURE; + } +} diff --git a/app/Commands/Service/LogsCommand.php b/app/Commands/Service/LogsCommand.php new file mode 100644 index 0000000..0a3ceda --- /dev/null +++ b/app/Commands/Service/LogsCommand.php @@ -0,0 +1,143 @@ +option('odin') === true + ? 'docker-compose.odin.yml' + : 'docker-compose.yml'; + + $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + + if (! file_exists(base_path($composeFile))) { + render(<< +
+
✗ Configuration Error
+
Docker Compose file not found: {$composeFile}
+
+ + HTML); + + return self::FAILURE; + } + + /** @var string|null $service */ + $service = $this->argument('service'); + + // If no service specified and not following, offer selection + if ($service === null && $this->option('follow') !== true) { + $service = select( + label: 'Which service logs would you like to view?', + options: [ + 'all' => 'All Services', + 'qdrant' => 'Qdrant (Vector Database)', + 'redis' => 'Redis (Cache)', + 'embeddings' => 'Embeddings (ML Service)', + 'ollama' => 'Ollama (LLM Engine)', + ], + default: 'all' + ); + + if ($service === 'all') { + $service = null; + } + } + + $serviceDisplay = is_string($service) ? ucfirst($service) : 'All Services'; + $followMode = $this->option('follow') === true ? 'Live' : 'Recent'; + $tailOption = $this->option('tail'); + $tailCount = is_string($tailOption) || is_int($tailOption) ? (string) $tailOption : '50'; + + // Display logs banner + render(<< +
+
+ 📋 + Service Logs: {$serviceDisplay} +
+
+ Environment: {$environment} + · + {$followMode} + · + Last {$tailCount} lines +
+
+ + HTML); + + if ($this->option('follow') === true) { + render(<<<'HTML' +
+
+
+ Press Ctrl+C to stop following logs +
+
+
+ HTML); + } + + $this->newLine(); + + $args = ['docker', 'compose', '-f', $composeFile, 'logs']; + + if ($this->option('follow') === true) { + $args[] = '-f'; + } + + $tailOption = $this->option('tail'); + if (is_string($tailOption) || is_int($tailOption)) { + $args[] = '--tail='.(string) $tailOption; + } + + if (is_string($service)) { + $args[] = $service; + } + + $result = Process::forever() + ->path(base_path()) + ->run($args); + + $exitCode = $result->exitCode() ?? self::FAILURE; + + // Only show footer if not following (Ctrl+C will interrupt) + if ($this->option('follow') !== true) { + $this->newLine(); + render(<<<'HTML' +
+
+
+ Tip: Use + --follow + to stream logs in real-time +
+
+
+ HTML); + } + + return $exitCode; + } +} diff --git a/app/Commands/Service/StatusCommand.php b/app/Commands/Service/StatusCommand.php new file mode 100644 index 0000000..04162b3 --- /dev/null +++ b/app/Commands/Service/StatusCommand.php @@ -0,0 +1,199 @@ +option('odin') === true + ? 'docker-compose.odin.yml' + : 'docker-compose.yml'; + + $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + + // Perform health checks with spinner + $healthData = spin( + fn () => $this->healthCheck->checkAll(), + 'Checking service health...' + ); + + // Get container status + $containerStatus = $this->getContainerStatus($composeFile); + + // Render beautiful dashboard + $this->renderDashboard($environment, $healthData, $containerStatus); + + return self::SUCCESS; + } + + /** + * @return array> + */ + private function getContainerStatus(string $composeFile): array + { + $result = Process::path(base_path()) + ->run(['docker', 'compose', '-f', $composeFile, 'ps', '--format', 'json']); + + if (! $result->successful()) { + return []; + } + + $output = $result->output(); + if ($output === '') { + return []; + } + + $containers = []; + foreach (explode("\n", trim($output)) as $line) { + if ($line === '') { + continue; + } + $data = json_decode($line, true); + if (is_array($data) && count($data) > 0) { + $containers[] = $data; + } + } + + return $containers; + } + + /** + * @param array $healthData + * @param array> $containers + */ + private function renderDashboard(string $environment, array $healthData, array $containers): void + { + $allHealthy = collect($healthData)->every(fn ($service) => $service['healthy']); + $healthyCount = collect($healthData)->filter(fn ($service) => $service['healthy'])->count(); + $totalCount = count($healthData); + + $statusColor = $allHealthy ? 'green' : ($healthyCount > 0 ? 'yellow' : 'red'); + $statusText = $allHealthy ? 'All Systems Operational' : ($healthyCount > 0 ? 'Partial Outage' : 'Major Outage'); + $statusIcon = '●'; + + render(<< +
+
+ KNOWLEDGE SERVICE STATUS + · + {$environment} +
+
+ {$statusIcon} + {$statusText} + {$healthyCount}/{$totalCount} +
+
+ + HTML); + + // Service Health Cards + render(<<<'HTML' +
+
+ SERVICE HEALTH +
+
+ HTML); + + foreach ($healthData as $service) { + $color = $service['healthy'] ? 'green' : 'red'; + $icon = $service['healthy'] ? '✓' : '✗'; + $status = $service['healthy'] ? 'Healthy' : 'Unhealthy'; + + $name = $service['name']; + $type = $service['type']; + $endpoint = $service['endpoint']; + + render(<< +
+
+ {$icon} + {$name} + {$status} +
+
+ {$type} + · + {$endpoint} +
+
+ + HTML); + } + + // Container Status + if (count($containers) > 0) { + render(<<<'HTML' +
+
+ CONTAINERS +
+
+ HTML); + + foreach ($containers as $container) { + $state = $container['State'] ?? 'unknown'; + $name = $container['Service'] ?? $container['Name'] ?? 'unknown'; + + $stateColor = match ($state) { + 'running' => 'green', + 'exited' => 'red', + 'paused' => 'yellow', + default => 'gray', + }; + + $stateIcon = match ($state) { + 'running' => '▶', + 'exited' => '■', + 'paused' => '❙❙', + default => '?', + }; + + render(<< +
+
+ {$stateIcon} + {$name} + {$state} +
+
+ + HTML); + } + } + + // Footer + render(<<<'HTML' +
+
+ Run know service:logs to view service logs +
+
+ HTML); + } +} diff --git a/app/Commands/Service/UpCommand.php b/app/Commands/Service/UpCommand.php new file mode 100644 index 0000000..61ccf5e --- /dev/null +++ b/app/Commands/Service/UpCommand.php @@ -0,0 +1,124 @@ +option('odin') === true + ? 'docker-compose.odin.yml' + : 'docker-compose.yml'; + + $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + + if (! file_exists(base_path($composeFile))) { + render(<< +
+
✗ Configuration Error
+
Docker Compose file not found: {$composeFile}
+
+ Run know service:init to initialize +
+
+ + HTML); + + return self::FAILURE; + } + + // Display startup banner + render(<< +
+
+ + Starting Knowledge Services +
+
Environment: {$environment}
+
+ + HTML); + + $args = ['docker', 'compose', '-f', $composeFile, 'up']; + + if ($this->option('detach') === true) { + $args[] = '-d'; + } + + $result = Process::forever() + ->path(base_path()) + ->run($args); + + $exitCode = $result->exitCode(); + + if ($exitCode === 0) { + if ($this->option('detach') === true) { + render(<<<'HTML' +
+
+
+ + Services Started Successfully +
+
All containers are running in detached mode
+
+
+ HTML); + + render(<<<'HTML' +
+
+
NEXT STEPS
+
+ + Check status: + know service:status +
+
+ + View logs: + know service:logs +
+
+ + Stop services: + know service:down +
+
+
+ HTML); + } + + return self::SUCCESS; + } + + render(<<<'HTML' +
+
+
+ + Failed to Start Services +
+
Check the error output above for details
+
+
+ HTML); + + return self::FAILURE; + } +} diff --git a/app/Contracts/HealthCheckInterface.php b/app/Contracts/HealthCheckInterface.php new file mode 100644 index 0000000..6842eea --- /dev/null +++ b/app/Contracts/HealthCheckInterface.php @@ -0,0 +1,29 @@ + + */ + public function checkAll(): array; + + /** + * Get list of available services. + * + * @return array + */ + public function getServices(): array; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 64cc884..cfefc42 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,8 +3,10 @@ namespace App\Providers; use App\Contracts\EmbeddingServiceInterface; +use App\Contracts\HealthCheckInterface; use App\Services\DailyLogService; use App\Services\DeletionTracker; +use App\Services\HealthCheckService; use App\Services\KnowledgeCacheService; use App\Services\KnowledgePathService; use App\Services\OdinSyncService; @@ -160,5 +162,8 @@ public function register(): void $this->app->singleton(DailyLogService::class, fn ($app): \App\Services\DailyLogService => new DailyLogService( $app->make(KnowledgePathService::class) )); + + // Health check service for service status commands + $this->app->singleton(HealthCheckInterface::class, fn (): HealthCheckService => new HealthCheckService); } } diff --git a/app/Services/HealthCheckService.php b/app/Services/HealthCheckService.php new file mode 100644 index 0000000..4993e44 --- /dev/null +++ b/app/Services/HealthCheckService.php @@ -0,0 +1,142 @@ + + */ + private array $services; + + public function __construct() + { + $this->services = [ + 'qdrant' => [ + 'name' => 'Qdrant', + 'type' => 'Vector Database', + 'checker' => fn () => $this->checkQdrant(), + ], + 'redis' => [ + 'name' => 'Redis', + 'type' => 'Cache', + 'checker' => fn () => $this->checkRedis(), + ], + 'embeddings' => [ + 'name' => 'Embeddings', + 'type' => 'ML Service', + 'checker' => fn () => $this->checkEmbeddings(), + ], + 'ollama' => [ + 'name' => 'Ollama', + 'type' => 'LLM Engine', + 'checker' => fn () => $this->checkOllama(), + ], + ]; + } + + public function check(string $service): array + { + if (! isset($this->services[$service])) { + return [ + 'name' => $service, + 'healthy' => false, + 'endpoint' => 'unknown', + 'type' => 'Unknown', + ]; + } + + $config = $this->services[$service]; + $endpoint = $this->getEndpoint($service); + + return [ + 'name' => $config['name'], + 'healthy' => ($config['checker'])(), + 'endpoint' => $endpoint, + 'type' => $config['type'], + ]; + } + + public function checkAll(): array + { + return array_map( + fn (string $service) => $this->check($service), + $this->getServices() + ); + } + + public function getServices(): array + { + return array_keys($this->services); + } + + private function getEndpoint(string $service): string + { + return match ($service) { + 'qdrant' => config('search.qdrant.host', 'localhost').':'.config('search.qdrant.port', 6333), + 'redis' => config('database.redis.default.host', '127.0.0.1').':'.config('database.redis.default.port', 6380), + 'embeddings' => config('search.qdrant.embedding_server', 'http://localhost:8001'), + 'ollama' => config('search.ollama.host', 'localhost').':'.config('search.ollama.port', 11434), + default => 'unknown', + }; + } + + private function checkQdrant(): bool + { + $host = config('search.qdrant.host', 'localhost'); + $port = config('search.qdrant.port', 6333); + + return $this->httpCheck("http://{$host}:{$port}/healthz"); + } + + private function checkRedis(): bool + { + if (! extension_loaded('redis')) { + return false; + } + + try { + $redis = new Redis; + $host = config('database.redis.default.host', '127.0.0.1'); + $port = (int) config('database.redis.default.port', 6380); + + return $redis->connect($host, $port, self::TIMEOUT_SECONDS); + } catch (\Exception) { + return false; + } + } + + private function checkEmbeddings(): bool + { + $server = config('search.qdrant.embedding_server', 'http://localhost:8001'); + + return $this->httpCheck("{$server}/health"); + } + + private function checkOllama(): bool + { + $host = config('search.ollama.host', 'localhost'); + $port = config('search.ollama.port', 11434); + + return $this->httpCheck("http://{$host}:{$port}/api/tags"); + } + + private function httpCheck(string $url): bool + { + $context = stream_context_create([ + 'http' => [ + 'timeout' => self::TIMEOUT_SECONDS, + 'method' => 'GET', + ], + ]); + + return @file_get_contents($url, false, $context) !== false; + } +} diff --git a/tests/Feature/AppServiceProviderTest.php b/tests/Feature/AppServiceProviderTest.php index 0ff56ef..a32a654 100644 --- a/tests/Feature/AppServiceProviderTest.php +++ b/tests/Feature/AppServiceProviderTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use App\Contracts\EmbeddingServiceInterface; +use App\Contracts\HealthCheckInterface; use App\Services\EmbeddingService; +use App\Services\HealthCheckService; use App\Services\KnowledgePathService; use App\Services\QdrantService; use App\Services\RuntimeEnvironment; @@ -88,6 +90,12 @@ expect($service)->toBeInstanceOf(QdrantService::class); }); + it('registers HealthCheckService', function (): void { + $service = app(HealthCheckInterface::class); + + expect($service)->toBeInstanceOf(HealthCheckService::class); + }); + it('uses custom embedding server configuration for qdrant provider', function (): void { config([ 'search.embedding_provider' => 'qdrant', diff --git a/tests/Feature/Commands/Service/DownCommandTest.php b/tests/Feature/Commands/Service/DownCommandTest.php new file mode 100644 index 0000000..a4dc372 --- /dev/null +++ b/tests/Feature/Commands/Service/DownCommandTest.php @@ -0,0 +1,112 @@ + Process::result(output: 'OK', exitCode: 0), + ]); + }); + + describe('configuration file validation', function () { + it('fails when local docker-compose file does not exist', function () { + $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); + mkdir($tempDir, 0755, true); + $this->app->setBasePath($tempDir); + + try { + $this->artisan('service:down') + ->assertFailed(); + } finally { + rmdir($tempDir); + } + }); + + it('fails when odin docker-compose file does not exist', function () { + $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); + mkdir($tempDir, 0755, true); + $this->app->setBasePath($tempDir); + + try { + $this->artisan('service:down', ['--odin' => true]) + ->assertFailed(); + } finally { + rmdir($tempDir); + } + }); + }); + + describe('docker compose execution', function () { + it('runs docker compose down successfully', function () { + $this->artisan('service:down') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('down', $process->command)); + }); + + it('runs docker compose down with volumes flag when forced', function () { + $this->artisan('service:down', ['--volumes' => true, '--force' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('-v', $process->command)); + }); + + it('uses odin compose file when odin flag is set', function () { + $this->artisan('service:down', ['--odin' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + }); + + it('returns failure when docker compose fails', function () { + Process::fake([ + '*docker*' => Process::result( + errorOutput: 'Docker daemon not running', + exitCode: 1, + ), + ]); + + $this->artisan('service:down') + ->assertFailed(); + }); + + it('combines odin and volumes flags correctly when forced', function () { + $this->artisan('service:down', ['--odin' => true, '--volumes' => true, '--force' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command) + && in_array('-v', $process->command)); + }); + }); + + describe('command signature', function () { + it('has correct command signature', function () { + $command = new \App\Commands\Service\DownCommand; + $reflection = new ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + $signatureProperty->setAccessible(true); + $signature = $signatureProperty->getValue($command); + + expect($signature)->toContain('service:down'); + expect($signature)->toContain('--volumes'); + expect($signature)->toContain('--odin'); + expect($signature)->toContain('--force'); + }); + + it('has correct description', function () { + $command = new \App\Commands\Service\DownCommand; + $reflection = new ReflectionClass($command); + $descProperty = $reflection->getProperty('description'); + $descProperty->setAccessible(true); + $description = $descProperty->getValue($command); + + expect($description)->toBe('Stop knowledge services'); + }); + }); +}); diff --git a/tests/Feature/Commands/Service/LogsCommandTest.php b/tests/Feature/Commands/Service/LogsCommandTest.php new file mode 100644 index 0000000..43d8217 --- /dev/null +++ b/tests/Feature/Commands/Service/LogsCommandTest.php @@ -0,0 +1,148 @@ + Process::result(output: 'Logs output...', exitCode: 0), + ]); + }); + + describe('configuration file validation', function () { + it('fails when local docker-compose file does not exist', function () { + $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); + mkdir($tempDir, 0755, true); + $this->app->setBasePath($tempDir); + + try { + $this->artisan('service:logs', ['service' => 'qdrant']) + ->assertFailed(); + } finally { + rmdir($tempDir); + } + }); + + it('fails when odin docker-compose file does not exist', function () { + $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); + mkdir($tempDir, 0755, true); + $this->app->setBasePath($tempDir); + + try { + $this->artisan('service:logs', [ + 'service' => 'qdrant', + '--odin' => true, + ]) + ->assertFailed(); + } finally { + rmdir($tempDir); + } + }); + }); + + describe('docker compose execution', function () { + it('runs docker compose logs for specific service', function () { + $this->artisan('service:logs', ['service' => 'qdrant']) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('logs', $process->command) + && in_array('qdrant', $process->command)); + }); + + it('runs docker compose logs with follow flag', function () { + $this->artisan('service:logs', ['service' => 'qdrant', '--follow' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('-f', $process->command) + && in_array('logs', $process->command)); + }); + + it('runs docker compose logs with custom tail count', function () { + $this->artisan('service:logs', ['service' => 'redis', '--tail' => 100]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('--tail=100', $process->command)); + }); + + it('uses odin compose file when odin flag is set', function () { + $this->artisan('service:logs', ['service' => 'qdrant', '--odin' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + }); + + it('returns exit code from docker compose', function () { + Process::fake([ + '*docker*' => Process::result( + errorOutput: 'Service not found', + exitCode: 1, + ), + ]); + + $this->artisan('service:logs', ['service' => 'nonexistent']) + ->assertExitCode(1); + }); + + it('combines follow and tail flags correctly', function () { + $this->artisan('service:logs', [ + 'service' => 'embeddings', + '--follow' => true, + '--tail' => 200, + ]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('-f', $process->command) + && in_array('--tail=200', $process->command)); + }); + + it('skips prompt when following without service', function () { + // When --follow is specified without a service, it doesn't prompt + $this->artisan('service:logs', ['--follow' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('-f', $process->command) + && in_array('logs', $process->command)); + }); + }); + + describe('command signature', function () { + it('has correct command signature', function () { + $command = new \App\Commands\Service\LogsCommand; + $reflection = new ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + $signatureProperty->setAccessible(true); + $signature = $signatureProperty->getValue($command); + + expect($signature)->toContain('service:logs'); + expect($signature)->toContain('{service?'); + expect($signature)->toContain('--f|follow'); + expect($signature)->toContain('--tail=50'); + expect($signature)->toContain('--odin'); + }); + + it('has correct description', function () { + $command = new \App\Commands\Service\LogsCommand; + $reflection = new ReflectionClass($command); + $descProperty = $reflection->getProperty('description'); + $descProperty->setAccessible(true); + $description = $descProperty->getValue($command); + + expect($description)->toBe('View service logs'); + }); + + it('makes service argument optional', function () { + $command = new \App\Commands\Service\LogsCommand; + $reflection = new ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + $signatureProperty->setAccessible(true); + $signature = $signatureProperty->getValue($command); + + expect($signature)->toContain('{service?'); + }); + }); +}); diff --git a/tests/Feature/Commands/Service/StatusCommandTest.php b/tests/Feature/Commands/Service/StatusCommandTest.php new file mode 100644 index 0000000..b741024 --- /dev/null +++ b/tests/Feature/Commands/Service/StatusCommandTest.php @@ -0,0 +1,248 @@ + Process::result(output: '', exitCode: 0), + ]); + + // Create a mock that returns fast, predictable responses + $healthCheck = mock(HealthCheckInterface::class); + + $healthCheck->shouldReceive('checkAll') + ->andReturn([ + ['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'], + ['name' => 'Redis', 'healthy' => false, 'endpoint' => '127.0.0.1:6380', 'type' => 'Cache'], + ['name' => 'Embeddings', 'healthy' => true, 'endpoint' => 'http://localhost:8001', 'type' => 'ML Service'], + ['name' => 'Ollama', 'healthy' => false, 'endpoint' => 'localhost:11434', 'type' => 'LLM Engine'], + ]); + + $healthCheck->shouldReceive('getServices') + ->andReturn(['qdrant', 'redis', 'embeddings', 'ollama']); + + $healthCheck->shouldReceive('check') + ->with('qdrant') + ->andReturn(['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database']); + + $healthCheck->shouldReceive('check') + ->with('redis') + ->andReturn(['name' => 'Redis', 'healthy' => false, 'endpoint' => '127.0.0.1:6380', 'type' => 'Cache']); + + app()->instance(HealthCheckInterface::class, $healthCheck); +}); + +afterEach(function () { + Mockery::close(); +}); + +describe('service:status command', function () { + describe('successful operations', function () { + it('always returns success exit code', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('displays service status dashboard', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('shows environment information', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('shows odin environment with --odin flag', function () { + $this->artisan('service:status', ['--odin' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + }); + }); + + describe('health checks via injected service', function () { + it('uses HealthCheckInterface for all service checks', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('displays healthy services correctly', function () { + $healthCheck = mock(HealthCheckInterface::class); + $healthCheck->shouldReceive('checkAll') + ->andReturn([ + ['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'], + ['name' => 'Redis', 'healthy' => true, 'endpoint' => 'localhost:6380', 'type' => 'Cache'], + ]); + + app()->instance(HealthCheckInterface::class, $healthCheck); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('displays unhealthy services correctly', function () { + $healthCheck = mock(HealthCheckInterface::class); + $healthCheck->shouldReceive('checkAll') + ->andReturn([ + ['name' => 'Qdrant', 'healthy' => false, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'], + ]); + + app()->instance(HealthCheckInterface::class, $healthCheck); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('handles partial outage scenario', function () { + $healthCheck = mock(HealthCheckInterface::class); + $healthCheck->shouldReceive('checkAll') + ->andReturn([ + ['name' => 'Qdrant', 'healthy' => true, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'], + ['name' => 'Redis', 'healthy' => false, 'endpoint' => 'localhost:6380', 'type' => 'Cache'], + ]); + + app()->instance(HealthCheckInterface::class, $healthCheck); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('handles major outage scenario', function () { + $healthCheck = mock(HealthCheckInterface::class); + $healthCheck->shouldReceive('checkAll') + ->andReturn([ + ['name' => 'Qdrant', 'healthy' => false, 'endpoint' => 'localhost:6333', 'type' => 'Vector Database'], + ['name' => 'Redis', 'healthy' => false, 'endpoint' => 'localhost:6380', 'type' => 'Cache'], + ]); + + app()->instance(HealthCheckInterface::class, $healthCheck); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + }); + + describe('container status via process', function () { + it('runs docker compose ps to get container status', function () { + Process::fake([ + '*docker*' => Process::result( + output: '{"Service":"qdrant","State":"running"}', + exitCode: 0, + ), + ]); + + $this->artisan('service:status') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('ps', $process->command)); + }); + + it('uses odin compose file with --odin flag', function () { + $this->artisan('service:status', ['--odin' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + }); + + it('handles docker compose failure gracefully', function () { + Process::fake([ + '*docker*' => Process::result( + errorOutput: 'Docker daemon not running', + exitCode: 1, + ), + ]); + + $this->artisan('service:status') + ->assertSuccessful(); // Command still succeeds, just no container info + }); + + it('displays running containers', function () { + Process::fake([ + '*docker*' => Process::result( + output: '{"Service":"qdrant","State":"running"}'."\n".'{"Service":"redis","State":"running"}', + exitCode: 0, + ), + ]); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('displays container states correctly', function () { + Process::fake([ + '*docker*' => Process::result( + output: '{"Service":"qdrant","State":"running"}'."\n".'{"Service":"redis","State":"exited"}'."\n".'{"Service":"ollama","State":"paused"}', + exitCode: 0, + ), + ]); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + }); + + describe('command signature', function () { + it('has correct command signature', function () { + $command = app(\App\Commands\Service\StatusCommand::class); + $reflection = new ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + $signatureProperty->setAccessible(true); + $signature = $signatureProperty->getValue($command); + + expect($signature)->toContain('service:status'); + expect($signature)->toContain('--odin'); + }); + + it('has correct description', function () { + $command = app(\App\Commands\Service\StatusCommand::class); + $reflection = new ReflectionClass($command); + $descProperty = $reflection->getProperty('description'); + $descProperty->setAccessible(true); + $description = $descProperty->getValue($command); + + expect($description)->toBe('Check service health status'); + }); + }); + + describe('output formatting', function () { + it('displays service status sections', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('displays tip about viewing logs', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + + it('shows service type for each service', function () { + $this->artisan('service:status') + ->assertSuccessful(); + }); + }); + + describe('process execution', function () { + it('is instance of Laravel Zero Command', function () { + $command = app(\App\Commands\Service\StatusCommand::class); + + expect($command)->toBeInstanceOf(\LaravelZero\Framework\Commands\Command::class); + }); + }); + + describe('container status handling', function () { + it('handles empty container list gracefully', function () { + Process::fake([ + '*docker*' => Process::result(output: '', exitCode: 0), + ]); + + $this->artisan('service:status') + ->assertSuccessful(); + }); + }); +}); diff --git a/tests/Feature/Commands/Service/UpCommandTest.php b/tests/Feature/Commands/Service/UpCommandTest.php new file mode 100644 index 0000000..1751d11 --- /dev/null +++ b/tests/Feature/Commands/Service/UpCommandTest.php @@ -0,0 +1,111 @@ + Process::result(output: 'OK', exitCode: 0), + ]); + }); + + describe('configuration file validation', function () { + it('fails when local docker-compose file does not exist', function () { + $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); + mkdir($tempDir, 0755, true); + $this->app->setBasePath($tempDir); + + try { + $this->artisan('service:up') + ->assertFailed(); + } finally { + rmdir($tempDir); + } + }); + + it('fails when odin docker-compose file does not exist', function () { + $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); + mkdir($tempDir, 0755, true); + $this->app->setBasePath($tempDir); + + try { + $this->artisan('service:up', ['--odin' => true]) + ->assertFailed(); + } finally { + rmdir($tempDir); + } + }); + }); + + describe('docker compose execution', function () { + it('runs docker compose up successfully', function () { + $this->artisan('service:up') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('up', $process->command)); + }); + + it('runs docker compose up with detach flag', function () { + $this->artisan('service:up', ['--detach' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker', $process->command) + && in_array('-d', $process->command)); + }); + + it('uses odin compose file when odin flag is set', function () { + $this->artisan('service:up', ['--odin' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + }); + + it('returns failure when docker compose fails', function () { + Process::fake([ + '*docker*' => Process::result( + errorOutput: 'Docker daemon not running', + exitCode: 1, + ), + ]); + + $this->artisan('service:up') + ->assertFailed(); + }); + + it('combines odin and detach flags correctly', function () { + $this->artisan('service:up', ['--odin' => true, '--detach' => true]) + ->assertSuccessful(); + + Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command) + && in_array('-d', $process->command)); + }); + }); + + describe('command signature', function () { + it('has correct command signature', function () { + $command = new \App\Commands\Service\UpCommand; + $reflection = new ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + $signatureProperty->setAccessible(true); + $signature = $signatureProperty->getValue($command); + + expect($signature)->toContain('service:up'); + expect($signature)->toContain('--d|detach'); + expect($signature)->toContain('--odin'); + }); + + it('has correct description', function () { + $command = new \App\Commands\Service\UpCommand; + $reflection = new ReflectionClass($command); + $descProperty = $reflection->getProperty('description'); + $descProperty->setAccessible(true); + $description = $descProperty->getValue($command); + + expect($description)->toContain('Start knowledge services'); + }); + }); +}); diff --git a/tests/Unit/Services/HealthCheckServiceTest.php b/tests/Unit/Services/HealthCheckServiceTest.php new file mode 100644 index 0000000..577c941 --- /dev/null +++ b/tests/Unit/Services/HealthCheckServiceTest.php @@ -0,0 +1,137 @@ +getServices(); + + expect($services)->toBe(['qdrant', 'redis', 'embeddings', 'ollama']); + }); + + it('returns unhealthy status for unknown service', function () { + $service = new HealthCheckService; + + $result = $service->check('nonexistent'); + + expect($result)->toBe([ + 'name' => 'nonexistent', + 'healthy' => false, + 'endpoint' => 'unknown', + 'type' => 'Unknown', + ]); + }); + + it('checks all services and returns array', function () { + $service = new HealthCheckService; + + $results = $service->checkAll(); + + expect($results)->toBeArray(); + expect($results)->toHaveCount(4); + + foreach ($results as $result) { + expect($result)->toHaveKeys(['name', 'healthy', 'endpoint', 'type']); + expect($result['healthy'])->toBeBool(); + expect($result['name'])->toBeString(); + expect($result['endpoint'])->toBeString(); + expect($result['type'])->toBeString(); + } + }); + + it('returns correct structure for qdrant check', function () { + $service = new HealthCheckService; + + $result = $service->check('qdrant'); + + expect($result['name'])->toBe('Qdrant'); + expect($result['type'])->toBe('Vector Database'); + expect($result['healthy'])->toBeBool(); + expect($result['endpoint'])->toBeString(); + }); + + it('returns correct structure for redis check', function () { + $service = new HealthCheckService; + + $result = $service->check('redis'); + + expect($result['name'])->toBe('Redis'); + expect($result['type'])->toBe('Cache'); + expect($result['healthy'])->toBeBool(); + expect($result['endpoint'])->toBeString(); + }); + + it('returns correct structure for embeddings check', function () { + $service = new HealthCheckService; + + $result = $service->check('embeddings'); + + expect($result['name'])->toBe('Embeddings'); + expect($result['type'])->toBe('ML Service'); + expect($result['healthy'])->toBeBool(); + expect($result['endpoint'])->toBeString(); + }); + + it('returns correct structure for ollama check', function () { + $service = new HealthCheckService; + + $result = $service->check('ollama'); + + expect($result['name'])->toBe('Ollama'); + expect($result['type'])->toBe('LLM Engine'); + expect($result['healthy'])->toBeBool(); + expect($result['endpoint'])->toBeString(); + }); + + it('uses config values for qdrant endpoint', function () { + config(['search.qdrant.host' => 'test-host']); + config(['search.qdrant.port' => 9999]); + + $service = new HealthCheckService; + $result = $service->check('qdrant'); + + expect($result['endpoint'])->toBe('test-host:9999'); + }); + + it('uses config values for redis endpoint', function () { + config(['database.redis.default.host' => 'redis-host']); + config(['database.redis.default.port' => 6380]); + + $service = new HealthCheckService; + $result = $service->check('redis'); + + expect($result['endpoint'])->toBe('redis-host:6380'); + }); + + it('uses config values for embeddings endpoint', function () { + config(['search.qdrant.embedding_server' => 'http://embed-host:8001']); + + $service = new HealthCheckService; + $result = $service->check('embeddings'); + + expect($result['endpoint'])->toBe('http://embed-host:8001'); + }); + + it('uses config values for ollama endpoint', function () { + config(['search.ollama.host' => 'ollama-host']); + config(['search.ollama.port' => 11434]); + + $service = new HealthCheckService; + $result = $service->check('ollama'); + + expect($result['endpoint'])->toBe('ollama-host:11434'); + }); + + it('checkAll returns same count as getServices', function () { + $service = new HealthCheckService; + + $services = $service->getServices(); + $results = $service->checkAll(); + + expect(count($results))->toBe(count($services)); + }); +});