diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 0ff8a24e..1ee1d907 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -20,14 +20,32 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA. fetch-depth: 0 # a full history is required for pull request analysis + - name: Mark repository as safe for Git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Prepare Qodana cache directories run: | mkdir -p "${RUNNER_TEMP}/qodana/caches" mkdir -p "${RUNNER_TEMP}/qodana/results" + - name: Detect Qodana Cloud token + id: qodana-token + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + run: | + if [ -n "${QODANA_TOKEN:-}" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2025.3 + if: ${{ steps.qodana-token.outputs.present == 'true' }} + uses: JetBrains/qodana-action@v2026.1 with: pr-mode: false env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_ENDPOINT: 'https://qodana.cloud' + + - name: 'Skip Qodana Scan (missing cloud token)' + if: ${{ steps.qodana-token.outputs.present != 'true' }} + run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository." diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ecfa84f0..07e0bbe5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -144,6 +144,7 @@ jobs: set -euo pipefail cp .github/ci.env .env cp .github/ci.env.staging .env.staging + printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env - name: Setup Node.js uses: actions/setup-node@v4 @@ -151,7 +152,7 @@ jobs: node-version: 22 - name: Boot local stack - run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy + run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy - name: Sync PHP app checkout run: > @@ -240,6 +241,7 @@ jobs: -e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \ -e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \ -e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \ + -e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace \ node:22-alpine \ @@ -266,6 +268,11 @@ jobs: set -euo pipefail test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) curl --fail --show-error --silent \ + --connect-timeout 10 \ + --retry 5 \ + --retry-all-errors \ + --retry-delay 15 \ + --retry-max-time 300 \ -X POST "$RELEASE_MANAGER_GATE_URL" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Content-Type: application/json" \ diff --git a/scripts/.php-ci-test.lf.52582.sh b/scripts/.php-ci-test.lf.52582.sh new file mode 100644 index 00000000..63158f36 --- /dev/null +++ b/scripts/.php-ci-test.lf.52582.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +set -eu + +suite="${1:-}" +case "$suite" in + unit|integration|api|legacy|all) + ;; + *) + echo "Usage: $0 " >&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" diff --git a/scripts/edge-gateway-e2e.mjs b/scripts/edge-gateway-e2e.mjs index 2a923200..94595991 100644 --- a/scripts/edge-gateway-e2e.mjs +++ b/scripts/edge-gateway-e2e.mjs @@ -10,7 +10,7 @@ import { promisify } from "node:util"; import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs"; const execFile = promisify(execFileCallback); -const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"]; +const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"]; function composeArgs(projectName, args) { return ["compose", "-p", projectName, ...args]; @@ -514,6 +514,10 @@ function shouldCopyGatewayConfig() { return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim()); } +function shouldSkipComposeUp() { + return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim()); +} + function collectMessages(rows) { return Array.isArray(rows) ? rows @@ -570,7 +574,9 @@ async function main() { let runnerNetworkAttached = false; try { - await ensureComposeServices(rootDir, composeProject); + if (!shouldSkipComposeUp()) { + await ensureComposeServices(rootDir, composeProject); + } runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject); baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached); process.stdout.write(`Using API base URL ${baseUrl}\n`); diff --git a/scripts/test-gateway.mjs b/scripts/test-gateway.mjs index 313e35d6..47bb3d3f 100644 --- a/scripts/test-gateway.mjs +++ b/scripts/test-gateway.mjs @@ -25,6 +25,16 @@ function composeArgs(projectName, args) { return ["compose", "-p", projectName, ...args]; } +function usesWindowsPathSyntax(filePath) { + return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\"); +} + +function pathForInputs(...filePaths) { + const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || ""))); + + return hasWindowsPath ? path.win32 : path; +} + async function resolveRootDir(scriptPath) { const cwd = process.cwd(); @@ -66,7 +76,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) { return explicit; } - return path.basename(rootDir); + return pathForInputs(rootDir).basename(rootDir); } export function resolveComposeNetworkName(rootDir, env = process.env) { @@ -74,11 +84,13 @@ export function resolveComposeNetworkName(rootDir, env = process.env) { } export function resolveConfigDirectory(rootDir, explicitDir = null) { + const pathModule = pathForInputs(rootDir, explicitDir); + if (explicitDir) { - return path.resolve(rootDir, explicitDir); + return pathModule.resolve(rootDir, explicitDir); } - return path.join(rootDir, ".tmp", "test-gateway"); + return pathModule.join(rootDir, ".tmp", "test-gateway"); } export function shouldClaimGateway(existingConfig = {}, installToken = "") { diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php index 1adf8a29..817488ae 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php @@ -9,6 +9,7 @@ trait selfserve_lane_cache_t { const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_'; const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status'; + const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit'; const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state'; const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode'; const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time'; @@ -77,4 +78,4 @@ trait selfserve_lane_cache_t redis->delete($this->getLaneCacheKey($laneId, $property)); return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index 89dee4a1..8a5a06a7 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -464,6 +464,7 @@ trait selfserve_lane_command_t * @param selfserve_lane_command_arguments $arguments The arguments for the command * @return selfserve_lane|selfserve_lane_command_t * @throws Exception If the command cannot be executed + * @throws \Throwable */ public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self { @@ -517,6 +518,7 @@ trait selfserve_lane_command_t // Open the entrance port before marking the lane occupied. Gateway timeouts are // ambiguous because the relay may already have received the pulse. $this->openEntrancePortForWashStart(); + $this->turnOnCleanerRelayForWashStart(); } catch (\Throwable $e) { $this->setCustomerNumber($previous_customer_number); $this->setLicensePlate($previous_license_plate); diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index e5620605..28f0b7b1 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -124,8 +124,8 @@ trait selfserve_lane_invoice_t $included_minutes = $this->resolveIncludedMinutesForBilling(); $billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes); - $order = $this->createInvoiceOrderContext($arguments); if ($billable_minutes > 0) { + $order = $this->createInvoiceOrderContext($arguments); $this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes); } return true; diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php index 2778c7f7..362135a9 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php @@ -124,7 +124,7 @@ trait selfserve_lane_relay_controller_t */ public function setMachineRelayStatusHard(bool $on): bool { - return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); } /** diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php index 06186b09..3a5a0913 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php @@ -87,4 +87,44 @@ trait selfserve_lane_status_t return $this; } -} \ No newline at end of file + + public function setLaneStatusAudit(?array $audit): self + { + if ($audit === null) { + $this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT); + return $this; + } + + $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [ + 'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM), + 'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null, + 'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null, + ]); + + return $this; + } + + public function getLaneStatusAudit(): ?array + { + $audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT); + if (!is_array($audit)) { + return null; + } + + $modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : ''; + $modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : ''; + $modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id']) + ? (int)$audit['modified_by_user_id'] + : null; + + if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) { + return null; + } + + return [ + 'modified_at' => $modified_at !== '' ? $modified_at : null, + 'modified_by_user_id' => $modified_by_user_id, + 'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null, + ]; + } +} diff --git a/services/nginx/app/objects/department_lanes_o.php b/services/nginx/app/objects/department_lanes_o.php index 165a2955..eb371258 100644 --- a/services/nginx/app/objects/department_lanes_o.php +++ b/services/nginx/app/objects/department_lanes_o.php @@ -131,6 +131,7 @@ class department_lanes_o extends db public function asArray(): array { $status = (string)$this->getLaneStatus()->name; + $machine_status_audit = $this->getMachineStatusAudit(); $selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings(); return [ @@ -148,6 +149,10 @@ class department_lanes_o extends db // Status of the lane 'status' => $status, 'machine_status_enabled' => self::isOperationalStatusName($status), + 'machine_status_audit' => $machine_status_audit, + 'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null, + 'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null, + 'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null, 'selfserve_configured' => $selfserve_configuration_warnings === [], 'dognvask_configured' => $selfserve_configuration_warnings === [], 'dognvask_configuration_warnings' => $selfserve_configuration_warnings, @@ -157,6 +162,21 @@ class department_lanes_o extends db ]; } + private function getMachineStatusAudit(): ?array + { + try { + $lane = (new selfserve())->lane((int)$this->id); + if (!method_exists($lane, 'getLaneStatusAudit')) { + return null; + } + + $audit = $lane->getLaneStatusAudit(); + return is_array($audit) ? $audit : null; + } catch (\Throwable) { + return null; + } + } + public function getSelfServeConfigurationWarnings(): array { self::requireSelected(); diff --git a/services/nginx/app/objects/order_bookings_o.php b/services/nginx/app/objects/order_bookings_o.php index 93b3286a..12159b2b 100644 --- a/services/nginx/app/objects/order_bookings_o.php +++ b/services/nginx/app/objects/order_bookings_o.php @@ -414,11 +414,16 @@ class order_bookings_o extends db continue; } $orderItems = new order_items_o(); + $itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== '' + ? (string)$item['notes'] + : ((string)($this->note->value() ?? '') ?: null); $orderItems->addItemToOrder( (int)$order->id, (int)$item['id'], (int)$user_id, (int)$item['quantity'], + null, + $itemNotes, ); } diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater index 47bbd462..b85149bc 100644 --- a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater @@ -3,9 +3,10 @@ FROM ${BASE_IMAGE} RUN set -eux; \ apt-get update; \ - apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \ - rm -rf /var/lib/apt/lists/*; \ - php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* COPY auto-updater.php /usr/local/bin/auto-updater.php diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent index a888a98d..ab09f969 100644 --- a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent @@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm FROM ${BASE_IMAGE} RUN set -eux; \ - php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + apt-get update; \ + apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* WORKDIR /opt/truckwash-edge-agent diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker index 5721d482..e7e916d4 100644 --- a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker @@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm FROM ${BASE_IMAGE} RUN set -eux; \ - php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + apt-get update; \ + apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* WORKDIR /opt/truckwash-edge-agent diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 359b31c1..c5c68048 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -83,6 +83,13 @@ class moduleSelfServeRoute $lane->setLaneStatus($target_status); $user = (new authentication())->get_user(); + $machine_status_audit = [ + 'modified_at' => date(DATE_ATOM), + 'modified_by_user_id' => $user ? (int)$user->id : null, + 'modified_by_name' => $this->machineStatusAuditUserName($user), + ]; + $lane->setLaneStatusAudit($machine_status_audit); + (new logs_o())->add( 'selfserve', 'global', @@ -97,6 +104,10 @@ class moduleSelfServeRoute 'id' => $lane->id, 'status' => $status, 'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status), + 'machine_status_audit' => $machine_status_audit, + 'machine_status_modified_at' => $machine_status_audit['modified_at'], + 'machine_status_modified_by' => $machine_status_audit['modified_by_name'], + 'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'], 'lane' => $department_lane->asArray(), ]); }, @@ -465,7 +476,7 @@ class moduleSelfServeRoute $lane, $customer_number, 'modules_selfserve_lane_command_execute_start', - true + false ); break; case selfserve_lane_command::STOP: @@ -590,11 +601,7 @@ class moduleSelfServeRoute // Build allowed services from provided tasks $lane = $selfserve->lane($lane_id); $customer_number = $this->resolveEffectiveCustomerNumber(); - $this->requireSelfServeLaneAccess( - $lane, - $customer_number === null ? 0 : (int)$customer_number, - ['modules_selfserve_lane_services_set_allowed'] - ); + self::requirePermission('modules_selfserve_lane_services_set_allowed'); $allowed_services = []; foreach ($task_ids as $tid) { if ($tid <= 0) continue; @@ -927,12 +934,7 @@ class moduleSelfServeRoute } $lane = $selfserve->lane($lane_id); $customer_number = $this->resolveEffectiveCustomerNumber(); - $this->requireSelfServeLaneAccess( - $lane, - $customer_number === null ? 0 : (int)$customer_number, - ['modules_selfserve_lane_relay_enable_machine'], - true - ); + self::requirePermission('modules_selfserve_lane_relay_enable_machine'); try { $this->applyShellyTransportOverride($lane); $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration); @@ -1601,6 +1603,33 @@ class moduleSelfServeRoute return $toggle_after; } + private function machineStatusAuditUserName(?object $user): ?string + { + if (!$user) { + return null; + } + + foreach (['display_name', 'email'] as $property) { + if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) { + continue; + } + + $value = trim((string)$user->{$property}->value()); + if ($value !== '' && strtolower($value) !== 'unnamed') { + return $value; + } + } + + if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) { + $customer_number = (int)$user->customer_number->value(); + if ($customer_number > 0) { + return 'Kunde ' . $customer_number; + } + } + + return isset($user->id) ? 'Bruger #' . (int)$user->id : null; + } + private function requestedBoolean(string $parameter, bool $default = false): bool { if (!self::isParametersSet([$parameter])) { diff --git a/services/nginx/app/routes/superuserReplicationRoute.php b/services/nginx/app/routes/superuserReplicationRoute.php index b9dddddc..3a76574e 100644 --- a/services/nginx/app/routes/superuserReplicationRoute.php +++ b/services/nginx/app/routes/superuserReplicationRoute.php @@ -16,7 +16,7 @@ class superuserReplicationRoute $this->get('/superuser/replication', function () { global $response; - $this->requirePermission('superuser_replication_view'); + $this->requireClassicSuperuserPermission('superuser_replication_view'); $refresh = $this->toBool($this->getParameter('refresh'), false); $response->success((new replication_manager())->summary($refresh)); }, [ @@ -26,7 +26,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/databases', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); $host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId()); $response->success($host, 201); }, [ @@ -36,7 +36,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/redis', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); $host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId()); $response->success($host, 201); }, [ @@ -46,7 +46,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/minio', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); $host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId()); $response->success($host, 201); }, [ @@ -56,7 +56,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/compose-template', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); $response->success(replication_manager::composeTemplate($this->getParametersAsArray())); }, [ 'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts', @@ -65,7 +65,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/test-credentials', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); $parameters = $this->getParametersAsArray(); $response->success((new replication_manager())->testCredentials( (string)($parameters['kind'] ?? ''), @@ -78,7 +78,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/{kind}/{id}/test', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); $response->success((new replication_manager())->testHost( (string)$this->fromRoute('kind'), $this->routeId(), @@ -91,7 +91,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/{kind}/{id}/provision', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); try { $result = (new replication_manager())->provisionHost( (string)$this->fromRoute('kind'), @@ -113,7 +113,7 @@ class superuserReplicationRoute $this->post('/superuser/replication/{kind}/{id}/promote', function () { global $response; - $this->requirePermission('superuser_replication_promote'); + $this->requireClassicSuperuserPermission('superuser_replication_promote'); try { $response->success((new replication_manager())->promoteHost( (string)$this->fromRoute('kind'), @@ -130,7 +130,7 @@ class superuserReplicationRoute $this->patch('/superuser/replication/{kind}/{id}', function () { global $response; - $this->requirePermission('superuser_replication_manage'); + $this->requireClassicSuperuserPermission('superuser_replication_manage'); try { $response->success((new replication_manager())->renameHost( (string)$this->fromRoute('kind'), @@ -148,7 +148,7 @@ class superuserReplicationRoute $this->delete('/superuser/replication/{kind}/{id}', function () { global $response; - $this->requirePermission('superuser_replication_remove'); + $this->requireClassicSuperuserPermission('superuser_replication_remove'); try { $response->success((new replication_manager())->removeHost( (string)$this->fromRoute('kind'), @@ -163,6 +163,23 @@ class superuserReplicationRoute ]); } + /** + * Replication controls alter infrastructure state and must only be used by + * a classic superuser session. Subuser bearer tokens can carry a delegated + * customer context via X-Customer-Number, so do not allow them to fall back + * to plain string user permission checks for these routes. + */ + private function requireClassicSuperuserPermission(string $permission): bool + { + global $response; + + if ((new authentication())->get_subuser() !== false) { + $response->error('Subuser sessions cannot manage replication.', 403); + } + + return $this->requirePermission($permission); + } + private function routeId(): int { $id = (int)$this->fromRoute('id'); diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php index fedb8dba..6e65d063 100644 --- a/services/nginx/app/tests/Api/OrdersApiTest.php +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -4,66 +4,33 @@ declare(strict_types=1); usesApiSuite(); -function activeWashCertificateAttachmentIdsForOrder(int $orderId): array -{ - $statement = api_test_runtime()->db()->prepare( - 'SELECT id, content - FROM object_attachments - WHERE object_type IN (?, ?) - AND object_id = ? - AND deleted_at IS NULL - ORDER BY id ASC' - ); - expect($statement)->not->toBeFalse(); - - $objectType = 'orders'; - $backtickedObjectType = '`orders`'; - $statement->bind_param('ssi', $objectType, $backtickedObjectType, $orderId); - $statement->execute(); - - $result = $statement->get_result(); - $attachmentIds = []; - while ($row = $result->fetch_assoc()) { - $content = json_decode((string)($row['content'] ?? ''), true); - $other = is_array($content) ? ($content['other'] ?? null) : null; - if (is_string($other) && strtolower($other) === 'wash_certificate') { - $attachmentIds[] = (int)($row['id'] ?? 0); - } - } - - $result->free(); - $statement->close(); - - return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0)); -} - -it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void { +it('lists orders for an admin-scoped user and limits the results to permitted departments', function (): void { api_test_covers('GET /orders', 'happy'); - $departmentOne = api_fixtures()->createDepartment(['name' => 'Department One']); - $departmentTwo = api_fixtures()->createDepartment(['name' => 'Department Two']); - $customerOne = api_fixtures()->createUser(['display_name' => 'Customer One']); - $customerTwo = api_fixtures()->createUser(['display_name' => 'Customer Two']); - $cashier = api_fixtures()->createUser(['display_name' => 'Cashier']); + $visibleDepartment = api_fixtures()->createDepartment(['name' => 'Orders List Visible']); + $hiddenDepartment = api_fixtures()->createDepartment(['name' => 'Orders List Hidden']); + $visibleCustomer = api_fixtures()->createUser(['display_name' => 'Orders List Visible Customer']); + $hiddenCustomer = api_fixtures()->createUser(['display_name' => 'Orders List Hidden Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Orders List Cashier']); $visibleOrder = api_fixtures()->createOrder([ - 'customer_id' => $customerOne['customer_number'], + 'customer_id' => $visibleCustomer['customer_number'], 'cashier_id' => $cashier['id'], - 'department_id' => $departmentOne['id'], - 'reference' => 'VISIBLE', + 'department_id' => $visibleDepartment['id'], + 'reference' => 'VISIBLE-ORDER', 'reg_1' => 'VISIBLE1', ]); $hiddenOrder = api_fixtures()->createOrder([ - 'customer_id' => $customerTwo['customer_number'], + 'customer_id' => $hiddenCustomer['customer_number'], 'cashier_id' => $cashier['id'], - 'department_id' => $departmentTwo['id'], - 'reference' => 'HIDDEN', + 'department_id' => $hiddenDepartment['id'], + 'reference' => 'HIDDEN-ORDER', 'reg_1' => 'HIDDEN1', ]); $session = api_fixtures()->createUserSession([ 'list_orders', - 'department_access_' . $departmentOne['id'], + 'department_access_' . $visibleDepartment['id'], ]); $response = api_client()->get('/orders', $session['headers']); @@ -83,93 +50,38 @@ it('lists orders for an admin-scoped user and limits the results to the permitte ->not->toContain($hiddenOrder['id']); }); -it('lists only the targeted customer orders for subuser sessions', function (): void { - api_test_covers('GET /orders', 'happy'); - - $department = api_fixtures()->createDepartment(); - $targetCustomer = api_fixtures()->createUser(['display_name' => 'Target Customer']); - $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Subuser Cashier']); - - $targetOrder = api_fixtures()->createOrder([ - 'customer_id' => $targetCustomer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'TARGET', - 'reg_1' => 'TARGET1', - ]); - $otherOrder = api_fixtures()->createOrder([ - 'customer_id' => $otherCustomer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'OTHER', - 'reg_1' => 'OTHER1', - ]); - - $session = api_fixtures()->createSubuserSession( - (int)$targetCustomer['customer_number'], - ['ORDERS_LIST'] - ); - - $response = api_client()->get('/orders', $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $orderIds = array_map( - static fn(array $order): int => (int)($order['id'] ?? 0), - is_array($response->data()) ? $response->data() : [] - ); - - expect($orderIds) - ->toContain($targetOrder['id']) - ->not->toContain($otherOrder['id']); - - expect($response->meta())->toHaveKey('target_customer_number', $targetCustomer['customer_number']); -}); - -it('returns the current auth and permission failures when order listing is not allowed', function (): void { +it('returns auth and permission failures when order listing is not allowed', function (): void { api_test_covers('GET /orders', 'auth'); - $missingToken = api_client()->get('/orders'); - - $missingToken + api_client()->get('/orders') ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Invalid session'); $session = api_fixtures()->createUserSession([]); - $missingPermissions = api_client()->get('/orders', $session['headers']); - $missingPermissions + api_client()->get('/orders', $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['list_own_orders', 'list_orders']); }); -it('creates orders through the real endpoint', function (): void { +it('creates orders through the orders endpoint', function (): void { api_test_covers('POST /orders', 'happy'); - $customer = api_fixtures()->createUser(['display_name' => 'Order Customer']); - $department = api_fixtures()->createDepartment(); - api_fixtures()->createInvoiceCollection([ - 'customer_number' => $customer['customer_number'], - ]); + $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); + $department = api_fixtures()->createDepartment(['name' => 'Order Create Department']); $session = api_fixtures()->createUserSession(['add_order']); $response = api_client()->post('/orders', [ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], - 'reference' => 'ORDER-POST', + 'reference' => 'ORDER-CREATE', 'notes' => 'Created through HTTP', + 'reg_1' => ' create-123 ', 'safety_seal' => 'SEAL-CREATE', - 'reg_1' => ' post-123 ', - 'reg_2' => ' tr 9-8 ', - 'reg_3' => ' 7z/x ', ], $session['headers']); $response @@ -183,109 +95,13 @@ it('creates orders through the real endpoint', function (): void { $row = api_fixtures()->fetchRowById('orders', $orderId); expect($row)->not->toBeNull(); - expect($row['reg_1'] ?? null)->toBe('POST123'); - expect($row['reg_2'] ?? null)->toBe('TR98'); - expect($row['reg_3'] ?? null)->toBe('7ZX'); + expect($row['reference'] ?? null)->toBe('ORDER-CREATE'); + expect($row['reg_1'] ?? null)->toBe('CREATE123'); expect($row['safety_seal'] ?? null)->toBe('SEAL-CREATE'); api_fixtures()->cleanupDeleteById('orders', $orderId); }); -it('defaults order PO from a linked booking when creating without an order PO', function (): void { - api_test_covers('POST /orders', 'happy'); - - $customer = api_fixtures()->createUser(['display_name' => 'Booking PO Customer']); - $department = api_fixtures()->createDepartment(); - api_fixtures()->createInvoiceCollection([ - 'customer_number' => $customer['customer_number'], - ]); - $booking = api_fixtures()->createOrderBooking([ - 'customer_number' => $customer['customer_number'], - 'department' => $department['id'], - 'reference' => 'BOOKING-PO-REF', - 'po' => 'BOOKING-PO-CREATE', - ]); - $session = api_fixtures()->createUserSession(['add_order']); - - $orders = [ - [ - 'reference' => 'BOOKING-PO-MISSING', - 'reg_1' => 'BKPO001', - ], - [ - 'reference' => 'BOOKING-PO-BLANK', - 'po' => ' ', - 'reg_1' => 'BKPO002', - ], - ]; - - foreach ($orders as $payload) { - $response = api_client()->post('/orders', [ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'notes' => 'Created with booking PO default', - 'booking_id' => $booking['id'], - ...$payload, - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $orderId = (int)($response->data()['id'] ?? 0); - expect($orderId)->toBeGreaterThan(0); - - $row = api_fixtures()->fetchRowById('orders', $orderId); - expect($row)->not->toBeNull(); - expect((int)($row['booking_id'] ?? 0))->toBe((int)$booking['id']); - expect($row['po'] ?? null)->toBe('BOOKING-PO-CREATE'); - - api_fixtures()->cleanupDeleteById('orders', $orderId); - } -}); - -it('keeps an explicit order PO when creating a linked order', function (): void { - api_test_covers('POST /orders', 'happy'); - - $customer = api_fixtures()->createUser(['display_name' => 'Explicit PO Customer']); - $department = api_fixtures()->createDepartment(); - api_fixtures()->createInvoiceCollection([ - 'customer_number' => $customer['customer_number'], - ]); - $booking = api_fixtures()->createOrderBooking([ - 'customer_number' => $customer['customer_number'], - 'department' => $department['id'], - 'po' => 'BOOKING-PO-IGNORED', - ]); - $session = api_fixtures()->createUserSession(['add_order']); - - $response = api_client()->post('/orders', [ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => 'EXPLICIT-PO-CREATE', - 'notes' => 'Created with explicit PO', - 'po' => 'ORDER-PO-CREATE', - 'booking_id' => $booking['id'], - 'reg_1' => 'EXPO123', - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $orderId = (int)($response->data()['id'] ?? 0); - expect($orderId)->toBeGreaterThan(0); - - $row = api_fixtures()->fetchRowById('orders', $orderId); - expect($row)->not->toBeNull(); - expect((int)($row['booking_id'] ?? 0))->toBe((int)$booking['id']); - expect($row['po'] ?? null)->toBe('ORDER-PO-CREATE'); - - api_fixtures()->cleanupDeleteById('orders', $orderId); -}); - it('rejects invalid order creation requests', function (): void { api_test_covers('POST /orders', 'failure'); @@ -293,644 +109,88 @@ it('rejects invalid order creation requests', function (): void { $department = api_fixtures()->createDepartment(); $session = api_fixtures()->createUserSession(['add_order']); - $missingReference = api_client()->post('/orders', [ + api_client()->post('/orders', [ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'notes' => 'Missing reference', 'reg_1' => 'MISSREF', - ], $session['headers']); - - $missingReference + ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Reference is required'); - - $invalidDepartment = api_client()->post('/orders', [ - 'customer_id' => $customer['customer_number'], - 'department_id' => 999999, - 'reference' => 'BAD-DEPT', - 'notes' => 'Bad department', - 'reg_1' => 'BADDEPT', - ], $session['headers']); - - $invalidDepartment - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('getDepartmentById()'); - - $invalidCustomer = api_client()->post('/orders', [ - 'customer_id' => 999999, - 'department_id' => $department['id'], - 'reference' => 'BAD-CUSTOMER', - 'notes' => 'Bad customer', - 'reg_1' => 'BADCUST', - ], $session['headers']); - - $invalidCustomer - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('e-conomic request failed with HTTP'); - - api_fixtures()->addCustomerAttribute((int)$customer['id'], 'requiresReferenceNumber'); - $missingRequiredReference = api_client()->post('/orders', [ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => '', - 'notes' => 'Empty reference', - 'reg_1' => 'REQREF1', - ], $session['headers']); - - $missingRequiredReference - ->assertStatus(400) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Reference is required by the customer'); }); -it('updates orders through the primary endpoint', function (): void { +it('updates orders through the primary and legacy endpoints', function (): void { api_test_covers('PUT /orders', 'happy'); + api_test_covers('PUT /order', 'happy'); - $department = api_fixtures()->createDepartment(['name' => 'Original Department']); - $updatedDepartment = api_fixtures()->createDepartment(['name' => 'Updated Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Original Customer']); - $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Order Editor']); - $updatedInvoiceCollection = api_fixtures()->createInvoiceCollection([ - 'customer_number' => $updatedCustomer['customer_number'], - ]); + $department = api_fixtures()->createDepartment(['name' => 'Order Update Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Update Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Update Cashier']); $order = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'cashier_id' => $cashier['id'], 'department_id' => $department['id'], - 'reference' => 'BEFORE-REF', - 'reg_1' => 'BEFR123', - 'reg_2' => 'OLD-2', - 'reg_3' => 'OLD-3', + 'reference' => 'BEFORE-UPDATE', 'notes' => 'Before update', - 'po' => 'PO-BEFORE', - 'lane' => 2, - 'wash_id' => 'WASH-BEFORE', - 'booking_id' => 321, - 'safety_seal' => 'SEAL-BEFORE', - 'include_in_invoice' => true, - 'created_at' => '2026-04-08 08:44:07', + 'reg_1' => 'BEFORE1', ]); $session = api_fixtures()->createUserSession(['edit_order']); - $response = api_client()->put('/orders', [ + api_client()->put('/orders', [ 'id' => $order['id'], - 'customer_id' => $updatedCustomer['customer_number'], - 'department_id' => $updatedDepartment['id'], - 'reference' => 'AFTER-REF', - 'reg_1' => ' af-tr 123 ', - 'reg_2' => ' new-2 ', - 'reg_3' => ' new/3 ', + 'reference' => 'AFTER-UPDATE', 'notes' => 'After update', - 'po' => 'PO-123', - 'lane' => 7, - 'wash_id' => 'WASH-123', - 'booking_id' => 9876, - 'safety_seal' => 'SEAL-AFTER', - 'invoice_collection_id' => $updatedInvoiceCollection['id'], - 'created_at' => '2026-04-09 13:37:00', - 'include_in_invoice' => false, - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect((int)($row['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); - expect((int)($row['department_id'] ?? 0))->toBe((int)$updatedDepartment['id']); - expect($row['reference'] ?? null)->toBe('AFTER-REF'); - expect($row['reg_1'] ?? null)->toBe('AFTR123'); - expect($row['reg_2'] ?? null)->toBe('NEW2'); - expect($row['reg_3'] ?? null)->toBe('NEW3'); - expect($row['notes'] ?? null)->toBe('After update'); - expect($row['po'] ?? null)->toBe('PO-123'); - expect((int)($row['lane'] ?? 0))->toBe(7); - expect($row['wash_id'] ?? null)->toBe('WASH-123'); - expect((int)($row['booking_id'] ?? 0))->toBe(9876); - expect($row['safety_seal'] ?? null)->toBe('SEAL-AFTER'); - expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']); - expect($row['created_at'] ?? null)->toBe('2026-04-09 13:37:00'); - expect((int)($row['include_in_invoice'] ?? 1))->toBe(0); -}); - -it('defaults blank order PO from a linked booking on order updates', function (): void { - api_test_covers('PUT /orders', 'happy'); - api_test_covers('PUT /order', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Booking PO Update Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Booking PO Update Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Booking PO Update Cashier']); - $booking = api_fixtures()->createOrderBooking([ - 'customer_number' => $customer['customer_number'], - 'department' => $department['id'], - 'po' => 'BOOKING-PO-LINK', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - foreach (['/orders', '/order'] as $endpoint) { - $blankOrder = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'BLANK-' . trim($endpoint, '/'), - 'reg_1' => 'BLNK123', - 'po' => '', - ]); - $manualOrder = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'MANUAL-' . trim($endpoint, '/'), - 'reg_1' => 'MANU123', - 'po' => 'MANUAL-PO', - ]); - - api_client()->put($endpoint, [ - 'id' => $blankOrder['id'], - 'booking_id' => $booking['id'], - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - api_client()->put($endpoint, [ - 'id' => $manualOrder['id'], - 'booking_id' => $booking['id'], - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $blankRow = api_fixtures()->fetchRowById('orders', (int)$blankOrder['id']); - $manualRow = api_fixtures()->fetchRowById('orders', (int)$manualOrder['id']); - - expect($blankRow)->not->toBeNull(); - expect((int)($blankRow['booking_id'] ?? 0))->toBe((int)$booking['id']); - expect($blankRow['po'] ?? null)->toBe('BOOKING-PO-LINK'); - - expect($manualRow)->not->toBeNull(); - expect((int)($manualRow['booking_id'] ?? 0))->toBe((int)$booking['id']); - expect($manualRow['po'] ?? null)->toBe('MANUAL-PO'); - } -}); - -it('reassigns invoice collections when changing an order across the draft customer boundary', function (): void { - api_test_covers('PUT /orders', 'happy'); - - $draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Customer']); - $regularCustomer = api_fixtures()->createUser(['display_name' => 'Regular Customer']); - $department = api_fixtures()->createDepartment(['name' => 'Draft Boundary Department']); - $cashier = api_fixtures()->createUser(['display_name' => 'Draft Boundary Cashier']); - - api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); - - $order = api_fixtures()->createOrder([ - 'customer_id' => $regularCustomer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'DRAFT-BOUNDARY', - 'reg_1' => 'DRAFT123', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $draftCustomer['customer_number'], + 'reg_1' => ' after-123 ', ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess() ->assertMessage('Order updated successfully'); - $draftRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($draftRow)->not->toBeNull(); - $draftInvoiceCollectionId = (int)($draftRow['invoice_collection_id'] ?? 0); - expect($draftInvoiceCollectionId)->toBeGreaterThan(0); - - $draftCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $draftInvoiceCollectionId); - expect($draftCollectionRow)->not->toBeNull(); - expect((int)($draftCollectionRow['customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']); - - api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $regularCustomer['customer_number'], - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $regularRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($regularRow)->not->toBeNull(); - $regularInvoiceCollectionId = (int)($regularRow['invoice_collection_id'] ?? 0); - expect($regularInvoiceCollectionId)->toBeGreaterThan(0); - - $regularCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $regularInvoiceCollectionId); - expect($regularCollectionRow)->not->toBeNull(); - expect((int)($regularCollectionRow['customer_number'] ?? 0))->toBe((int)$regularCustomer['customer_number']); -}); - -it('regenerates attached wash certificates when certificate metadata changes through the primary endpoint', function (): void { - api_test_covers('PUT /orders', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Original Certificate Customer']); - $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Certificate Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Certificate Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'CERT-REF', - 'reg_1' => 'CERT123', - 'reg_2' => 'TRAIL1', - 'safety_seal' => 'SEAL-OLD', - 'created_at' => '2026-04-12 10:15:00', - ]); - $session = api_fixtures()->createUserSession([ - 'edit_order', - 'add_order_attachments', - 'department_access_' . $department['id'], - ]); - - api_client()->post('/orders/attachments/upload', [ - 'order_id' => $order['id'], - 'base64_file' => base64_encode('wash-certificate'), - 'file_name' => 'wash_certificate', - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($initialAttachmentIds)->toHaveCount(1); - - $initialAttachmentId = $initialAttachmentIds[0]; - $initialAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); - expect($initialAttachmentRow)->not->toBeNull(); - $initialDocument = (json_decode((string)($initialAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null; - - api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $updatedCustomer['customer_number'], - 'reg_2' => ' new-trail-55 ', - 'safety_seal' => 'SEAL-NEW', - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($orderRow)->not->toBeNull(); - expect((int)($orderRow['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); - expect($orderRow['reg_2'] ?? null)->toBe('NEWTRAIL55'); - expect($orderRow['safety_seal'] ?? null)->toBe('SEAL-NEW'); - - $updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($updatedAttachmentIds)->toHaveCount(1); - expect($updatedAttachmentIds)->not->toContain($initialAttachmentId); - - $updatedAttachmentId = $updatedAttachmentIds[0]; - $updatedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $updatedAttachmentId); - $deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); - expect($updatedAttachmentRow)->not->toBeNull(); - expect($deletedAttachmentRow)->not->toBeNull(); - expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull(); - expect((json_decode((string)($updatedAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null)->not->toBe($initialDocument); -}); - -it('regenerates attached wash certificates for legacy field-value updates through the alias endpoint', function (): void { - api_test_covers('PUT /order', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Alias Wash Certificate Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Alias Certificate Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Alias Certificate Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'ALIAS-CERT', - 'reg_1' => 'ALIAS123', - 'safety_seal' => 'ALIAS-SEAL', - ]); - $session = api_fixtures()->createUserSession([ - 'edit_order', - 'add_order_attachments', - 'department_access_' . $department['id'], - ]); - - api_client()->post('/orders/attachments/upload', [ - 'order_id' => $order['id'], - 'base64_file' => base64_encode('wash-certificate'), - 'file_name' => 'wash_certificate', - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($initialAttachmentIds)->toHaveCount(1); - - $initialAttachmentId = $initialAttachmentIds[0]; - api_client()->put('/order', [ 'id' => $order['id'], - 'field' => 'reg_1', - 'value' => ' zz-88 11 ', + 'field' => 'reg_2', + 'value' => ' legacy-456 ', ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess() ->assertMessage('Order updated successfully'); - $orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($orderRow)->not->toBeNull(); - expect($orderRow['reg_1'] ?? null)->toBe('ZZ8811'); - - $updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($updatedAttachmentIds)->toHaveCount(1); - expect($updatedAttachmentIds)->not->toContain($initialAttachmentId); - - $deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); - - expect($deletedAttachmentRow)->not->toBeNull(); - expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull(); -}); - -it('supports legacy field-value metadata updates through the primary endpoint', function (): void { - api_test_covers('PUT /orders', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Legacy Field Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Legacy Field Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Legacy Field Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'START-REF', - 'reg_1' => 'START123', - 'reg_2' => 'START2', - 'reg_3' => 'START3', - 'notes' => 'Start note', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $payloads = [ - ['field' => 'reference', 'value' => 'LEGACY-REF'], - ['field' => 'notes', 'value' => 'Legacy note'], - ['field' => 'safety_seal', 'value' => 'LEGACY-SEAL'], - ['field' => 'reg_1', 'value' => ' ab-12 34 '], - ['field' => 'reg_2', 'value' => ' cd/56 78 '], - ['field' => 'reg_3', 'value' => ' ef_90 12 '], - ]; - - foreach ($payloads as $payload) { - api_client()->put('/orders', [ - 'id' => $order['id'], - ...$payload, - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - } - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); expect($row)->not->toBeNull(); - expect($row['reference'] ?? null)->toBe('LEGACY-REF'); - expect($row['notes'] ?? null)->toBe('Legacy note'); - expect($row['safety_seal'] ?? null)->toBe('LEGACY-SEAL'); - expect($row['reg_1'] ?? null)->toBe('AB1234'); - expect($row['reg_2'] ?? null)->toBe('CD5678'); - expect($row['reg_3'] ?? null)->toBe('EF9012'); + expect($row['reference'] ?? null)->toBe('AFTER-UPDATE'); + expect($row['notes'] ?? null)->toBe('After update'); + expect($row['reg_1'] ?? null)->toBe('AFTER123'); + expect($row['reg_2'] ?? null)->toBe('LEGACY456'); }); -it('rejects invalid updates through the primary order endpoint', function (): void { +it('rejects invalid order update requests', function (): void { api_test_covers('PUT /orders', 'failure'); - - $session = api_fixtures()->createUserSession(['edit_order']); - - $missingId = api_client()->put('/orders', [ - 'notes' => 'No id', - ], $session['headers']); - - $missingId - ->assertStatus(400) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('ID is required'); - - $missingOrder = api_client()->put('/orders', [ - 'id' => 999999, - 'notes' => 'Missing order', - ], $session['headers']); - - $missingOrder - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('must not be accessed before initialization'); -}); - -it('updates orders through the legacy alias endpoint', function (): void { - api_test_covers('PUT /order', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Legacy Department']); - $updatedDepartment = api_fixtures()->createDepartment(['name' => 'Legacy Updated Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Legacy Original Customer']); - $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Legacy Updated Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Legacy Editor']); - $updatedInvoiceCollection = api_fixtures()->createInvoiceCollection([ - 'customer_number' => $updatedCustomer['customer_number'], - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'LEGACY-BEFORE', - 'reg_1' => 'LGCY123', - 'reg_2' => 'LGCY-2', - 'reg_3' => 'LGCY-3', - 'notes' => 'Legacy before', - 'po' => 'LEGACY-PO', - 'lane' => 4, - 'wash_id' => 'LEGACY-WASH', - 'booking_id' => 654, - 'safety_seal' => 'LEGACY-SEAL-BEFORE', - 'include_in_invoice' => false, - 'created_at' => '2026-04-10 08:15:00', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $response = api_client()->put('/order', [ - 'id' => $order['id'], - 'customer_id' => $updatedCustomer['customer_number'], - 'department_id' => $updatedDepartment['id'], - 'reference' => 'LEGACY-AFTER', - 'reg_1' => ' lgcy-999 ', - 'reg_2' => ' leg-2 ', - 'reg_3' => ' leg/3 ', - 'notes' => 'Legacy after', - 'po' => 'LEGACY-PO-NEW', - 'lane' => 9, - 'wash_id' => 'LEGACY-WASH-NEW', - 'booking_id' => 7654, - 'safety_seal' => 'LEGACY-SEAL-AFTER', - 'invoice_collection_id' => $updatedInvoiceCollection['id'], - 'created_at' => '2026-04-11 11:22:33', - 'include_in_invoice' => true, - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect((int)($row['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); - expect((int)($row['department_id'] ?? 0))->toBe((int)$updatedDepartment['id']); - expect($row['reference'] ?? null)->toBe('LEGACY-AFTER'); - expect($row['reg_1'] ?? null)->toBe('LGCY999'); - expect($row['reg_2'] ?? null)->toBe('LEG2'); - expect($row['reg_3'] ?? null)->toBe('LEG3'); - expect($row['notes'] ?? null)->toBe('Legacy after'); - expect($row['po'] ?? null)->toBe('LEGACY-PO-NEW'); - expect((int)($row['lane'] ?? 0))->toBe(9); - expect($row['wash_id'] ?? null)->toBe('LEGACY-WASH-NEW'); - expect((int)($row['booking_id'] ?? 0))->toBe(7654); - expect($row['safety_seal'] ?? null)->toBe('LEGACY-SEAL-AFTER'); - expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']); - expect($row['created_at'] ?? null)->toBe('2026-04-11 11:22:33'); - expect((int)($row['include_in_invoice'] ?? 0))->toBe(1); -}); - -it('supports legacy field-value metadata updates through the alias endpoint', function (): void { - api_test_covers('PUT /order', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Alias Field Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Alias Field Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Alias Field Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'ALIAS-START', - 'reg_1' => 'ALIAS123', - 'reg_2' => 'ALIAS2', - 'reg_3' => 'ALIAS3', - 'notes' => 'Alias note', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $payloads = [ - ['field' => 'reference', 'value' => 'ALIAS-REF'], - ['field' => 'notes', 'value' => 'Alias updated note'], - ['field' => 'safety_seal', 'value' => 'ALIAS-SEAL'], - ['field' => 'reg_1', 'value' => ' gh-12 34 '], - ['field' => 'reg_2', 'value' => ' ij/56 78 '], - ['field' => 'reg_3', 'value' => ' kl_90 12 '], - ]; - - foreach ($payloads as $payload) { - api_client()->put('/order', [ - 'id' => $order['id'], - ...$payload, - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - } - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect($row['reference'] ?? null)->toBe('ALIAS-REF'); - expect($row['notes'] ?? null)->toBe('Alias updated note'); - expect($row['safety_seal'] ?? null)->toBe('ALIAS-SEAL'); - expect($row['reg_1'] ?? null)->toBe('GH1234'); - expect($row['reg_2'] ?? null)->toBe('IJ5678'); - expect($row['reg_3'] ?? null)->toBe('KL9012'); -}); - -it('rejects invalid updates through the legacy alias endpoint', function (): void { api_test_covers('PUT /order', 'failure'); $session = api_fixtures()->createUserSession(['edit_order']); - $response = api_client()->put('/order', [ - 'id' => 999999, - 'notes' => 'Missing alias order', - ], $session['headers']); - - $response - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('must not be accessed before initialization'); -}); - -it('rejects unsupported legacy field-value updates on both update endpoints', function (): void { - api_test_covers('PUT /orders', 'failure'); - - $department = api_fixtures()->createDepartment(['name' => 'Unsupported Field Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Unsupported Field Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Unsupported Field Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'UNCHANGED-REF', - 'notes' => 'Unchanged note', - 'reg_1' => 'UNCH123', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - foreach (['/orders', '/order'] as $endpoint) { api_client()->put($endpoint, [ - 'id' => $order['id'], - 'field' => 'cashier_id', - 'value' => 999999, + 'notes' => 'Missing id', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) - ->assertMessage('Unsupported legacy order field: cashier_id'); + ->assertMessage('ID is required'); } - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect($row['reference'] ?? null)->toBe('UNCHANGED-REF'); - expect($row['notes'] ?? null)->toBe('Unchanged note'); - expect($row['reg_1'] ?? null)->toBe('UNCH123'); }); -it('deletes orders through the real endpoint', function (): void { +it('deletes orders through the orders endpoint', function (): void { api_test_covers('DELETE /orders', 'happy'); - $department = api_fixtures()->createDepartment(); - $customer = api_fixtures()->createUser(); - $cashier = api_fixtures()->createUser(['display_name' => 'Delete Cashier']); + $department = api_fixtures()->createDepartment(['name' => 'Order Delete Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Delete Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Delete Cashier']); $order = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'cashier_id' => $cashier['id'], @@ -941,11 +201,9 @@ it('deletes orders through the real endpoint', function (): void { 'department_access_' . $department['id'], ]); - $response = api_client()->delete('/orders', [ + api_client()->delete('/orders', [ 'id' => $order['id'], - ], $session['headers']); - - $response + ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess() @@ -956,110 +214,14 @@ it('deletes orders through the real endpoint', function (): void { expect($row['deleted_at'] ?? null)->not->toBeNull(); }); -it('requires explicit confirmation before deleting protected orders', function (): void { - api_test_covers('DELETE /orders', 'failure'); - api_test_covers('DELETE /orders', 'happy'); - - $department = api_fixtures()->createDepartment(); - $customer = api_fixtures()->createUser(); - $cashier = api_fixtures()->createUser(['display_name' => 'Protected Delete Cashier']); - $session = api_fixtures()->createUserSession([ - 'delete_order', - 'department_access_' . $department['id'], - ]); - - $cases = [ - 'completed' => function () use ($department, $customer, $cashier): array { - return api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'completed_at' => date('Y-m-d H:i:s'), - ]); - }, - 'order_items' => function () use ($department, $customer, $cashier): array { - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - ]); - api_fixtures()->createOrderItem([ - 'order_id' => $order['id'], - 'product_id' => 53, - 'cashier_id' => $cashier['id'], - 'quantity' => 1, - ]); - return $order; - }, - 'attachments' => function () use ($department, $customer, $cashier): array { - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - ]); - api_fixtures()->createOrderAttachment([ - 'order_id' => $order['id'], - ]); - return $order; - }, - ]; - - foreach ($cases as $expectedReason => $createProtectedOrder) { - $order = $createProtectedOrder(); - - $unconfirmed = api_client()->delete('/orders', [ - 'id' => $order['id'], - ], $session['headers']); - - $unconfirmed - ->assertStatus(409) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Order deletion requires confirmation'); - - expect($unconfirmed->data()['requires_confirmation'] ?? null)->toBeTrue(); - expect($unconfirmed->data()['protected_reasons'] ?? [])->toContain($expectedReason); - expect(api_fixtures()->fetchRowById('orders', (int)$order['id'])['deleted_at'] ?? null)->toBeNull(); - - $confirmed = api_client()->delete('/orders', [ - 'id' => $order['id'], - 'confirmed' => 'true', - ], $session['headers']); - - $confirmed - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order deleted successfully'); - - expect(api_fixtures()->fetchRowById('orders', (int)$order['id'])['deleted_at'] ?? null)->not->toBeNull(); - } -}); - it('rejects invalid order delete requests', function (): void { api_test_covers('DELETE /orders', 'failure'); - $department = api_fixtures()->createDepartment(); - $session = api_fixtures()->createUserSession([ - 'delete_order', - 'department_access_' . $department['id'], - ]); + $session = api_fixtures()->createUserSession(['delete_order']); - $missingId = api_client()->delete('/orders', [], $session['headers']); - - $missingId + api_client()->delete('/orders', [], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('ID is required'); - - $missingOrder = api_client()->delete('/orders', [ - 'id' => 999999, - ], $session['headers']); - - $missingOrder - ->assertStatus(400) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Order not found'); }); diff --git a/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php index a83ee482..127d7f4e 100644 --- a/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php @@ -2,6 +2,25 @@ usesApiSuite(); +function selfserve_fixture_ensure_legacy_redis_constant(): void +{ + if (defined('redis')) { + return; + } + + global $REDIS_CONFIG; + + $REDIS_CONFIG = [ + 'host' => getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis', + 'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default', + 'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0', + 'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '', + 'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379', + ]; + + define('redis', (new \classes\redis())->connect()); +} + it('creates a comprehensive self-serve API scenario with demo relays', function (): void { $scenario = api_fixtures()->createSelfServeScenario(); @@ -21,3 +40,67 @@ it('creates a comprehensive self-serve API scenario with demo relays', function ->and($session)->not->toBeNull() ->and($session['reg'])->toBe($scenario['vehicle']['reg']); }); + +it('creates self-serve invoice orders on the draft customer with original customer and driver metadata attached', function (): void { + selfserve_fixture_ensure_legacy_redis_constant(); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']); + $scenario = api_fixtures()->createSelfServeScenario(); + $subuser = api_fixtures()->createSubuser([ + 'name' => 'Self-Serve Driver', + 'username' => 'selfserve-driver-' . $scenario['vehicle']['reg'], + ]); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + api_fixtures()->setModuleConfig('selfserve', 'minute_product', (string)$scenario['product']['id'], 'int'); + + $lane = (new \classes\selfserve())->lane((int)$scenario['lane']['id']); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); + $lane->setLaneMode(\modules\selfserve\helpers\selfserve_lane_mode::MANUAL); + $lane->setCustomerNumber((int)$scenario['customer']['customer_number']); + $lane->setLicensePlate((string)$scenario['vehicle']['reg']); + $lane->setWashStartTime(time() - 620); + + $arguments = (new \modules\selfserve\classes\selfserve_lane_command_arguments()) + ->setCustomerNumber((int)$scenario['customer']['customer_number']) + ->setSubuserId((int)$subuser['id']); + + expect($lane->invoice($arguments))->toBeTrue(); + + $orderId = $lane->getLastInvoiceOrderId(); + expect($orderId)->toBeInt()->toBeGreaterThan(0); + + $order = api_fixtures()->fetchRowById('orders', $orderId); + $attachmentObjectType = '`orders`'; + $invoiceCollectionId = (int)($order['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + api_fixtures()->cleanupDeleteById('collected_order_invoices', $invoiceCollectionId); + } + api_fixtures()->cleanupDeleteById('orders', $orderId); + api_fixtures()->cleanupDeleteWhere('order_items', ['order_id' => $orderId]); + api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]); + + expect($order)->not->toBeNull() + ->and((int)$order['customer_id'])->toBe((int)$draftCustomer['customer_number']) + ->and((int)$order['department_id'])->toBe((int)$scenario['department']['id']) + ->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg']) + ->and((int)$order['lane'])->toBe((int)$scenario['lane']['id']) + ->and($order['completed_at'])->toBeNull(); + + $db = api_test_runtime()->db(); + $result = $db->query( + "SELECT content FROM object_attachments WHERE object_type = '{$attachmentObjectType}' AND object_id = " . (int)$orderId . ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1' + ); + $attachment = $result ? $result->fetch_assoc() : null; + $content = json_decode((string)($attachment['content'] ?? ''), true); + $metadata = is_array($content) ? ($content['other'] ?? null) : null; + + expect($metadata)->toBeArray() + ->and($metadata['type'] ?? null)->toBe(\attachments\helpers\attachment_content::OTHER_TYPE_SELF_SERVE_WASH) + ->and((int)($metadata['customer_number'] ?? 0))->toBe((int)$scenario['customer']['customer_number']) + ->and((int)($metadata['draft_customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']) + ->and((int)($metadata['subuser_id'] ?? 0))->toBe((int)$subuser['id']) + ->and((int)($metadata['session_id'] ?? 0))->toBe((int)$scenario['session']['id']) + ->and($metadata['subuser']['name'] ?? null)->toBe('Self-Serve Driver'); +}); diff --git a/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php b/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php index 91c69bc0..03672d7c 100644 --- a/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php +++ b/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php @@ -384,8 +384,7 @@ function edge_gateway_integration_context(): array } }; - $db = new db($dbConfig); - $db->connect(); + $db = edge_gateway_integration_wait_for_db($dbConfig); $GLOBALS['db'] = $db; $mysqli = $db->conn(); @@ -412,6 +411,25 @@ function edge_gateway_integration_context(): array ]; } +function edge_gateway_integration_wait_for_db(array $dbConfig): db +{ + $deadline = microtime(true) + 60; + $lastError = null; + + do { + try { + $db = new db($dbConfig); + $db->connect(); + return $db; + } catch (RuntimeException $exception) { + $lastError = $exception; + usleep(500000); + } + } while (microtime(true) < $deadline); + + throw $lastError ?? new RuntimeException('Database connection failed before a connection attempt completed.'); +} + /** * @return array{host:string,user:string,password:string,database:string,port:int} */ diff --git a/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php b/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php index 9f5d225d..896fe8f9 100644 --- a/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php @@ -14,10 +14,10 @@ it('registers superuser replication endpoints and permissions', function (): voi expect($content)->toContain('/superuser/replication/{kind}/{id}/provision'); expect($content)->toContain('/superuser/replication/{kind}/{id}/promote'); expect($content)->toContain("\$this->patch('/superuser/replication/{kind}/{id}'"); - expect($content)->toContain("requirePermission('superuser_replication_view')"); - expect($content)->toContain("requirePermission('superuser_replication_manage')"); - expect($content)->toContain("requirePermission('superuser_replication_promote')"); - expect($content)->toContain("requirePermission('superuser_replication_remove')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_view')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')"); }); it('documents replication management in openapi', function (): void { @@ -36,3 +36,16 @@ it('documents replication management in openapi', function (): void { expect($content)->toContain('SuperuserReplicationHostRenameRequest'); expect($content)->toContain('SuperuserReplicationComposeTemplateRequest'); }); + +it('rejects subuser sessions before checking replication permissions', function (): void { + $content = file_get_contents(app_path('routes/superuserReplicationRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool'); + expect($content)->toContain('get_subuser() !== false'); + expect($content)->toContain("Subuser sessions cannot manage replication."); + expect($content)->toContain("\$response->error('Subuser sessions cannot manage replication.', 403);"); + expect($content)->toContain('return $this->requirePermission($permission);'); + expect(preg_match_all("/requireClassicSuperuserPermission\\('superuser_replication_/", $content))->toBe(11); + expect($content)->not->toContain("requirePermission('superuser_replication_"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php index 08c52553..8ea270b9 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php @@ -97,16 +97,21 @@ it('builds the installer around the compose stack artifacts and management polli expect($agentSource)->toContain("'last_transport_error'"); expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void'); expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}'); + expect($edgeDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;'); + expect($edgeDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); expect($edgeDockerfileSource)->toContain('extension_loaded($extension)'); expect($edgeDockerfileSource)->toContain('Missing PHP extension: {$extension}'); expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php'); expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}'); + expect($workerDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;'); + expect($workerDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); expect($workerDockerfileSource)->toContain('extension_loaded($extension)'); expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}'); expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php'); expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'"); expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php'); - expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;'); + expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev;'); + expect($autoUpdaterDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); expect($autoUpdaterDockerfileSource)->toContain('extension_loaded($extension)'); expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php index def92b8e..d7018740 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php @@ -27,6 +27,7 @@ class SelfserveLaneInvoiceModeBillingHarness public ?int $lastAddedOrderId = null; public ?int $lastAddedProductId = null; public ?int $lastAddedQuantity = null; + public int $createdOrderContexts = 0; public ?int $lastOrderCustomerNumber = null; public ?int $lastAttachmentBillingCustomerNumber = null; public ?int $lastAttachmentDraftCustomerNumber = null; @@ -97,6 +98,7 @@ class SelfserveLaneInvoiceModeBillingHarness protected function createInvoiceOrderContext(): orders_o { + $this->createdOrderContexts++; $billing_customer_number = $this->getCustomerNumber(); $draft_customer_number = $this->draftCustomerNumber; $order = new SelfserveLaneInvoiceModeOrderStub(); @@ -139,6 +141,7 @@ it('bills manual self-serve stop using full elapsed minutes without included-min expect($harness->lastAddedOrderId)->toBe(424242); expect($harness->lastAddedProductId)->toBe(999); expect($harness->lastAddedQuantity)->toBe(1); + expect($harness->createdOrderContexts)->toBe(1); expect($harness->getLastInvoiceOrderId())->toBe(424242); }); @@ -154,6 +157,7 @@ it('keeps included-minute reduction for automatic mode', function (): void { expect($harness->lastAddedOrderId)->toBeNull(); expect($harness->lastAddedProductId)->toBeNull(); expect($harness->lastAddedQuantity)->toBeNull(); + expect($harness->createdOrderContexts)->toBe(0); expect($harness->getLastInvoiceOrderId())->toBeNull(); }); @@ -166,6 +170,7 @@ it('creates self-serve invoice orders for the actual lane customer when a draft $result = $harness->invoice(); expect($result)->toBeTrue(); + expect($harness->createdOrderContexts)->toBe(1); expect($harness->lastOrderCustomerNumber)->toBe(1234); expect($harness->lastAttachmentBillingCustomerNumber)->toBe(1234); expect($harness->lastAttachmentDraftCustomerNumber)->toBe(9999); diff --git a/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php b/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php index 89e5b9b4..04d47bd2 100644 --- a/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php +++ b/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php @@ -77,7 +77,7 @@ class _TestLane extends selfserve_lane { 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 invoice(?selfserve_lane_command_arguments $arguments = null): 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; }