Merge branch 'next' into improve-scheduled-tasks

This commit is contained in:
Andras Bacsai 2025-11-11 11:38:04 +01:00 committed by GitHub
commit 104e68a9ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 388 additions and 45 deletions

View file

@ -125,15 +125,15 @@ private function deleteApplicationPreview()
}
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->first();
->get();
if ($activeDeployment) {
foreach ($activeDeployments as $activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([

View file

@ -39,7 +39,7 @@ public function mount()
{
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->service->getRequiredPort();
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
}
@ -113,8 +113,7 @@ public function submit()
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->getRequiredPort();
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port

View file

@ -135,7 +135,7 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->service->getRequiredPort();
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
@ -268,8 +268,7 @@ public function submit()
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->getRequiredPort();
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port

View file

@ -109,6 +109,11 @@ public function fileStorages()
return $this->morphMany(LocalFileVolume::class, 'resource');
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function fqdns(): Attribute
{
return Attribute::make(
@ -174,4 +179,77 @@ public function isBackupSolutionAvailable()
{
return false;
}
/**
* Get the required port for this service application.
* Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables
* stored at the Service level, filtering by normalized container name.
* Falls back to service-level port if no port-specific variable is found.
*/
public function getRequiredPort(): ?int
{
try {
// Normalize container name same way as variable creation
// (uppercase, replace - and . with _)
$normalizedName = str($this->name)
->upper()
->replace('-', '_')
->replace('.', '_')
->value();
// Get all environment variables from the service
$serviceEnvVars = $this->service->environment_variables()->get();
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
foreach ($serviceEnvVars as $envVar) {
$key = str($envVar->key);
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
continue;
}
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
if ($key->startsWith('SERVICE_FQDN_')) {
$suffix = $key->after('SERVICE_FQDN_');
} else {
$suffix = $key->after('SERVICE_URL_');
}
// Check if this variable starts with our normalized container name
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
if (! $suffix->startsWith($normalizedName)) {
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
'expected_start' => $normalizedName,
'actual_suffix' => $suffix->value(),
]);
continue;
}
// Check if there's a port suffix after the container name
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
$afterName = $suffix->after($normalizedName)->value();
// If there's content after the name, it should start with underscore
if ($afterName !== '' && str($afterName)->startsWith('_')) {
// Extract port: _3210 -> 3210
$port = str($afterName)->after('_')->value();
// Validate that the extracted port is numeric
if (is_numeric($port)) {
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
'port' => (int) $port,
]);
return (int) $port;
}
}
}
// Fall back to service-level port if no port-specific variable is found
$fallbackPort = $this->service->getRequiredPort();
return $fallbackPort;
} catch (\Throwable $e) {
return null;
}
}
}

View file

@ -457,13 +457,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// for example SERVICE_FQDN_APP_3000 (without a value)
if ($key->startsWith('SERVICE_FQDN_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
if (substr_count(str($key)->value(), '_') === 3) {
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
$port = $key->afterLast('_')->value();
} else {
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
$port = null;
}
$parsed = parseServiceEnvironmentVariable($key->value());
$fqdnFor = $parsed['service_name'];
$port = $parsed['port'];
$fqdn = $resource->fqdn;
if (blank($resource->fqdn)) {
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
@ -486,7 +482,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$resource->save();
}
if (substr_count(str($key)->value(), '_') === 2) {
if (! $parsed['has_port']) {
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@ -496,7 +492,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
]);
}
if (substr_count(str($key)->value(), '_') === 3) {
if ($parsed['has_port']) {
$newKey = str($key)->beforeLast('_');
$resource->environment_variables()->updateOrCreate([
@ -567,12 +563,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
} elseif ($command->value() === 'URL') {
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
// SERVICE_URL_APP or SERVICE_URL_APP_3000
// Detect if there's a port suffix
$parsed = parseServiceEnvironmentVariable($key->value());
$urlFor = $parsed['service_name'];
$port = $parsed['port'];
$originalUrlFor = str($urlFor)->replace('_', '-');
if (str($urlFor)->contains('-')) {
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
}
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
// Append port if specified
$urlWithPort = $url;
if ($port && is_numeric($port)) {
$urlWithPort = "$url:$port";
}
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@ -599,12 +604,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
if ($domainExists !== $envExists->value) {
$envExists->update([
'value' => $url,
'value' => $urlWithPort,
]);
}
if (is_null($domainExists)) {
$domains->put((string) $urlFor, [
'domain' => $url,
'domain' => $urlWithPort,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
@ -1541,27 +1546,16 @@ function serviceParser(Service $resource): Collection
// Get magic environments where we need to preset the FQDN / URL
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
if (substr_count(str($key)->value(), '_') === 3) {
if ($key->startsWith('SERVICE_FQDN_')) {
$urlFor = null;
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
}
if ($key->startsWith('SERVICE_URL_')) {
$fqdnFor = null;
$urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
}
$port = $key->afterLast('_')->value();
} else {
if ($key->startsWith('SERVICE_FQDN_')) {
$urlFor = null;
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
}
if ($key->startsWith('SERVICE_URL_')) {
$fqdnFor = null;
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
}
$port = null;
$parsed = parseServiceEnvironmentVariable($key->value());
if ($key->startsWith('SERVICE_FQDN_')) {
$urlFor = null;
$fqdnFor = $parsed['service_name'];
}
if ($key->startsWith('SERVICE_URL_')) {
$fqdnFor = null;
$urlFor = $parsed['service_name'];
}
$port = $parsed['port'];
if (blank($savedService->fqdn)) {
if ($fqdnFor) {
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
@ -1606,7 +1600,7 @@ function serviceParser(Service $resource): Collection
}
$savedService->save();
}
if (substr_count(str($key)->value(), '_') === 2) {
if (! $parsed['has_port']) {
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@ -1624,7 +1618,7 @@ function serviceParser(Service $resource): Collection
'is_preview' => false,
]);
}
if (substr_count(str($key)->value(), '_') === 3) {
if ($parsed['has_port']) {
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
// keep the port suffix in the key and use the URL with port
$resource->environment_variables()->updateOrCreate([
@ -1658,8 +1652,17 @@ function serviceParser(Service $resource): Collection
$url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_FQDN_UMAMI_3000)
$portSuffixedExists = $resource->environment_variables()
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
$serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
// Check if FQDN already has a port set (contains ':' after the domain)
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
// Only set FQDN if it's for the current service being processed (prevent race conditions)
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
// Save URL otherwise it won't work.
$serviceExists->fqdn = $url;
$serviceExists->save();
@ -1678,8 +1681,17 @@ function serviceParser(Service $resource): Collection
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
$portSuffixedExists = $resource->environment_variables()
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
$serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
// Check if FQDN already has a port set (contains ':' after the domain)
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
// Only set FQDN if it's for the current service being processed (prevent race conditions)
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
$serviceExists->fqdn = $url;
$serviceExists->save();
}

View file

@ -184,3 +184,53 @@ function serviceKeys()
{
return get_service_templates()->keys();
}
/**
* Parse a SERVICE_URL_* or SERVICE_FQDN_* variable to extract the service name and port.
*
* This function detects if a service environment variable has a port suffix by checking
* if the last segment after the underscore is numeric.
*
* Examples:
* - SERVICE_URL_APP_3000 ['service_name' => 'app', 'port' => '3000', 'has_port' => true]
* - SERVICE_URL_MY_API_8080 ['service_name' => 'my_api', 'port' => '8080', 'has_port' => true]
* - SERVICE_URL_MY_APP ['service_name' => 'my_app', 'port' => null, 'has_port' => false]
* - SERVICE_FQDN_REDIS_CACHE_6379 ['service_name' => 'redis_cache', 'port' => '6379', 'has_port' => true]
*
* @param string $key The environment variable key (e.g., SERVICE_URL_APP_3000)
* @return array{service_name: string, port: string|null, has_port: bool} Parsed service information
*/
function parseServiceEnvironmentVariable(string $key): array
{
$strKey = str($key);
$lastSegment = $strKey->afterLast('_')->value();
$hasPort = is_numeric($lastSegment) && ctype_digit($lastSegment);
if ($hasPort) {
// Port-specific variable (e.g., SERVICE_URL_APP_3000)
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
} else {
$serviceName = '';
}
$port = $lastSegment;
} else {
// Base variable without port (e.g., SERVICE_URL_APP)
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->lower()->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->lower()->value();
} else {
$serviceName = '';
}
$port = null;
}
return [
'service_name' => $serviceName,
'port' => $port,
'has_port' => $hasPort,
];
}

View file

@ -0,0 +1,158 @@
<?php
/**
* Unit tests to verify the parser logic for detecting port-specific SERVICE variables.
* These tests simulate the logic used in bootstrap/helpers/parsers.php without database operations.
*
* The parser should detect when a SERVICE_URL_* or SERVICE_FQDN_* variable has a numeric
* port suffix and extract both the service name and port correctly.
*/
it('detects port suffix using numeric check (correct logic)', function () {
// This tests the CORRECT logic: check if last segment is numeric
$testCases = [
// [variable_name, expected_service_name, expected_port, is_port_specific]
// 2-underscore pattern: SERVICE_URL_{SERVICE}_{PORT}
['SERVICE_URL_MYAPP_3000', 'myapp', '3000', true],
['SERVICE_URL_REDIS_6379', 'redis', '6379', true],
['SERVICE_FQDN_NGINX_80', 'nginx', '80', true],
// 3-underscore pattern: SERVICE_URL_{SERVICE}_{NAME}_{PORT}
['SERVICE_URL_MY_API_8080', 'my_api', '8080', true],
['SERVICE_URL_WEB_APP_3000', 'web_app', '3000', true],
['SERVICE_FQDN_DB_SERVER_5432', 'db_server', '5432', true],
// 4-underscore pattern: SERVICE_URL_{SERVICE}_{NAME}_{OTHER}_{PORT}
['SERVICE_URL_REDIS_CACHE_SERVER_6379', 'redis_cache_server', '6379', true],
['SERVICE_URL_MY_LONG_APP_8080', 'my_long_app', '8080', true],
['SERVICE_FQDN_POSTGRES_PRIMARY_DB_5432', 'postgres_primary_db', '5432', true],
// Non-numeric suffix: should NOT be port-specific
['SERVICE_URL_MY_APP', 'my_app', null, false],
['SERVICE_URL_REDIS_PRIMARY', 'redis_primary', null, false],
['SERVICE_FQDN_WEB_SERVER', 'web_server', null, false],
['SERVICE_URL_APP_CACHE_REDIS', 'app_cache_redis', null, false],
// Single word without port
['SERVICE_URL_APP', 'app', null, false],
['SERVICE_FQDN_DB', 'db', null, false],
// Edge cases with numbers in service name
['SERVICE_URL_REDIS2_MASTER', 'redis2_master', null, false],
['SERVICE_URL_WEB3_APP', 'web3_app', null, false],
];
foreach ($testCases as [$varName, $expectedService, $expectedPort, $isPortSpecific]) {
// Use the actual helper function from bootstrap/helpers/services.php
$parsed = parseServiceEnvironmentVariable($varName);
// Assertions
expect($parsed['service_name'])->toBe($expectedService, "Service name mismatch for $varName");
expect($parsed['port'])->toBe($expectedPort, "Port mismatch for $varName");
expect($parsed['has_port'])->toBe($isPortSpecific, "Port detection mismatch for $varName");
}
});
it('shows current underscore-counting logic fails for some patterns', function () {
// This demonstrates the CURRENT BROKEN logic: substr_count === 3
$testCases = [
// [variable_name, underscore_count, should_detect_port]
// Works correctly with current logic (3 underscores total)
['SERVICE_URL_APP_3000', 3, true], // 3 underscores ✓
['SERVICE_URL_API_8080', 3, true], // 3 underscores ✓
// FAILS: 4 underscores (two-word service + port) - current logic says no port
['SERVICE_URL_MY_API_8080', 4, true], // 4 underscores ✗
['SERVICE_URL_WEB_APP_3000', 4, true], // 4 underscores ✗
// FAILS: 5+ underscores (three-word service + port) - current logic says no port
['SERVICE_URL_REDIS_CACHE_SERVER_6379', 5, true], // 5 underscores ✗
['SERVICE_URL_MY_LONG_APP_8080', 5, true], // 5 underscores ✗
// Works correctly (no port, not 3 underscores)
['SERVICE_URL_MY_APP', 3, false], // 3 underscores but non-numeric ✓
['SERVICE_URL_APP', 2, false], // 2 underscores ✓
];
foreach ($testCases as [$varName, $expectedUnderscoreCount, $shouldDetectPort]) {
$key = str($varName);
// Current logic: count underscores
$underscoreCount = substr_count($key->value(), '_');
expect($underscoreCount)->toBe($expectedUnderscoreCount, "Underscore count for $varName");
$currentLogicDetectsPort = ($underscoreCount === 3);
// Correct logic: check if numeric
$lastSegment = $key->afterLast('_')->value();
$correctLogicDetectsPort = is_numeric($lastSegment);
expect($correctLogicDetectsPort)->toBe($shouldDetectPort, "Correct logic should detect port for $varName");
// Show the discrepancy where current logic fails
if ($currentLogicDetectsPort !== $correctLogicDetectsPort) {
// This is a known bug - current logic is wrong
expect($currentLogicDetectsPort)->not->toBe($correctLogicDetectsPort, "Bug confirmed: current logic wrong for $varName");
}
}
});
it('generates correct URL with port suffix', function () {
// Test that URLs are correctly formatted with port appended
$testCases = [
['http://umami-abc123.domain.com', '3000', 'http://umami-abc123.domain.com:3000'],
['http://api-xyz789.domain.com', '8080', 'http://api-xyz789.domain.com:8080'],
['https://db-server.example.com', '5432', 'https://db-server.example.com:5432'],
['http://app.local', '80', 'http://app.local:80'],
];
foreach ($testCases as [$baseUrl, $port, $expectedUrlWithPort]) {
$urlWithPort = "$baseUrl:$port";
expect($urlWithPort)->toBe($expectedUrlWithPort);
}
});
it('generates correct FQDN with port suffix', function () {
// Test that FQDNs are correctly formatted with port appended
$testCases = [
['umami-abc123.domain.com', '3000', 'umami-abc123.domain.com:3000'],
['postgres-xyz789.domain.com', '5432', 'postgres-xyz789.domain.com:5432'],
['redis-cache.example.com', '6379', 'redis-cache.example.com:6379'],
];
foreach ($testCases as [$baseFqdn, $port, $expectedFqdnWithPort]) {
$fqdnWithPort = "$baseFqdn:$port";
expect($fqdnWithPort)->toBe($expectedFqdnWithPort);
}
});
it('correctly identifies service name with various patterns', function () {
// Test service name extraction with different patterns
$testCases = [
// After parsing, service names should preserve underscores
['SERVICE_URL_MY_API_8080', 'my_api'],
['SERVICE_URL_REDIS_CACHE_6379', 'redis_cache'],
['SERVICE_URL_NEW_API_3000', 'new_api'],
['SERVICE_FQDN_DB_SERVER_5432', 'db_server'],
// Single-word services
['SERVICE_URL_UMAMI_3000', 'umami'],
['SERVICE_URL_MYAPP_8080', 'myapp'],
// Without port
['SERVICE_URL_MY_APP', 'my_app'],
['SERVICE_URL_REDIS_PRIMARY', 'redis_primary'],
];
foreach ($testCases as [$varName, $expectedServiceName]) {
// Use the actual helper function from bootstrap/helpers/services.php
$parsed = parseServiceEnvironmentVariable($varName);
expect($parsed['service_name'])->toBe($expectedServiceName, "Service name extraction for $varName");
}
});

View file

@ -172,3 +172,50 @@
expect($extractedPort)->toBe((string) $port, "Port extraction failed for $description");
}
});
it('detects port-specific variables with numeric suffix', function () {
// Test that variables ending with a numeric port are detected correctly
// This tests the logic: if last segment after _ is numeric, it's a port
$tests = [
// 2-underscore pattern: single-word service name + port
'SERVICE_URL_MYAPP_3000' => ['service' => 'myapp', 'port' => '3000', 'hasPort' => true],
'SERVICE_URL_REDIS_6379' => ['service' => 'redis', 'port' => '6379', 'hasPort' => true],
'SERVICE_FQDN_NGINX_80' => ['service' => 'nginx', 'port' => '80', 'hasPort' => true],
// 3-underscore pattern: two-word service name + port
'SERVICE_URL_MY_API_8080' => ['service' => 'my_api', 'port' => '8080', 'hasPort' => true],
'SERVICE_URL_WEB_APP_3000' => ['service' => 'web_app', 'port' => '3000', 'hasPort' => true],
'SERVICE_FQDN_DB_SERVER_5432' => ['service' => 'db_server', 'port' => '5432', 'hasPort' => true],
// 4-underscore pattern: three-word service name + port
'SERVICE_URL_REDIS_CACHE_SERVER_6379' => ['service' => 'redis_cache_server', 'port' => '6379', 'hasPort' => true],
'SERVICE_URL_MY_LONG_APP_8080' => ['service' => 'my_long_app', 'port' => '8080', 'hasPort' => true],
'SERVICE_FQDN_POSTGRES_PRIMARY_DB_5432' => ['service' => 'postgres_primary_db', 'port' => '5432', 'hasPort' => true],
// Non-numeric suffix: should NOT be treated as port-specific
'SERVICE_URL_MY_APP' => ['service' => 'my_app', 'port' => null, 'hasPort' => false],
'SERVICE_URL_REDIS_PRIMARY' => ['service' => 'redis_primary', 'port' => null, 'hasPort' => false],
'SERVICE_FQDN_WEB_SERVER' => ['service' => 'web_server', 'port' => null, 'hasPort' => false],
'SERVICE_URL_APP_CACHE_REDIS' => ['service' => 'app_cache_redis', 'port' => null, 'hasPort' => false],
// Edge numeric cases
'SERVICE_URL_APP_0' => ['service' => 'app', 'port' => '0', 'hasPort' => true], // Port 0
'SERVICE_URL_APP_99999' => ['service' => 'app', 'port' => '99999', 'hasPort' => true], // Port out of range
'SERVICE_URL_APP_3.14' => ['service' => 'app_3.14', 'port' => null, 'hasPort' => false], // Float (should not be port)
'SERVICE_URL_APP_1e5' => ['service' => 'app_1e5', 'port' => null, 'hasPort' => false], // Scientific notation
// Edge cases
'SERVICE_URL_APP' => ['service' => 'app', 'port' => null, 'hasPort' => false],
'SERVICE_FQDN_DB' => ['service' => 'db', 'port' => null, 'hasPort' => false],
];
foreach ($tests as $varName => $expected) {
// Use the actual helper function from bootstrap/helpers/services.php
$parsed = parseServiceEnvironmentVariable($varName);
expect($parsed['service_name'])->toBe($expected['service'], "Service name mismatch for $varName");
expect($parsed['port'])->toBe($expected['port'], "Port mismatch for $varName");
expect($parsed['has_port'])->toBe($expected['hasPort'], "Port detection mismatch for $varName");
}
});