Enhance MinIO handling in replication management and update legacy test bootstrap. Add MinIO replication logic, legacy setup cleanup, and include necessary tests for improved MinIO interaction and error tolerance.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
USE_ENV=true
|
||||
DEBUG=true
|
||||
ENCRYPTION_KEY=ci-test-encryption-key
|
||||
CORS=*
|
||||
CONFIG_TIMEZONE=Europe/Copenhagen
|
||||
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=root
|
||||
CONFIG_DB_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=root
|
||||
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_DEBUG_PORT=3306
|
||||
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
|
||||
|
||||
REDIS_CONFIG_HOST=redis
|
||||
REDIS_CONFIG_USER=default
|
||||
REDIS_CONFIG_DATABASE=0
|
||||
REDIS_CONFIG_PASSWORD=
|
||||
REDIS_CONFIG_PORT=6379
|
||||
REDIS_CONFIG_DEBUG_HOST=redis
|
||||
REDIS_CONFIG_DEBUG_USER=default
|
||||
REDIS_CONFIG_DEBUG_DATABASE=0
|
||||
REDIS_CONFIG_DEBUG_PASSWORD=
|
||||
REDIS_CONFIG_DEBUG_PORT=6379
|
||||
|
||||
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
|
||||
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
|
||||
WORDPRESS_STATIC_TOKEN=ci-test
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
|
||||
WORDPRESS_API_URL=http://localhost
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
SLACK_DEFAULT_WEBHOOK=
|
||||
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL=0
|
||||
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
|
||||
@@ -0,0 +1,48 @@
|
||||
USE_ENV=true
|
||||
DEBUG=true
|
||||
ENCRYPTION_KEY=ci-test-encryption-key
|
||||
CORS=*
|
||||
CONFIG_TIMEZONE=Europe/Copenhagen
|
||||
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=root
|
||||
CONFIG_DB_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=root
|
||||
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_DEBUG_PORT=3306
|
||||
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
|
||||
|
||||
REDIS_CONFIG_HOST=redis
|
||||
REDIS_CONFIG_USER=default
|
||||
REDIS_CONFIG_DATABASE=0
|
||||
REDIS_CONFIG_PASSWORD=
|
||||
REDIS_CONFIG_PORT=6379
|
||||
REDIS_CONFIG_DEBUG_HOST=redis
|
||||
REDIS_CONFIG_DEBUG_USER=default
|
||||
REDIS_CONFIG_DEBUG_DATABASE=0
|
||||
REDIS_CONFIG_DEBUG_PASSWORD=
|
||||
REDIS_CONFIG_DEBUG_PORT=6379
|
||||
|
||||
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
|
||||
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
|
||||
WORDPRESS_STATIC_TOKEN=ci-test
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
|
||||
WORDPRESS_API_URL=http://localhost
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
SLACK_DEFAULT_WEBHOOK=
|
||||
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL=0
|
||||
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
|
||||
mysql-debug:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-mysql-debug"
|
||||
ports: !reset []
|
||||
|
||||
edge-broker:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||
@@ -19,6 +20,7 @@ services:
|
||||
|
||||
caddy:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-caddy"
|
||||
depends_on: !reset []
|
||||
labels:
|
||||
- "traefik.http.routers.local-api-ci.rule=PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.local-api-ci.entrypoints=web"
|
||||
@@ -30,8 +32,29 @@ services:
|
||||
|
||||
php1:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-php1"
|
||||
depends_on: !reset []
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
USE_ENV: "true"
|
||||
CONFIG_DB_TARGET: "debug"
|
||||
CONFIG_DB_HOST: "mysql-debug"
|
||||
CONFIG_DB_USER: "root"
|
||||
CONFIG_DB_PASSWORD: "debug_root_password"
|
||||
CONFIG_DB_DATABASE: "nnks_db_debug"
|
||||
CONFIG_DB_PORT: "3306"
|
||||
CONFIG_DB_DEBUG_HOST: "mysql-debug"
|
||||
CONFIG_DB_DEBUG_USER: "root"
|
||||
CONFIG_DB_DEBUG_PASSWORD: "debug_root_password"
|
||||
CONFIG_DB_DEBUG_DATABASE: "nnks_db_debug"
|
||||
CONFIG_DB_DEBUG_PORT: "3306"
|
||||
REDIS_CONFIG_HOST: "redis"
|
||||
REDIS_CONFIG_PORT: "6379"
|
||||
REDIS_CONFIG_DATABASE: "0"
|
||||
REDIS_CONFIG_DEBUG_HOST: "redis"
|
||||
REDIS_CONFIG_DEBUG_PORT: "6379"
|
||||
REDIS_CONFIG_DEBUG_DATABASE: "0"
|
||||
TRUCKWASH_TEST_BLOCK_REAL_SHELLY: "1"
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL: "0"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
|
||||
+42
-158
@@ -5,69 +5,42 @@ on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Unit (required)
|
||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: [unit, integration, api, legacy]
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Check AI workflow sync
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
- name: Run PHP ${{ matrix.suite }} suite
|
||||
run: bash scripts/php-ci-test.sh ${{ matrix.suite }}
|
||||
|
||||
- name: Boot php1 test stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
|
||||
|
||||
- name: Run unit tests
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer test:unit"
|
||||
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && if php -m | grep -Eq '^(pcov|xdebug)$'; then composer test:coverage; else echo 'Skipping coverage: no pcov/xdebug extension available in php1 image.'; fi"
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Upload PHP suite logs
|
||||
if: ${{ failure() }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-coverage-clover
|
||||
path: services/nginx/app/build/logs/clover.xml
|
||||
name: php-${{ matrix.suite }}-logs
|
||||
path: .tmp/ci-logs/${{ matrix.suite }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 1
|
||||
|
||||
- name: Tear down php1 test stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
retention-days: 3
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
@@ -100,22 +73,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
- name: Validate compose contracts
|
||||
run: |
|
||||
@@ -153,22 +115,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -179,10 +130,16 @@ jobs:
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
run: >
|
||||
tar
|
||||
--exclude='./vendor'
|
||||
--exclude='./.phpunit.cache'
|
||||
--exclude='./build/logs'
|
||||
-C services/nginx/app -cf - .
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||
|
||||
- name: Verify edge gateway test files
|
||||
run: >
|
||||
@@ -200,7 +157,13 @@ jobs:
|
||||
"cd /var/www/html &&
|
||||
RUN_API_TESTS=1
|
||||
API_TEST_BOOTSTRAP_SCHEMA=1
|
||||
API_TEST_ALLOW_LIVE_DB=1
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
|
||||
CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
|
||||
CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
|
||||
CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
|
||||
@@ -221,6 +184,11 @@ jobs:
|
||||
"cd /var/www/html &&
|
||||
RUN_INTEGRATION_TESTS=1
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
|
||||
CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
|
||||
CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
|
||||
CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
|
||||
@@ -261,87 +229,3 @@ jobs:
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
integration:
|
||||
name: Integration (advisory)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
|
||||
- name: Boot integration stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
|
||||
|
||||
- name: Run integration tests
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T -e RUN_INTEGRATION_TESTS=1 php1 sh -lc "cd /var/www/html && composer test:integration"
|
||||
|
||||
- name: Tear down integration stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
api:
|
||||
name: API (advisory)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
|
||||
- name: Boot API stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
|
||||
|
||||
- name: Run API tests
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T -e RUN_API_TESTS=1 -e API_TEST_BOOTSTRAP_SCHEMA=1 php1 sh -lc "cd /var/www/html && composer test:api"
|
||||
|
||||
- name: Tear down API stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
suite="${1:-}"
|
||||
case "$suite" in
|
||||
unit|integration|api|legacy|all)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
|
||||
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
|
||||
mkdir -p "$env_backup_dir"
|
||||
had_env=0
|
||||
had_env_staging=0
|
||||
if [ -f .env ]; then
|
||||
cp .env "$env_backup_dir/env"
|
||||
had_env=1
|
||||
fi
|
||||
if [ -f .env.staging ]; then
|
||||
cp .env.staging "$env_backup_dir/env.staging"
|
||||
had_env_staging=1
|
||||
fi
|
||||
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
collect_logs() {
|
||||
status="$1"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
|
||||
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
docker compose $compose_files down -v >/dev/null 2>&1 || true
|
||||
if [ "$had_env" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env" .env
|
||||
else
|
||||
rm -f .env
|
||||
fi
|
||||
if [ "$had_env_staging" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env.staging" .env.staging
|
||||
else
|
||||
rm -f .env.staging
|
||||
fi
|
||||
rm -rf "$env_backup_dir"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker compose $compose_files up -d redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
for i in $(seq 1 90); do
|
||||
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
|
||||
-h "${CONFIG_DB_HOST:-mysql-debug}" \
|
||||
-P "${CONFIG_DB_PORT:-3306}" \
|
||||
-u "${CONFIG_DB_USER:-root}" \
|
||||
ping --silent >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for mysql-debug" >&2
|
||||
exit 1
|
||||
'
|
||||
|
||||
tar \
|
||||
--exclude='./vendor' \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
File diff suppressed because one or more lines are too long
@@ -67,6 +67,7 @@ class replication_bootstrap_config
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
|
||||
$database = self::activeDatabaseConfigFromSnapshot($active['database'] ?? null);
|
||||
$redis = self::activeRedisConfigFromSnapshot($active['redis'] ?? null);
|
||||
$minio = self::activeMinioConfigFromSnapshot($active['minio'] ?? null);
|
||||
|
||||
if ($database !== null) {
|
||||
$GLOBALS['CONFIG_DB'] = array_merge($GLOBALS['CONFIG_DB'] ?? [], $database);
|
||||
@@ -75,6 +76,10 @@ class replication_bootstrap_config
|
||||
if ($redis !== null) {
|
||||
$GLOBALS['REDIS_CONFIG'] = array_merge($GLOBALS['REDIS_CONFIG'] ?? [], $redis);
|
||||
}
|
||||
|
||||
if ($minio !== null) {
|
||||
$GLOBALS['MINIO'] = array_merge($GLOBALS['MINIO'] ?? [], $minio);
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[replication-bootstrap] Falling back to environment configuration: ' . $throwable->getMessage());
|
||||
}
|
||||
@@ -123,6 +128,26 @@ class replication_bootstrap_config
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeMinioConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$endpoint = trim((string)($config['endpoint'] ?? ''));
|
||||
$accessKey = trim((string)($config['access_key'] ?? $config['user'] ?? ''));
|
||||
if ($endpoint === '' || $accessKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'endpoint' => $endpoint,
|
||||
'access_key' => $accessKey,
|
||||
'secret_key' => replication_secret_box::decrypt($config['secret_key_secret'] ?? $config['password_secret'] ?? ''),
|
||||
'buckets' => is_array($config['buckets'] ?? null) ? array_values($config['buckets']) : ($config['buckets'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function storageDir(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ class superuser_system_status_service
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -301,9 +302,11 @@ class superuser_system_status_service
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
@@ -526,7 +529,7 @@ class superuser_system_status_service
|
||||
|
||||
protected function minioBuckets(): array
|
||||
{
|
||||
return ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev'];
|
||||
return replication_manager::normalizeMinioBuckets($GLOBALS['MINIO']['buckets'] ?? ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev']);
|
||||
}
|
||||
|
||||
protected function collectModules(bool $force, array &$warnings): array
|
||||
|
||||
@@ -21,6 +21,10 @@ final class workfeed_shift_time_resolver
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_approval = self::hasShiftApproval($record);
|
||||
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
|
||||
$can_use_saved_bounds = $has_approval || $update_time !== null;
|
||||
|
||||
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||
'checkIn.time',
|
||||
'checkIn',
|
||||
@@ -30,6 +34,14 @@ final class workfeed_shift_time_resolver
|
||||
'clockInTime',
|
||||
'approval.originalStart',
|
||||
]);
|
||||
if ($actual_start === null && $can_use_saved_bounds) {
|
||||
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||
'start',
|
||||
'startTime',
|
||||
'from',
|
||||
]);
|
||||
}
|
||||
|
||||
$scheduled_end = self::firstDateTimeFromPaths($record, [
|
||||
'approval.originalEnd',
|
||||
'end',
|
||||
@@ -53,6 +65,13 @@ final class workfeed_shift_time_resolver
|
||||
'checkOut',
|
||||
]);
|
||||
$saved_actual_end = $actual_only_end;
|
||||
if ($saved_actual_end === null && $can_use_saved_bounds) {
|
||||
$saved_actual_end = self::firstDateTimeFromPaths($record, [
|
||||
'end',
|
||||
'endTime',
|
||||
'to',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($saved_actual_end === null && $check_in_punch !== null && $check_out_punch === null) {
|
||||
$saved_actual_end = new DateTime();
|
||||
@@ -71,7 +90,7 @@ final class workfeed_shift_time_resolver
|
||||
'actualStart' => $actual_start,
|
||||
'scheduledEnd' => $scheduled_end,
|
||||
'actualEnd' => $actual_end,
|
||||
'hasApproval' => self::hasShiftApproval($record),
|
||||
'hasApproval' => $has_approval,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,26 @@
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:ci:unit": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php unit"
|
||||
],
|
||||
"test:ci:integration": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php integration"
|
||||
],
|
||||
"test:ci:api": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php api"
|
||||
],
|
||||
"test:ci:legacy": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php legacy"
|
||||
],
|
||||
"test:ci:all": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php all"
|
||||
],
|
||||
"test:coverage": [
|
||||
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
|
||||
"@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
|
||||
|
||||
@@ -21,7 +21,9 @@ require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php'
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_wash_flow_i.php';
|
||||
require_once WD . '/objects/customer_vehicles_o.php';
|
||||
require_once WD . '/objects/department_lanes_o.php';
|
||||
if (!class_exists(\objects\department_lanes_o::class, false)) {
|
||||
require_once WD . '/objects/department_lanes_o.php';
|
||||
}
|
||||
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
|
||||
require_once WD . '/objects/department_selfserve_conditions_o.php';
|
||||
require_once WD . '/objects/department_selfserve_questions_o.php';
|
||||
|
||||
@@ -9,9 +9,15 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
if (!class_exists(\modules\selfserve\classes\selfserve_wash_flow::class, false)) {
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
}
|
||||
if (!class_exists(\modules\selfserve\classes\selfserve_studio_action_runner::class, false)) {
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
|
||||
}
|
||||
if (!class_exists(\modules\selfserve\classes\selfserve_studio_actions::class, false)) {
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
}
|
||||
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
|
||||
@@ -10586,7 +10586,7 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Database and Redis replication topology
|
||||
summary: Database, Redis, and MinIO replication topology
|
||||
operationId: getSuperuserReplication
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -10652,6 +10652,29 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/minio:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Add MinIO replication host credentials
|
||||
operationId: addSuperuserMinioReplicationHost
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHostCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: MinIO replication host added
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHostResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/compose-template:
|
||||
post:
|
||||
tags:
|
||||
@@ -12287,7 +12310,7 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [databases, redis]
|
||||
enum: [databases, redis, minio]
|
||||
SuperuserReplicationHostIdParam:
|
||||
name: id
|
||||
in: path
|
||||
@@ -12432,10 +12455,10 @@ components:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, redis]
|
||||
enum: [database, redis, minio]
|
||||
engine:
|
||||
type: string
|
||||
enum: [mariadb, redis]
|
||||
enum: [mariadb, redis, minio]
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica]
|
||||
@@ -12443,6 +12466,9 @@ components:
|
||||
type: string
|
||||
host_port:
|
||||
type: integer
|
||||
console_port:
|
||||
type: integer
|
||||
nullable: true
|
||||
server_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
@@ -12471,6 +12497,20 @@ components:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
endpoint:
|
||||
type: string
|
||||
scheme:
|
||||
type: string
|
||||
enum: [http, https]
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
console_port:
|
||||
type: integer
|
||||
space_headroom_percent:
|
||||
type: number
|
||||
format: float
|
||||
database:
|
||||
oneOf:
|
||||
- type: string
|
||||
@@ -12505,6 +12545,8 @@ components:
|
||||
$ref: '#/components/schemas/SuperuserReplicationKindSummary'
|
||||
redis:
|
||||
$ref: '#/components/schemas/SuperuserReplicationKindSummary'
|
||||
minio:
|
||||
$ref: '#/components/schemas/SuperuserReplicationKindSummary'
|
||||
write_freeze:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@@ -12550,7 +12592,7 @@ components:
|
||||
type: integer
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, redis]
|
||||
enum: [database, redis, minio]
|
||||
label:
|
||||
type: string
|
||||
host:
|
||||
@@ -12561,6 +12603,25 @@ components:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: integer
|
||||
nullable: true
|
||||
endpoint:
|
||||
type: string
|
||||
nullable: true
|
||||
scheme:
|
||||
type: string
|
||||
enum: [http, https]
|
||||
nullable: true
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
console_port:
|
||||
type: integer
|
||||
nullable: true
|
||||
space_headroom_percent:
|
||||
type: number
|
||||
format: float
|
||||
nullable: true
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica, inactive]
|
||||
@@ -12589,6 +12650,10 @@ components:
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
description: Hostname or MinIO endpoint. MinIO hosts may include http(s) scheme; the backend stores the host without scheme.
|
||||
endpoint:
|
||||
type: string
|
||||
description: Optional MinIO endpoint alias for host.
|
||||
port:
|
||||
type: integer
|
||||
database:
|
||||
@@ -12597,9 +12662,27 @@ components:
|
||||
- type: integer
|
||||
username:
|
||||
type: string
|
||||
description: Database/Redis username or MinIO access key.
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
description: Database/Redis password or MinIO secret key.
|
||||
scheme:
|
||||
type: string
|
||||
enum: [http, https]
|
||||
description: MinIO endpoint scheme.
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: MinIO buckets to replicate.
|
||||
console_port:
|
||||
type: integer
|
||||
description: Optional MinIO console port for UI display.
|
||||
space_headroom_percent:
|
||||
type: number
|
||||
format: float
|
||||
description: MinIO free-space headroom required before provisioning. Defaults to 20.
|
||||
admin_username:
|
||||
type: string
|
||||
admin_password:
|
||||
@@ -12618,6 +12701,18 @@ components:
|
||||
allow_preseeded_replica:
|
||||
type: boolean
|
||||
description: Allow configuring replication when the replica has already been safely seeded outside the orchestrator. Required for MariaDB, which does not support MySQL Clone.
|
||||
scheme:
|
||||
type: string
|
||||
enum: [http, https]
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
console_port:
|
||||
type: integer
|
||||
space_headroom_percent:
|
||||
type: number
|
||||
format: float
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationUnsavedCredentialTestRequest:
|
||||
@@ -12629,7 +12724,7 @@ components:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, databases, mysql, redis]
|
||||
enum: [database, databases, mysql, redis, minio, s3, object-storage, object_storage]
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica]
|
||||
@@ -12639,7 +12734,7 @@ components:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, databases, mysql, redis]
|
||||
enum: [database, databases, mysql, redis, minio, s3, object-storage, object_storage]
|
||||
default: database
|
||||
role:
|
||||
type: string
|
||||
@@ -12688,6 +12783,23 @@ components:
|
||||
minimum: 1
|
||||
maximum: 65535
|
||||
description: Redis primary port used when generating a Redis replica template.
|
||||
primary_password:
|
||||
type: string
|
||||
format: password
|
||||
description: Redis primary password used in the generated Redis replica .env file. If omitted, the .env keeps the value blank for manual entry.
|
||||
primary_username:
|
||||
type: string
|
||||
description: Optional Redis primary ACL username used in the generated Redis replica .env file. Leave blank or default for the default Redis user.
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: MinIO buckets to create, version, and replicate.
|
||||
console_port:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 65535
|
||||
description: MinIO console port exposed by the generated compose service.
|
||||
|
||||
SuperuserSystemStatusPayload:
|
||||
type: object
|
||||
@@ -12835,6 +12947,8 @@ components:
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
replication:
|
||||
$ref: '#/components/schemas/SuperuserReplicationStatus'
|
||||
|
||||
SuperuserModuleStatus:
|
||||
type: object
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<testsuite name="Api">
|
||||
<directory suffix="Test.php">tests/Api</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Legacy">
|
||||
<directory suffix="Test.php">tests/Legacy</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
|
||||
@@ -20,7 +20,7 @@ class superuserReplicationRoute
|
||||
$refresh = $this->toBool($this->getParameter('refresh'), false);
|
||||
$response->success((new replication_manager())->summary($refresh));
|
||||
}, [
|
||||
'superuser_replication_view' => 'View database and Redis replication topology and status',
|
||||
'superuser_replication_view' => 'View database, Redis, and MinIO replication topology and status',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/databases', function () {
|
||||
@@ -43,13 +43,23 @@ class superuserReplicationRoute
|
||||
'superuser_replication_manage' => 'Add and manage Redis replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/minio', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage MinIO replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/compose-template', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database and Redis hosts',
|
||||
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/test-credentials', function () {
|
||||
@@ -62,7 +72,7 @@ class superuserReplicationRoute
|
||||
$parameters
|
||||
));
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Test database and Redis replication host credentials before saving them',
|
||||
'superuser_replication_manage' => 'Test database, Redis, and MinIO replication host credentials before saving them',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/test', function () {
|
||||
@@ -75,7 +85,7 @@ class superuserReplicationRoute
|
||||
$this->actorUserId()
|
||||
));
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Validate database and Redis replication host connectivity and privileges',
|
||||
'superuser_replication_manage' => 'Validate database, Redis, and MinIO replication host connectivity and privileges',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/provision', function () {
|
||||
@@ -96,7 +106,7 @@ class superuserReplicationRoute
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Provision a database or Redis host as a replica of the current primary',
|
||||
'superuser_replication_manage' => 'Provision a database, Redis, or MinIO host as a replica of the current primary',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/promote', function () {
|
||||
@@ -113,7 +123,7 @@ class superuserReplicationRoute
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_replication_promote' => 'Promote a healthy caught-up database or Redis replica to primary',
|
||||
'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/replication/{kind}/{id}', function () {
|
||||
@@ -130,7 +140,7 @@ class superuserReplicationRoute
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database or Redis replicas',
|
||||
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @return array<int, array{path:string, classification:string, type:string, reason?:string}>
|
||||
*/
|
||||
function legacy_test_manifest(): array
|
||||
{
|
||||
return require app_path('tests/Support/legacy_test_manifest.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path:string, classification:string, type:string, bootstrap?:string} $entry
|
||||
* @return array{exitCode:int, output:string}
|
||||
*/
|
||||
function run_legacy_manifest_entry(array $entry): array
|
||||
{
|
||||
$path = app_path($entry['path']);
|
||||
$php = escapeshellarg(PHP_BINARY);
|
||||
|
||||
if ($entry['type'] === 'phpunit') {
|
||||
$command = $php
|
||||
. ' ' . escapeshellarg(app_path('vendor/bin/phpunit'))
|
||||
. ' --bootstrap ' . escapeshellarg(app_path('tests/Support/legacy_bootstrap.php'))
|
||||
. ' ' . escapeshellarg($path)
|
||||
. ' 2>&1';
|
||||
} else {
|
||||
$bootstrap = $entry['bootstrap'] ?? 'app';
|
||||
$command = $php
|
||||
. ' ' . escapeshellarg(app_path('tests/Support/run_legacy_script.php'))
|
||||
. ' ' . escapeshellarg($entry['path'])
|
||||
. ' ' . escapeshellarg($bootstrap)
|
||||
. ' 2>&1';
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
$exitCode = 0;
|
||||
exec($command, $lines, $exitCode);
|
||||
|
||||
return [
|
||||
'exitCode' => $exitCode,
|
||||
'output' => implode(PHP_EOL, $lines),
|
||||
];
|
||||
}
|
||||
|
||||
foreach (legacy_test_manifest() as $entry) {
|
||||
it('runs legacy PHP test ' . $entry['path'], function () use ($entry): void {
|
||||
if ($entry['classification'] === 'manual-external') {
|
||||
test()->markTestSkipped($entry['reason'] ?? 'Legacy test requires an external dependency.');
|
||||
}
|
||||
|
||||
if (getenv('RUN_LEGACY_TESTS') !== '1') {
|
||||
test()->markTestSkipped('Set RUN_LEGACY_TESTS=1 to run legacy PHP tests.');
|
||||
}
|
||||
|
||||
$result = run_legacy_manifest_entry($entry);
|
||||
|
||||
expect($result['exitCode'])->toBe(
|
||||
0,
|
||||
'Legacy test failed: ' . $entry['path'] . PHP_EOL . $result['output']
|
||||
);
|
||||
})->group('legacy', $entry['classification']);
|
||||
}
|
||||
@@ -7,3 +7,4 @@ use Tests\Support\Api\ApiTestCase;
|
||||
uses()->group('unit')->in('Unit');
|
||||
uses()->group('integration')->in('Integration');
|
||||
uses(ApiTestCase::class)->group('api')->in('Api');
|
||||
uses()->group('legacy')->in('Legacy');
|
||||
|
||||
@@ -20,6 +20,7 @@ final class ApiSchemaBootstrap
|
||||
}
|
||||
|
||||
$this->ensureDepartmentArchiveSchema();
|
||||
$this->ensureOrderInvoiceCollectionSchema();
|
||||
|
||||
foreach ($this->viewStatements() as $name => $sql) {
|
||||
$this->execute($name, $sql);
|
||||
@@ -85,7 +86,7 @@ CREATE TABLE IF NOT EXISTS `departments` (
|
||||
`economic_department_id` INT NOT NULL DEFAULT 0,
|
||||
`slack_webhook` TEXT NULL,
|
||||
`dimension` INT NOT NULL DEFAULT 0,
|
||||
`branding` INT NOT NULL DEFAULT 0,
|
||||
`branding` INT NULL DEFAULT NULL,
|
||||
`visible` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`archived` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
@@ -126,6 +127,246 @@ CREATE TABLE IF NOT EXISTS `department_gates` (
|
||||
KEY `idx_department_gates_department` (`department`),
|
||||
KEY `idx_department_gates_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'branding' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `branding` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(255) NULL,
|
||||
`description` TEXT NULL,
|
||||
`cvr` INT NULL,
|
||||
`address` VARCHAR(255) NULL,
|
||||
`phone_country_code` INT NULL,
|
||||
`phone` INT NULL,
|
||||
`email` VARCHAR(255) NULL,
|
||||
`website` VARCHAR(255) NULL,
|
||||
`banner` VARCHAR(255) NULL,
|
||||
`logo` VARCHAR(255) NULL,
|
||||
`favicon` VARCHAR(255) NULL,
|
||||
`signature` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_branding_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_lanes' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_lanes` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`relay_in_id` VARCHAR(255) NULL,
|
||||
`relay_out_id` VARCHAR(255) NULL,
|
||||
`relay_machine_id` VARCHAR(255) NULL,
|
||||
`relay_machine_program_picker_id` VARCHAR(255) NULL,
|
||||
`relay_machine_cleaner_id` VARCHAR(255) NULL,
|
||||
`dynamic_image_id` INT NULL,
|
||||
`machine_type_id` INT NULL,
|
||||
`selfserve_enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_lanes_department` (`department`),
|
||||
KEY `idx_department_lanes_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_selfserve_conditions' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_selfserve_conditions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department` INT NOT NULL,
|
||||
`lane` INT NULL,
|
||||
`product` INT NULL,
|
||||
`machine_type_id` INT NULL,
|
||||
`condition_id` INT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_selfserve_conditions_department` (`department`),
|
||||
KEY `idx_department_selfserve_conditions_lane` (`lane`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_selfserve_condition_rules' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_selfserve_condition_rules` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`condition_id` INT NOT NULL,
|
||||
`type` VARCHAR(64) NOT NULL,
|
||||
`object_type` VARCHAR(64) NOT NULL,
|
||||
`object_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NULL,
|
||||
`description` TEXT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_selfserve_condition_rules_condition` (`condition_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_selfserve_questions' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_selfserve_questions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department` INT NOT NULL,
|
||||
`lane` INT NULL,
|
||||
`product` INT NULL,
|
||||
`condition_id` INT NULL,
|
||||
`question` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`order_priority` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_selfserve_questions_department` (`department`),
|
||||
KEY `idx_department_selfserve_questions_lane` (`lane`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_selfserve_tasks' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_selfserve_tasks` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department` INT NOT NULL,
|
||||
`lane` INT NULL,
|
||||
`product` INT NULL,
|
||||
`machine_type_id` INT NULL,
|
||||
`condition_id` INT NULL,
|
||||
`gate_type` VARCHAR(16) NULL DEFAULT 'ALWAYS',
|
||||
`gate_ref_id` INT NULL,
|
||||
`task` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`order_priority` INT NOT NULL DEFAULT 0,
|
||||
`services` JSON NULL,
|
||||
`buttons` JSON NULL,
|
||||
`dynamic_images_vehicle_type` INT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_selfserve_tasks_department` (`department`),
|
||||
KEY `idx_department_selfserve_tasks_lane` (`lane`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'department_selfserve_vehicle_conditions' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `department_selfserve_vehicle_conditions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department` INT NOT NULL,
|
||||
`lane` INT NULL,
|
||||
`customer_id` INT NULL,
|
||||
`reg` VARCHAR(64) NULL,
|
||||
`question` INT NOT NULL,
|
||||
`value` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_department_selfserve_vehicle_conditions_department` (`department`),
|
||||
KEY `idx_department_selfserve_vehicle_conditions_reg` (`reg`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'selfserve_machine_types' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `selfserve_machine_types` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_selfserve_machine_types_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'selfserve_wash_sessions' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`lane_id` INT NOT NULL,
|
||||
`department_id` INT NOT NULL,
|
||||
`machine_type_id` INT NULL,
|
||||
`customer_number` INT NULL,
|
||||
`vehicle_id` INT NULL,
|
||||
`vehicle_type_id` INT NULL,
|
||||
`reg` VARCHAR(255) NOT NULL,
|
||||
`status` VARCHAR(64) NOT NULL DEFAULT 'PENDING_QUESTIONS',
|
||||
`allowed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`machine_relay_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`machine_relay_enabled_at` DATETIME NULL,
|
||||
`machine_start_triggered` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`machine_start_triggered_at` DATETIME NULL,
|
||||
`wash_started_at` DATETIME NULL,
|
||||
`order_id` INT NULL,
|
||||
`completed_at` DATETIME NULL,
|
||||
`metadata_json` JSON NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`),
|
||||
KEY `idx_selfserve_wash_sessions_status` (`status`),
|
||||
KEY `idx_selfserve_wash_sessions_customer` (`customer_number`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'selfserve_wash_session_answers' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `selfserve_wash_session_answers` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`session_id` INT NOT NULL,
|
||||
`question_id` INT NOT NULL,
|
||||
`question_text` VARCHAR(255) NOT NULL,
|
||||
`answer_value` TINYINT(1) NOT NULL,
|
||||
`answered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_selfserve_wash_session_answer` (`session_id`, `question_id`),
|
||||
KEY `idx_selfserve_wash_session_answers_session` (`session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'selfserve_wash_session_tasks' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `selfserve_wash_session_tasks` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`session_id` INT NOT NULL,
|
||||
`task_id` INT NULL,
|
||||
`task_text` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`services` JSON NULL,
|
||||
`buttons` JSON NULL,
|
||||
`dynamic_image_id` INT NULL,
|
||||
`dynamic_images_vehicle_type` INT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_selfserve_wash_session_tasks_session` (`session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'selfserve_wash_session_events' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `selfserve_wash_session_events` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`session_id` INT NOT NULL,
|
||||
`event_type` VARCHAR(64) NOT NULL,
|
||||
`payload_json` JSON NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_selfserve_wash_session_events_session` (`session_id`),
|
||||
KEY `idx_selfserve_wash_session_events_type` (`event_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'plate_scanners' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `plate_scanners` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department_id` INT NOT NULL,
|
||||
`lane_id` INT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`notes` TEXT NULL,
|
||||
`api_key` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_plate_scanners_department_id` (`department_id`),
|
||||
KEY `idx_plate_scanners_lane_id` (`lane_id`),
|
||||
KEY `idx_plate_scanners_api_key` (`api_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'categories' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `categories` (
|
||||
@@ -616,6 +857,44 @@ SQL,
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureOrderInvoiceCollectionSchema(): void
|
||||
{
|
||||
if (!$this->columnExists('orders', 'invoice_collection_id')) {
|
||||
$this->execute(
|
||||
'orders.invoice_collection_id',
|
||||
'ALTER TABLE `orders` ADD COLUMN `invoice_collection_id` INT NULL'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->indexExists('orders', 'idx_orders_invoice_collection_id')) {
|
||||
$this->execute(
|
||||
'orders.idx_orders_invoice_collection_id',
|
||||
'ALTER TABLE `orders` ADD INDEX `idx_orders_invoice_collection_id` (`invoice_collection_id`)'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->columnExists('collected_order_invoices', 'processor')) {
|
||||
$this->execute(
|
||||
'collected_order_invoices.processor',
|
||||
'ALTER TABLE `collected_order_invoices` ADD COLUMN `processor` INT NOT NULL DEFAULT 0'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->columnExists('collected_order_invoices', 'booked_invoice_id')) {
|
||||
$this->execute(
|
||||
'collected_order_invoices.booked_invoice_id',
|
||||
'ALTER TABLE `collected_order_invoices` ADD COLUMN `booked_invoice_id` INT NULL'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->columnExists('collected_order_invoices', 'closed_at')) {
|
||||
$this->execute(
|
||||
'collected_order_invoices.closed_at',
|
||||
'ALTER TABLE `collected_order_invoices` ADD COLUMN `closed_at` DATETIME NULL'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
$table = $this->db->real_escape_string($table);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$legacyRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (!defined('WD')) {
|
||||
define('WD', $legacyRoot);
|
||||
}
|
||||
|
||||
chdir(rtrim(WD, DIRECTORY_SEPARATOR));
|
||||
|
||||
$legacyDefaults = [
|
||||
'USE_ENV' => 'true',
|
||||
'DEBUG' => 'true',
|
||||
'ENCRYPTION_KEY' => 'ci-test-encryption-key',
|
||||
'CORS' => '*',
|
||||
'CONFIG_TIMEZONE' => 'Europe/Copenhagen',
|
||||
'CONFIG_DB_TARGET' => 'debug',
|
||||
'CONFIG_DB_HOST' => 'mysql-debug',
|
||||
'CONFIG_DB_USER' => 'root',
|
||||
'CONFIG_DB_PASSWORD' => 'debug_root_password',
|
||||
'CONFIG_DB_DATABASE' => 'nnks_db_debug',
|
||||
'CONFIG_DB_PORT' => '3306',
|
||||
'CONFIG_DB_DEBUG_HOST' => 'mysql-debug',
|
||||
'CONFIG_DB_DEBUG_USER' => 'root',
|
||||
'CONFIG_DB_DEBUG_PASSWORD' => 'debug_root_password',
|
||||
'CONFIG_DB_DEBUG_DATABASE' => 'nnks_db_debug',
|
||||
'CONFIG_DB_DEBUG_PORT' => '3306',
|
||||
'REDIS_CONFIG_HOST' => 'redis',
|
||||
'REDIS_CONFIG_USER' => 'default',
|
||||
'REDIS_CONFIG_DATABASE' => '0',
|
||||
'REDIS_CONFIG_PASSWORD' => '',
|
||||
'REDIS_CONFIG_PORT' => '6379',
|
||||
'REDIS_CONFIG_DEBUG_HOST' => 'redis',
|
||||
'REDIS_CONFIG_DEBUG_USER' => 'default',
|
||||
'REDIS_CONFIG_DEBUG_DATABASE' => '0',
|
||||
'REDIS_CONFIG_DEBUG_PASSWORD' => '',
|
||||
'REDIS_CONFIG_DEBUG_PORT' => '6379',
|
||||
'ECONOMIC_API_APP_ACCESS_GRANT' => 'ci-test',
|
||||
'ECONOMIC_API_APP_ACCESS_GRANT2' => 'ci-test-secondary',
|
||||
'ECONOMIC_API_APP_SECRET_TOKEN' => 'ci-test-secret',
|
||||
'WORDPRESS_STATIC_TOKEN' => 'ci-test',
|
||||
'EMAIL_WASH_CERTIFICATE_TOKEN' => 'ci-test',
|
||||
'WORDPRESS_API_URL' => 'http://localhost',
|
||||
'MINIO_ENDPOINT' => '',
|
||||
'MINIO_ACCESS_KEY' => '',
|
||||
'MINIO_SECRET_KEY' => '',
|
||||
'SLACK_DEFAULT_WEBHOOK' => '',
|
||||
'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci',
|
||||
'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0',
|
||||
'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1',
|
||||
];
|
||||
|
||||
foreach ($legacyDefaults as $key => $value) {
|
||||
if (getenv($key) !== false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
require_once WD . 'vendor/autoload.php';
|
||||
require_once WD . 'config.php';
|
||||
|
||||
if (getenv('API_TEST_BOOTSTRAP_SCHEMA') === '1') {
|
||||
require_once WD . 'tests/Support/Api/ApiSchemaBootstrap.php';
|
||||
|
||||
$schemaDb = new mysqli(
|
||||
getenv('CONFIG_DB_HOST') ?: 'mysql-debug',
|
||||
getenv('CONFIG_DB_USER') ?: 'root',
|
||||
getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password',
|
||||
getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug',
|
||||
(int)(getenv('CONFIG_DB_PORT') ?: 3306)
|
||||
);
|
||||
|
||||
if ($schemaDb->connect_errno) {
|
||||
fwrite(STDERR, 'Unable to bootstrap legacy schema: ' . $schemaDb->connect_error . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
(new Tests\Support\Api\ApiSchemaBootstrap($schemaDb))->ensureSchema();
|
||||
$schemaDb->close();
|
||||
}
|
||||
|
||||
if (!isset($GLOBALS['db']) || !$GLOBALS['db'] instanceof classes\db) {
|
||||
$GLOBALS['db'] = new classes\db($GLOBALS['CONFIG_DB']);
|
||||
$GLOBALS['db']->connect();
|
||||
}
|
||||
|
||||
$GLOBALS['db']->query("
|
||||
INSERT INTO departments (id, name, visible, archived)
|
||||
VALUES (1, 'CI Self-Serve Department', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
visible = VALUES(visible),
|
||||
archived = VALUES(archived)
|
||||
");
|
||||
$GLOBALS['db']->query("
|
||||
INSERT INTO department_lanes (id, department, name, relay_machine_id, selfserve_enabled)
|
||||
VALUES (1, 1, 'CI Lane', 'demo-machine-relay', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
department = VALUES(department),
|
||||
name = VALUES(name),
|
||||
relay_machine_id = VALUES(relay_machine_id),
|
||||
selfserve_enabled = VALUES(selfserve_enabled),
|
||||
deleted_at = NULL
|
||||
");
|
||||
$GLOBALS['db']->query("
|
||||
DELETE FROM department_variables
|
||||
WHERE department_id = 1
|
||||
AND variable = 'selfserve_enabled'
|
||||
");
|
||||
$GLOBALS['db']->query("
|
||||
INSERT INTO department_variables (department_id, variable, value)
|
||||
VALUES (1, 'selfserve_enabled', 'true')
|
||||
");
|
||||
|
||||
if (!defined('redis')) {
|
||||
try {
|
||||
define('redis', (new classes\redis())->connect());
|
||||
} catch (Throwable $throwable) {
|
||||
fwrite(STDERR, 'Unable to connect Redis for legacy test bootstrap: ' . $throwable->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/TwoFactorAuthTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/auth/WebAuthnInstallTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/bookingModule/BookingModuleTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/bookingModule/bookingSyncTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Requires the live WordPress bookings API and wash certificate object storage.'],
|
||||
['path' => 'tests/dynamicimages/DepartmentLanesImageTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'],
|
||||
['path' => 'tests/economicOrderParser/economicOrderParserTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Calls the live e-conomic API and is not deterministic in CI.'],
|
||||
['path' => 'tests/goals/DepartmentDailyTargetsRendererTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/goals/MonthlyTargetRendererTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/goalsModule/goalsTest.php', 'classification' => 'unit', 'type' => 'phpunit'],
|
||||
['path' => 'tests/lanes/DepartmentLaneDynamicImageIdTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/minio/minioTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/permissions/PermissionNodeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/permissions/PermissionRedisCacheTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/redis/redisLogSyncTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/redis/redisTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/selfserve/ButtonsNormalizationTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'],
|
||||
['path' => 'tests/selfserve/DynamicImagesVehicleTypeNormalizationTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'],
|
||||
['path' => 'tests/selfserve/ForceMachineRelayBypassTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/selfserve/SelfserveLaneServicesEnumTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/selfserve/SelfServeRelayGatingTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/selfserve/StopTurnsOffRelayTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'],
|
||||
['path' => 'tests/slackModule/SlackModuleTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Requires a configured Slack webhook and department webhook cache state.'],
|
||||
['path' => 'tests/subusers/SelfservePermissionInitTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/subusers/SubusersRoutePermissionLinkTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/subusers/SubuserUserGrantInitTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
];
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$suite = strtolower((string)($argv[1] ?? ''));
|
||||
|
||||
$commonEnv = [
|
||||
'USE_ENV' => 'true',
|
||||
'CONFIG_DB_TARGET' => 'debug',
|
||||
'CONFIG_DB_HOST' => 'mysql-debug',
|
||||
'CONFIG_DB_USER' => 'root',
|
||||
'CONFIG_DB_PASSWORD' => 'debug_root_password',
|
||||
'CONFIG_DB_DATABASE' => 'nnks_db_debug',
|
||||
'CONFIG_DB_PORT' => '3306',
|
||||
'CONFIG_DB_DEBUG_HOST' => 'mysql-debug',
|
||||
'CONFIG_DB_DEBUG_USER' => 'root',
|
||||
'CONFIG_DB_DEBUG_PASSWORD' => 'debug_root_password',
|
||||
'CONFIG_DB_DEBUG_DATABASE' => 'nnks_db_debug',
|
||||
'CONFIG_DB_DEBUG_PORT' => '3306',
|
||||
'REDIS_CONFIG_HOST' => 'redis',
|
||||
'REDIS_CONFIG_PORT' => '6379',
|
||||
'REDIS_CONFIG_DATABASE' => '0',
|
||||
'REDIS_CONFIG_DEBUG_HOST' => 'redis',
|
||||
'REDIS_CONFIG_DEBUG_PORT' => '6379',
|
||||
'REDIS_CONFIG_DEBUG_DATABASE' => '0',
|
||||
'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci',
|
||||
'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0',
|
||||
'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1',
|
||||
];
|
||||
|
||||
foreach ($commonEnv as $key => $value) {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
$commands = [
|
||||
'unit' => [
|
||||
'vendor/bin/pest --testsuite=Unit --colors=always',
|
||||
],
|
||||
'integration' => [
|
||||
'RUN_INTEGRATION_TESTS=1 vendor/bin/pest --testsuite=Integration --colors=always',
|
||||
],
|
||||
'api' => [
|
||||
'RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always',
|
||||
],
|
||||
'legacy' => [
|
||||
'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always',
|
||||
],
|
||||
];
|
||||
|
||||
function reset_ci_state(): void
|
||||
{
|
||||
$database = getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug';
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $database)) {
|
||||
fwrite(STDERR, 'Refusing to reset unsafe database name: ' . $database . PHP_EOL);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$db = new mysqli(
|
||||
getenv('CONFIG_DB_HOST') ?: 'mysql-debug',
|
||||
getenv('CONFIG_DB_USER') ?: 'root',
|
||||
getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password',
|
||||
'',
|
||||
(int)(getenv('CONFIG_DB_PORT') ?: 3306)
|
||||
);
|
||||
|
||||
if ($db->connect_errno) {
|
||||
fwrite(STDERR, 'Unable to reset CI database: ' . $db->connect_error . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db->query("DROP DATABASE IF EXISTS `{$database}`");
|
||||
$db->query("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$db->close();
|
||||
|
||||
$redisHost = escapeshellarg(getenv('REDIS_CONFIG_HOST') ?: 'redis');
|
||||
$redisPort = (int)(getenv('REDIS_CONFIG_PORT') ?: 6379);
|
||||
$redisDb = (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0);
|
||||
passthru("redis-cli -h {$redisHost} -p {$redisPort} -n {$redisDb} FLUSHDB >/dev/null", $redisExitCode);
|
||||
if ($redisExitCode !== 0) {
|
||||
fwrite(STDERR, 'Unable to reset CI Redis database.' . PHP_EOL);
|
||||
exit($redisExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
if ($suite === 'all') {
|
||||
foreach (['unit', 'integration', 'api', 'legacy'] as $selectedSuite) {
|
||||
reset_ci_state();
|
||||
foreach ($commands[$selectedSuite] as $command) {
|
||||
passthru($command, $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
exit($exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
exit(0);
|
||||
} elseif (isset($commands[$suite])) {
|
||||
$selectedCommands = $commands[$suite];
|
||||
} else {
|
||||
fwrite(STDERR, "Usage: php tests/Support/run_ci_suite.php <unit|integration|api|legacy|all>" . PHP_EOL);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
foreach ($selectedCommands as $command) {
|
||||
passthru($command, $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
exit($exitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR;
|
||||
|
||||
$relativePath = str_replace('\\', '/', (string)($argv[1] ?? ''));
|
||||
if ($relativePath === '' || str_contains($relativePath, '..')) {
|
||||
fwrite(STDERR, 'Missing or invalid legacy test path.' . PHP_EOL);
|
||||
exit(2);
|
||||
}
|
||||
$bootstrap = strtolower((string)($argv[2] ?? 'app'));
|
||||
if (!in_array($bootstrap, ['app', 'lite'], true)) {
|
||||
fwrite(STDERR, 'Invalid legacy bootstrap mode: ' . $bootstrap . PHP_EOL);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$scriptPath = $root . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
|
||||
if (!is_file($scriptPath)) {
|
||||
fwrite(STDERR, 'Legacy test not found: ' . $relativePath . PHP_EOL);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
if ($bootstrap === 'app') {
|
||||
require_once $root . 'tests/Support/legacy_bootstrap.php';
|
||||
}
|
||||
|
||||
try {
|
||||
require $scriptPath;
|
||||
} catch (Throwable $throwable) {
|
||||
fwrite(STDERR, get_class($throwable) . ': ' . $throwable->getMessage() . PHP_EOL);
|
||||
fwrite(STDERR, $throwable->getFile() . ':' . $throwable->getLine() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -120,6 +120,7 @@ it('normalizes period pagination options and clamps invalid page and limit value
|
||||
'page' => 1,
|
||||
'limit' => 500,
|
||||
'search' => 'Nordic',
|
||||
'flagTab' => 'all',
|
||||
'includeRequiresAction' => false,
|
||||
'includeBooked' => false,
|
||||
]);
|
||||
|
||||
@@ -34,6 +34,9 @@ it('normalizes public replication kind aliases', function (): void {
|
||||
expect(replication_manager::normalizeKind('databases'))->toBe('database');
|
||||
expect(replication_manager::normalizeKind('mysql'))->toBe('database');
|
||||
expect(replication_manager::normalizeKind('redis'))->toBe('redis');
|
||||
expect(replication_manager::normalizeKind('minio'))->toBe('minio');
|
||||
expect(replication_manager::normalizeKind('s3'))->toBe('minio');
|
||||
expect(replication_manager::normalizeKind('object-storage'))->toBe('minio');
|
||||
});
|
||||
|
||||
it('generates replication-ready MariaDB compose templates without embedding secrets', function (): void {
|
||||
@@ -149,15 +152,115 @@ it('generates Redis replica compose templates with primary connection placeholde
|
||||
|
||||
expect($template['kind'])->toBe('redis');
|
||||
expect($template['compose'])->toContain('image: "redis:7"');
|
||||
expect($template['compose'])->toContain('"--replicaof"');
|
||||
expect($template['compose'])->toContain('"redis-primary.internal"');
|
||||
expect($template['compose'])->toContain('"6379"');
|
||||
expect($template['compose'])->toContain('REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"');
|
||||
expect($template['compose'])->toContain('REDIS_PRIMARY_HOST: "${REDIS_PRIMARY_HOST:?set REDIS_PRIMARY_HOST}"');
|
||||
expect($template['compose'])->toContain('REDIS_PRIMARY_PORT: "${REDIS_PRIMARY_PORT:-6379}"');
|
||||
expect($template['compose'])->toContain('${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}');
|
||||
expect($template['compose'])->toContain('REDIS_PRIMARY_USERNAME: "${REDIS_PRIMARY_USERNAME:-}"');
|
||||
expect($template['compose'])->toContain('if [ ! -f /data/redis.conf ]; then');
|
||||
expect($template['compose'])->toContain('> /data/redis.conf');
|
||||
expect($template['compose'])->toContain('exec redis-server /data/redis.conf');
|
||||
expect($template['compose'])->toContain('echo "replicaof $$REDIS_PRIMARY_HOST $${REDIS_PRIMARY_PORT:-6379}"');
|
||||
expect($template['compose'])->toContain('echo "masterauth $$REDIS_PRIMARY_PASSWORD"');
|
||||
expect($template['compose'])->toContain('echo "masteruser $$REDIS_PRIMARY_USERNAME"');
|
||||
expect($template['compose'])->toContain('redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep PONG');
|
||||
expect($template['compose'])->not->toContain($template['credentials']['password']);
|
||||
expect($template['env'])->toMatch('/REDIS_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['env'])->toMatch('/REDIS_PRIMARY_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['env'])->toContain('REDIS_PRIMARY_HOST=redis-primary.internal');
|
||||
expect($template['env'])->toContain('REDIS_PRIMARY_PORT=6379');
|
||||
expect($template['env'])->toContain("REDIS_PRIMARY_PASSWORD=\n");
|
||||
expect($template['env'])->toContain("REDIS_PRIMARY_USERNAME=\n");
|
||||
expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/');
|
||||
});
|
||||
|
||||
it('generates MinIO replica compose templates without embedding secrets', function (): void {
|
||||
$template = replication_manager::composeTemplate([
|
||||
'kind' => 'minio',
|
||||
'role' => 'replica',
|
||||
'service_name' => 'MinIO Replica 1',
|
||||
'host_port' => 9010,
|
||||
'console_port' => 9011,
|
||||
'buckets' => ['attachments', 'uploads'],
|
||||
]);
|
||||
|
||||
expect($template['kind'])->toBe('minio');
|
||||
expect($template['engine'])->toBe('minio');
|
||||
expect($template['service_name'])->toBe('minio-replica-1');
|
||||
expect($template['host_port'])->toBe(9010);
|
||||
expect($template['console_port'])->toBe(9011);
|
||||
expect($template['compose'])->toContain('image: "minio/minio:latest"');
|
||||
expect($template['compose'])->toContain('MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"');
|
||||
expect($template['compose'])->toContain('MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"');
|
||||
expect($template['compose'])->toContain('"9010:9000"');
|
||||
expect($template['compose'])->toContain('"9011:9001"');
|
||||
expect($template['compose'])->toContain('mc mb --ignore-existing');
|
||||
expect($template['compose'])->toContain('mc version enable');
|
||||
expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT');
|
||||
expect($template['compose'])->not->toContain($template['credentials']['password']);
|
||||
expect($template['env'])->toMatch('/MINIO_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['env'])->toContain('MINIO_BUCKETS=attachments,uploads');
|
||||
expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT=');
|
||||
expect($template['credentials']['scheme'])->toBe('http');
|
||||
expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']);
|
||||
expect($template['credentials']['space_headroom_percent'])->toBe(20.0);
|
||||
});
|
||||
|
||||
it('computes MinIO free-space and catch-up math safely', function (): void {
|
||||
expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200);
|
||||
expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0);
|
||||
expect(replication_manager::minioByteReplicationPercent(0, 0))->toBe(100.0);
|
||||
expect(replication_manager::minioSpaceBlockers(1199, 1200))->toContain('MinIO target does not have enough free space. Required 1200 bytes, available 1199 bytes.');
|
||||
expect(replication_manager::minioSpaceBlockers(null, 1200))->toContain('MinIO target free space could not be determined.');
|
||||
expect(replication_manager::minioAvailableBytesFromAdminInfo([
|
||||
'servers' => [
|
||||
['drives' => [['availableSpace' => 4096]]],
|
||||
],
|
||||
]))->toBe(4096);
|
||||
expect(replication_manager::normalizeMinioBuckets('Attachments, uploads backups'))->toBe(['attachments', 'uploads', 'backups']);
|
||||
});
|
||||
|
||||
it('wires MinIO replication through routes and bootstrap snapshots', function (): void {
|
||||
$manager = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
$routes = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
|
||||
$openapi = file_get_contents(app_path('openapi.yaml'));
|
||||
|
||||
expect($manager)->toContain('private const KIND_MINIO');
|
||||
expect($manager)->toContain('provisionMinioHost($host, $operationId)');
|
||||
expect($manager)->toContain('promoteMinioHost($host)');
|
||||
expect($manager)->toContain('testMinioHost($host)');
|
||||
expect($manager)->toContain('minioTargetFreeBytes($host)');
|
||||
expect($manager)->toContain('minioRequiredFreeBytes');
|
||||
expect($manager)->toContain("'minio' => [");
|
||||
expect($routes)->toContain("/superuser/replication/minio");
|
||||
expect($openapi)->toContain('enum: [database, redis, minio]');
|
||||
expect($openapi)->toContain('endpoint:');
|
||||
expect($openapi)->toContain('space_headroom_percent:');
|
||||
});
|
||||
|
||||
it('provisions Redis replicas after a connectivity-only preflight and reports sync progress', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
expect($content)->toContain('provisionRedisHost($host, $operationId)');
|
||||
expect($content)->toContain("testRedisHost(array_merge(\$host, ['test_connectivity_only' => true]))");
|
||||
expect($content)->toContain("executeRaw(['REPLICAOF', (string)\$primary['host'], (string)\$primary['port']])");
|
||||
expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])");
|
||||
expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.');
|
||||
expect($content)->toContain('$onlySyncBlockers');
|
||||
expect($content)->toContain("'status' => 'running'");
|
||||
expect($content)->toContain('Redis replica is syncing from the primary.');
|
||||
});
|
||||
|
||||
it('keeps Redis promotion caught-up, durable, and metadata-safe', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
expect($content)->toContain("application_write_freeze::freeze('Replication promotion in progress.'");
|
||||
expect($content)->toContain("if (\$status['blockers'] !== [] || (float)\$status['replication_percent'] < 100.0)");
|
||||
expect($content)->toContain("executeRaw(['REPLICAOF', 'NO', 'ONE'])");
|
||||
expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])");
|
||||
expect($content)->toContain('switchPrimary(self::KIND_REDIS');
|
||||
expect($content)->toContain('writeBootstrapSnapshot()');
|
||||
});
|
||||
|
||||
it('does not require replica SQL threads before database provisioning configures them', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ it('builds active database and redis config from encrypted bootstrap snapshots',
|
||||
'user' => 'default',
|
||||
'password_secret' => replication_secret_box::encrypt('redis-secret'),
|
||||
],
|
||||
'minio' => [
|
||||
'endpoint' => 'https://minio-replica.internal:9000',
|
||||
'access_key' => 'minio-access',
|
||||
'secret_key_secret' => replication_secret_box::encrypt('minio-secret'),
|
||||
'buckets' => ['attachments', 'uploads'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -63,4 +69,10 @@ it('builds active database and redis config from encrypted bootstrap snapshots',
|
||||
'user' => 'default',
|
||||
'password' => 'redis-secret',
|
||||
]);
|
||||
expect(replication_bootstrap_config::activeMinioConfigFromSnapshot($snapshot['active']['minio']))->toMatchArray([
|
||||
'endpoint' => 'https://minio-replica.internal:9000',
|
||||
'access_key' => 'minio-access',
|
||||
'secret_key' => 'minio-secret',
|
||||
'buckets' => ['attachments', 'uploads'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ it('registers superuser replication endpoints and permissions', function (): voi
|
||||
expect($content)->toContain('/superuser/replication');
|
||||
expect($content)->toContain('/superuser/replication/databases');
|
||||
expect($content)->toContain('/superuser/replication/redis');
|
||||
expect($content)->toContain('/superuser/replication/minio');
|
||||
expect($content)->toContain('/superuser/replication/compose-template');
|
||||
expect($content)->toContain('/superuser/replication/test-credentials');
|
||||
expect($content)->toContain('/superuser/replication/{kind}/{id}/test');
|
||||
@@ -25,6 +26,9 @@ it('documents replication management in openapi', function (): void {
|
||||
expect($content)->toContain('operationId: getSuperuserReplication');
|
||||
expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate');
|
||||
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
|
||||
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
|
||||
expect($content)->toContain('enum: [database, redis, minio]');
|
||||
expect($content)->toContain('space_headroom_percent');
|
||||
expect($content)->toContain('SuperuserReplicationStatus');
|
||||
expect($content)->toContain('SuperuserReplicationHostCreateRequest');
|
||||
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
|
||||
|
||||
@@ -18,12 +18,18 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
||||
$originalServer = $_SERVER;
|
||||
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
|
||||
$originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL');
|
||||
$publicBrokerConfig = new edgegateway_public_broker_url_c();
|
||||
$originalPublicBrokerConfig = $publicBrokerConfig->getVariableValue();
|
||||
$publicBrokerConfig = null;
|
||||
$originalPublicBrokerConfig = null;
|
||||
|
||||
$_SERVER = $server;
|
||||
putenv('EDGE_PUBLIC_BROKER_URL');
|
||||
$publicBrokerConfig->setVariableValue('');
|
||||
try {
|
||||
$publicBrokerConfig = new edgegateway_public_broker_url_c();
|
||||
$originalPublicBrokerConfig = $publicBrokerConfig->getVariableValue();
|
||||
$publicBrokerConfig->setVariableValue('');
|
||||
} catch (Throwable) {
|
||||
$publicBrokerConfig = null;
|
||||
}
|
||||
|
||||
try {
|
||||
$callback();
|
||||
@@ -39,7 +45,12 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
||||
} else {
|
||||
putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl);
|
||||
}
|
||||
$publicBrokerConfig->setVariableValue($originalPublicBrokerConfig);
|
||||
if ($publicBrokerConfig !== null && $originalPublicBrokerConfig !== null) {
|
||||
try {
|
||||
$publicBrokerConfig->setVariableValue($originalPublicBrokerConfig);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ if (!class_exists('EdgeGatewayViewCacheRedisFake')) {
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->oldTtl = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
|
||||
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=15');
|
||||
$this->redis = new EdgeGatewayViewCacheRedisFake();
|
||||
edge_gateway_view_cache::setAdapterForTests($this->redis);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('keeps every legacy PHP test accounted for in the manifest', function (): void {
|
||||
$manifest = require app_path('tests/Support/legacy_test_manifest.php');
|
||||
$manifestPaths = array_map(
|
||||
static fn(array $entry): string => str_replace('\\', '/', $entry['path']),
|
||||
$manifest
|
||||
);
|
||||
sort($manifestPaths);
|
||||
|
||||
$testsRoot = app_path('tests');
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($testsRoot, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
$actual = [];
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file instanceof SplFileInfo || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_ends_with($file->getFilename(), 'Test.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', substr($file->getPathname(), strlen(app_path()) + 1));
|
||||
if (preg_match('#^tests/(Unit|Integration|Api|Legacy)/#', $relative) === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actual[] = $relative;
|
||||
}
|
||||
sort($actual);
|
||||
|
||||
expect($actual)->toBe($manifestPaths);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('keeps schema bootstrap SQL compatible with the MySQL runner', function (): void {
|
||||
$roots = [
|
||||
app_path('classes'),
|
||||
app_path('modules'),
|
||||
app_path('objects'),
|
||||
app_path('routes'),
|
||||
app_path('traits'),
|
||||
app_path('tests/Support'),
|
||||
];
|
||||
|
||||
$unsupportedPattern = '/\bALTER\s+TABLE\b[^;]*(?:ADD|DROP)\s+COLUMN\s+IF\s+(?:NOT\s+)?EXISTS\b|\b(?:CREATE|ADD)\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/is';
|
||||
$violations = [];
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if (!is_dir($root)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file instanceof SplFileInfo || !$file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = str_replace('\\', '/', $file->getPathname());
|
||||
if (str_contains($path, '/vendor/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($file->getPathname());
|
||||
if ($contents !== false && preg_match($unsupportedPattern, $contents) === 1) {
|
||||
$violations[] = str_replace('\\', '/', substr($file->getPathname(), strlen(app_path()) + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($violations);
|
||||
|
||||
expect($violations)->toBe(
|
||||
[],
|
||||
'Avoid MariaDB-only schema syntax in CI-runner MySQL bootstraps: ' . implode(', ', $violations)
|
||||
);
|
||||
});
|
||||
@@ -11,10 +11,25 @@ namespace {
|
||||
namespace classes { class db { public function escape_string($s){ return (string)$s; } }
|
||||
class object_property { public function __construct($t=null,$i=null,$n='',$type='',$nullable=false){} public function value(){ return null; } } }
|
||||
namespace traits { trait db_object_t { protected string $table=''; protected int $id=0; public function setTable(string $t){ $this->table=$t; } public static function add_object(array $fields){ return 1; } public function select($id){ $this->id=(int)$id; return $this; } public function requireSelected(): void {} public function delete(): void {} } }
|
||||
namespace objects { class department_lanes_o { public $department; public $relay_machine_id; public function __construct(int $departmentId = 0, string $relayId = ''){ $this->department = new \_ValueHolder($departmentId); $this->relay_machine_id = new \_ValueHolder($relayId); } public function exists(): bool { return true; } } }
|
||||
namespace objects { class department_lanes_o { public $department; public $relay_machine_id; public $relay_machine_program_picker_id; public $relay_machine_cleaner_id; public function __construct(int $departmentId = 0, string $relayId = ''){ $this->department = new \_ValueHolder($departmentId); $this->relay_machine_id = new \_ValueHolder($relayId); $this->relay_machine_program_picker_id = new \_ValueHolder(''); $this->relay_machine_cleaner_id = new \_ValueHolder(''); } public function exists(): bool { return true; } } }
|
||||
|
||||
namespace modules\selfserve\classes {
|
||||
class selfserve_studio_actions {
|
||||
public const EVENT_WASH_STOP_COMMAND = 'wash_stop_command';
|
||||
public const MODE_MACHINE = 'machine';
|
||||
public const MODE_MANUAL = 'manual';
|
||||
}
|
||||
class selfserve_studio_action_runner { public function executeForLaneEvent(...$args): array { return []; } }
|
||||
class selfserve_wash_flow {
|
||||
public function hasMachineStartTriggeredForLane(...$args): bool { return true; }
|
||||
public function completeLatestSessionForLane(...$args): void {}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
require_once WD . '/traits/module_config_variable_t.php';
|
||||
require_once WD . '/traits/module_config_t.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
@@ -58,9 +73,13 @@ class _TestLane extends selfserve_lane {
|
||||
|
||||
// Stub out external effects
|
||||
protected function isDepartmentSelfServeEnabled(): bool { return true; }
|
||||
public function open(selfserve_lane_port $port): bool { return true; }
|
||||
protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array { return []; }
|
||||
protected function hasMachineStartSignalForStop(): bool { return true; }
|
||||
protected function completeLatestSessionForStop(): void {}
|
||||
public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { return true; }
|
||||
public function invoice(): bool { return true; }
|
||||
public function logLaneAction(\modules\selfserve\helpers\selfserve_lane_log_action $action, int $status_code = 200, array $extra_data = []): void { /* no-op */ }
|
||||
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool { if ($relay === selfserve_lane_relay::MACHINE && $on === false) { $this->relayOffCalled = true; } return true; }
|
||||
public function turnOffRelay(selfserve_lane_relay $relay): bool { $this->relayOffCalled = true; return true; }
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,15 @@ RUN set -eux; \
|
||||
ca-certificates \
|
||||
mariadb-client; \
|
||||
update-ca-certificates; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) mc_arch="amd64" ;; \
|
||||
arm64) mc_arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture for MinIO Client: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://dl.min.io/client/mc/release/linux-${mc_arch}/mc" -o /usr/local/bin/mc; \
|
||||
chmod +x /usr/local/bin/mc; \
|
||||
mc --version; \
|
||||
docker-php-ext-configure gd --with-freetype --with-jpeg; \
|
||||
docker-php-ext-install -j"$(nproc)" \
|
||||
mbstring \
|
||||
|
||||
Reference in New Issue
Block a user