- Create .ai/ directory as single source of truth for all AI docs - Organize by topic: core/, development/, patterns/, meta/ - Update CLAUDE.md to reference .ai/ files instead of embedding content - Remove 18KB of duplicated Laravel Boost guidelines from CLAUDE.md - Fix testing command descriptions (pest runs all tests, not just unit) - Standardize version numbers (Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4) - Replace all .cursor/rules/*.mdc with single coolify-ai-docs.mdc reference - Delete dev_workflow.mdc (non-Coolify Task Master content) - Merge cursor_rules.mdc + self_improve.mdc into maintaining-docs.md - Update .AI_INSTRUCTIONS_SYNC.md to redirect to new location Benefits: - Single source of truth - no more duplication - Consistent versions across all documentation - Better organization by topic - Platform-agnostic .ai/ directory works for all AI tools - Reduced CLAUDE.md from 719 to ~320 lines - Clear cross-references between files
20 KiB
20 KiB
Coolify Testing Architecture & Patterns
Cross-Reference: These detailed testing patterns align with the testing guidelines in CLAUDE.md. Both documents share the same core principles about Docker execution and mocking preferences.
Testing Philosophy
Coolify employs comprehensive testing strategies using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
Test Execution Rules
CRITICAL: Tests are categorized by database dependency:
Unit Tests (tests/Unit/)
- MUST NOT use database connections
- MUST use mocking for models and external dependencies
- CAN run outside Docker:
./vendor/bin/pest tests/Unit - Purpose: Test isolated logic, helper functions, and business rules
Feature Tests (tests/Feature/)
- MAY use database connections (factories, migrations, models)
- MUST run inside Docker container:
docker exec coolify php artisan test - MUST use
RefreshDatabasetrait if touching database - Purpose: Test API endpoints, workflows, and integration scenarios
Rule of thumb: If your test needs Server::factory()->create() or any database operation, it's a Feature test and MUST run in Docker.
Prefer Mocking Over Database
When writing tests, always prefer mocking over real database operations:
// ❌ BAD: Unit test using database
it('extracts custom commands', function () {
$server = Server::factory()->create(['ip' => '1.2.3.4']);
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
// ✅ GOOD: Unit test using mocking
it('extracts custom commands', function () {
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn('traefik');
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
Design principles for testable code:
- Use dependency injection instead of global state
- Create interfaces for external dependencies (SSH, Docker, etc.)
- Separate business logic from data persistence
- Make functions accept interfaces instead of concrete models when possible
Testing Framework Stack
Core Testing Tools
- Pest PHP 3.8+ - Primary testing framework with expressive syntax
- Laravel Dusk - Browser automation and end-to-end testing
- PHPUnit - Underlying unit testing framework
- Mockery - Mocking and stubbing for isolated tests
Testing Configuration
- tests/Pest.php - Pest configuration and global setup (1.5KB, 45 lines)
- tests/TestCase.php - Base test case class (163B, 11 lines)
- tests/CreatesApplication.php - Application factory trait (375B, 22 lines)
- tests/DuskTestCase.php - Browser testing setup (1.4KB, 58 lines)
Test Directory Structure
Test Organization
- tests/Feature/ - Feature and integration tests
- tests/Unit/ - Unit tests for isolated components
- tests/Browser/ - Laravel Dusk browser tests
- tests/Traits/ - Shared testing utilities
Unit Testing Patterns
Model Testing
// Testing Eloquent models
test('application model has correct relationships', function () {
$application = Application::factory()->create();
expect($application->server)->toBeInstanceOf(Server::class);
expect($application->environment)->toBeInstanceOf(Environment::class);
expect($application->deployments)->toBeInstanceOf(Collection::class);
});
test('application can generate deployment configuration', function () {
$application = Application::factory()->create([
'name' => 'test-app',
'git_repository' => 'https://github.com/user/repo.git'
]);
$config = $application->generateDockerCompose();
expect($config)->toContain('test-app');
expect($config)->toContain('image:');
expect($config)->toContain('networks:');
});
Service Layer Testing
// Testing service classes
test('configuration generator creates valid docker compose', function () {
$generator = new ConfigurationGenerator();
$application = Application::factory()->create();
$compose = $generator->generateDockerCompose($application);
expect($compose)->toBeString();
expect(yaml_parse($compose))->toBeArray();
expect($compose)->toContain('version: "3.8"');
});
test('docker image parser validates image names', function () {
$parser = new DockerImageParser();
expect($parser->isValid('nginx:latest'))->toBeTrue();
expect($parser->isValid('invalid-image-name'))->toBeFalse();
expect($parser->parse('nginx:1.21'))->toEqual([
'registry' => 'docker.io',
'namespace' => 'library',
'repository' => 'nginx',
'tag' => '1.21'
]);
});
Action Testing
// Testing Laravel Actions
test('deploy application action creates deployment queue', function () {
$application = Application::factory()->create();
$action = new DeployApplicationAction();
$deployment = $action->handle($application);
expect($deployment)->toBeInstanceOf(ApplicationDeploymentQueue::class);
expect($deployment->status)->toBe('queued');
expect($deployment->application_id)->toBe($application->id);
});
test('server validation action checks ssh connectivity', function () {
$server = Server::factory()->create([
'ip' => '192.168.1.100',
'port' => 22
]);
$action = new ValidateServerAction();
// Mock SSH connection
$this->mock(SshConnection::class, function ($mock) {
$mock->shouldReceive('connect')->andReturn(true);
$mock->shouldReceive('execute')->with('docker --version')->andReturn('Docker version 20.10.0');
});
$result = $action->handle($server);
expect($result['ssh_connection'])->toBeTrue();
expect($result['docker_installed'])->toBeTrue();
});
Feature Testing Patterns
API Testing
// Testing API endpoints
test('authenticated user can list applications', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team);
$applications = Application::factory(3)->create([
'team_id' => $team->id
]);
$response = $this->actingAs($user)
->getJson('/api/v1/applications');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'fqdn', 'status', 'created_at']
]
]);
});
test('user cannot access applications from other teams', function () {
$user = User::factory()->create();
$otherTeam = Team::factory()->create();
$application = Application::factory()->create([
'team_id' => $otherTeam->id
]);
$response = $this->actingAs($user)
->getJson("/api/v1/applications/{$application->id}");
$response->assertStatus(403);
});
Deployment Testing
// Testing deployment workflows
test('application deployment creates docker containers', function () {
$application = Application::factory()->create([
'git_repository' => 'https://github.com/laravel/laravel.git',
'git_branch' => 'main'
]);
// Mock Docker operations
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('app:latest');
$mock->shouldReceive('createContainer')->andReturn('container_id');
$mock->shouldReceive('startContainer')->andReturn(true);
});
$deployment = $application->deploy();
expect($deployment->status)->toBe('queued');
// Process the deployment job
$this->artisan('queue:work --once');
$deployment->refresh();
expect($deployment->status)->toBe('success');
});
test('failed deployment triggers rollback', function () {
$application = Application::factory()->create();
// Mock failed deployment
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andThrow(new DeploymentException('Build failed'));
});
$deployment = $application->deploy();
$this->artisan('queue:work --once');
$deployment->refresh();
expect($deployment->status)->toBe('failed');
expect($deployment->error_message)->toContain('Build failed');
});
Webhook Testing
// Testing webhook endpoints
test('github webhook triggers deployment', function () {
$application = Application::factory()->create([
'git_repository' => 'https://github.com/user/repo.git',
'git_branch' => 'main'
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => [
'clone_url' => 'https://github.com/user/repo.git'
],
'head_commit' => [
'id' => 'abc123',
'message' => 'Update application'
]
];
$response = $this->postJson("/webhooks/github/{$application->id}", $payload);
$response->assertStatus(200);
expect($application->deployments()->count())->toBe(1);
expect($application->deployments()->first()->commit_sha)->toBe('abc123');
});
test('webhook validates payload signature', function () {
$application = Application::factory()->create();
$payload = ['invalid' => 'payload'];
$response = $this->postJson("/webhooks/github/{$application->id}", $payload);
$response->assertStatus(400);
});
Browser Testing (Laravel Dusk)
End-to-End Testing
// Testing complete user workflows
test('user can create and deploy application', function () {
$user = User::factory()->create();
$server = Server::factory()->create(['team_id' => $user->currentTeam->id]);
$this->browse(function (Browser $browser) use ($user, $server) {
$browser->loginAs($user)
->visit('/applications/create')
->type('name', 'Test Application')
->type('git_repository', 'https://github.com/laravel/laravel.git')
->type('git_branch', 'main')
->select('server_id', $server->id)
->press('Create Application')
->assertPathIs('/applications/*')
->assertSee('Test Application')
->press('Deploy')
->waitForText('Deployment started', 10)
->assertSee('Deployment started');
});
});
test('user can monitor deployment logs in real-time', function () {
$user = User::factory()->create();
$application = Application::factory()->create(['team_id' => $user->currentTeam->id]);
$this->browse(function (Browser $browser) use ($user, $application) {
$browser->loginAs($user)
->visit("/applications/{$application->id}")
->press('Deploy')
->waitForText('Deployment started')
->click('@logs-tab')
->waitFor('@deployment-logs')
->assertSee('Building Docker image')
->waitForText('Deployment completed', 30);
});
});
UI Component Testing
// Testing Livewire components
test('server status component updates in real-time', function () {
$user = User::factory()->create();
$server = Server::factory()->create(['team_id' => $user->currentTeam->id]);
$this->browse(function (Browser $browser) use ($user, $server) {
$browser->loginAs($user)
->visit("/servers/{$server->id}")
->assertSee('Status: Online')
->waitFor('@server-metrics')
->assertSee('CPU Usage')
->assertSee('Memory Usage')
->assertSee('Disk Usage');
// Simulate server going offline
$server->update(['status' => 'offline']);
$browser->waitForText('Status: Offline', 5)
->assertSee('Status: Offline');
});
});
Database Testing Patterns
Migration Testing
// Testing database migrations
test('applications table has correct structure', function () {
expect(Schema::hasTable('applications'))->toBeTrue();
expect(Schema::hasColumns('applications', [
'id', 'name', 'fqdn', 'git_repository', 'git_branch',
'server_id', 'environment_id', 'created_at', 'updated_at'
]))->toBeTrue();
});
test('foreign key constraints are properly set', function () {
$application = Application::factory()->create();
expect($application->server)->toBeInstanceOf(Server::class);
expect($application->environment)->toBeInstanceOf(Environment::class);
// Test cascade deletion
$application->server->delete();
expect(Application::find($application->id))->toBeNull();
});
Factory Testing
// Testing model factories
test('application factory creates valid models', function () {
$application = Application::factory()->create();
expect($application->name)->toBeString();
expect($application->git_repository)->toStartWith('https://');
expect($application->server_id)->toBeInt();
expect($application->environment_id)->toBeInt();
});
test('application factory can create with custom attributes', function () {
$application = Application::factory()->create([
'name' => 'Custom App',
'git_branch' => 'develop'
]);
expect($application->name)->toBe('Custom App');
expect($application->git_branch)->toBe('develop');
});
Queue Testing
Job Testing
// Testing background jobs
test('deploy application job processes successfully', function () {
$application = Application::factory()->create();
$deployment = ApplicationDeploymentQueue::factory()->create([
'application_id' => $application->id,
'status' => 'queued'
]);
$job = new DeployApplicationJob($deployment);
// Mock external dependencies
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('app:latest');
$mock->shouldReceive('deployContainer')->andReturn(true);
});
$job->handle();
$deployment->refresh();
expect($deployment->status)->toBe('success');
});
test('failed job is retried with exponential backoff', function () {
$application = Application::factory()->create();
$deployment = ApplicationDeploymentQueue::factory()->create([
'application_id' => $application->id
]);
$job = new DeployApplicationJob($deployment);
// Mock failure
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andThrow(new Exception('Network error'));
});
expect(fn() => $job->handle())->toThrow(Exception::class);
// Job should be retried
expect($job->tries)->toBe(3);
expect($job->backoff())->toBe([1, 5, 10]);
});
Security Testing
Authentication Testing
// Testing authentication and authorization
test('unauthenticated users cannot access protected routes', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
test('users can only access their team resources', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$team1 = Team::factory()->create();
$team2 = Team::factory()->create();
$user1->teams()->attach($team1);
$user2->teams()->attach($team2);
$application = Application::factory()->create(['team_id' => $team1->id]);
$response = $this->actingAs($user2)
->get("/applications/{$application->id}");
$response->assertStatus(403);
});
Input Validation Testing
// Testing input validation and sanitization
test('application creation validates required fields', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/applications', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'git_repository', 'server_id']);
});
test('malicious input is properly sanitized', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/applications', [
'name' => '<script>alert("xss")</script>',
'git_repository' => 'javascript:alert("xss")',
'server_id' => 'invalid'
]);
$response->assertStatus(422);
});
Performance Testing
Load Testing
// Testing application performance under load
test('application list endpoint handles concurrent requests', function () {
$user = User::factory()->create();
$applications = Application::factory(100)->create(['team_id' => $user->currentTeam->id]);
$startTime = microtime(true);
$response = $this->actingAs($user)
->getJson('/api/v1/applications');
$endTime = microtime(true);
$responseTime = ($endTime - $startTime) * 1000; // Convert to milliseconds
$response->assertStatus(200);
expect($responseTime)->toBeLessThan(500); // Should respond within 500ms
});
Memory Usage Testing
// Testing memory efficiency
test('deployment process does not exceed memory limits', function () {
$initialMemory = memory_get_usage();
$application = Application::factory()->create();
$deployment = $application->deploy();
// Process deployment
$this->artisan('queue:work --once');
$finalMemory = memory_get_usage();
$memoryIncrease = $finalMemory - $initialMemory;
expect($memoryIncrease)->toBeLessThan(50 * 1024 * 1024); // Less than 50MB
});
Test Utilities and Helpers
Custom Assertions
// Custom test assertions
expect()->extend('toBeValidDockerCompose', function () {
$yaml = yaml_parse($this->value);
return $yaml !== false &&
isset($yaml['version']) &&
isset($yaml['services']) &&
is_array($yaml['services']);
});
expect()->extend('toHaveValidSshConnection', function () {
$server = $this->value;
try {
$connection = new SshConnection($server);
return $connection->test();
} catch (Exception $e) {
return false;
}
});
Test Traits
// Shared testing functionality
trait CreatesTestServers
{
protected function createTestServer(array $attributes = []): Server
{
return Server::factory()->create(array_merge([
'name' => 'Test Server',
'ip' => '127.0.0.1',
'port' => 22,
'team_id' => $this->user->currentTeam->id
], $attributes));
}
}
trait MocksDockerOperations
{
protected function mockDockerService(): void
{
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('test:latest');
$mock->shouldReceive('createContainer')->andReturn('container_123');
$mock->shouldReceive('startContainer')->andReturn(true);
$mock->shouldReceive('stopContainer')->andReturn(true);
});
}
}
Continuous Integration Testing
GitHub Actions Integration
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
- name: Install dependencies
run: composer install
- name: Run tests
run: ./vendor/bin/pest
Test Coverage
// Generate test coverage reports
test('application has adequate test coverage', function () {
$coverage = $this->getCoverageData();
expect($coverage['application'])->toBeGreaterThan(80);
expect($coverage['models'])->toBeGreaterThan(90);
expect($coverage['actions'])->toBeGreaterThan(85);
});