Merge origin/master and resolve invoice billing test conflict
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 = "") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
|
||||
+20
-2
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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_");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user