mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 17:21:04 +00:00
Merge remote-tracking branch 'origin/next' into s3-restore
Resolve merge conflicts in: - bootstrap/helpers/shared.php (kept both formatBytes, isSafeTmpPath, and formatContainerStatus functions) - database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php (added Schema::hasTable check) - database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php (added Schema::hasTable check) - resources/views/livewire/project/application/general.blade.php (formatting/whitespace) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
46
tests/Unit/Actions/Server/ValidatePrerequisitesTest.php
Normal file
46
tests/Unit/Actions/Server/ValidatePrerequisitesTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\ValidatePrerequisites;
|
||||
|
||||
/**
|
||||
* These tests verify the return structure and logic of ValidatePrerequisites.
|
||||
*
|
||||
* Note: Since instant_remote_process is a global helper function that executes
|
||||
* SSH commands, we cannot easily mock it in pure unit tests. These tests verify
|
||||
* the expected return structure and array shapes.
|
||||
*/
|
||||
it('returns array with success, missing, and found keys', function () {
|
||||
$action = new ValidatePrerequisites;
|
||||
|
||||
// We're testing the structure, not the actual SSH execution
|
||||
// The action should always return an array with these three keys
|
||||
$expectedKeys = ['success', 'missing', 'found'];
|
||||
|
||||
// This test verifies the contract of the return value
|
||||
expect(true)->toBeTrue()
|
||||
->and('ValidatePrerequisites should return array with keys: '.implode(', ', $expectedKeys))
|
||||
->toBeString();
|
||||
});
|
||||
|
||||
it('validates required commands list', function () {
|
||||
// Verify the action checks for the correct prerequisites
|
||||
$requiredCommands = ['git', 'curl', 'jq'];
|
||||
|
||||
expect($requiredCommands)->toHaveCount(3)
|
||||
->and($requiredCommands)->toContain('git')
|
||||
->and($requiredCommands)->toContain('curl')
|
||||
->and($requiredCommands)->toContain('jq');
|
||||
});
|
||||
|
||||
it('return structure has correct types', function () {
|
||||
// Verify the expected return structure types
|
||||
$expectedStructure = [
|
||||
'success' => 'boolean',
|
||||
'missing' => 'array',
|
||||
'found' => 'array',
|
||||
];
|
||||
|
||||
expect($expectedStructure['success'])->toBe('boolean')
|
||||
->and($expectedStructure['missing'])->toBe('array')
|
||||
->and($expectedStructure['found'])->toBe('array');
|
||||
});
|
||||
223
tests/Unit/AllExcludedContainersConsistencyTest.php
Normal file
223
tests/Unit/AllExcludedContainersConsistencyTest.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify consistent handling of all-excluded containers
|
||||
* across PushServerUpdateJob, GetContainersStatus, and ComplexStatusCheck.
|
||||
*
|
||||
* These tests verify the fix for issue where different code paths handled
|
||||
* all-excluded containers inconsistently:
|
||||
* - PushServerUpdateJob (Sentinel, ~30s) previously skipped updates
|
||||
* - GetContainersStatus (SSH, ~1min) previously skipped updates
|
||||
* - ComplexStatusCheck (Multi-server) correctly calculated :excluded status
|
||||
*
|
||||
* After this fix, all three paths now calculate and return :excluded status
|
||||
* consistently, preventing status drift and UI inconsistencies.
|
||||
*/
|
||||
it('ensures CalculatesExcludedStatus trait exists with required methods', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify trait has both status calculation methods
|
||||
expect($traitFile)
|
||||
->toContain('trait CalculatesExcludedStatus')
|
||||
->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
|
||||
->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
|
||||
->toContain('protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection');
|
||||
});
|
||||
|
||||
it('ensures ComplexStatusCheck uses CalculatesExcludedStatus trait', function () {
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
|
||||
// Verify trait is used
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('use App\Traits\CalculatesExcludedStatus;')
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
|
||||
// Verify it uses the trait method instead of inline code
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
|
||||
|
||||
// Verify it uses the trait helper for excluded containers
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures PushServerUpdateJob uses CalculatesExcludedStatus trait', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
|
||||
// Verify trait is used
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('use App\Traits\CalculatesExcludedStatus;')
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
|
||||
// Verify it calculates excluded status instead of skipping (old behavior: continue)
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
|
||||
// Verify it uses the trait helper for excluded containers
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures PushServerUpdateJob calculates excluded status for applications', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
|
||||
// In aggregateMultiContainerStatuses, verify the all-excluded scenario
|
||||
// calculates status and updates the application
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('if ($relevantStatuses->isEmpty()) {')
|
||||
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
|
||||
->toContain('if ($aggregatedStatus && $application->status !== $aggregatedStatus) {')
|
||||
->toContain('$application->status = $aggregatedStatus;')
|
||||
->toContain('$application->save();');
|
||||
});
|
||||
|
||||
it('ensures PushServerUpdateJob calculates excluded status for services', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
|
||||
// Count occurrences - should appear twice (once for applications, once for services)
|
||||
$calculateExcludedCount = substr_count(
|
||||
$pushServerUpdateJobFile,
|
||||
'$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);'
|
||||
);
|
||||
|
||||
expect($calculateExcludedCount)->toBe(2, 'Should calculate excluded status for both applications and services');
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus uses CalculatesExcludedStatus trait', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify trait is used
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('use App\Traits\CalculatesExcludedStatus;')
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
|
||||
// Verify it calculates excluded status instead of returning null
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
|
||||
// Verify it uses the trait helper for excluded containers
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus calculates excluded status for applications', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// In aggregateApplicationStatus, verify the all-excluded scenario returns status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('if ($relevantStatuses->isEmpty()) {')
|
||||
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus calculates excluded status for services', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// In aggregateServiceContainerStatuses, verify the all-excluded scenario updates status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
|
||||
->toContain('if ($aggregatedStatus) {')
|
||||
->toContain('$statusFromDb = $subResource->status;')
|
||||
->toContain("if (\$statusFromDb !== \$aggregatedStatus) {\n \$subResource->update(['status' => \$aggregatedStatus]);");
|
||||
});
|
||||
|
||||
it('ensures excluded status format is consistent across all paths', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Trait now delegates to ContainerStatusAggregator and uses appendExcludedSuffix helper
|
||||
expect($traitFile)
|
||||
->toContain('use App\\Services\\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('private function appendExcludedSuffix(string $status): string');
|
||||
|
||||
// Check that appendExcludedSuffix returns consistent colon format with :excluded suffix
|
||||
expect($traitFile)
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'starting:excluded';")
|
||||
->toContain("return 'exited';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded, running:unhealthy:excluded, etc.
|
||||
});
|
||||
|
||||
it('ensures all three paths check for exclude_from_hc flag consistently', function () {
|
||||
// All three should use the trait helper method
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
|
||||
// The trait method should check both exclude_from_hc and restart: no
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
expect($traitFile)
|
||||
->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);')
|
||||
->toContain('$restartPolicy = data_get($serviceConfig, \'restart\', \'always\');')
|
||||
->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {');
|
||||
});
|
||||
|
||||
it('ensures calculateExcludedStatus uses ContainerStatusAggregator', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
|
||||
expect($traitFile)
|
||||
->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
|
||||
->toContain('use App\Services\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('$aggregator->aggregateFromContainers($excludedOnly)');
|
||||
|
||||
// Check that it has appendExcludedSuffix helper for all states
|
||||
expect($traitFile)
|
||||
->toContain('private function appendExcludedSuffix(string $status): string')
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'starting:excluded';")
|
||||
->toContain("return 'exited';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded
|
||||
});
|
||||
|
||||
it('ensures calculateExcludedStatusFromStrings uses ContainerStatusAggregator', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
|
||||
expect($traitFile)
|
||||
->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
|
||||
->toContain('use App\Services\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('$aggregator->aggregateFromStrings($containerStatuses)');
|
||||
|
||||
// Check that it has appendExcludedSuffix helper for all states
|
||||
expect($traitFile)
|
||||
->toContain('private function appendExcludedSuffix(string $status): string')
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'starting:excluded';")
|
||||
->toContain("return 'exited';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded
|
||||
});
|
||||
|
||||
it('verifies no code path skips update when all containers excluded', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// These patterns should NOT exist anymore (old behavior that caused drift)
|
||||
expect($pushServerUpdateJobFile)
|
||||
->not->toContain("// If all containers are excluded, don't update status");
|
||||
|
||||
expect($getContainersStatusFile)
|
||||
->not->toContain("// If all containers are excluded, don't update status");
|
||||
|
||||
// Instead, both should calculate excluded status
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers');
|
||||
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers');
|
||||
});
|
||||
182
tests/Unit/ApplicationParserStringableTest.php
Normal file
182
tests/Unit/ApplicationParserStringableTest.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that the applicationParser function in parsers.php
|
||||
* properly converts Stringable objects to plain strings to fix strict
|
||||
* comparison and collection key lookup issues.
|
||||
*
|
||||
* Related issue: Lines 539 and 541 in parsers.php were creating Stringable
|
||||
* objects which caused:
|
||||
* - Strict comparisons (===) to fail (line 606)
|
||||
* - Collection key lookups to fail (line 615)
|
||||
*/
|
||||
it('ensures service name normalization returns plain strings not Stringable objects', function () {
|
||||
// Test the exact transformations that happen in parsers.php lines 539-541
|
||||
|
||||
// Simulate what happens at line 520
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
|
||||
$serviceName = $parsed['service_name']; // 'my-service'
|
||||
|
||||
// Line 539: $originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||
|
||||
// Line 541: $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
// Verify both are plain strings, not Stringable objects
|
||||
expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
|
||||
expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string');
|
||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
|
||||
// Verify the transformations work correctly
|
||||
expect($originalServiceName)->toBe('my-service');
|
||||
expect($serviceName)->toBe('my_service');
|
||||
});
|
||||
|
||||
it('ensures strict comparison works with normalized service names', function () {
|
||||
// This tests the fix for line 606 where strict comparison failed
|
||||
|
||||
// Simulate service name from docker-compose services array (line 604-605)
|
||||
$serviceNameKey = 'my-service';
|
||||
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
// Simulate service name from environment variable parsing (line 520, 541)
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
|
||||
$serviceName = $parsed['service_name'];
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
// Line 606: if ($transformedServiceName === $serviceName)
|
||||
// This MUST work - both should be plain strings and match
|
||||
expect($transformedServiceName === $serviceName)->toBeTrue(
|
||||
'Strict comparison should work when both are plain strings'
|
||||
);
|
||||
expect($transformedServiceName)->toBe($serviceName);
|
||||
});
|
||||
|
||||
it('ensures collection key lookup works with normalized service names', function () {
|
||||
// This tests the fix for line 615 where collection->get() failed
|
||||
|
||||
// Simulate service name normalization (line 541)
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_app-name');
|
||||
$serviceName = $parsed['service_name'];
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
// Create a collection like $domains at line 614
|
||||
$domains = collect([
|
||||
'app_name' => [
|
||||
'domain' => 'https://example.com',
|
||||
],
|
||||
]);
|
||||
|
||||
// Line 615: $domainExists = data_get($domains->get($serviceName), 'domain');
|
||||
// This MUST work - $serviceName should be a plain string 'app_name'
|
||||
$domainExists = data_get($domains->get($serviceName), 'domain');
|
||||
|
||||
expect($domainExists)->toBe('https://example.com', 'Collection lookup should find the domain');
|
||||
expect($domainExists)->not->toBeNull('Collection lookup should not return null');
|
||||
});
|
||||
|
||||
it('handles service names with dots correctly', function () {
|
||||
// Test service names with dots (e.g., 'my.service')
|
||||
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my.service');
|
||||
$serviceName = $parsed['service_name'];
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
expect(is_string($serviceName))->toBeTrue();
|
||||
expect($serviceName)->toBe('my_service');
|
||||
|
||||
// Verify it matches transformed service name from docker-compose
|
||||
$serviceNameKey = 'my.service';
|
||||
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
expect($transformedServiceName === $serviceName)->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles service names with underscores correctly', function () {
|
||||
// Test service names that already have underscores
|
||||
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service');
|
||||
$serviceName = $parsed['service_name'];
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
expect(is_string($serviceName))->toBeTrue();
|
||||
expect($serviceName)->toBe('my_service');
|
||||
});
|
||||
|
||||
it('handles mixed special characters in service names', function () {
|
||||
// Test service names with mix of dashes, dots, underscores
|
||||
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-app.service_v2');
|
||||
$serviceName = $parsed['service_name'];
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
expect(is_string($serviceName))->toBeTrue();
|
||||
expect($serviceName)->toBe('my_app_service_v2');
|
||||
|
||||
// Verify collection operations work
|
||||
$domains = collect([
|
||||
'my_app_service_v2' => ['domain' => 'https://test.com'],
|
||||
]);
|
||||
|
||||
$found = $domains->get($serviceName);
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found['domain'])->toBe('https://test.com');
|
||||
});
|
||||
|
||||
it('ensures originalServiceName conversion works for FQDN generation', function () {
|
||||
// Test line 539: $originalServiceName conversion
|
||||
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service');
|
||||
$serviceName = $parsed['service_name']; // 'my_service'
|
||||
|
||||
// Line 539: Convert underscores to dashes for FQDN generation
|
||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||
|
||||
expect(is_string($originalServiceName))->toBeTrue();
|
||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($originalServiceName)->toBe('my-service');
|
||||
|
||||
// Verify it can be used in string interpolation (line 544)
|
||||
$uuid = 'test-uuid';
|
||||
$random = "$originalServiceName-$uuid";
|
||||
expect($random)->toBe('my-service-test-uuid');
|
||||
});
|
||||
|
||||
it('prevents duplicate domain entries in collection', function () {
|
||||
// This tests that using plain strings prevents duplicate entries
|
||||
// (one with Stringable key, one with string key)
|
||||
|
||||
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_webapp');
|
||||
$serviceName = $parsed['service_name'];
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
$domains = collect();
|
||||
|
||||
// Add domain entry (line 621)
|
||||
$domains->put($serviceName, [
|
||||
'domain' => 'https://webapp.com',
|
||||
]);
|
||||
|
||||
// Try to lookup the domain (line 615)
|
||||
$found = $domains->get($serviceName);
|
||||
|
||||
expect($found)->not->toBeNull('Should find the domain we just added');
|
||||
expect($found['domain'])->toBe('https://webapp.com');
|
||||
|
||||
// Verify only one entry exists
|
||||
expect($domains->count())->toBe(1);
|
||||
expect($domains->has($serviceName))->toBeTrue();
|
||||
});
|
||||
|
||||
it('verifies parsers.php has the ->value() calls', function () {
|
||||
// Ensure the fix is actually in the code
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Line 539: Check originalServiceName conversion
|
||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
|
||||
|
||||
// Line 541: Check serviceName normalization
|
||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()");
|
||||
});
|
||||
190
tests/Unit/ApplicationServiceEnvironmentVariablesTest.php
Normal file
190
tests/Unit/ApplicationServiceEnvironmentVariablesTest.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that Applications using Docker Compose handle
|
||||
* SERVICE_URL and SERVICE_FQDN environment variables correctly.
|
||||
*
|
||||
* This ensures consistency with Service behavior where BOTH URL and FQDN
|
||||
* pairs are always created together, regardless of which one is in the template.
|
||||
*/
|
||||
it('ensures parsers.php creates both URL and FQDN pairs for applications', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that the fix is in place
|
||||
expect($parsersFile)->toContain('ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs');
|
||||
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||
expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||
});
|
||||
|
||||
it('extracts service name with case preservation for applications', function () {
|
||||
// Simulate what the parser does for applications
|
||||
$templateVar = 'SERVICE_URL_WORDPRESS';
|
||||
|
||||
$strKey = str($templateVar);
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
|
||||
if ($parsed['has_port']) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
} else {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
}
|
||||
|
||||
expect($serviceName)->toBe('WORDPRESS');
|
||||
expect($parsed['service_name'])->toBe('wordpress'); // lowercase for internal use
|
||||
});
|
||||
|
||||
it('handles port-specific application service variables', function () {
|
||||
$templateVar = 'SERVICE_URL_APP_3000';
|
||||
|
||||
$strKey = str($templateVar);
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
|
||||
if ($parsed['has_port']) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
} else {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
}
|
||||
|
||||
expect($serviceName)->toBe('APP');
|
||||
expect($parsed['port'])->toBe('3000');
|
||||
expect($parsed['has_port'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('application should create 2 base variables when template has base SERVICE_URL', function () {
|
||||
// Given: Template defines SERVICE_URL_WP
|
||||
// Then: Should create both:
|
||||
// 1. SERVICE_URL_WP
|
||||
// 2. SERVICE_FQDN_WP
|
||||
|
||||
$templateVar = 'SERVICE_URL_WP';
|
||||
$strKey = str($templateVar);
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
|
||||
$urlKey = "SERVICE_URL_{$serviceName}";
|
||||
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||
|
||||
expect($urlKey)->toBe('SERVICE_URL_WP');
|
||||
expect($fqdnKey)->toBe('SERVICE_FQDN_WP');
|
||||
expect($parsed['has_port'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('application should create 4 variables when template has port-specific SERVICE_URL', function () {
|
||||
// Given: Template defines SERVICE_URL_APP_8080
|
||||
// Then: Should create all 4:
|
||||
// 1. SERVICE_URL_APP (base)
|
||||
// 2. SERVICE_FQDN_APP (base)
|
||||
// 3. SERVICE_URL_APP_8080 (port-specific)
|
||||
// 4. SERVICE_FQDN_APP_8080 (port-specific)
|
||||
|
||||
$templateVar = 'SERVICE_URL_APP_8080';
|
||||
$strKey = str($templateVar);
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
$port = $parsed['port'];
|
||||
|
||||
$baseUrlKey = "SERVICE_URL_{$serviceName}";
|
||||
$baseFqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||
$portUrlKey = "SERVICE_URL_{$serviceName}_{$port}";
|
||||
$portFqdnKey = "SERVICE_FQDN_{$serviceName}_{$port}";
|
||||
|
||||
expect($baseUrlKey)->toBe('SERVICE_URL_APP');
|
||||
expect($baseFqdnKey)->toBe('SERVICE_FQDN_APP');
|
||||
expect($portUrlKey)->toBe('SERVICE_URL_APP_8080');
|
||||
expect($portFqdnKey)->toBe('SERVICE_FQDN_APP_8080');
|
||||
});
|
||||
|
||||
it('application should create pairs when template has only SERVICE_FQDN', function () {
|
||||
// Given: Template defines SERVICE_FQDN_DB
|
||||
// Then: Should create both:
|
||||
// 1. SERVICE_FQDN_DB
|
||||
// 2. SERVICE_URL_DB (created automatically)
|
||||
|
||||
$templateVar = 'SERVICE_FQDN_DB';
|
||||
$strKey = str($templateVar);
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||
|
||||
$urlKey = "SERVICE_URL_{$serviceName}";
|
||||
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||
|
||||
expect($fqdnKey)->toBe('SERVICE_FQDN_DB');
|
||||
expect($urlKey)->toBe('SERVICE_URL_DB');
|
||||
expect($parsed['has_port'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('verifies application deletion nulls both URL and FQDN', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that deletion handles both types
|
||||
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceNameFormatted}');
|
||||
expect($parsersFile)->toContain('SERVICE_URL_{$serviceNameFormatted}');
|
||||
|
||||
// Both should be set to null when domain is empty
|
||||
expect($parsersFile)->toContain('\'value\' => null');
|
||||
});
|
||||
|
||||
it('handles abbreviated service names in applications', function () {
|
||||
// Applications can have abbreviated names in compose files just like services
|
||||
$templateVar = 'SERVICE_URL_WP'; // WordPress abbreviated
|
||||
|
||||
$strKey = str($templateVar);
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
|
||||
expect($serviceName)->toBe('WP');
|
||||
expect($serviceName)->not->toBe('WORDPRESS');
|
||||
});
|
||||
|
||||
it('application compose parsing creates pairs regardless of template type', function () {
|
||||
// Test that whether template uses SERVICE_URL or SERVICE_FQDN,
|
||||
// the parser creates both
|
||||
|
||||
$testCases = [
|
||||
'SERVICE_URL_APP' => ['base' => 'APP', 'port' => null],
|
||||
'SERVICE_FQDN_APP' => ['base' => 'APP', 'port' => null],
|
||||
'SERVICE_URL_APP_3000' => ['base' => 'APP', 'port' => '3000'],
|
||||
'SERVICE_FQDN_APP_3000' => ['base' => 'APP', 'port' => '3000'],
|
||||
];
|
||||
|
||||
foreach ($testCases as $templateVar => $expected) {
|
||||
$strKey = str($templateVar);
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
|
||||
if ($parsed['has_port']) {
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
} else {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||
}
|
||||
} else {
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
} else {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||
}
|
||||
}
|
||||
|
||||
expect($serviceName)->toBe($expected['base'], "Failed for $templateVar");
|
||||
expect($parsed['port'])->toBe($expected['port'], "Port mismatch for $templateVar");
|
||||
}
|
||||
});
|
||||
|
||||
it('verifies both application and service use same logic', function () {
|
||||
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Both should have the same pattern of creating pairs
|
||||
expect($servicesFile)->toContain('ALWAYS create base pair');
|
||||
expect($parsersFile)->toContain('ALWAYS create BOTH');
|
||||
|
||||
// Both should create SERVICE_URL_
|
||||
expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||
expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||
|
||||
// Both should create SERVICE_FQDN_
|
||||
expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||
});
|
||||
342
tests/Unit/ContainerHealthStatusTest.php
Normal file
342
tests/Unit/ContainerHealthStatusTest.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* Unit tests to verify that containers without health checks are not
|
||||
* incorrectly marked as unhealthy.
|
||||
*
|
||||
* This tests the fix for the issue where defaulting missing health status
|
||||
* to 'unhealthy' would treat containers without healthchecks as unhealthy.
|
||||
*
|
||||
* The fix removes the 'unhealthy' default and only checks health status
|
||||
* when it explicitly exists and equals 'unhealthy'.
|
||||
*/
|
||||
it('does not mark containers as unhealthy when health status is missing', function () {
|
||||
// Mock an application with a server
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$server = Mockery::mock('App\Models\Server')->makePartial();
|
||||
$destination = Mockery::mock('App\Models\StandaloneDocker')->makePartial();
|
||||
|
||||
$destination->shouldReceive('getAttribute')
|
||||
->with('server')
|
||||
->andReturn($server);
|
||||
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('destination')
|
||||
->andReturn($destination);
|
||||
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('additional_servers')
|
||||
->andReturn(collect());
|
||||
|
||||
$server->shouldReceive('getAttribute')
|
||||
->with('id')
|
||||
->andReturn(1);
|
||||
|
||||
$server->shouldReceive('isFunctional')
|
||||
->andReturn(true);
|
||||
|
||||
// Create a container without health check (State.Health.Status is null)
|
||||
$containerWithoutHealthCheck = [
|
||||
'Config' => [
|
||||
'Labels' => [
|
||||
'com.docker.compose.service' => 'web',
|
||||
],
|
||||
],
|
||||
'State' => [
|
||||
'Status' => 'running',
|
||||
// Note: State.Health.Status is intentionally missing
|
||||
],
|
||||
];
|
||||
|
||||
// Mock the remote process to return our container
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('id')
|
||||
->andReturn(123);
|
||||
|
||||
// We can't easily test the private aggregateContainerStatuses method directly,
|
||||
// but we can verify that the code doesn't default to 'unhealthy'
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify the fix: health status should not default to 'unhealthy'
|
||||
expect($aggregatorFile)
|
||||
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
|
||||
->toContain("data_get(\$container, 'State.Health.Status')");
|
||||
|
||||
// Verify the health check logic
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($health === \'unhealthy\') {');
|
||||
});
|
||||
|
||||
it('only marks containers as unhealthy when health status explicitly equals unhealthy', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify the service checks for explicit 'unhealthy' status
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($health === \'unhealthy\') {')
|
||||
->toContain('$hasUnhealthy = true;');
|
||||
});
|
||||
|
||||
it('handles missing health status correctly in GetContainersStatus', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify health status doesn't default to 'unhealthy'
|
||||
expect($getContainersStatusFile)
|
||||
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
|
||||
->toContain("data_get(\$container, 'State.Health.Status')");
|
||||
|
||||
// Verify it uses 'unknown' when health status is missing (now using colon format)
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$healthSuffix = $containerHealth ?? \'unknown\';')
|
||||
->toContain('ContainerStatusAggregator'); // Uses the service
|
||||
});
|
||||
|
||||
it('treats containers with running status and no healthcheck as not unhealthy', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// The logic should be:
|
||||
// 1. Get health status (may be null)
|
||||
// 2. Only mark as unhealthy if health status EXISTS and equals 'unhealthy'
|
||||
// 3. Don't mark as unhealthy if health status is null/missing
|
||||
|
||||
// Verify the condition explicitly checks for unhealthy
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($health === \'unhealthy\')');
|
||||
|
||||
// Verify this check is done for running containers
|
||||
expect($aggregatorFile)
|
||||
->toContain('} elseif ($state === \'running\') {')
|
||||
->toContain('$hasRunning = true;');
|
||||
});
|
||||
|
||||
it('tracks unknown health state in aggregation', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify that $hasUnknown tracking variable exists in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasUnknown = false;');
|
||||
|
||||
// Verify that unknown state is detected in status parsing
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('unknown')")
|
||||
->toContain('$hasUnknown = true;');
|
||||
});
|
||||
|
||||
it('preserves unknown health state in aggregated status with correct priority', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service (using colon format)
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify three-way priority in aggregation:
|
||||
// 1. Unhealthy (highest priority)
|
||||
// 2. Unknown (medium priority)
|
||||
// 3. Healthy (only when all explicitly healthy)
|
||||
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasUnhealthy) {')
|
||||
->toContain("return 'running:unhealthy';")
|
||||
->toContain('} elseif ($hasUnknown) {')
|
||||
->toContain("return 'running:unknown';")
|
||||
->toContain('} else {')
|
||||
->toContain("return 'running:healthy';");
|
||||
});
|
||||
|
||||
it('tracks unknown health state in ContainerStatusAggregator for all applications', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify that $hasUnknown tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasUnknown = false;');
|
||||
|
||||
// Verify that unknown state is detected when health is null or 'starting'
|
||||
expect($aggregatorFile)
|
||||
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
|
||||
->toContain('$hasUnknown = true;');
|
||||
});
|
||||
|
||||
it('preserves unknown health state in ContainerStatusAggregator aggregated status', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify three-way priority for running containers in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasUnhealthy) {')
|
||||
->toContain("return 'running:unhealthy';")
|
||||
->toContain('} elseif ($hasUnknown) {')
|
||||
->toContain("return 'running:unknown';")
|
||||
->toContain('} else {')
|
||||
->toContain("return 'running:healthy';");
|
||||
|
||||
// Verify ComplexStatusCheck delegates to the service
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('use App\\Services\\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('$aggregator->aggregateFromContainers($relevantContainers);');
|
||||
});
|
||||
|
||||
it('preserves unknown health state in Service model aggregation', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Verify unknown is handled correctly
|
||||
expect($serviceFile)
|
||||
->toContain("} elseif (\$health->value() === 'unknown') {")
|
||||
->toContain("if (\$aggregateHealth !== 'unhealthy') {")
|
||||
->toContain("\$aggregateHealth = 'unknown';");
|
||||
|
||||
// The pattern should appear at least once (Service model has different aggregation logic than ContainerStatusAggregator)
|
||||
$unknownCount = substr_count($serviceFile, "} elseif (\$health->value() === 'unknown') {");
|
||||
expect($unknownCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles starting state (created/starting) in GetContainersStatus', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasStarting = false;');
|
||||
|
||||
// Verify detection for created/starting states
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('created') || str(\$status)->contains('starting')")
|
||||
->toContain('$hasStarting = true;');
|
||||
|
||||
// Verify aggregation returns starting status (colon format)
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasStarting) {')
|
||||
->toContain("return 'starting:unknown';");
|
||||
});
|
||||
|
||||
it('handles paused state in GetContainersStatus', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasPaused = false;');
|
||||
|
||||
// Verify detection for paused state
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('paused')")
|
||||
->toContain('$hasPaused = true;');
|
||||
|
||||
// Verify aggregation returns paused status (colon format)
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasPaused) {')
|
||||
->toContain("return 'paused:unknown';");
|
||||
});
|
||||
|
||||
it('handles dead/removing states in GetContainersStatus', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasDead = false;');
|
||||
|
||||
// Verify detection for dead/removing states
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('dead') || str(\$status)->contains('removing')")
|
||||
->toContain('$hasDead = true;');
|
||||
|
||||
// Verify aggregation returns degraded status (colon format)
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasDead) {')
|
||||
->toContain("return 'degraded:unhealthy';");
|
||||
});
|
||||
|
||||
it('handles edge case states in ContainerStatusAggregator for all containers', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variables exist in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasStarting = false;')
|
||||
->toContain('$hasPaused = false;')
|
||||
->toContain('$hasDead = false;');
|
||||
|
||||
// Verify detection for created/starting
|
||||
expect($aggregatorFile)
|
||||
->toContain("} elseif (\$state === 'created' || \$state === 'starting') {")
|
||||
->toContain('$hasStarting = true;');
|
||||
|
||||
// Verify detection for paused
|
||||
expect($aggregatorFile)
|
||||
->toContain("} elseif (\$state === 'paused') {")
|
||||
->toContain('$hasPaused = true;');
|
||||
|
||||
// Verify detection for dead/removing
|
||||
expect($aggregatorFile)
|
||||
->toContain("} elseif (\$state === 'dead' || \$state === 'removing') {")
|
||||
->toContain('$hasDead = true;');
|
||||
});
|
||||
|
||||
it('handles edge case states in ContainerStatusAggregator aggregation', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify aggregation logic for edge cases in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasDead) {')
|
||||
->toContain("return 'degraded:unhealthy';")
|
||||
->toContain('if ($hasPaused) {')
|
||||
->toContain("return 'paused:unknown';")
|
||||
->toContain('if ($hasStarting) {')
|
||||
->toContain("return 'starting:unknown';");
|
||||
});
|
||||
|
||||
it('handles edge case states in Service model', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Check for created/starting handling pattern
|
||||
$createdStartingCount = substr_count($serviceFile, "\$status->startsWith('created') || \$status->startsWith('starting')");
|
||||
expect($createdStartingCount)->toBeGreaterThan(0, 'created/starting handling should exist');
|
||||
|
||||
// Check for paused handling pattern
|
||||
$pausedCount = substr_count($serviceFile, "\$status->startsWith('paused')");
|
||||
expect($pausedCount)->toBeGreaterThan(0, 'paused handling should exist');
|
||||
|
||||
// Check for dead/removing handling pattern
|
||||
$deadRemovingCount = substr_count($serviceFile, "\$status->startsWith('dead') || \$status->startsWith('removing')");
|
||||
expect($deadRemovingCount)->toBeGreaterThan(0, 'dead/removing handling should exist');
|
||||
});
|
||||
|
||||
it('appends :excluded suffix to excluded container statuses in GetContainersStatus', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify that we use the trait for calculating excluded status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('CalculatesExcludedStatus');
|
||||
|
||||
// Verify that we use the trait to calculate excluded status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
});
|
||||
|
||||
it('skips containers with :excluded suffix in Service model non-excluded sections', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Verify that we have exclude_from_status field handling
|
||||
expect($serviceFile)
|
||||
->toContain('exclude_from_status');
|
||||
});
|
||||
|
||||
it('processes containers with :excluded suffix in Service model excluded sections', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Verify that we handle excluded status
|
||||
expect($serviceFile)
|
||||
->toContain(':excluded')
|
||||
->toContain('exclude_from_status');
|
||||
});
|
||||
|
||||
it('treats containers with starting health status as unknown in ContainerStatusAggregator', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify that 'starting' health status is treated the same as null (unknown)
|
||||
// During Docker health check grace period, the health status is 'starting'
|
||||
// This should be treated as 'unknown' rather than 'healthy'
|
||||
expect($aggregatorFile)
|
||||
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
|
||||
->toContain('$hasUnknown = true;');
|
||||
});
|
||||
540
tests/Unit/ContainerStatusAggregatorTest.php
Normal file
540
tests/Unit/ContainerStatusAggregatorTest.php
Normal file
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->aggregator = new ContainerStatusAggregator;
|
||||
});
|
||||
|
||||
describe('aggregateFromStrings', function () {
|
||||
test('returns exited for empty collection', function () {
|
||||
$result = $this->aggregator->aggregateFromStrings(collect());
|
||||
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns running:healthy for single healthy running container', function () {
|
||||
$statuses = collect(['running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('returns running:unhealthy for single unhealthy running container', function () {
|
||||
$statuses = collect(['running:unhealthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unknown for single running container with unknown health', function () {
|
||||
$statuses = collect(['running:unknown']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for restarting container', function () {
|
||||
$statuses = collect(['restarting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for mixed running and exited containers', function () {
|
||||
$statuses = collect(['running:healthy', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unhealthy when one of multiple running containers is unhealthy', function () {
|
||||
$statuses = collect(['running:healthy', 'running:unhealthy', 'running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unknown when running containers have unknown health', function () {
|
||||
$statuses = collect(['running:unknown', 'running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns exited for exited containers without restart count', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for dead container', function () {
|
||||
$statuses = collect(['dead']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for removing container', function () {
|
||||
$statuses = collect(['removing']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns paused:unknown for paused container', function () {
|
||||
$statuses = collect(['paused']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for starting container', function () {
|
||||
$statuses = collect(['starting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for created container', function () {
|
||||
$statuses = collect(['created']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('handles parentheses format input (backward compatibility)', function () {
|
||||
$statuses = collect(['running (healthy)', 'running (unhealthy)']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('handles mixed colon and parentheses formats', function () {
|
||||
$statuses = collect(['running:healthy', 'running (unhealthy)', 'running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes restarting over all other states', function () {
|
||||
$statuses = collect(['restarting', 'running:healthy', 'paused', 'starting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes crash loop over running containers', function () {
|
||||
$statuses = collect(['exited', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes mixed state over healthy running', function () {
|
||||
$statuses = collect(['running:healthy', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes running over paused/starting/exited', function () {
|
||||
$statuses = collect(['running:healthy', 'starting', 'paused']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('prioritizes dead over paused/starting/exited', function () {
|
||||
$statuses = collect(['dead', 'paused', 'starting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes paused over starting/exited', function () {
|
||||
$statuses = collect(['paused', 'starting', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
test('prioritizes starting over exited', function () {
|
||||
$statuses = collect(['starting', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateFromContainers', function () {
|
||||
test('returns exited for empty collection', function () {
|
||||
$result = $this->aggregator->aggregateFromContainers(collect());
|
||||
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns running:healthy for single healthy running container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'healthy'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('returns running:unhealthy for single unhealthy running container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'unhealthy'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unknown for running container without health check', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for restarting container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'restarting',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for mixed running and exited containers', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'healthy'],
|
||||
],
|
||||
],
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'exited',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'exited',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns exited for exited containers without restart count', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'exited',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for dead container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'dead',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns paused:unknown for paused container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'paused',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for starting container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'starting',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for created container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'created',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('handles multiple containers with various states', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'healthy'],
|
||||
],
|
||||
],
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'unhealthy'],
|
||||
],
|
||||
],
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('state priority enforcement', function () {
|
||||
test('restarting has highest priority', function () {
|
||||
$statuses = collect([
|
||||
'restarting',
|
||||
'running:healthy',
|
||||
'dead',
|
||||
'paused',
|
||||
'starting',
|
||||
'exited',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('crash loop has second highest priority', function () {
|
||||
$statuses = collect([
|
||||
'exited',
|
||||
'running:healthy',
|
||||
'paused',
|
||||
'starting',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('mixed state (running + exited) has third priority', function () {
|
||||
$statuses = collect([
|
||||
'running:healthy',
|
||||
'exited',
|
||||
'paused',
|
||||
'starting',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('running:unhealthy has priority over running:unknown', function () {
|
||||
$statuses = collect([
|
||||
'running:unknown',
|
||||
'running:unhealthy',
|
||||
'running:healthy',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('running:unknown has priority over running:healthy', function () {
|
||||
$statuses = collect([
|
||||
'running:unknown',
|
||||
'running:healthy',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxRestartCount validation', function () {
|
||||
test('negative maxRestartCount is corrected to 0 in aggregateFromStrings', function () {
|
||||
// Mock the Log facade to avoid "facade root not set" error in unit tests
|
||||
Log::shouldReceive('warning')->once();
|
||||
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// With negative value, should be treated as 0 (no restarts)
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: -5);
|
||||
|
||||
// Should return exited (not degraded) since corrected to 0
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('negative maxRestartCount is corrected to 0 in aggregateFromContainers', function () {
|
||||
// Mock the Log facade to avoid "facade root not set" error in unit tests
|
||||
Log::shouldReceive('warning')->once();
|
||||
|
||||
$containers = collect([
|
||||
[
|
||||
'State' => [
|
||||
'Status' => 'exited',
|
||||
'ExitCode' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// With negative value, should be treated as 0 (no restarts)
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: -10);
|
||||
|
||||
// Should return exited (not degraded) since corrected to 0
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('zero maxRestartCount works correctly', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
// Zero is valid default - no crash loop detection
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
|
||||
test('positive maxRestartCount works correctly', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
// Positive value enables crash loop detection
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('crash loop detection still functions after validation', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// Test with various positive restart counts
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
|
||||
->toBe('degraded:unhealthy');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
|
||||
->toBe('degraded:unhealthy');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
|
||||
->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('default maxRestartCount parameter works', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// Call without specifying maxRestartCount (should default to 0)
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
});
|
||||
151
tests/Unit/ExcludeFromHealthCheckTest.php
Normal file
151
tests/Unit/ExcludeFromHealthCheckTest.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that applications and services with all containers
|
||||
* excluded from health checks (exclude_from_hc: true) show correct status.
|
||||
*
|
||||
* These tests verify the fix for the issue where services with all containers
|
||||
* excluded would show incorrect status, causing broken UI state.
|
||||
*
|
||||
* The fix now returns status with :excluded suffix to show real container state
|
||||
* while indicating monitoring is disabled (e.g., "running:excluded").
|
||||
*/
|
||||
it('ensures ComplexStatusCheck returns excluded status when all containers excluded', function () {
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
|
||||
// Check that when all containers are excluded, ComplexStatusCheck uses the trait
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('// but mark it with :excluded to indicate monitoring is disabled')
|
||||
->toContain('if ($relevantContainers->isEmpty()) {')
|
||||
->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
|
||||
|
||||
// Check that the trait uses ContainerStatusAggregator and appends :excluded suffix
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
expect($traitFile)
|
||||
->toContain('ContainerStatusAggregator')
|
||||
->toContain('appendExcludedSuffix')
|
||||
->toContain('$aggregator->aggregateFromContainers($excludedOnly)')
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'exited';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded
|
||||
});
|
||||
|
||||
it('ensures Service model returns excluded status when all services excluded', function () {
|
||||
$serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Check that when all services are excluded from status checks,
|
||||
// the Service model calculates real status and returns it with :excluded suffix
|
||||
expect($serviceModelFile)
|
||||
->toContain('exclude_from_status')
|
||||
->toContain(':excluded')
|
||||
->toContain('CalculatesExcludedStatus');
|
||||
});
|
||||
|
||||
it('ensures Service model returns unknown:unknown:excluded when no containers exist', function () {
|
||||
$serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Check that when a service has no applications or databases at all,
|
||||
// the Service model returns 'unknown:unknown:excluded' instead of 'exited'
|
||||
// This prevents misleading status display when containers don't exist
|
||||
expect($serviceModelFile)
|
||||
->toContain('// If no status was calculated at all (no containers exist), return unknown')
|
||||
->toContain('if ($excludedStatus === null && $excludedHealth === null) {')
|
||||
->toContain("return 'unknown:unknown:excluded';");
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus calculates excluded status when all containers excluded', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Check that when all containers are excluded, the aggregateApplicationStatus
|
||||
// method calculates and returns status with :excluded suffix
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('if ($relevantStatuses->isEmpty()) {')
|
||||
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
});
|
||||
|
||||
it('ensures exclude_from_hc flag is properly checked in ComplexStatusCheck', function () {
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
|
||||
// Verify that exclude_from_hc is parsed using trait helper
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures exclude_from_hc flag is properly checked in GetContainersStatus', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify that exclude_from_hc is parsed using trait helper
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures UI displays excluded status correctly in status component', function () {
|
||||
$servicesStatusFile = file_get_contents(__DIR__.'/../../resources/views/components/status/services.blade.php');
|
||||
|
||||
// Verify that the status component uses formatContainerStatus helper to display status
|
||||
expect($servicesStatusFile)
|
||||
->toContain('formatContainerStatus($complexStatus)');
|
||||
});
|
||||
|
||||
it('ensures UI handles excluded status in service heading buttons', function () {
|
||||
$headingFile = file_get_contents(__DIR__.'/../../resources/views/livewire/project/service/heading.blade.php');
|
||||
|
||||
// Verify that the heading properly handles running/degraded/exited status with :excluded suffix
|
||||
// The logic should use contains() to match the base status (running, degraded, exited)
|
||||
// which will work for both regular statuses and :excluded suffixed ones
|
||||
expect($headingFile)
|
||||
->toContain('str($service->status)->contains(\'running\')')
|
||||
->toContain('str($service->status)->contains(\'degraded\')')
|
||||
->toContain('str($service->status)->contains(\'exited\')');
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for YAML validation in CalculatesExcludedStatus trait
|
||||
*/
|
||||
it('ensures YAML validation has proper exception handling for parse errors', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that ParseException is imported and caught separately from generic Exception
|
||||
expect($traitFile)
|
||||
->toContain('use Symfony\Component\Yaml\Exception\ParseException')
|
||||
->toContain('use Illuminate\Support\Facades\Log')
|
||||
->toContain('} catch (ParseException $e) {')
|
||||
->toContain('} catch (\Exception $e) {');
|
||||
});
|
||||
|
||||
it('ensures YAML validation logs parse errors with context', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that parse errors are logged with useful context (error message, line, snippet)
|
||||
expect($traitFile)
|
||||
->toContain('Log::warning(\'Failed to parse Docker Compose YAML for health check exclusions\'')
|
||||
->toContain('\'error\' => $e->getMessage()')
|
||||
->toContain('\'line\' => $e->getParsedLine()')
|
||||
->toContain('\'snippet\' => $e->getSnippet()');
|
||||
});
|
||||
|
||||
it('ensures YAML validation logs unexpected errors', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that unexpected errors are logged with error level
|
||||
expect($traitFile)
|
||||
->toContain('Log::error(\'Unexpected error parsing Docker Compose YAML\'')
|
||||
->toContain('\'trace\' => $e->getTraceAsString()');
|
||||
});
|
||||
|
||||
it('ensures YAML validation checks structure after parsing', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that parsed result is validated to be an array
|
||||
expect($traitFile)
|
||||
->toContain('if (! is_array($dockerCompose)) {')
|
||||
->toContain('Log::warning(\'Docker Compose YAML did not parse to array\'');
|
||||
|
||||
// Verify that services is validated to be an array
|
||||
expect($traitFile)
|
||||
->toContain('if (! is_array($services)) {')
|
||||
->toContain('Log::warning(\'Docker Compose services is not an array\'');
|
||||
});
|
||||
201
tests/Unit/FormatContainerStatusTest.php
Normal file
201
tests/Unit/FormatContainerStatusTest.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
describe('formatContainerStatus helper', function () {
|
||||
describe('colon-delimited format parsing', function () {
|
||||
it('transforms running:healthy to Running (healthy)', function () {
|
||||
$result = formatContainerStatus('running:healthy');
|
||||
|
||||
expect($result)->toBe('Running (healthy)');
|
||||
});
|
||||
|
||||
it('transforms running:unhealthy to Running (unhealthy)', function () {
|
||||
$result = formatContainerStatus('running:unhealthy');
|
||||
|
||||
expect($result)->toBe('Running (unhealthy)');
|
||||
});
|
||||
|
||||
it('transforms exited:0 to Exited (0)', function () {
|
||||
$result = formatContainerStatus('exited:0');
|
||||
|
||||
expect($result)->toBe('Exited (0)');
|
||||
});
|
||||
|
||||
it('transforms restarting:starting to Restarting (starting)', function () {
|
||||
$result = formatContainerStatus('restarting:starting');
|
||||
|
||||
expect($result)->toBe('Restarting (starting)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('excluded suffix handling', function () {
|
||||
it('transforms running:unhealthy:excluded to Running (unhealthy, excluded)', function () {
|
||||
$result = formatContainerStatus('running:unhealthy:excluded');
|
||||
|
||||
expect($result)->toBe('Running (unhealthy, excluded)');
|
||||
});
|
||||
|
||||
it('transforms running:healthy:excluded to Running (healthy, excluded)', function () {
|
||||
$result = formatContainerStatus('running:healthy:excluded');
|
||||
|
||||
expect($result)->toBe('Running (healthy, excluded)');
|
||||
});
|
||||
|
||||
it('transforms exited:excluded to Exited (excluded)', function () {
|
||||
$result = formatContainerStatus('exited:excluded');
|
||||
|
||||
expect($result)->toBe('Exited (excluded)');
|
||||
});
|
||||
|
||||
it('transforms stopped:excluded to Stopped (excluded)', function () {
|
||||
$result = formatContainerStatus('stopped:excluded');
|
||||
|
||||
expect($result)->toBe('Stopped (excluded)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('simple status format', function () {
|
||||
it('transforms running to Running', function () {
|
||||
$result = formatContainerStatus('running');
|
||||
|
||||
expect($result)->toBe('Running');
|
||||
});
|
||||
|
||||
it('transforms exited to Exited', function () {
|
||||
$result = formatContainerStatus('exited');
|
||||
|
||||
expect($result)->toBe('Exited');
|
||||
});
|
||||
|
||||
it('transforms stopped to Stopped', function () {
|
||||
$result = formatContainerStatus('stopped');
|
||||
|
||||
expect($result)->toBe('Stopped');
|
||||
});
|
||||
|
||||
it('transforms restarting to Restarting', function () {
|
||||
$result = formatContainerStatus('restarting');
|
||||
|
||||
expect($result)->toBe('Restarting');
|
||||
});
|
||||
|
||||
it('transforms degraded to Degraded', function () {
|
||||
$result = formatContainerStatus('degraded');
|
||||
|
||||
expect($result)->toBe('Degraded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy status preservation', function () {
|
||||
it('preserves Proxy:running without parsing colons', function () {
|
||||
$result = formatContainerStatus('Proxy:running');
|
||||
|
||||
expect($result)->toBe('Proxy:running');
|
||||
});
|
||||
|
||||
it('preserves Proxy:exited without parsing colons', function () {
|
||||
$result = formatContainerStatus('Proxy:exited');
|
||||
|
||||
expect($result)->toBe('Proxy:exited');
|
||||
});
|
||||
|
||||
it('preserves Proxy:healthy without parsing colons', function () {
|
||||
$result = formatContainerStatus('Proxy:healthy');
|
||||
|
||||
expect($result)->toBe('Proxy:healthy');
|
||||
});
|
||||
|
||||
it('applies headline formatting to Proxy statuses', function () {
|
||||
$result = formatContainerStatus('proxy:running');
|
||||
|
||||
expect($result)->toBe('Proxy (running)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('headline transformation', function () {
|
||||
it('applies headline to simple lowercase status', function () {
|
||||
$result = formatContainerStatus('running');
|
||||
|
||||
expect($result)->toBe('Running');
|
||||
});
|
||||
|
||||
it('applies headline to uppercase status', function () {
|
||||
// headline() adds spaces between capital letters
|
||||
$result = formatContainerStatus('RUNNING');
|
||||
|
||||
expect($result)->toBe('R U N N I N G');
|
||||
});
|
||||
|
||||
it('applies headline to mixed case status', function () {
|
||||
// headline() adds spaces between capital letters
|
||||
$result = formatContainerStatus('RuNnInG');
|
||||
|
||||
expect($result)->toBe('Ru Nn In G');
|
||||
});
|
||||
|
||||
it('applies headline to first part of colon format', function () {
|
||||
// headline() adds spaces between capital letters
|
||||
$result = formatContainerStatus('RUNNING:healthy');
|
||||
|
||||
expect($result)->toBe('R U N N I N G (healthy)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', function () {
|
||||
it('handles empty string gracefully', function () {
|
||||
$result = formatContainerStatus('');
|
||||
|
||||
expect($result)->toBe('');
|
||||
});
|
||||
|
||||
it('handles multiple colons beyond expected format', function () {
|
||||
// Only first two parts should be used (or three with :excluded)
|
||||
$result = formatContainerStatus('running:healthy:extra:data');
|
||||
|
||||
expect($result)->toBe('Running (healthy)');
|
||||
});
|
||||
|
||||
it('handles status with spaces in health part', function () {
|
||||
$result = formatContainerStatus('running:health check failed');
|
||||
|
||||
expect($result)->toBe('Running (health check failed)');
|
||||
});
|
||||
|
||||
it('handles single colon with empty second part', function () {
|
||||
$result = formatContainerStatus('running:');
|
||||
|
||||
expect($result)->toBe('Running ()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', function () {
|
||||
it('handles typical running healthy container', function () {
|
||||
$result = formatContainerStatus('running:healthy');
|
||||
|
||||
expect($result)->toBe('Running (healthy)');
|
||||
});
|
||||
|
||||
it('handles degraded container with health issues', function () {
|
||||
$result = formatContainerStatus('degraded:unhealthy');
|
||||
|
||||
expect($result)->toBe('Degraded (unhealthy)');
|
||||
});
|
||||
|
||||
it('handles excluded unhealthy container', function () {
|
||||
$result = formatContainerStatus('running:unhealthy:excluded');
|
||||
|
||||
expect($result)->toBe('Running (unhealthy, excluded)');
|
||||
});
|
||||
|
||||
it('handles proxy container status', function () {
|
||||
$result = formatContainerStatus('Proxy:running');
|
||||
|
||||
expect($result)->toBe('Proxy:running');
|
||||
});
|
||||
|
||||
it('handles stopped container', function () {
|
||||
$result = formatContainerStatus('stopped');
|
||||
|
||||
expect($result)->toBe('Stopped');
|
||||
});
|
||||
});
|
||||
});
|
||||
90
tests/Unit/GetContainersStatusServiceAggregationTest.php
Normal file
90
tests/Unit/GetContainersStatusServiceAggregationTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for GetContainersStatus service aggregation logic (SSH path).
|
||||
*
|
||||
* These tests verify that the SSH-based status updates (GetContainersStatus)
|
||||
* correctly aggregates container statuses for services with multiple containers,
|
||||
* using the same logic as PushServerUpdateJob (Sentinel path).
|
||||
*
|
||||
* This ensures consistency across both status update paths and prevents
|
||||
* race conditions where the last container processed wins.
|
||||
*/
|
||||
it('implements service multi-container aggregation in SSH path', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify service container collection property exists
|
||||
expect($actionFile)
|
||||
->toContain('protected ?Collection $serviceContainerStatuses;');
|
||||
|
||||
// Verify aggregateServiceContainerStatuses method exists
|
||||
expect($actionFile)
|
||||
->toContain('private function aggregateServiceContainerStatuses($services)')
|
||||
->toContain('$this->aggregateServiceContainerStatuses($services);');
|
||||
|
||||
// Verify service aggregation uses same logic as applications
|
||||
expect($actionFile)
|
||||
->toContain('$hasUnknown = false;');
|
||||
});
|
||||
|
||||
it('services use same priority as applications in SSH path', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Both aggregation methods should use the same priority logic
|
||||
$priorityLogic = <<<'PHP'
|
||||
if ($hasUnhealthy) {
|
||||
$aggregatedStatus = 'running (unhealthy)';
|
||||
} elseif ($hasUnknown) {
|
||||
$aggregatedStatus = 'running (unknown)';
|
||||
} else {
|
||||
$aggregatedStatus = 'running (healthy)';
|
||||
}
|
||||
PHP;
|
||||
|
||||
// Should appear in service aggregation
|
||||
expect($actionFile)->toContain($priorityLogic);
|
||||
});
|
||||
|
||||
it('collects service containers before aggregating in SSH path', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify service containers are collected, not immediately updated
|
||||
expect($actionFile)
|
||||
->toContain('$key = $serviceLabelId.\':\'.$subType.\':\'.$subId;')
|
||||
->toContain('$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);');
|
||||
|
||||
// Verify aggregation happens before ServiceChecked dispatch
|
||||
expect($actionFile)
|
||||
->toContain('$this->aggregateServiceContainerStatuses($services);')
|
||||
->toContain('ServiceChecked::dispatch($this->server->team->id);');
|
||||
});
|
||||
|
||||
it('SSH and Sentinel paths use identical service aggregation logic', function () {
|
||||
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Both should track the same status flags
|
||||
expect($jobFile)->toContain('$hasUnknown = false;');
|
||||
expect($actionFile)->toContain('$hasUnknown = false;');
|
||||
|
||||
// Both should check for unknown status
|
||||
expect($jobFile)->toContain('if (str($status)->contains(\'unknown\')) {');
|
||||
expect($actionFile)->toContain('if (str($status)->contains(\'unknown\')) {');
|
||||
|
||||
// Both should have elseif for unknown priority
|
||||
expect($jobFile)->toContain('} elseif ($hasUnknown) {');
|
||||
expect($actionFile)->toContain('} elseif ($hasUnknown) {');
|
||||
});
|
||||
|
||||
it('handles service status updates consistently', function () {
|
||||
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Both should parse service key with same format
|
||||
expect($jobFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
|
||||
expect($actionFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
|
||||
|
||||
// Both should handle excluded containers
|
||||
expect($jobFile)->toContain('$excludedContainers = collect();');
|
||||
expect($actionFile)->toContain('$excludedContainers = collect();');
|
||||
});
|
||||
181
tests/Unit/Livewire/BoardingPrerequisitesTest.php
Normal file
181
tests/Unit/Livewire/BoardingPrerequisitesTest.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Boarding\Index;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* These tests verify the fix for the prerequisite installation race condition.
|
||||
* The key behavior is that installation runs asynchronously via Activity,
|
||||
* and revalidation only happens after the ActivityMonitor callback.
|
||||
*/
|
||||
it('dispatches activity to monitor when prerequisites are missing', function () {
|
||||
// This test verifies the core fix: that we dispatch to ActivityMonitor
|
||||
// instead of immediately revalidating after starting installation.
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('validatePrerequisites')
|
||||
->andReturn([
|
||||
'success' => false,
|
||||
'missing' => ['git'],
|
||||
'found' => ['curl', 'jq'],
|
||||
]);
|
||||
|
||||
$activity = Mockery::mock(Activity::class);
|
||||
$activity->id = 'test-activity-123';
|
||||
$server->shouldReceive('installPrerequisites')
|
||||
->once()
|
||||
->andReturn($activity);
|
||||
|
||||
$component = Mockery::mock(Index::class)->makePartial();
|
||||
$component->createdServer = $server;
|
||||
$component->prerequisiteInstallAttempts = 0;
|
||||
$component->maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
// Key assertion: verify activityMonitor event is dispatched with correct params
|
||||
$component->shouldReceive('dispatch')
|
||||
->once()
|
||||
->with('activityMonitor', 'test-activity-123', 'prerequisitesInstalled')
|
||||
->andReturnSelf();
|
||||
|
||||
// Invoke the prerequisite check logic (simulating what validateServer does)
|
||||
$validationResult = $component->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
if ($component->prerequisiteInstallAttempts >= $component->maxPrerequisiteInstallAttempts) {
|
||||
throw new Exception('Max attempts exceeded');
|
||||
}
|
||||
$activity = $component->createdServer->installPrerequisites();
|
||||
$component->prerequisiteInstallAttempts++;
|
||||
$component->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
|
||||
}
|
||||
|
||||
expect($component->prerequisiteInstallAttempts)->toBe(1);
|
||||
});
|
||||
|
||||
it('does not retry when prerequisites install successfully', function () {
|
||||
// This test verifies the callback behavior when installation succeeds.
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('validatePrerequisites')
|
||||
->andReturn([
|
||||
'success' => true,
|
||||
'missing' => [],
|
||||
'found' => ['git', 'curl', 'jq'],
|
||||
]);
|
||||
|
||||
// installPrerequisites should NOT be called again
|
||||
$server->shouldNotReceive('installPrerequisites');
|
||||
|
||||
$component = Mockery::mock(Index::class)->makePartial();
|
||||
$component->createdServer = $server;
|
||||
$component->prerequisiteInstallAttempts = 1;
|
||||
$component->maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
// Simulate the callback logic
|
||||
$validationResult = $component->createdServer->validatePrerequisites();
|
||||
if ($validationResult['success']) {
|
||||
// Prerequisites are now valid, we'd call continueValidation()
|
||||
// For the test, just verify we don't try to install again
|
||||
expect($validationResult['success'])->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries when prerequisites still missing after callback', function () {
|
||||
// This test verifies retry logic in the callback.
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('validatePrerequisites')
|
||||
->andReturn([
|
||||
'success' => false,
|
||||
'missing' => ['git'],
|
||||
'found' => ['curl', 'jq'],
|
||||
]);
|
||||
|
||||
$activity = Mockery::mock(Activity::class);
|
||||
$activity->id = 'retry-activity-456';
|
||||
$server->shouldReceive('installPrerequisites')
|
||||
->once()
|
||||
->andReturn($activity);
|
||||
|
||||
$component = Mockery::mock(Index::class)->makePartial();
|
||||
$component->createdServer = $server;
|
||||
$component->prerequisiteInstallAttempts = 1; // Already tried once
|
||||
$component->maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
$component->shouldReceive('dispatch')
|
||||
->once()
|
||||
->with('activityMonitor', 'retry-activity-456', 'prerequisitesInstalled')
|
||||
->andReturnSelf();
|
||||
|
||||
// Simulate callback logic
|
||||
$validationResult = $component->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
if ($component->prerequisiteInstallAttempts < $component->maxPrerequisiteInstallAttempts) {
|
||||
$activity = $component->createdServer->installPrerequisites();
|
||||
$component->prerequisiteInstallAttempts++;
|
||||
$component->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
|
||||
}
|
||||
}
|
||||
|
||||
expect($component->prerequisiteInstallAttempts)->toBe(2);
|
||||
});
|
||||
|
||||
it('throws exception when max attempts exceeded', function () {
|
||||
// This test verifies that we stop retrying after max attempts.
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('validatePrerequisites')
|
||||
->andReturn([
|
||||
'success' => false,
|
||||
'missing' => ['git', 'curl'],
|
||||
'found' => ['jq'],
|
||||
]);
|
||||
|
||||
// installPrerequisites should NOT be called when at max attempts
|
||||
$server->shouldNotReceive('installPrerequisites');
|
||||
|
||||
$component = Mockery::mock(Index::class)->makePartial();
|
||||
$component->createdServer = $server;
|
||||
$component->prerequisiteInstallAttempts = 3; // Already at max
|
||||
$component->maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
// Simulate callback logic - should throw exception
|
||||
$validationResult = $component->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
if ($component->prerequisiteInstallAttempts >= $component->maxPrerequisiteInstallAttempts) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
throw new Exception("Prerequisites ({$missingCommands}) could not be installed after {$component->maxPrerequisiteInstallAttempts} attempts.");
|
||||
}
|
||||
}
|
||||
})->throws(Exception::class, 'Prerequisites (git, curl) could not be installed after 3 attempts');
|
||||
|
||||
it('does not install when prerequisites already present', function () {
|
||||
// This test verifies we skip installation when everything is already installed.
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('validatePrerequisites')
|
||||
->andReturn([
|
||||
'success' => true,
|
||||
'missing' => [],
|
||||
'found' => ['git', 'curl', 'jq'],
|
||||
]);
|
||||
|
||||
// installPrerequisites should NOT be called
|
||||
$server->shouldNotReceive('installPrerequisites');
|
||||
|
||||
$component = Mockery::mock(Index::class)->makePartial();
|
||||
$component->createdServer = $server;
|
||||
$component->prerequisiteInstallAttempts = 0;
|
||||
$component->maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
// Simulate validation logic
|
||||
$validationResult = $component->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
// Should not reach here
|
||||
$component->prerequisiteInstallAttempts++;
|
||||
}
|
||||
|
||||
// Attempts should remain 0
|
||||
expect($component->prerequisiteInstallAttempts)->toBe(0);
|
||||
expect($validationResult['success'])->toBeTrue();
|
||||
});
|
||||
53
tests/Unit/ServerStatusAccessorTest.php
Normal file
53
tests/Unit/ServerStatusAccessorTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* Test the Application::serverStatus() accessor
|
||||
*
|
||||
* This accessor determines if the underlying server infrastructure is functional.
|
||||
* It should check Server::isFunctional() for the main server and all additional servers.
|
||||
* It should NOT be affected by container/application health status (e.g., degraded:unhealthy).
|
||||
*
|
||||
* The bug that was fixed: Previously, it checked pivot.status and returned false
|
||||
* when any additional server had status != 'running', including 'degraded:unhealthy'.
|
||||
* This caused false "server has problems" warnings when the server was fine but
|
||||
* containers were unhealthy.
|
||||
*/
|
||||
it('checks server infrastructure health not container status', function () {
|
||||
// This is a documentation test to explain the fix
|
||||
// The serverStatus accessor should:
|
||||
// 1. Check if main server is functional (Server::isFunctional())
|
||||
// 2. Check if each additional server is functional (Server::isFunctional())
|
||||
// 3. NOT check pivot.status (that's application/container status, not server status)
|
||||
//
|
||||
// Before fix: Checked pivot.status !== 'running', causing false positives
|
||||
// After fix: Only checks Server::isFunctional() for infrastructure health
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
})->note('The serverStatus accessor now correctly checks only server infrastructure health, not container status');
|
||||
|
||||
it('has correct logic in serverStatus accessor', function () {
|
||||
// Read the actual code to verify the fix
|
||||
$reflection = new ReflectionClass(Application::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
|
||||
// Extract just the serverStatus accessor method
|
||||
preg_match('/protected function serverStatus\(\): Attribute\s*\{.*?^\s{4}\}/ms', $source, $matches);
|
||||
$serverStatusCode = $matches[0] ?? '';
|
||||
|
||||
expect($serverStatusCode)->not->toBeEmpty('serverStatus accessor should exist');
|
||||
|
||||
// Check that the new logic exists (checks isFunctional on each server)
|
||||
expect($serverStatusCode)
|
||||
->toContain('$main_server_functional = $this->destination?->server?->isFunctional()')
|
||||
->toContain('foreach ($this->additional_servers as $server)')
|
||||
->toContain('if (! $server->isFunctional())');
|
||||
|
||||
// Check that the old buggy logic is removed from serverStatus accessor
|
||||
expect($serverStatusCode)
|
||||
->not->toContain('pluck(\'pivot.status\')')
|
||||
->not->toContain('str($status)->before(\':\')')
|
||||
->not->toContain('if ($server_status !== \'running\')');
|
||||
})->note('Verifies that the serverStatus accessor uses the correct logic');
|
||||
321
tests/Unit/ServiceExcludedStatusTest.php
Normal file
321
tests/Unit/ServiceExcludedStatusTest.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
|
||||
/**
|
||||
* Test suite for Service model's excluded status calculation.
|
||||
*
|
||||
* These tests verify the Service model's aggregateResourceStatuses() method
|
||||
* and getStatusAttribute() accessor, which aggregate status from applications
|
||||
* and databases. This is separate from the CalculatesExcludedStatus trait
|
||||
* because Service works with Eloquent model relationships (database records)
|
||||
* rather than Docker container objects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper to create a mock resource (application or database) with status.
|
||||
*/
|
||||
function makeResource(string $status, bool $excludeFromStatus = false): object
|
||||
{
|
||||
$resource = new stdClass;
|
||||
$resource->status = $status;
|
||||
$resource->exclude_from_status = $excludeFromStatus;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
describe('Service Excluded Status Calculation', function () {
|
||||
it('returns starting status when service is starting', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(true);
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect());
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('starting:unhealthy');
|
||||
});
|
||||
|
||||
it('aggregates status from non-excluded applications', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('returns excluded status when all containers are excluded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy:excluded');
|
||||
});
|
||||
|
||||
it('returns unknown status when no containers exist', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect());
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('unknown:unknown:excluded');
|
||||
});
|
||||
|
||||
it('handles mixed excluded and non-excluded containers', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('exited', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
// Should only consider non-excluded containers
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('detects degraded status with mixed running and exited containers', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('exited', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles unknown health state', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:unknown', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
it('prioritizes unhealthy over unknown health', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:unknown', excludeFromStatus: false);
|
||||
$app2 = makeResource('running:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
it('prioritizes unknown over healthy health', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running (healthy)', excludeFromStatus: false);
|
||||
$app2 = makeResource('running (unknown)', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
it('handles restarting status as degraded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('restarting:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles paused status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('paused:unknown', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
it('handles dead status as degraded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('dead:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles removing status as degraded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('removing:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles created status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('created:unknown', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
it('aggregates status from both applications and databases', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$db1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect([$db1]));
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('detects unhealthy when database is unhealthy', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$db1 = makeResource('running:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect([$db1]));
|
||||
|
||||
expect($service->status)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
it('skips containers with :excluded suffix in non-excluded aggregation', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('exited:excluded', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
// Should skip app2 because it has :excluded suffix
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('strips :excluded suffix when processing excluded containers', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy:excluded', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy:excluded');
|
||||
});
|
||||
|
||||
it('returns exited when excluded containers have no valid status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('exited');
|
||||
});
|
||||
|
||||
it('handles all excluded containers with degraded state', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: true);
|
||||
$app2 = makeResource('exited', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy:excluded');
|
||||
});
|
||||
|
||||
it('handles all excluded containers with unknown health', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:unknown', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unknown:excluded');
|
||||
});
|
||||
|
||||
it('handles exited containers correctly', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('exited', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('exited');
|
||||
});
|
||||
|
||||
it('prefers running over starting status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('starting:unknown', excludeFromStatus: false);
|
||||
$app2 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('treats empty health as healthy', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
});
|
||||
@@ -151,3 +151,120 @@ it('checks if all FQDNs have port - null FQDN', function () {
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('detects port from map-style SERVICE_URL environment variable', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
trigger:
|
||||
environment:
|
||||
SERVICE_URL_TRIGGER_3000: ""
|
||||
OTHER_VAR: value
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'trigger';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
// Call the actual getRequiredPort method
|
||||
$result = $app->getRequiredPort();
|
||||
|
||||
expect($result)->toBe(3000);
|
||||
});
|
||||
|
||||
it('detects port from map-style SERVICE_FQDN environment variable', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
langfuse:
|
||||
environment:
|
||||
SERVICE_FQDN_LANGFUSE_3000: localhost
|
||||
DATABASE_URL: postgres://...
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'langfuse';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
$result = $app->getRequiredPort();
|
||||
|
||||
expect($result)->toBe(3000);
|
||||
});
|
||||
|
||||
it('returns null for map-style environment without port', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
db:
|
||||
environment:
|
||||
SERVICE_FQDN_DB: localhost
|
||||
SERVICE_URL_DB: http://localhost
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'db';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
$result = $app->getRequiredPort();
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles list-style environment with port', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
umami:
|
||||
environment:
|
||||
- SERVICE_URL_UMAMI_3000
|
||||
- DATABASE_URL=postgres://db/umami
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'umami';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
$result = $app->getRequiredPort();
|
||||
|
||||
expect($result)->toBe(3000);
|
||||
});
|
||||
|
||||
it('prioritizes first port found in environment', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
multi:
|
||||
environment:
|
||||
SERVICE_URL_MULTI_3000: ""
|
||||
SERVICE_URL_MULTI_8080: ""
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'multi';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
$result = $app->getRequiredPort();
|
||||
|
||||
// Should return one of the ports (depends on array iteration order)
|
||||
expect($result)->toBeIn([3000, 8080]);
|
||||
});
|
||||
|
||||
229
tests/Unit/StripCoolifyCustomFieldsTest.php
Normal file
229
tests/Unit/StripCoolifyCustomFieldsTest.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertEquals;
|
||||
|
||||
test('removes exclude_from_hc from service level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'web' => [
|
||||
'image' => 'nginx:latest',
|
||||
'exclude_from_hc' => true,
|
||||
'ports' => ['80:80'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
assertEquals('nginx:latest', $result['services']['web']['image']);
|
||||
assertEquals(['80:80'], $result['services']['web']['ports']);
|
||||
expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
|
||||
});
|
||||
|
||||
test('removes content from volume level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'php:8.4',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './config.xml',
|
||||
'target' => '/app/config.xml',
|
||||
'content' => '<?xml version="1.0"?><config></config>',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['app']['volumes'][0])->not->toHaveKey('content');
|
||||
});
|
||||
|
||||
test('removes isDirectory from volume level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'node:20',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './data',
|
||||
'target' => '/app/data',
|
||||
'isDirectory' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['app']['volumes'][0])->not->toHaveKey('isDirectory');
|
||||
});
|
||||
|
||||
test('removes is_directory from volume level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'python:3.12',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './logs',
|
||||
'target' => '/var/log/app',
|
||||
'is_directory' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['app']['volumes'][0])->not->toHaveKey('is_directory');
|
||||
});
|
||||
|
||||
test('removes all custom fields together', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'web' => [
|
||||
'image' => 'nginx:latest',
|
||||
'exclude_from_hc' => true,
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './config.xml',
|
||||
'target' => '/etc/nginx/config.xml',
|
||||
'content' => '<config></config>',
|
||||
'isDirectory' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './data',
|
||||
'target' => '/var/www/data',
|
||||
'is_directory' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'worker' => [
|
||||
'image' => 'worker:latest',
|
||||
'exclude_from_hc' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
// Verify service-level custom fields removed
|
||||
expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
|
||||
expect($result['services']['worker'])->not->toHaveKey('exclude_from_hc');
|
||||
|
||||
// Verify volume-level custom fields removed
|
||||
expect($result['services']['web']['volumes'][0])->not->toHaveKey('content');
|
||||
expect($result['services']['web']['volumes'][0])->not->toHaveKey('isDirectory');
|
||||
expect($result['services']['web']['volumes'][1])->not->toHaveKey('is_directory');
|
||||
|
||||
// Verify standard fields preserved
|
||||
assertEquals('nginx:latest', $result['services']['web']['image']);
|
||||
assertEquals('worker:latest', $result['services']['worker']['image']);
|
||||
});
|
||||
|
||||
test('preserves standard Docker Compose fields', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'db' => [
|
||||
'image' => 'postgres:16',
|
||||
'environment' => [
|
||||
'POSTGRES_DB' => 'mydb',
|
||||
'POSTGRES_USER' => 'user',
|
||||
],
|
||||
'ports' => ['5432:5432'],
|
||||
'volumes' => [
|
||||
'db-data:/var/lib/postgresql/data',
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'pg_isready'],
|
||||
'interval' => '5s',
|
||||
],
|
||||
'restart' => 'unless-stopped',
|
||||
'networks' => ['backend'],
|
||||
],
|
||||
],
|
||||
'networks' => [
|
||||
'backend' => [
|
||||
'driver' => 'bridge',
|
||||
],
|
||||
],
|
||||
'volumes' => [
|
||||
'db-data' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
// All standard fields should be preserved
|
||||
expect($result)->toHaveKeys(['services', 'networks', 'volumes']);
|
||||
expect($result['services']['db'])->toHaveKeys([
|
||||
'image', 'environment', 'ports', 'volumes',
|
||||
'healthcheck', 'restart', 'networks',
|
||||
]);
|
||||
assertEquals('postgres:16', $result['services']['db']['image']);
|
||||
assertEquals(['5432:5432'], $result['services']['db']['ports']);
|
||||
});
|
||||
|
||||
test('handles missing services gracefully', function () {
|
||||
$yaml = [
|
||||
'version' => '3.8',
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result)->toBe($yaml);
|
||||
});
|
||||
|
||||
test('handles missing volumes in service gracefully', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'nginx:latest',
|
||||
'exclude_from_hc' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app'])->not->toHaveKey('exclude_from_hc');
|
||||
expect($result['services']['app'])->not->toHaveKey('volumes');
|
||||
assertEquals('nginx:latest', $result['services']['app']['image']);
|
||||
});
|
||||
|
||||
test('handles traccar.yaml example with multiline content', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'traccar' => [
|
||||
'image' => 'traccar/traccar:latest',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './srv/traccar/conf/traccar.xml',
|
||||
'target' => '/opt/traccar/conf/traccar.xml',
|
||||
'content' => "<?xml version='1.0' encoding='UTF-8'?>\n<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>\n<properties>\n <entry key='config.default'>./conf/default.xml</entry>\n</properties>",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['traccar']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['traccar']['volumes'][0])->not->toHaveKey('content');
|
||||
assertEquals('./srv/traccar/conf/traccar.xml', $result['services']['traccar']['volumes'][0]['source']);
|
||||
});
|
||||
563
tests/Unit/UpdateComposeAbbreviatedVariablesTest.php
Normal file
563
tests/Unit/UpdateComposeAbbreviatedVariablesTest.php
Normal file
@@ -0,0 +1,563 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that updateCompose() correctly handles abbreviated
|
||||
* SERVICE_URL and SERVICE_FQDN variable names from templates.
|
||||
*
|
||||
* This tests the fix for GitHub issue #7243 where SERVICE_URL_OPDASHBOARD
|
||||
* wasn't being updated when the domain changed, while SERVICE_URL_OPDASHBOARD_3000
|
||||
* was being updated correctly.
|
||||
*
|
||||
* The issue occurs when template variable names are abbreviated (e.g., OPDASHBOARD)
|
||||
* instead of using the full container name (e.g., OPENPANEL_DASHBOARD).
|
||||
*/
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
it('detects SERVICE_URL variables directly declared in template environment', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
openpanel-dashboard:
|
||||
environment:
|
||||
- SERVICE_URL_OPDASHBOARD_3000
|
||||
- OTHER_VAR=value
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.openpanel-dashboard');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $envVar) {
|
||||
if (is_string($envVar)) {
|
||||
$envVarName = str($envVar)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_OPDASHBOARD_3000');
|
||||
expect($templateVariableNames)->not->toContain('OTHER_VAR');
|
||||
});
|
||||
|
||||
it('only detects directly declared SERVICE_URL variables not references', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
openpanel-dashboard:
|
||||
environment:
|
||||
- SERVICE_URL_OPDASHBOARD_3000
|
||||
- NEXT_PUBLIC_DASHBOARD_URL=${SERVICE_URL_OPDASHBOARD}
|
||||
- NEXT_PUBLIC_API_URL=${SERVICE_URL_OPAPI}
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.openpanel-dashboard');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $envVar) {
|
||||
if (is_string($envVar)) {
|
||||
$envVarName = str($envVar)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should only detect the direct declaration
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_OPDASHBOARD_3000');
|
||||
// Should NOT detect references (those belong to other services)
|
||||
expect($templateVariableNames)->not->toContain('SERVICE_URL_OPDASHBOARD');
|
||||
expect($templateVariableNames)->not->toContain('SERVICE_URL_OPAPI');
|
||||
});
|
||||
|
||||
it('detects multiple directly declared SERVICE_URL variables', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- SERVICE_URL_APP
|
||||
- SERVICE_URL_APP_3000
|
||||
- SERVICE_FQDN_API
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $envVar) {
|
||||
if (is_string($envVar)) {
|
||||
// Extract variable name (before '=' if present)
|
||||
$envVarName = str($envVar)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$templateVariableNames = array_unique($templateVariableNames);
|
||||
|
||||
expect($templateVariableNames)->toHaveCount(3);
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
|
||||
expect($templateVariableNames)->toContain('SERVICE_FQDN_API');
|
||||
});
|
||||
|
||||
it('removes duplicates from template variable names', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- SERVICE_URL_APP
|
||||
- PUBLIC_URL=${SERVICE_URL_APP}
|
||||
- PRIVATE_URL=${SERVICE_URL_APP}
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $envVar) {
|
||||
if (is_string($envVar)) {
|
||||
$envVarName = str($envVar)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
if (is_string($envVar) && str($envVar)->contains('${')) {
|
||||
preg_match_all('/\$\{(SERVICE_(?:FQDN|URL)_[^}]+)\}/', $envVar, $matches);
|
||||
if (! empty($matches[1])) {
|
||||
foreach ($matches[1] as $match) {
|
||||
$templateVariableNames[] = $match;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$templateVariableNames = array_unique($templateVariableNames);
|
||||
|
||||
// SERVICE_URL_APP appears 3 times but should only be in array once
|
||||
expect($templateVariableNames)->toHaveCount(1);
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
|
||||
});
|
||||
|
||||
it('detects SERVICE_FQDN variables in addition to SERVICE_URL', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- SERVICE_FQDN_APP
|
||||
- SERVICE_FQDN_APP_3000
|
||||
- SERVICE_URL_APP
|
||||
- SERVICE_URL_APP_8080
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $envVar) {
|
||||
if (is_string($envVar)) {
|
||||
$envVarName = str($envVar)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($templateVariableNames)->toHaveCount(4);
|
||||
expect($templateVariableNames)->toContain('SERVICE_FQDN_APP');
|
||||
expect($templateVariableNames)->toContain('SERVICE_FQDN_APP_3000');
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP_8080');
|
||||
});
|
||||
|
||||
it('handles abbreviated service names that differ from container names', function () {
|
||||
// This is the actual OpenPanel case from GitHub issue #7243
|
||||
// Container name: openpanel-dashboard
|
||||
// Template variable: SERVICE_URL_OPDASHBOARD (abbreviated)
|
||||
|
||||
$containerName = 'openpanel-dashboard';
|
||||
$templateVariableName = 'SERVICE_URL_OPDASHBOARD';
|
||||
|
||||
// The old logic would generate this from container name:
|
||||
$generatedFromContainer = 'SERVICE_URL_'.str($containerName)->upper()->replace('-', '_')->value();
|
||||
|
||||
// This shows the mismatch
|
||||
expect($generatedFromContainer)->toBe('SERVICE_URL_OPENPANEL_DASHBOARD');
|
||||
expect($generatedFromContainer)->not->toBe($templateVariableName);
|
||||
|
||||
// The template uses the abbreviated form
|
||||
expect($templateVariableName)->toBe('SERVICE_URL_OPDASHBOARD');
|
||||
});
|
||||
|
||||
it('correctly identifies abbreviated variable patterns', function () {
|
||||
$tests = [
|
||||
// Full name transformations (old logic)
|
||||
['container' => 'openpanel-dashboard', 'generated' => 'SERVICE_URL_OPENPANEL_DASHBOARD'],
|
||||
['container' => 'my-long-service', 'generated' => 'SERVICE_URL_MY_LONG_SERVICE'],
|
||||
|
||||
// Abbreviated forms (template logic)
|
||||
['container' => 'openpanel-dashboard', 'template' => 'SERVICE_URL_OPDASHBOARD'],
|
||||
['container' => 'openpanel-api', 'template' => 'SERVICE_URL_OPAPI'],
|
||||
['container' => 'my-long-service', 'template' => 'SERVICE_URL_MLS'],
|
||||
];
|
||||
|
||||
foreach ($tests as $test) {
|
||||
if (isset($test['generated'])) {
|
||||
$generated = 'SERVICE_URL_'.str($test['container'])->upper()->replace('-', '_')->value();
|
||||
expect($generated)->toBe($test['generated']);
|
||||
}
|
||||
|
||||
if (isset($test['template'])) {
|
||||
// Template abbreviations can't be generated from container name
|
||||
// They must be parsed from the actual template
|
||||
expect($test['template'])->toMatch('/^SERVICE_URL_[A-Z0-9_]+$/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('verifies direct declarations are not confused with references', function () {
|
||||
// Direct declarations should be detected
|
||||
$directDeclaration = 'SERVICE_URL_APP';
|
||||
expect(str($directDeclaration)->startsWith('SERVICE_URL_'))->toBeTrue();
|
||||
expect(str($directDeclaration)->before('=')->value())->toBe('SERVICE_URL_APP');
|
||||
|
||||
// References should not be detected as declarations
|
||||
$reference = 'NEXT_PUBLIC_URL=${SERVICE_URL_APP}';
|
||||
$varName = str($reference)->before('=')->trim();
|
||||
expect($varName->startsWith('SERVICE_URL_'))->toBeFalse();
|
||||
expect($varName->value())->toBe('NEXT_PUBLIC_URL');
|
||||
});
|
||||
|
||||
it('ensures updateCompose helper file has template parsing logic', function () {
|
||||
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||
|
||||
// Check that the fix is in place
|
||||
expect($servicesFile)->toContain('Extract SERVICE_URL and SERVICE_FQDN variable names from the compose template');
|
||||
expect($servicesFile)->toContain('to ensure we use the exact names defined in the template');
|
||||
expect($servicesFile)->toContain('$templateVariableNames');
|
||||
expect($servicesFile)->toContain('DIRECTLY DECLARED');
|
||||
expect($servicesFile)->toContain('not variables that are merely referenced from other services');
|
||||
});
|
||||
|
||||
it('verifies that service names are extracted to create both URL and FQDN pairs', function () {
|
||||
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||
|
||||
// Verify the logic to create both pairs exists
|
||||
expect($servicesFile)->toContain('create BOTH SERVICE_URL and SERVICE_FQDN pairs');
|
||||
expect($servicesFile)->toContain('ALWAYS create base pair');
|
||||
expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||
expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||
});
|
||||
|
||||
it('extracts service names correctly for pairing', function () {
|
||||
// Simulate what the updateCompose function does
|
||||
$templateVariableNames = [
|
||||
'SERVICE_URL_OPDASHBOARD',
|
||||
'SERVICE_URL_OPDASHBOARD_3000',
|
||||
'SERVICE_URL_OPAPI',
|
||||
];
|
||||
|
||||
$serviceNamesToProcess = [];
|
||||
foreach ($templateVariableNames as $templateVarName) {
|
||||
$parsed = parseServiceEnvironmentVariable($templateVarName);
|
||||
$serviceName = $parsed['service_name'];
|
||||
|
||||
if (! isset($serviceNamesToProcess[$serviceName])) {
|
||||
$serviceNamesToProcess[$serviceName] = [
|
||||
'base' => $serviceName,
|
||||
'ports' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
$serviceNamesToProcess[$serviceName]['ports'][] = $parsed['port'];
|
||||
}
|
||||
}
|
||||
|
||||
// Should extract 2 unique service names
|
||||
expect($serviceNamesToProcess)->toHaveCount(2);
|
||||
expect($serviceNamesToProcess)->toHaveKey('opdashboard');
|
||||
expect($serviceNamesToProcess)->toHaveKey('opapi');
|
||||
|
||||
// OPDASHBOARD should have port 3000 tracked
|
||||
expect($serviceNamesToProcess['opdashboard']['ports'])->toContain('3000');
|
||||
|
||||
// OPAPI should have no ports
|
||||
expect($serviceNamesToProcess['opapi']['ports'])->toBeEmpty();
|
||||
});
|
||||
|
||||
it('should create both URL and FQDN when only URL is in template', function () {
|
||||
// Given: Template defines only SERVICE_URL_APP
|
||||
$templateVar = 'SERVICE_URL_APP';
|
||||
|
||||
// When: Processing this variable
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
$serviceName = $parsed['service_name'];
|
||||
|
||||
// Then: We should create both:
|
||||
// - SERVICE_URL_APP (or SERVICE_URL_app depending on template)
|
||||
// - SERVICE_FQDN_APP (or SERVICE_FQDN_app depending on template)
|
||||
expect($serviceName)->toBe('app');
|
||||
|
||||
$urlKey = 'SERVICE_URL_'.str($serviceName)->upper();
|
||||
$fqdnKey = 'SERVICE_FQDN_'.str($serviceName)->upper();
|
||||
|
||||
expect($urlKey)->toBe('SERVICE_URL_APP');
|
||||
expect($fqdnKey)->toBe('SERVICE_FQDN_APP');
|
||||
});
|
||||
|
||||
it('should create both URL and FQDN when only FQDN is in template', function () {
|
||||
// Given: Template defines only SERVICE_FQDN_DATABASE
|
||||
$templateVar = 'SERVICE_FQDN_DATABASE';
|
||||
|
||||
// When: Processing this variable
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
$serviceName = $parsed['service_name'];
|
||||
|
||||
// Then: We should create both:
|
||||
// - SERVICE_URL_DATABASE (or SERVICE_URL_database depending on template)
|
||||
// - SERVICE_FQDN_DATABASE (or SERVICE_FQDN_database depending on template)
|
||||
expect($serviceName)->toBe('database');
|
||||
|
||||
$urlKey = 'SERVICE_URL_'.str($serviceName)->upper();
|
||||
$fqdnKey = 'SERVICE_FQDN_'.str($serviceName)->upper();
|
||||
|
||||
expect($urlKey)->toBe('SERVICE_URL_DATABASE');
|
||||
expect($fqdnKey)->toBe('SERVICE_FQDN_DATABASE');
|
||||
});
|
||||
|
||||
it('should create all 4 variables when port-specific variable is in template', function () {
|
||||
// Given: Template defines SERVICE_URL_UMAMI_3000
|
||||
$templateVar = 'SERVICE_URL_UMAMI_3000';
|
||||
|
||||
// When: Processing this variable
|
||||
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||
$serviceName = $parsed['service_name'];
|
||||
$port = $parsed['port'];
|
||||
|
||||
// Then: We should create all 4:
|
||||
// 1. SERVICE_URL_UMAMI (base)
|
||||
// 2. SERVICE_FQDN_UMAMI (base)
|
||||
// 3. SERVICE_URL_UMAMI_3000 (port-specific)
|
||||
// 4. SERVICE_FQDN_UMAMI_3000 (port-specific)
|
||||
|
||||
expect($serviceName)->toBe('umami');
|
||||
expect($port)->toBe('3000');
|
||||
|
||||
$serviceNameUpper = str($serviceName)->upper();
|
||||
$baseUrlKey = "SERVICE_URL_{$serviceNameUpper}";
|
||||
$baseFqdnKey = "SERVICE_FQDN_{$serviceNameUpper}";
|
||||
$portUrlKey = "SERVICE_URL_{$serviceNameUpper}_{$port}";
|
||||
$portFqdnKey = "SERVICE_FQDN_{$serviceNameUpper}_{$port}";
|
||||
|
||||
expect($baseUrlKey)->toBe('SERVICE_URL_UMAMI');
|
||||
expect($baseFqdnKey)->toBe('SERVICE_FQDN_UMAMI');
|
||||
expect($portUrlKey)->toBe('SERVICE_URL_UMAMI_3000');
|
||||
expect($portFqdnKey)->toBe('SERVICE_FQDN_UMAMI_3000');
|
||||
});
|
||||
|
||||
it('should handle multiple ports for same service', function () {
|
||||
$templateVariableNames = [
|
||||
'SERVICE_URL_API_3000',
|
||||
'SERVICE_URL_API_8080',
|
||||
];
|
||||
|
||||
$serviceNamesToProcess = [];
|
||||
foreach ($templateVariableNames as $templateVarName) {
|
||||
$parsed = parseServiceEnvironmentVariable($templateVarName);
|
||||
$serviceName = $parsed['service_name'];
|
||||
|
||||
if (! isset($serviceNamesToProcess[$serviceName])) {
|
||||
$serviceNamesToProcess[$serviceName] = [
|
||||
'base' => $serviceName,
|
||||
'ports' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
$serviceNamesToProcess[$serviceName]['ports'][] = $parsed['port'];
|
||||
}
|
||||
}
|
||||
|
||||
// Should have one service with two ports
|
||||
expect($serviceNamesToProcess)->toHaveCount(1);
|
||||
expect($serviceNamesToProcess['api']['ports'])->toHaveCount(2);
|
||||
expect($serviceNamesToProcess['api']['ports'])->toContain('3000');
|
||||
expect($serviceNamesToProcess['api']['ports'])->toContain('8080');
|
||||
|
||||
// Should create 6 variables total:
|
||||
// 1. SERVICE_URL_API (base)
|
||||
// 2. SERVICE_FQDN_API (base)
|
||||
// 3. SERVICE_URL_API_3000
|
||||
// 4. SERVICE_FQDN_API_3000
|
||||
// 5. SERVICE_URL_API_8080
|
||||
// 6. SERVICE_FQDN_API_8080
|
||||
});
|
||||
|
||||
it('detects SERVICE_URL variables in map-style environment format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
trigger:
|
||||
environment:
|
||||
SERVICE_URL_TRIGGER_3000: ""
|
||||
SERVICE_FQDN_DB: localhost
|
||||
OTHER_VAR: value
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.trigger');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style
|
||||
$envVarName = str($value)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
} elseif (is_string($key)) {
|
||||
// Map-style
|
||||
$envVarName = str($key);
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($templateVariableNames)->toHaveCount(2);
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_TRIGGER_3000');
|
||||
expect($templateVariableNames)->toContain('SERVICE_FQDN_DB');
|
||||
expect($templateVariableNames)->not->toContain('OTHER_VAR');
|
||||
});
|
||||
|
||||
it('handles multiple map-style SERVICE_URL and SERVICE_FQDN variables', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
SERVICE_URL_APP_3000: ""
|
||||
SERVICE_FQDN_API: api.local
|
||||
SERVICE_URL_WEB: ""
|
||||
OTHER_VAR: value
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style
|
||||
$envVarName = str($value)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
} elseif (is_string($key)) {
|
||||
// Map-style
|
||||
$envVarName = str($key);
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($templateVariableNames)->toHaveCount(3);
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
|
||||
expect($templateVariableNames)->toContain('SERVICE_FQDN_API');
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_WEB');
|
||||
expect($templateVariableNames)->not->toContain('OTHER_VAR');
|
||||
});
|
||||
|
||||
it('does not detect SERVICE_URL references in map-style values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
SERVICE_URL_APP_3000: ""
|
||||
NEXT_PUBLIC_URL: ${SERVICE_URL_APP}
|
||||
API_ENDPOINT: ${SERVICE_URL_API}
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style
|
||||
$envVarName = str($value)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
} elseif (is_string($key)) {
|
||||
// Map-style
|
||||
$envVarName = str($key);
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should only detect the direct declaration, not references in values
|
||||
expect($templateVariableNames)->toHaveCount(1);
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
|
||||
expect($templateVariableNames)->not->toContain('SERVICE_URL_APP');
|
||||
expect($templateVariableNames)->not->toContain('SERVICE_URL_API');
|
||||
expect($templateVariableNames)->not->toContain('NEXT_PUBLIC_URL');
|
||||
expect($templateVariableNames)->not->toContain('API_ENDPOINT');
|
||||
});
|
||||
|
||||
it('handles map-style with abbreviated service names', function () {
|
||||
// Simulating the langfuse.yaml case with map-style
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
langfuse:
|
||||
environment:
|
||||
SERVICE_URL_LANGFUSE_3000: ${SERVICE_URL_LANGFUSE_3000}
|
||||
DATABASE_URL: postgres://...
|
||||
YAML;
|
||||
|
||||
$dockerCompose = Yaml::parse($yaml);
|
||||
$serviceConfig = data_get($dockerCompose, 'services.langfuse');
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
$templateVariableNames = [];
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style
|
||||
$envVarName = str($value)->before('=')->trim();
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
} elseif (is_string($key)) {
|
||||
// Map-style
|
||||
$envVarName = str($key);
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($templateVariableNames)->toHaveCount(1);
|
||||
expect($templateVariableNames)->toContain('SERVICE_URL_LANGFUSE_3000');
|
||||
expect($templateVariableNames)->not->toContain('DATABASE_URL');
|
||||
});
|
||||
|
||||
it('verifies updateCompose helper has dual-format handling', function () {
|
||||
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||
|
||||
// Check that both formats are handled
|
||||
expect($servicesFile)->toContain('is_int($key) && is_string($value)');
|
||||
expect($servicesFile)->toContain('List-style');
|
||||
expect($servicesFile)->toContain('elseif (is_string($key))');
|
||||
expect($servicesFile)->toContain('Map-style');
|
||||
});
|
||||
Reference in New Issue
Block a user