coolify/tests/Unit/ServiceExcludedStatusTest.php
Andras Bacsai 70fb4c6869 refactor: standardize Service model status aggregation to use ContainerStatusAggregator
Fixes inconsistency where Service model used manual state machine logic while
all other components (Application, ComplexStatusCheck, GetContainersStatus)
use the centralized ContainerStatusAggregator service.

Changes:
- Refactored Service::aggregateResourceStatuses() to use ContainerStatusAggregator
- Removed ~60 lines of duplicated state machine logic
- Added comprehensive ServiceExcludedStatusTest with 24 test cases
- Fixed bugs in old logic where paused/starting containers were incorrectly
  marked as unhealthy (should be unknown)

Benefits:
- Single source of truth for status aggregation across all models
- Leverages 42 existing ContainerStatusAggregator tests
- Consistent behavior between Service and Application/Database models
- Easier maintenance (state machine changes only in one place)

All tests pass (37 total):
- ServiceExcludedStatusTest: 24/24 passed
- AllExcludedContainersConsistencyTest: 13/13 passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 15:03:18 +01:00

321 lines
14 KiB
PHP

<?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:unhealthy', 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:unhealthy', 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:unhealthy: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:unhealthy:excluded 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:unhealthy:excluded');
});
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:unhealthy', 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:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('exited:unhealthy');
});
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');
});
});