param( [string]$TestCommand = "composer test:unit", [ValidateSet("live", "debug")] [string]$SourceTarget = "", [string]$SourceHost = "", [int]$SourcePort = 3306, [string]$SourceDatabase = "", [string]$SourceUser = "", [string]$SourcePassword = "", [string]$NetworkName = "api-test-net", [string]$DbContainerName = "api-test-db", [string]$RedisContainerName = "api-test-redis", [string]$PhpImage = "api-php-test-runner", [string]$CloneDatabase = "", [string]$CloneRootPassword = "test_root_password", [switch]$SkipBuild, [switch]$ForceBuild, [switch]$SkipTests, [switch]$KeepContainers, [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Read-DotEnv { param([string]$Path) $values = @{} if (-not (Test-Path $Path)) { return $values } foreach ($line in Get-Content $Path) { $trimmed = $line.Trim() if ($trimmed -eq "" -or $trimmed.StartsWith("#")) { continue } $idx = $trimmed.IndexOf("=") if ($idx -lt 1) { continue } $key = $trimmed.Substring(0, $idx).Trim() $value = $trimmed.Substring($idx + 1).Trim() if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { $value = $value.Substring(1, $value.Length - 2) } $values[$key] = $value } return $values } function Get-EnvOrDotEnv { param( [hashtable]$DotEnv, [string]$Key ) $envValue = [Environment]::GetEnvironmentVariable($Key) if (-not [string]::IsNullOrWhiteSpace($envValue)) { return $envValue } if ($DotEnv.ContainsKey($Key) -and -not [string]::IsNullOrWhiteSpace([string]$DotEnv[$Key])) { return [string]$DotEnv[$Key] } return "" } function Resolve-DbValueByTarget { param( [hashtable]$DotEnv, [string]$Target, [string]$Suffix ) $resolvedTarget = $Target if ([string]::IsNullOrWhiteSpace($resolvedTarget)) { $resolvedTarget = Get-EnvOrDotEnv -DotEnv $DotEnv -Key "CONFIG_DB_TARGET" } if ([string]::IsNullOrWhiteSpace($resolvedTarget)) { $resolvedTarget = "live" } $resolvedTarget = $resolvedTarget.ToLowerInvariant() $liveKey = "CONFIG_DB_$Suffix" $debugKey = "CONFIG_DB_DEBUG_$Suffix" if ($resolvedTarget -eq "debug") { $debugValue = Get-EnvOrDotEnv -DotEnv $DotEnv -Key $debugKey if (-not [string]::IsNullOrWhiteSpace($debugValue)) { return $debugValue } } return Get-EnvOrDotEnv -DotEnv $DotEnv -Key $liveKey } function Remove-ContainerIfExists { param([string]$Name) $existingRaw = & docker ps -aq --filter "name=^${Name}$" $existing = (($existingRaw | Out-String).Trim()) if (-not [string]::IsNullOrWhiteSpace($existing)) { & docker rm -f $Name | Out-Null } } $RootDir = Split-Path -Parent $PSScriptRoot Set-Location $RootDir if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker is required but was not found in PATH." } $dotEnv = Read-DotEnv -Path (Join-Path $RootDir ".env") if ([string]::IsNullOrWhiteSpace($SourceTarget)) { $SourceTarget = (Get-EnvOrDotEnv -DotEnv $dotEnv -Key "CONFIG_DB_TARGET").ToLowerInvariant() } if ([string]::IsNullOrWhiteSpace($SourceTarget)) { $SourceTarget = "live" } if ([string]::IsNullOrWhiteSpace($SourceHost)) { $SourceHost = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "HOST" } if ([string]::IsNullOrWhiteSpace($SourceDatabase)) { $SourceDatabase = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "DATABASE" } if ([string]::IsNullOrWhiteSpace($SourceUser)) { $SourceUser = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "USER" } if ([string]::IsNullOrWhiteSpace($SourcePassword)) { $SourcePassword = Resolve-DbValueByTarget -DotEnv $dotEnv -Target $SourceTarget -Suffix "PASSWORD" } if ([string]::IsNullOrWhiteSpace($CloneDatabase)) { $CloneDatabase = "${SourceDatabase}_test_clone" } if ([string]::IsNullOrWhiteSpace($SourceHost) -or [string]::IsNullOrWhiteSpace($SourceDatabase) -or [string]::IsNullOrWhiteSpace($SourceUser) -or [string]::IsNullOrWhiteSpace($SourcePassword)) { throw "Missing source DB credentials for target '$SourceTarget'. Set CONFIG_DB_TARGET and CONFIG_DB_* / CONFIG_DB_DEBUG_* in .env or pass script parameters." } if (-not $Force) { Write-Host "This will clone data from the '$SourceTarget' database target into a local Docker MySQL container." -ForegroundColor Yellow $confirmation = Read-Host "Type CLONE to continue" if ($confirmation -ne "CLONE") { throw "Cancelled." } } if (-not $SkipBuild) { & docker image inspect $PhpImage *> $null $imageExists = ($LASTEXITCODE -eq 0) if ($imageExists -and -not $ForceBuild) { Write-Host "Using cached PHP test image ($PhpImage). Use -ForceBuild to rebuild." -ForegroundColor Cyan } else { Write-Host "Building PHP test image ($PhpImage)..." -ForegroundColor Cyan & docker build -f services/php/Dockerfile -t $PhpImage . } } $networkExists = (& docker network ls --format "{{.Name}}" | Where-Object { $_ -eq $NetworkName }) if (-not $networkExists) { Write-Host "Creating docker network $NetworkName..." -ForegroundColor Cyan & docker network create $NetworkName | Out-Null } Write-Host "Resetting test containers..." -ForegroundColor Cyan Remove-ContainerIfExists -Name $DbContainerName Remove-ContainerIfExists -Name $RedisContainerName Write-Host "Starting cloned MySQL container ($DbContainerName)..." -ForegroundColor Cyan & docker run -d ` --name $DbContainerName ` --network $NetworkName ` -e "MYSQL_ROOT_PASSWORD=$CloneRootPassword" ` -e "MYSQL_DATABASE=$CloneDatabase" ` mysql:8.4 | Out-Null $maxAttempts = 60 for ($i = 1; $i -le $maxAttempts; $i++) { & docker exec -e "MYSQL_PWD=$CloneRootPassword" $DbContainerName sh -lc "mysqladmin -u root ping --silent" *> $null if ($LASTEXITCODE -eq 0) { break } if ($i -eq $maxAttempts) { throw "Timed out waiting for $DbContainerName to be ready." } Start-Sleep -Seconds 2 } Write-Host "Cloning '$SourceTarget' database $SourceDatabase from $SourceHost into $DbContainerName/$CloneDatabase..." -ForegroundColor Cyan $dumpDir = Join-Path $RootDir ".tmp-db-clone" $dumpFile = Join-Path $dumpDir "dump.sql" if (-not (Test-Path $dumpDir)) { New-Item -Path $dumpDir -ItemType Directory | Out-Null } if (Test-Path $dumpFile) { Remove-Item -Path $dumpFile -Force } $dumpMount = $dumpDir.Replace('\', '/') $dumpCmd = "exec mysqldump --compress --single-transaction --quick --set-gtid-purged=OFF -h '$SourceHost' -P '$SourcePort' -u '$SourceUser' '$SourceDatabase' > /dump/dump.sql" & docker run --rm ` -e "MYSQL_PWD=$SourcePassword" ` -v "${dumpMount}:/dump" ` mysql:8.4 sh -lc $dumpCmd if ($LASTEXITCODE -ne 0 -or -not (Test-Path $dumpFile)) { throw "Database dump failed." } $importCmd = "exec mysql -h '$DbContainerName' -u root '$CloneDatabase' < /dump/dump.sql" & docker run --rm ` --network $NetworkName ` -e "MYSQL_PWD=$CloneRootPassword" ` -v "${dumpMount}:/dump" ` mysql:8.4 sh -lc $importCmd if ($LASTEXITCODE -ne 0) { throw "Database import failed." } Remove-Item -Path $dumpFile -Force -ErrorAction SilentlyContinue Write-Host "Starting isolated Redis container ($RedisContainerName)..." -ForegroundColor Cyan & docker run -d --name $RedisContainerName --network $NetworkName redis:7 | Out-Null if (-not $SkipTests) { Write-Host "Running tests in a separate PHP container against cloned DB..." -ForegroundColor Cyan $appPath = Join-Path $RootDir "services/nginx/app" $phpIniPath = Join-Path $RootDir "services/php/php.ini" & docker run --rm ` --network $NetworkName ` --env-file (Join-Path $RootDir ".env") ` -e "CONFIG_DB_HOST=$DbContainerName" ` -e "CONFIG_DB_DATABASE=$CloneDatabase" ` -e "CONFIG_DB_USER=root" ` -e "CONFIG_DB_PASSWORD=$CloneRootPassword" ` -e "REDIS_CONFIG_HOST=$RedisContainerName" ` -e "AUTO_COMPOSER_INSTALL=false" ` -v "${appPath}:/var/www/html" ` -v "${phpIniPath}:/usr/local/etc/php/conf.d/zz-custom.ini:ro" ` $PhpImage ` sh -lc "cd /var/www/html && if [ ! -f vendor/autoload.php ]; then composer install --no-interaction --prefer-dist; fi && $TestCommand" } if (-not $KeepContainers) { Write-Host "Cleaning up test containers..." -ForegroundColor Cyan Remove-ContainerIfExists -Name $RedisContainerName Remove-ContainerIfExists -Name $DbContainerName } else { Write-Host "Keeping containers for inspection:" -ForegroundColor Yellow Write-Host " DB: $DbContainerName" Write-Host " Redis: $RedisContainerName" } Write-Host "Done." -ForegroundColor Green