Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] 31214f0af0 fix: mark workspace as git safe directory before qodana 2026-05-28 17:34:58 +00:00
copilot-swe-agent[bot] cd0e0f0e61 Initial plan 2026-05-28 17:30:45 +00:00
Jeppe B 76dfcd70d1 Merge pull request #158 from copenhagentruckwash/fix-authorization-bypass-in-self-serve-lanes
Harden self-serve lane mutation authorization
2026-05-28 19:25:01 +02:00
Jeppe B 893ed1bda5 Fix PHP CI legacy and edge gateway tests
- Match self-serve legacy test double invoice signature.
- Wait for the edge gateway integration database before bootstrapping schema.
2026-05-28 18:03:30 +02:00
Jeppe Bundgaard 90ebec84bf Add PHP CI test script and optimize Redis config in tests
- Introduced a PHP CI test script for managing test suites.
- Consolidated Redis configuration retrieval.
- Optimized test fixture queries with dynamic object type assignments.
2026-05-28 17:58:06 +02:00
Jeppe Bundgaard bdf2a787d6 Merge remote-tracking branch 'origin/master' 2026-05-28 17:33:10 +02:00
Jeppe Bundgaard f8c254607d Implement Lane Status Audit and Comprehensive Self-Serve API Enhancements
- Introduced `machine_status_audit` in self-serve lanes for tracking changes.
- Added new methods to handle audit data including `setLaneStatusAudit` and `getMachineStatusAudit`.
- Enhanced API tests to include legacy Redis constant checks and validated comprehensive self-serve invoice creation.
- Updated department lanes to reflect audit logs in their responses.
2026-05-28 17:27:25 +02:00
Jeppe B 20eb92891a Avoid empty self-serve invoice orders
Only create the invoice order context when elapsed minute billing has a positive quantity. This preserves automatic-mode included-minute reduction without leaving an empty order id on the lane.

Tests:
- bash scripts/php-ci-test.sh unit
2026-05-28 17:21:36 +02:00
Jeppe Bundgaard 4cfe906f55 Update invoice function in selfserve_lane_command_t to accept command arguments and add necessary requires in selfserve_lane_invoice_t. 2026-05-28 16:36:09 +02:00
Jeppe Bundgaard 184ea1ca6c Enhance invoice and self-serve logic with subuser support
- Add subuser ID management to `selfserve_lane_command_arguments`.
- Update `invoice` function to include optional command arguments.
- Attach metadata to orders with self-serve and subuser details.
- Introduce `OTHER_TYPE_SELF_SERVE_WASH` in `attachment_content`.
2026-05-28 16:11:14 +02:00
Jeppe Bundgaard ae3657e7aa Add new API tests for order item note requirements, subuser route updates, and department lane status management
- Introduced tests for validating note requirements on order items.
- Updated subuser route management contract tests with new route coverage.
- Added endpoints to manage department lane and self-serve lane statuses, with associated tests.
2026-05-28 16:06:14 +02:00
Jeppe Bundgaard 54de2e5674 Add fake classes for relay logic and refactor relay shutdown without pre-checking status
Introduce helper classes `SelfserveWashCompletionRelayValueFake`, `SelfserveWashCompletionDepartmentLaneFake`, `SelfserveWashCompletionRelayLaneFake`, and `SelfserveWashCompletionFlowHarness` to simulate relay logic for unit tests. Refactor `turnOffRelayIfConfiguredAndOn` to `turnOffRelayIfConfigured`, removing relay status pre-check for cleaner and machine relays when completing a wash session, and test associated relay actions.
2026-05-27 19:30:31 +02:00
Jeppe Bundgaard eef436d44b Add tests for subuser password validation and grant permission normalization
Introduce unit and API tests for subuser password policies ensuring compliance with complexity requirements. Normalize subuser grant permission handling for consistency, including support for legacy zero permissions.
2026-05-27 19:17:19 +02:00
Jeppe Bundgaard b7aeb11801 Add department_selfserve_path_confirmations table and enhance PingApiTest
Introduce a new database table `department_selfserve_path_confirmations` to store path confirmations related to department configurations. Update `PingApiTest` to verify additional keys, ensuring `backend_version` and `api_commit_sha` are checked in the response.
2026-05-27 17:35:16 +02:00
Jeppe Bundgaard d52ceb8513 Add robust release update and API health checks
This commit introduces a release update mechanism, including candidate detection, asset pre-downloading, and installation workflows with proper state management. Additionally, it implements API health checks both for successful and failure scenarios and adds related unit and e2e tests for enhanced reliability.
2026-05-27 13:24:15 +02:00
50 changed files with 13156 additions and 5018 deletions
+2
View File
@@ -20,6 +20,8 @@ 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"
+24
View File
@@ -253,3 +253,27 @@ jobs:
- name: Tear down local stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, default]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
steps:
- name: Record Release Manager API gate
run: |
set -euo pipefail
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+9 -1
View File
@@ -7705,7 +7705,15 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema: {}
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
/worker/debug:
get:
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL,
commit_sha VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
source VARCHAR(64) NULL,
workflow_url VARCHAR(512) NULL,
gate_operation_id BIGINT UNSIGNED NULL,
sync_operation_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
error_message TEXT NULL,
metadata_json LONGTEXT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gate_passed_at DATETIME NULL,
synced_at DATETIME NULL,
promoted_at DATETIME NULL,
failed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
INDEX idx_release_auto_sync_gate (gate_operation_id),
INDEX idx_release_auto_sync_sync (sync_operation_id),
INDEX idx_release_auto_sync_deployment (deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
@@ -408,6 +437,7 @@ class release_manager_schema_bootstrap
'release_versions',
'release_channel_versions',
'release_assignments',
'release_auto_sync_events',
'release_service_sets',
'release_deployments',
'release_bundles',
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
lane_id INT NULL,
vehicle_type_id INT NULL,
config_version_id INT NULL,
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
path_signature VARCHAR(128) NOT NULL,
result_signature VARCHAR(128) NOT NULL,
answers_json JSON NOT NULL,
result_json JSON NOT NULL,
scope_json JSON NULL,
confirmed_by INT NULL,
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
stale_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
@@ -5,6 +5,7 @@ namespace attachments\helpers;
class attachment_content
{
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
public ?string $image; // Used to store the attachment object name, in the attachment store.
public ?string $document; // Used to store the attachment object name, in the attachment store.
public ?attachment_relation $relation; // Used to store the attachment relation object.
@@ -49,4 +50,4 @@ class attachment_content
$this->relation = $relation;
return $this;
}
}
}
@@ -6,6 +6,7 @@ class selfserve_lane_command_arguments
{
public ?string $license_plate = null;
public ?int $customer_number = null;
public ?int $subuser_id = null;
public bool $defer_relay_side_effects = false;
/**
@@ -25,6 +26,12 @@ class selfserve_lane_command_arguments
return $this;
}
public function setSubuserId(?int $subuser_id): self
{
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
return $this;
}
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{
$this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -40,6 +47,9 @@ class selfserve_lane_command_arguments
if (array_key_exists('customer_number', $params)) {
$this->setCustomerNumber($params['customer_number']);
}
if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
}
if (array_key_exists('defer_relay_side_effects', $params)) {
$this->setDeferRelaySideEffects(filter_var(
$params['defer_relay_side_effects'],
File diff suppressed because it is too large Load Diff
@@ -273,28 +273,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return;
}
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
} catch (\Throwable) {
// Best effort only; session completion flow must continue.
}
}
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
{
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
return;
}
try {
$status = $lane->getRelayStatus($relay);
if ((bool)($status['on'] ?? false) !== true) {
return;
}
} catch (\Throwable) {
// If relay status can't be read, still attempt turn-off as best effort.
}
try {
$lane->setRelayStatusHard($relay, false);
} catch (\Throwable) {
@@ -723,14 +714,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
{
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$laneId = (int)$snapshot['lane']['id'];
$lane = (new selfserve())->lane($laneId);
$this->enableCleanerRelayForStartedWash($lane);
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$session->markRelayEnabled();
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
'lane_id' => $laneId,
@@ -2408,11 +2399,20 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function taskUsesProgramPicker(array $task): bool
{
return in_array(
if (in_array(
'PROGRAM_PICKER',
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
true
);
)) {
return true;
}
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
}
protected function isProgramNumberButton(mixed $button): bool
{
return is_int($button) && $button >= 0 && $button <= 11;
}
protected function dynamicImageButtonSequenceForTask(array $task): array
@@ -2422,7 +2422,15 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $buttons;
}
return $this->normalizeButtonList(array_merge(['program_picker'], $buttons));
$sequence = ['program_picker'];
foreach ($buttons as $button) {
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
continue;
}
$sequence[] = $button;
}
return $this->normalizeButtonList($sequence);
}
/**
@@ -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;
}
}
}
@@ -567,7 +567,7 @@ trait selfserve_lane_command_t
// Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Invoice the customer
$this->invoice();
$this->invoice($arguments);
// Only bill the machine wash product when the physical machine start signal was recorded.
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
// Finalize any active self-serve wash session for this lane
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/attachments/helpers/attachment_content.php';
require_once WD . '/objects/selfserve_wash_sessions_o.php';
require_once WD . '/objects/subusers_o.php';
use classes\selfserve;
use classes\economic;
use Exception;
use attachments\helpers\attachment_content;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\helpers\selfserve_lane_mode;
use modules\selfserve\helpers\selfserve_lane_status;
use objects\customer_vehicles_o;
use objects\order_items_o;
use objects\orders_o;
use objects\selfserve_wash_sessions_o;
use objects\subusers_o;
trait selfserve_lane_invoice_t
{
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
* @return bool True on success, false on failure
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
*/
public function invoice(): bool
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
{
$this->last_invoice_order_id = null;
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
$included_minutes = $this->resolveIncludedMinutesForBilling();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes <= 0) {
return true;
if ($billable_minutes > 0) {
$order = $this->createInvoiceOrderContext($arguments);
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
}
$order = $this->createInvoiceOrderContext();
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
@@ -212,10 +219,13 @@ trait selfserve_lane_invoice_t
return max(0, $elapsed_minutes - $included_minutes);
}
protected function createInvoiceOrderContext(): orders_o
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
{
$billing_customer_number = $this->getCustomerNumber();
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
$order_customer_number = $draft_customer_number ?? $billing_customer_number;
$order = (new orders_o())->add(
$this->getCustomerNumber(),
$order_customer_number,
self::INVOICE_SYSTEM_USER_ID,
'',
'',
@@ -224,10 +234,101 @@ trait selfserve_lane_invoice_t
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
return $order;
}
protected function attachSelfServeMetadataToOrder(
orders_o $order,
int $billing_customer_number,
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): void {
try {
$order->addAttachment(
(new attachment_content())->setOther(
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
)
);
} catch (\Throwable) {
// Metadata attachments must not block billing; the order itself is the source of record.
}
}
protected function buildSelfServeOrderAttachmentPayload(
int $billing_customer_number,
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): array {
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
$subuser_id = $arguments?->subuser_id;
return [
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
'source' => 'selfserve',
'customer_number' => $billing_customer_number,
'draft_customer_number' => $draft_customer_number,
'subuser_id' => $subuser_id,
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
'session_id' => $session?->id,
'lane_id' => (int)$this->id,
'department_id' => (int)$this->department_lane->department->value(),
'license_plate' => (string)$this->getLicensePlate(),
'lane_status' => $this->getLaneStatus()->name,
'lane_mode' => $this->getLaneMode()->name,
'wash_start_time' => (int)$this->getWashStartTime(),
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
'created_at' => date('Y-m-d H:i:s'),
];
}
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
{
$license_plate = trim((string)$this->getLicensePlate());
if ($license_plate === '') {
return null;
}
try {
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
(int)$this->id,
selfserve::standardize_registration($license_plate),
$billing_customer_number > 0 ? $billing_customer_number : null
);
return $session->exists() ? $session : null;
} catch (\Throwable) {
return null;
}
}
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
{
if ($subuser_id === null || $subuser_id <= 0) {
return null;
}
try {
$subuser = (new subusers_o())->select($subuser_id);
if (!$subuser->exists()) {
return [
'id' => $subuser_id,
];
}
return [
'id' => (int)$subuser->id,
'name' => $subuser->name->value(),
'username' => $subuser->username->value(),
'email' => $subuser->email->value(),
];
} catch (\Throwable) {
return [
'id' => $subuser_id,
];
}
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
{
return (new order_items_o())->addItemToOrder(
@@ -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,
];
}
}
@@ -130,6 +130,10 @@ 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 [
'id' => (int)$this->id,
'department' => (int)$this->department->value(),
@@ -143,13 +147,76 @@ class department_lanes_o extends db
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
'selfserve_enabled' => $this->isSelfServeEnabled(),
// Status of the lane
'status' => (string)$this->getLaneStatus()->name,
'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,
// Timestamps
'created_at' => (string)$this->created_at->value(),
'updated_at' => (string)$this->updated_at->value(),
];
}
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();
$required_fields = [
'relay_in_id' => 'Indgangsrelæ',
'relay_out_id' => 'Udgangsrelæ',
'relay_machine_id' => 'Maskinrelæ',
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
'relay_machine_cleaner_id' => 'Vaskerelæ',
'dynamic_image_id' => 'Maskinstatusbillede',
'machine_type_id' => 'Maskintype',
];
$warnings = [];
foreach ($required_fields as $field => $label) {
if ($this->hasConfiguredFieldValue($field)) {
continue;
}
$warnings[] = [
'field' => $field,
'label' => $label,
'message' => $label . ' mangler',
];
}
return $warnings;
}
public function isSelfServeConfigured(): bool
{
return $this->getSelfServeConfigurationWarnings() === [];
}
public static function isOperationalStatusName(string $status): bool
{
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
}
public function isSelfServeEnabled(): bool
{
self::requireSelected();
@@ -184,6 +251,29 @@ class department_lanes_o extends db
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function hasConfiguredFieldValue(string $field): bool
{
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
return false;
}
$value = $this->{$field}->value();
if ($value === null) {
return false;
}
if (is_string($value)) {
$value = trim($value);
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
}
if (is_numeric($value)) {
return (int)$value > 0;
}
return (bool)$value;
}
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
{
if ($lane_id <= 0) {
+26 -1
View File
@@ -11,6 +11,9 @@ class products_o extends db
{
use db_object_t;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
/**
* The name of the product
* @var object_property
@@ -214,7 +217,7 @@ class products_o extends db
'piktogram' => $this->piktogram->value(),
'economic_product_id' => $this->economic_product_id->value(),
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
'requires_note' => (bool)$this->requires_note->value(),
'requires_note' => $this->requiresOrderItemNote(),
'is_wash' => (bool)$this->is_wash->value(),
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
'order_priority' => (int)$this->order_priority->value(),
@@ -224,6 +227,28 @@ class products_o extends db
];
}
public static function productDataRequiresOrderItemNote(array $product): bool
{
if ((bool)($product['requires_note'] ?? false)) {
return true;
}
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
return true;
}
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
}
public function requiresOrderItemNote(): bool
{
return self::productDataRequiresOrderItemNote([
'id' => $this->id,
'name' => (string)$this->name->value(),
'requires_note' => (bool)$this->requires_note->value(),
]);
}
/**
* Apply department pricing to a list of products
* @param array $products
+42 -12
View File
@@ -22,28 +22,57 @@ class subuser_grants_o extends db
public object_property $updated_at;
public object_property $deleted_at;
const defaultPermissions = [
subusers_permission_node_key::VEHICLES_LIST,
subusers_permission_node_key::SELFSERVE_ADD,
subusers_permission_node_key::BOOKINGS_LIST,
subusers_permission_node_key::BOOKINGS_ADD,
subusers_permission_node_key::BOOKINGS_EDIT,
subusers_permission_node_key::BOOKINGS_DELETE,
'VEHICLES_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
];
private static function normalizePermissionsValue(mixed $raw): array
public static function normalizePermissionsValue(mixed $raw): array
{
if ($raw === null || $raw === '') {
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
return [];
}
if ($raw instanceof subusers_permission_node_key) {
return [$raw->name];
}
if (is_array($raw)) {
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
$permissions = [];
$permissionCandidates = array_is_list($raw)
? $raw
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
foreach ($permissionCandidates as $permission) {
if ($permission instanceof subusers_permission_node_key) {
$permission = $permission->name;
}
if (!is_string($permission)) {
continue;
}
$permission = strtoupper(trim($permission));
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
if (json_last_error() === JSON_ERROR_NONE) {
return self::normalizePermissionsValue($decoded);
}
$permission = strtoupper(trim($raw));
if (subusers_permission_node_key::tryFrom($permission) !== null) {
return [$permission];
}
}
@@ -103,12 +132,13 @@ class subuser_grants_o extends db
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
{
global $db;
$permissions = self::normalizePermissionsValue($permissions);
$tmp = $this->add_object([
'billing_customer_number' => (int)$billing_customer_number,
'subuser' => (int)$subuser,
'enabled' => (bool)$enabled,
'note' => !empty($note) ? $db->escape_string($note) : null,
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$this->id = (int)$tmp;
$this->getObjectProperties();
+26 -5
View File
@@ -15,6 +15,11 @@ class subusers_o extends db
{
use db_object_t;
public const PASSWORD_MIN_LENGTH = 8;
public const PASSWORD_MAX_LENGTH = 255;
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
public object_property $username;
public object_property $password;
public object_property $name;
@@ -62,6 +67,23 @@ class subusers_o extends db
return (bool)$this->two_factor_enabled->value();
}
/**
* @throws Exception
*/
public static function assertValidPassword(string $password): void
{
if (
strlen($password) < self::PASSWORD_MIN_LENGTH
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|| !preg_match(self::PASSWORD_PATTERN, $password)
) {
throw new Exception(
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
);
}
}
/**
* @throws Exception
*/
@@ -103,11 +125,9 @@ class subusers_o extends db
{
global $db, $response;
try {
if (!empty($password)) {
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
}
$passwordWasProvided = !empty($password);
if ($passwordWasProvided) {
self::assertValidPassword($password);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
}
@@ -163,6 +183,7 @@ class subusers_o extends db
public function setPassword(string $password): self
{
self::requireSelected();
self::assertValidPassword($password);
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
return $this;
}
+135 -4
View File
@@ -5460,7 +5460,7 @@ paths:
tags:
- Self-Serve
summary: Bulk save all-in-one self-serve studio graph changes
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
operationId: saveSelfserveStudioGraph
requestBody:
required: true
@@ -5617,6 +5617,29 @@ paths:
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/path-confirmations:
post:
tags:
- Self-Serve
summary: Confirm or reset a projected self-serve studio path
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
operationId: confirmSelfserveStudioPath
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
responses:
'200':
description: Path confirmation updated
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/publish:
post:
tags:
@@ -8047,7 +8070,15 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema: {}
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
/worker/debug:
get:
@@ -17890,10 +17921,10 @@ components:
properties:
action:
type: string
enum: [create, update, delete, connect, disconnect, reorder]
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
entity:
type: string
enum: [question, condition, task]
enum: [question, condition, task, action, path]
description: Standalone rule operations are not accepted for schema_version 2 drafts.
id:
type: integer
@@ -18280,6 +18311,8 @@ components:
items: { type: integer }
max_states: { type: integer }
path_sample_count: { type: integer }
confirmations:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
outcomes:
type: array
items:
@@ -18295,6 +18328,15 @@ components:
type: boolean
progress:
$ref: '#/components/schemas/SelfserveStudioPathProgress'
confirmations:
type: object
properties:
summary:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
removed:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
SelfserveStudioPathProgress:
type: object
@@ -18380,6 +18422,95 @@ components:
node_ids:
type: array
items: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathConfirmationSummary:
type: object
properties:
confirmed: { type: integer }
unconfirmed: { type: integer }
stale: { type: integer }
removed: { type: integer }
total: { type: integer }
SelfserveStudioPathConfirmationRequest:
type: object
required: [department, path_signature]
properties:
department: { type: integer }
action:
type: string
enum: [confirm, reset, delete, clear]
default: confirm
path_signature: { type: string }
result_signature:
type: string
description: Required when action is confirm.
scope:
type: object
additionalProperties: true
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
SelfserveStudioPathConfirmation:
type: object
properties:
id:
type: integer
nullable: true
department_id: { type: integer }
lane_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
config_version_id:
type: integer
nullable: true
config_source: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
scope:
type: object
additionalProperties: true
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathTask:
type: object
@@ -18,6 +18,35 @@ class departmentLanesRoute
public function run(): void
{
$this->get('/department/lanes/status-toggles', function () {
global $response;
$this->requirePermission('list_department_lanes');
self::requireParameters(['department_id']);
$department_id = (int)self::getParameter('department_id');
self::requireType($department_id, self::type_int());
self::requireMinValue($department_id, 1);
self::requireDepartmentAccess($department_id);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
$response->error('Invalid session', 400);
}
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
$response->success(
array_map(
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
(new department_lanes_o())->getDepartmentLanes($department_id)
)
);
},
[
'list_department_lanes' => 'List department lane status toggles'
]
);
$this->get('/department/lanes', function () {
// Require the user to be logged in
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$payload = self::getParametersAsArray();
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
$service = new selfserve_studio_graph();
$result = in_array($action, ['delete', 'reset', 'clear'], true)
? $service->resetPathConfirmation($departmentId, $payload)
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
$response->success($result);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
]);
$this->post('/department/selfserve/studio/publish', function (): void {
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
@@ -62,6 +62,60 @@ class moduleSelfServeRoute
]
);
$this->put('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_set');
self::requireParameters(['lane_id', 'enabled']);
$lane_id = (int)self::getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$department_lane = (new department_lanes_o())->select($lane_id);
if (!$department_lane->exists()) {
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((int)$department_lane->department->value());
$enabled = $this->requestedBoolean('enabled');
$target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
$lane = (new selfserve())->lane($lane_id);
$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',
1,
$user ? $user->id : 0,
'SET_LANE_MACHINE_STATUS',
'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
);
$status = (string)$lane->getLaneStatus()->name;
$response->success([
'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(),
]);
},
[
'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
]
);
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
global $response;
@@ -476,10 +530,12 @@ class moduleSelfServeRoute
// Execute the command
try {
$this->applyShellyTransportOverride($lane);
$subuser = (new authentication())->get_subuser();
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
$args->setParameters([
...$this->getParametersAsArray(), // Pass all parameters
'customer_number' => $customer_number, // Get customer number from request user
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
]);
$lane->execute($command, $args);
$response->success([
@@ -1547,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])) {
@@ -6,6 +6,7 @@ use classes\authentication;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use traits\route_t;
class orderItemsRoute
@@ -69,6 +70,13 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -203,6 +211,14 @@ class orderItemsRoute
if (!isset($data['quantity'])) {
$response->error('Quantity is required', 400);
}
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
if (!$orderItem->exists()) {
$response->error('Order item not found', 404);
}
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
$response->error('Notes is required for this product', 400);
}
// Update the order item
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
// Log the incident
+3
View File
@@ -2,6 +2,7 @@
namespace routes;
use classes\release_manager;
use traits\route_t;
class pingRoute
@@ -15,6 +16,8 @@ class pingRoute
$response->success([
'message' => 'pong',
'time' => date('c'),
'backend_version' => release_manager::backendVersion(),
'api_commit_sha' => release_manager::backendCommitSha(),
]);
});
}
+2 -2
View File
@@ -152,7 +152,7 @@ class productsRoute
'piktogram' => (string)$product['piktogram'],
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (boolean)$product['apply_category_discount'],
'requires_note' => (boolean)$product['requires_note'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => (new product_options_o())->getProductOptions($product['id']),
@@ -432,4 +432,4 @@ class productsRoute
]
);
}
}
}
+389 -89
View File
@@ -44,6 +44,14 @@ class subusersRoute
}
}
private function requireSubuserPasswordPolicy(string $password): void
{
self::requireType($password, self::type_string());
self::requireMinLength('password', subusers_o::PASSWORD_MIN_LENGTH);
self::requireMaxLength('password', subusers_o::PASSWORD_MAX_LENGTH);
self::requireRegex($password, subusers_o::PASSWORD_PATTERN, subusers_o::PASSWORD_COMPLEXITY_MESSAGE);
}
private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int
{
global $response;
@@ -121,6 +129,36 @@ class subusersRoute
return $normalized === '' ? null : $normalized;
}
private function resolveCustomerNames(array $customerNumbers): array
{
$customerNumbers = array_values(array_unique(array_filter(
array_map('intval', $customerNumbers),
static fn (int $customerNumber): bool => $customerNumber > 0
)));
if ($customerNumbers === []) {
return [];
}
return (new users_o())->getCustomerNames($customerNumbers, false);
}
private function resolveCustomerName(int $customerNumber, array $customerNames = []): ?string
{
if ($customerNumber <= 0) {
return null;
}
$key = (string)$customerNumber;
$name = $customerNames[$key] ?? null;
if (!is_string($name)) {
$name = $this->resolveCustomerNames([$customerNumber])[$key] ?? null;
}
$name = trim((string)$name);
return $name === '' || $name === 'Unknown Customer' ? null : $name;
}
private function assertSubuserIdentifiersAvailable(
?int $phoneCountryCode,
?int $phone,
@@ -244,10 +282,10 @@ class subusersRoute
];
}
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber): array
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
{
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? $this->parsePermissionsPayload($grant->permissions->value(), []) : [];
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
@@ -274,6 +312,8 @@ class subusersRoute
'invite_accepted' => $inviteAccepted,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => $customerNumber,
'customer_name' => $customerName ?? $this->resolveCustomerName($customerNumber),
'grant_id' => $grant ? (int)$grant->id : null,
'grant_enabled' => $grantEnabled,
'grant_note' => $grant ? $grant->note->value() : null,
@@ -290,6 +330,10 @@ class subusersRoute
'enabled' => 1,
'deleted_at' => null,
], ['permissions', 'billing_customer_number']);
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
));
return [
'id' => (int)$subuser->id,
@@ -298,11 +342,12 @@ class subusersRoute
'email' => $subuser->email->value(),
'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
'grants' => array_map(function ($grant) {
'grants' => array_map(function ($grant) use ($customerNames) {
$customerNumber = (int)$grant['billing_customer_number'];
return [
'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']),
'billing_customer_number' => (int)$grant['billing_customer_number'],
'permissions' => $this->parsePermissionsPayload($grant['permissions'] ?? null, []),
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
'billing_customer_number' => $customerNumber,
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
];
}, $grants),
'created_at' => $subuser->created_at->value() ?? null,
@@ -312,6 +357,297 @@ class subusersRoute
];
}
private function parseSuperuserPaginationRequest(): array
{
global $response;
$page = max(1, (int)($response->getRequestParameter('page') ?: 1));
$limitRaw = $response->getRequestParameter('limit');
$limit = is_string($limitRaw) && strtolower($limitRaw) === 'all'
? 1000
: (int)($limitRaw ?: 100);
$limit = max(1, min($limit, 1000));
$search = $this->normalizeOptionalString($response->getRequestParameter('search'));
$orderRaw = (string)($response->getRequestParameter('order') ?: 'created_at:DESC');
$orderParts = explode(':', $orderRaw, 2);
$orderField = $orderParts[0] ?? 'created_at';
$orderDirection = strtoupper($orderParts[1] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$allowedOrderFields = [
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'name' => 's.`name`',
];
if (!isset($allowedOrderFields[$orderField])) {
$orderField = 'created_at';
}
return [
'page' => $page,
'limit' => $limit,
'search' => $search,
'order_field' => $orderField,
'order_sql' => $allowedOrderFields[$orderField],
'order_direction' => $orderDirection,
];
}
private function bindStatementParameters(\mysqli_stmt $statement, string $types, array $params): void
{
if ($params === []) {
return;
}
$refs = [];
foreach ($params as $key => $value) {
$refs[$key] = &$params[$key];
}
$statement->bind_param($types, ...$refs);
}
private function buildSuperuserSubuserManagementPayload(array $row): array
{
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$accessState = 'inactive';
if (!empty($row['grant_id']) && $grantEnabled) {
$accessState = $setupRequired ? 'pending_setup' : 'active';
} elseif (!empty($row['grant_id'])) {
$accessState = 'disabled';
}
return [
'id' => (int)$row['id'],
'username' => $row['username'] ?? null,
'name' => $row['name'] ?? null,
'email' => $row['email'] ?? null,
'phone_country_code' => $row['phone_country_code'] !== null ? (int)$row['phone_country_code'] : null,
'phone' => $row['phone'] !== null ? (int)$row['phone'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['row_updated_at'] ?? $row['updated_at'] ?? null,
'suspended_at' => $row['suspended_at'] ?? null,
'two_factor_enabled' => (bool)((int)($row['two_factor_enabled'] ?? 0)),
'setup_required' => $setupRequired,
'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_id' => (int)$row['grant_id'],
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'access_state' => $accessState,
];
}
private function listSuperuserSubusers(): array
{
global $db, $response;
$pagination = $this->parseSuperuserPaginationRequest();
$offset = ((int)$pagination['page'] - 1) * (int)$pagination['limit'];
$where = ['g.`deleted_at` IS NULL'];
$params = [];
$types = '';
$includeNonEnabled = true;
if (self::isParametersSet(['include_non_enabled'])) {
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeNonEnabled = $tmp === null ? true : (bool)$tmp;
}
if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1';
}
if ($pagination['search'] !== null) {
$where[] = "(
CAST(s.`id` AS CHAR) LIKE ?
OR s.`username` LIKE ?
OR s.`name` LIKE ?
OR s.`email` LIKE ?
OR CAST(s.`phone_country_code` AS CHAR) LIKE ?
OR CAST(s.`phone` AS CHAR) LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR u.`display_name` LIKE ?
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
$params[] = $search;
$types .= 's';
}
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number`
";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
}
$this->bindStatementParameters($countStatement, $types, $params);
$countStatement->execute();
$countResult = $countStatement->get_result();
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close();
$dataSql = "
SELECT
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`,
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
u.`display_name` AS `customer_name`
$fromSql
$whereSql
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
$dataStatement = $db->conn->prepare($dataSql);
if ($dataStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
}
$dataParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams);
$dataStatement->execute();
$result = $dataStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
$dataStatement->close();
$response->paginate(
(int)$pagination['page'],
(int)$pagination['limit'],
$total,
$pagination['search'],
null,
[$pagination['order_field'] => $pagination['order_direction']]
);
return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows);
}
private function handleInviteSubuserForCustomer(int $customerNumber): void
{
global $response;
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
self::requireParameters(['name', 'phone_country_code', 'phone']);
$name = $this->normalizeOptionalString(self::getParameter('name'));
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$enabled = $tmp === null ? true : (bool)$tmp;
}
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
}
self::requireType($phoneCountryCode, self::type_int());
self::requireType($phone, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
if ($subuser === null) {
try {
$subuser = (new subusers_o())->add(
null,
null,
$name,
null,
$phoneCountryCode,
$phone
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
if ($grant === null) {
try {
$grant = (new subuser_grants_o())->add(
$customerNumber,
(int)$subuser->id,
$enabled,
$note,
$permissions ?? subuser_grants_o::defaultPermissions
);
} catch (Exception $exception) {
$response->error('Failed to create subuser grant', 500);
}
} else {
$grantUpdates = ['enabled' => $enabled];
if (self::isParametersSet(['note'])) {
$grantUpdates['note'] = $note;
}
if ($permissions !== null) {
$grantUpdates['permissions'] = $permissions;
}
try {
$grant->update($grantUpdates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$grant = (new subuser_grants_o())->select((int)$grant->id);
$grant->getObjectProperties();
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'invite' => $invite,
]);
}
public function run(): void
{
// =============================
@@ -387,7 +723,7 @@ class subusersRoute
'name' => $subuser->name->value(),
'enabled' => $o->enabled,
'note' => $o->note,
'permissions' => json_decode($o->permissions, true) ?: [],
'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null),
'created_at' => $o->created_at,
'updated_at' => $o->updated_at,
];
@@ -649,10 +985,7 @@ class subusersRoute
$token = (string)self::getParameter('token');
$password = (string)self::getParameter('password');
$name = (string)self::getParameter('name');
self::requireType($password, self::type_string());
self::requireMinLength('password', 8);
self::requireMaxLength('password', 255);
self::requireRegex($password, '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/', 'Password must contain at least one uppercase letter, one lowercase letter, and one number');
$this->requireSubuserPasswordPolicy($password);
self::requireType($name, self::type_string());
self::requireMinLength('name', 3);
self::requireMaxLength('name', 255);
@@ -747,9 +1080,7 @@ class subusersRoute
}
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
self::requireType($password, self::type_string());
self::requireMinLength('password', 8);
self::requireMaxLength('password', 255);
$this->requireSubuserPasswordPolicy($password);
try {
if (password_verify($password, $subuser->password->value())) {
if ($subuser->isTwoFactorEnabled()) {
@@ -770,9 +1101,18 @@ class subusersRoute
// =============================
// Subusers - List & Get (with grant visibility)
// =============================
$this->get('/superuser/subusers', function () {
global $response;
$this->requirePermission('list_subusers');
$response->success($this->listSuperuserSubusers());
}, [
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->get('/subusers', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
$customerName = $this->resolveCustomerName($customerNumber);
$includeNonEnabled = false;
if (self::isParametersSet(['include_non_enabled'])) {
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -786,13 +1126,13 @@ class subusersRoute
);
$objects = (new subusers_o())
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) {
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber, $customerName) {
$subuser = (new subusers_o())->select((int)$o['id']);
if (!$subuser->exists()) {
return null;
}
$subuser->getObjectProperties();
return $this->buildSubuserManagementPayload($subuser, $customerNumber);
return $this->buildSubuserManagementPayload($subuser, $customerNumber, $customerName);
}, null, [], $existsClause);
if (is_array($objects)) {
@@ -873,92 +1213,52 @@ class subusersRoute
$response->success($this->buildCurrentSubuserPayload($subuser));
}, []);
$this->post('/superuser/subusers/invite', function () {
self::requireParameters(['customer_number']);
$this->requirePermission('add_subusers');
$customerNumber = (int)self::getParameter('customer_number');
self::requireType($customerNumber, self::type_int());
$this->handleInviteSubuserForCustomer($customerNumber);
}, [
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
]);
$this->post('/subusers/invite', function () {
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber);
}, [
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
]);
$this->post('/superuser/subusers/invite/resend', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
self::requireParameters(['name', 'phone_country_code', 'phone']);
$this->requirePermission('edit_subusers');
self::requireParameters(['id']);
$subuserId = (int)self::getParameter('id');
self::requireType($subuserId, self::type_int());
$name = $this->normalizeOptionalString(self::getParameter('name'));
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$enabled = $tmp === null ? true : (bool)$tmp;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
$subuser->getObjectProperties();
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
}
self::requireType($phoneCountryCode, self::type_int());
self::requireType($phone, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
if ($subuser === null) {
try {
$subuser = (new subusers_o())->add(
null,
null,
$name,
null,
$phoneCountryCode,
$phone
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
if ($grant === null) {
try {
$grant = (new subuser_grants_o())->add(
$customerNumber,
(int)$subuser->id,
$enabled,
$note,
$permissions ?? subuser_grants_o::defaultPermissions
);
} catch (Exception $exception) {
$response->error('Failed to create subuser grant', 500);
}
} else {
$grantUpdates = ['enabled' => $enabled];
if (self::isParametersSet(['note'])) {
$grantUpdates['note'] = $note;
}
if ($permissions !== null) {
$grantUpdates['permissions'] = $permissions;
}
try {
$grant->update($grantUpdates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$grant = (new subuser_grants_o())->select((int)$grant->id);
$grant->getObjectProperties();
if (!$subuser->requiresSetup()) {
$response->error('Driver account already accepted the invitation.', 409);
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'subuser' => [
'id' => (int)$subuser->id,
'setup_required' => true,
'can_resend_invite' => true,
],
'invite' => $invite,
]);
}, [
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
'edit_subusers' => 'Resend chauffeur invites for any customer (superuser).',
]);
$this->post('/subusers/invite/resend', function () {
+3 -1
View File
@@ -4,6 +4,7 @@ namespace routes;
use classes\db;
use classes\economic;
use classes\release_manager;
use classes\router;
use classes\shelly;
use classes\slack;
@@ -133,6 +134,7 @@ class workerRoute
'timezone' => date_default_timezone_get(),
'host' => gethostname(),
'version' => '1.0.1',
'api_commit_sha' => release_manager::backendCommitSha(),
'routes' => $router->countRoutes(),
'redis' => [
'host' => $REDIS_CONFIG['host'],
@@ -299,4 +301,4 @@ class workerRoute
// Convert to uppercase
return strtoupper($cleaned);
}
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'NOTE-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => 902701,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => ' ',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Graffiti removal on left side',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
});
it('does not allow clearing notes for order items whose product requires notes', function (): void {
api_test_covers('PUT /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'NOTE-EDIT',
]);
$product = api_fixtures()->createProduct([
'id' => 902702,
'name' => 'API Note Required Product',
'price' => 199,
'requires_note' => 1,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => $cashier['id'],
'price' => 199,
'quantity' => 1,
'notes' => 'Initial note',
]);
$session = api_fixtures()->createUserSession(['edit_order_items']);
api_client()
->put('/order/items', [
'id' => $orderItem['id'],
'price' => 199,
'quantity' => 1,
'reference' => '',
'notes' => '',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
});
it('returns the extraordinary chemistry product with requires_note enabled', function (): void {
api_test_covers('GET /products', 'happy');
$product = api_fixtures()->createProduct([
'id' => 902703,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get('/products?id=' . $product['id'], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['requires_note'] ?? null)->toBeTrue();
});
+3 -1
View File
@@ -17,5 +17,7 @@ it('returns the ping contract', function (): void {
expect($response->data())
->toBeArray()
->toHaveKey('message', 'pong')
->toHaveKey('time');
->toHaveKey('time')
->toHaveKey('backend_version')
->toHaveKey('api_commit_sha');
});
@@ -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');
});
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
usesApiSuite();
const SUBUSER_PASSWORD_POLICY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
it('lists subusers when an existing grant has legacy zero permissions', function (): void {
api_test_covers('GET /subusers', 'happy');
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$subuser = api_fixtures()->createSubuser([
'name' => 'Legacy Permission Driver',
]);
$grantId = api_fixtures()->grantSubuser(
$subuser['id'],
$session['user']['customer_number'],
['VEHICLES_LIST']
);
$legacyPermissions = '0';
$statement = api_test_runtime()->db()->prepare(
'UPDATE `subuser_grants` SET `permissions` = ? WHERE `id` = ?'
);
$statement->bind_param('si', $legacyPermissions, $grantId);
$statement->execute();
$statement->close();
$response = api_client()->get('/subusers?page=1&limit=5&include_non_enabled=true', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$matchingSubusers = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id']
));
expect($matchingSubusers)->toHaveCount(1);
expect($matchingSubusers[0]['grant_permissions'] ?? null)->toBe([]);
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
});
it('uses the same password policy for subuser setup and password auth', function (): void {
api_test_covers('POST /subusers/setup', 'failure');
api_test_covers('POST /subusers/auth/password', 'failure');
$subuser = api_fixtures()->createSubuser();
$setupResponse = api_client()->post('/subusers/setup', [
'token' => 'policy-test-token',
'name' => 'Policy Driver',
'password' => 'invalidpassword',
]);
$authResponse = api_client()->post('/subusers/auth/password', [
'subuser_id' => $subuser['id'],
'password' => 'invalidpassword',
]);
$setupResponse
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
$authResponse
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
});
it('lists chauffeur grants across customers for superusers', function (): void {
$session = api_fixtures()->createUserSession(['list_subusers']);
$firstCustomer = api_fixtures()->createUser([
'display_name' => 'Fleet Customer Alpha',
'economic_customer_name' => 'Fleet Customer Alpha',
]);
$secondCustomer = api_fixtures()->createUser([
'display_name' => 'Fleet Customer Beta',
'economic_customer_name' => 'Fleet Customer Beta',
]);
$firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']);
$secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']);
$firstGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$firstCustomer['customer_number'],
['VEHICLES_LIST', 'SUBUSERS_LIST']
);
$secondGrantId = api_fixtures()->grantSubuser(
(int)$secondSubuser['id'],
(int)$secondCustomer['customer_number'],
['BOOKINGS_LIST']
);
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item)
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true)
));
expect($rows)->toHaveCount(2);
$byGrantId = [];
foreach ($rows as $row) {
$byGrantId[(int)$row['grant_id']] = $row;
}
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha');
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']);
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']);
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
$session = api_fixtures()->createUserSession(['add_subusers']);
$customer = api_fixtures()->createUser([
'display_name' => 'Invite Target Customer',
'economic_customer_name' => 'Invite Target Customer',
]);
$phone = 71000000 + ((int)$customer['customer_number'] % 1000000);
$createdSubuserId = null;
$createdGrantId = null;
$setupToken = null;
try {
$response = api_client()->post('/superuser/subusers/invite', [
'customer_number' => (int)$customer['customer_number'],
'name' => 'Invited Driver',
'phone_country_code' => 45,
'phone' => $phone,
'permissions' => ['VEHICLES_LIST'],
'note' => 'Created by superuser test',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
expect($payload['subuser']['customer_number'] ?? null)->toBe((int)$customer['customer_number']);
expect($payload['subuser']['name'] ?? null)->toBe('Invited Driver');
expect($payload['subuser']['grant_permissions'] ?? null)->toBe(['VEHICLES_LIST']);
expect($payload['grant']['note'] ?? null)->toBe('Created by superuser test');
expect($payload['invite']['setup_link'] ?? null)->toBeString();
} finally {
if ($setupToken !== null && $setupToken !== '') {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
if ($createdGrantId !== null) {
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
}
if ($createdSubuserId !== null) {
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
}
}
});
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('returns the running API commit in worker status', function (): void {
api_test_covers('GET /worker/status', 'happy');
$response = api_client()->get('/worker/status');
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('api_commit_sha');
expect($response->data()['api_commit_sha'])
->toBeString()
->not->toBe('');
});
@@ -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}
*/
@@ -61,6 +61,8 @@ final class ApiFixtures
'updated_at' => $attributes['updated_at'] ?? $now,
]);
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
$this->cleanup->add(function () use ($userId, $customerNumber): void {
$this->purgeCustomerTraceData($userId, $customerNumber);
$this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber);
@@ -70,6 +72,7 @@ final class ApiFixtures
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
$this->deleteRedisKey('`users`_' . $userId . '_economic_customer');
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
});
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
@@ -535,6 +538,48 @@ final class ApiFixtures
return ['id' => $categoryId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createProduct(array $attributes = []): array
{
$categoryId = (int)($attributes['category'] ?? 0);
if ($categoryId <= 0) {
$category = $this->createCategory();
$categoryId = (int)$category['id'];
}
$productData = [
'name' => (string)($attributes['name'] ?? ('API Product ' . $this->uniqueSuffix())),
'description' => (string)($attributes['description'] ?? 'API product'),
'price' => (int)($attributes['price'] ?? 100),
'subscription_allowed' => (int)($attributes['subscription_allowed'] ?? 1),
'category' => $categoryId,
'piktogram' => $attributes['piktogram'] ?? 'truck',
'economic_product_id' => $attributes['economic_product_id'] ?? 0,
'apply_category_discount' => (int)($attributes['apply_category_discount'] ?? 0),
'requires_note' => (int)($attributes['requires_note'] ?? 0),
'is_wash' => (int)($attributes['is_wash'] ?? 0),
'display_in_booking_form' => (int)($attributes['display_in_booking_form'] ?? 1),
'order_priority' => (int)($attributes['order_priority'] ?? 0),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
];
if (isset($attributes['id'])) {
$productData = ['id' => (int)$attributes['id']] + $productData;
}
$productId = $this->insertRowWithExistingColumns('products', $productData);
$this->deleteRedisPattern('obj_prop:products:' . $productId . ':*');
$this->cleanup->add(fn() => $this->deleteById('products', $productId));
$this->cleanup->add(fn() => $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*'));
return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []);
}
public function linkDepartmentCategory(int $departmentId, int $categoryId): int
{
$linkId = $this->insertRow('department_categories', [
@@ -175,6 +175,67 @@ it('does not block channels on unhealthy data targets when data services are pro
]);
});
it('marks beta ready from the production frontend and API services', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 1,
'slug' => 'stable',
'name' => 'Stable',
'default_channel' => true,
'enabled' => true,
'frontend_base_url' => 'https://app.example.test',
'api_base_url' => 'https://api.example.test',
'versions' => releaseStatusReadyVersions(),
],
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'versions' => [],
'availability' => [
'configured' => false,
'status' => 'unconfigured',
'missing' => ['frontend_version', 'frontend_base_url', 'api_version', 'api_base_url'],
],
],
],
'deployment_targets' => [
['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-prod'],
['id' => 12, 'channel_id' => 1, 'app' => 'api', 'coolify_service_uuid' => 'api-prod'],
],
'deployments' => [
['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['service_policy'])->toBe('production_shared')
->and($channel['service_channel_slug'])->toBe('stable')
->and($channel['missing_values'])->toBe([])
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'frontend_base_url' => 'https://app.example.test',
'api_base_url' => 'https://api.example.test',
])
->and(releaseStatusServiceByKey($channel, 'frontend'))->toMatchArray([
'status' => 'production_shared',
'service_policy' => 'production_shared',
'service_channel_slug' => 'stable',
])
->and(releaseStatusServiceByKey($channel, 'api'))->toMatchArray([
'status' => 'production_shared',
'service_policy' => 'production_shared',
'service_channel_slug' => 'stable',
]);
});
it('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
@@ -62,6 +62,40 @@ it('verifies CI release gate bearer tokens from dedicated release credentials',
}
});
it('normalizes app-specific release gate auto-sync metadata', function (): void {
$manager = new release_manager();
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
$normalizeGate->setAccessible(true);
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
$appMatches->setAccessible(true);
$gate = $normalizeGate->invoke($manager, [
'channel_slug' => 'stable',
'app' => 'api',
'repository' => 'https://github.com/copenhagentruckwash/api.git',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
], ['slug' => 'stable']);
expect($gate)->toMatchArray([
'channel_slug' => 'stable',
'route_slug' => 'master',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
]);
expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'api'))->toBeFalse();
});
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
@@ -456,6 +490,81 @@ it('builds API Coolify runtime environment from allowed process variables', func
}
});
it('resolves backend commit sha from API runtime environment in priority order', function (): void {
$keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
$previous = [];
foreach ($keys as $key) {
$previous[$key] = [
'process' => getenv($key),
'env_set' => array_key_exists($key, $_ENV),
'env' => $_ENV[$key] ?? null,
'server_set' => array_key_exists($key, $_SERVER),
'server' => $_SERVER[$key] ?? null,
];
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
}
$set = static function (string $key, string $value): void {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
};
try {
$set('API_COMMIT_SHA', 'not-a-sha');
$set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
$set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc');
expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
$set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
} finally {
foreach ($previous as $key => $state) {
if ($state['process'] === false) {
putenv($key);
} else {
putenv($key . '=' . $state['process']);
}
if ($state['env_set']) {
$_ENV[$key] = $state['env'];
} else {
unset($_ENV[$key]);
}
if ($state['server_set']) {
$_SERVER[$key] = $state['server'];
} else {
unset($_SERVER[$key]);
}
}
}
});
it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void {
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '1111111111111111111111111111111111111111';
$explicitCommit = '2222222222222222222222222222222222222222';
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
'commit_sha' => $selectedCommit,
], [
'coolify_env' => [
'COMMIT_SHA' => $explicitCommit,
],
]);
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['COMMIT_SHA'])->toBe($explicitCommit);
});
it('keeps beta API runtime environment on production database target', function (): void {
$keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG'];
$previous = [];
@@ -535,14 +644,13 @@ it('detects explicit data target ids so beta service sets can stay data-only', f
]))->toBeTrue();
});
it('rejects beta release bundles that resolve to isolated cloned or fresh data services', function (): void {
it('allows beta production-service bundles only when data services stay production-shared', function (): void {
$manager = new release_manager();
$assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy');
$assert->setAccessible(true);
$betaChannel = ['id' => 2, 'slug' => 'beta'];
expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))
->toThrow(RuntimeException::class, 'frontend and API targets');
expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull();
foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) {
expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode]))
@@ -550,7 +658,7 @@ it('rejects beta release bundles that resolve to isolated cloned or fresh data s
}
});
it('keeps release branch services out of the production Coolify environment except beta', function (): void {
it('keeps release branch services out of the production Coolify environment', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
@@ -589,8 +697,8 @@ it('keeps release branch services out of the production Coolify environment exce
'default_server_uuid' => 'server-default',
]);
expect($betaPayload['environment_name'])->toBe('production');
expect($betaPayload['environment_uuid'])->toBe('env-production');
expect($betaPayload['environment_name'])->toBe('release-beta');
expect($betaPayload)->not->toHaveKey('environment_uuid');
});
it('does not invent GHCR images for Coolify service payloads', function (): void {
@@ -653,6 +761,8 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events');
expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
@@ -722,6 +832,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('verifyGithubSignature');
expect($manager)->toContain('verifyReleaseGateToken');
expect($manager)->toContain('normalizeReleaseGateInput');
expect($manager)->toContain('processReleaseGateAutoSync');
expect($manager)->toContain('release_auto_sync_events');
expect($manager)->toContain('require_readiness');
expect($manager)->toContain('release-manifest.json');
expect($manager)->toContain('static_artifact');
expect($manager)->toContain('api_gateway');
@@ -24,6 +24,7 @@ class SelfserveLaneInvoiceModeBillingHarness
public ?int $lastAddedOrderId = null;
public ?int $lastAddedProductId = null;
public ?int $lastAddedQuantity = null;
public int $createdOrderContexts = 0;
private selfserve_lane_status $laneStatus = selfserve_lane_status::OCCUPIED;
private selfserve_lane_mode $laneMode = selfserve_lane_mode::MANUAL;
@@ -85,6 +86,7 @@ class SelfserveLaneInvoiceModeBillingHarness
protected function createInvoiceOrderContext(): orders_o
{
$this->createdOrderContexts++;
$order = new SelfserveLaneInvoiceModeOrderStub();
$order->id = 424242;
$this->last_invoice_order_id = (int)$order->id;
@@ -111,6 +113,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);
});
@@ -126,6 +129,6 @@ 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();
});
@@ -76,6 +76,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
expect($content)->toContain('/department/selfserve/studio/simulate:');
expect($content)->toContain('/department/selfserve/studio/path-outcomes:');
expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:');
expect($content)->toContain('/department/selfserve/studio/path-confirmations:');
expect($content)->toContain('/department/selfserve/studio/publish:');
expect($content)->toContain('/department/selfserve/studio/rollback:');
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
@@ -86,8 +87,11 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
expect($content)->toContain('SelfserveStudioPathOutcomesResponse:');
expect($content)->toContain('SelfserveStudioPathResult:');
expect($content)->toContain('SelfserveStudioPathProgress:');
expect($content)->toContain('SelfserveStudioPathConfirmationRequest:');
expect($content)->toContain('SelfserveStudioPathConfirmation:');
expect($content)->toContain('projectSelfserveStudioPathOutcomes');
expect($content)->toContain('streamSelfserveStudioPathOutcomes');
expect($content)->toContain('confirmSelfserveStudioPath');
expect($content)->toContain('runSelfserveStudioGatewayAction');
});
@@ -108,16 +108,19 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes');
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream');
expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations');
expect($studioRoute)->toContain("ini_set('display_errors', '0')");
expect($studioRoute)->toContain('/department/selfserve/studio/publish');
expect($studioRoute)->toContain('/department/selfserve/studio/rollback');
expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action');
expect($studioRoute)->toContain('projectPathOutcomes');
expect($studioRoute)->toContain('confirmPathOutcome');
expect($studioRoute)->toContain('modules_shelly_config');
expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop');
expect($studioGraph)->not->toBeFalse();
expect($studioGraph)->toContain('department_selfserve_studio_layouts');
expect($studioGraph)->toContain('department_selfserve_path_confirmations');
expect($studioGraph)->toContain('buildGatewayWorkspace');
expect($studioGraph)->toContain('runGatewayAction');
expect($studioGraph)->toContain('layout_affects_runtime');
@@ -166,6 +169,32 @@ it('wires machine relay status get and set endpoints', function (): void {
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
});
it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void {
$lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php'));
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($lanesRoute)->not->toBeFalse()
->and($lanesRoute)->toContain('/department/lanes/status-toggles')
->and($lanesRoute)->toContain('LIST_DEPARTMENT_LANE_STATUS_TOGGLES')
->and($lanesRoute)->toContain('getDepartmentLanes($department_id)');
expect($laneObject)->not->toBeFalse()
->and($laneObject)->toContain("'machine_status_enabled' => self::isOperationalStatusName(\$status)")
->and($laneObject)->toContain("'dognvask_configured' => \$selfserve_configuration_warnings === []")
->and($laneObject)->toContain("'dognvask_configuration_warnings' => \$selfserve_configuration_warnings")
->and($laneObject)->toContain('public function getSelfServeConfigurationWarnings(): array')
->and($laneObject)->toContain('relay_machine_program_picker_id')
->and($laneObject)->toContain('machine_type_id');
expect($moduleSelfServeRoute)->not->toBeFalse()
->and($moduleSelfServeRoute)->toContain("\$this->put('/modules/self-serve/lane/status'")
->and($moduleSelfServeRoute)->toContain('modules_selfserve_lane_status_set')
->and($moduleSelfServeRoute)->toContain('setLaneStatus($target_status)')
->and($moduleSelfServeRoute)->toContain('selfserve_lane_status::MAINTENANCE')
->and($moduleSelfServeRoute)->toContain('machine_status_enabled');
});
it('applies Shelly transport overrides across self-serve relay side-effect routes', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
@@ -50,3 +50,13 @@ it('uses mysql-safe identifiers for self-serve studio virtual hardware storage',
expect(strlen($identifier))->toBeLessThanOrEqual(64);
}
});
it('creates self-serve studio path confirmation storage', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
expect($bootstrapContent)->not->toBeFalse();
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations');
expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL');
expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL');
expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature');
});
@@ -22,6 +22,11 @@ it('keeps cleaner relay enable wired into machine relay start paths', function (
expect($enableMachineRelayMethodOffset)->not->toBeFalse();
$enableMachineRelayMethod = substr($washFlow, (int)$enableMachineRelayMethodOffset, 1200);
expect($enableMachineRelayMethod)->toContain('$this->enableCleanerRelayForStartedWash($lane);');
$cleanerEnableOffset = strpos($enableMachineRelayMethod, '$this->enableCleanerRelayForStartedWash($lane);');
$alreadyEnabledGuardOffset = strpos($enableMachineRelayMethod, 'if ((bool)$session->machine_relay_enabled->value() === true)');
expect($cleanerEnableOffset)->not->toBeFalse()
->and($alreadyEnabledGuardOffset)->not->toBeFalse()
->and($cleanerEnableOffset)->toBeLessThan($alreadyEnabledGuardOffset);
expect($moduleRoute)->not->toBeFalse();
$machineEnableRouteOffset = strpos($moduleRoute, '/modules/self-serve/lane/relay/machine/enable');
@@ -370,6 +370,168 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed
expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
expect($studioGraphSource)->toContain('upsert_path');
});
it('upserts path editor answers into generated condition and task config rows', function (): void {
$service = selfserve_studio_graph_without_constructor();
$method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
$method->setAccessible(true);
$config = [
'schema_version' => 2,
'questions' => [
['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1],
['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2],
],
'conditions' => [],
'rules' => [],
'tasks' => [],
'actions' => [],
'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]],
];
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'allowed_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => true],
['question_id' => 12, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker', 'reset', 2, 'start'],
'dynamic_images_vehicle_type' => 3,
'tasks' => [
[
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker'],
'dynamic_images_vehicle_type' => 3,
],
[
'task' => 'Press reset',
'services' => ['MACHINE'],
'buttons' => ['reset'],
],
[
'task' => 'Press machine button 2',
'services' => ['MACHINE'],
'buttons' => [2],
],
[
'task' => 'Press start',
'services' => ['MACHINE'],
'buttons' => ['start'],
],
],
],
],
]]);
$condition = $config['conditions'][0] ?? [];
$tasks = array_values($config['tasks'] ?? []);
$pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
$validation = (new class extends selfserve_config_versioning {
public function __construct()
{
}
})->validateConfig($config);
expect($condition['generated_by'])->toBe('path_editor')
->and($condition['path_key'])->toBe('allowed_front')
->and($condition['lane'])->toBe(7)
->and($condition['product'])->toBe(2)
->and($condition['machine_type_id'])->toBe(1001)
->and($condition['expression']['children'])->toHaveCount(2)
->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE')
->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE')
->and($tasks)->toHaveCount(4)
->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start'])
->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40])
->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']])
->and($tasks[0]['generated_by'])->toBe('path_editor')
->and($tasks[0]['gate_type'])->toBe('CONDITION')
->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER'])
->and($tasks[0]['buttons'])->toBe(['program_picker'])
->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3)
->and($tasks[1]['buttons'])->toBe(['reset'])
->and($tasks[2]['buttons'])->toBe([2])
->and($tasks[3]['buttons'])->toBe(['start'])
->and($pathMeta['condition_id'])->toBe((int)$condition['id'])
->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id'])
->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks))
->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start'])
->and($pathMeta['path_signature'])->not->toBe('')
->and($validation['valid'])->toBeTrue();
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'allowed_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => true],
['question_id' => 12, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker', 'start'],
'dynamic_images_vehicle_type' => 3,
'tasks' => [
['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3],
['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']],
],
],
],
]]);
$updatedTasks = array_values(array_filter(
$config['tasks'] ?? [],
static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front'
));
$updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
expect($updatedTasks)->toHaveCount(2)
->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']])
->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start'])
->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]);
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'legacy_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Legacy start',
'services' => ['MACHINE'],
'buttons' => ['start'],
],
],
]]);
$legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? [];
$legacyTask = array_values(array_filter(
$config['tasks'] ?? [],
static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front'
))[0] ?? [];
expect($legacyTask['task'])->toBe('Legacy start')
->and($legacyTask['buttons'])->toBe(['start'])
->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id'])
->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]);
});
it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void {
@@ -647,7 +809,7 @@ it('builds guided simulator debug payload with blockers and canvas annotations',
->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
});
it('adds program picker before mapped machine buttons in simulator debug decisions', function (): void {
it('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
@@ -666,7 +828,7 @@ it('adds program picker before mapped machine buttons in simulator debug decisio
'answers' => [],
'questions' => [],
'tasks' => [
['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
],
'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'],
@@ -689,7 +851,7 @@ it('adds program picker before mapped machine buttons in simulator debug decisio
'conditions' => [],
'rules' => [],
'tasks' => [
['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
],
],
@@ -1016,10 +1178,93 @@ it('projects visible question answer paths into grouped task service and signal
->and($allowedPath['answers'])->toHaveCount(2)
->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?')
->and($allowedPath['services'])->toBe(['MACHINE'])
->and($allowedPath['path_signature'])->not->toBe('')
->and($allowedPath['result_signature'])->not->toBe('')
->and($allowedPath['confirmation_status'])->toBe('unconfirmed')
->and($allowedPath['node_ids'])->toContain('question:11')
->and($allowedPath['node_ids'])->toContain('task:41');
});
it('marks projected path confirmations confirmed or stale by stable signatures', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = static function (string $button): callable {
return static function (array $overrides) use ($button): array {
$answers = [];
foreach ($overrides as $entry) {
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
}
$allowed = ($answers[11] ?? null) === true;
return [
'allowed' => $allowed,
'questions' => [
['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null],
],
'tasks' => $allowed ? [
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]],
] : [],
'allowed_services' => $allowed ? ['MACHINE'] : [],
'debug' => [
'questions' => [
['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null],
],
'tasks' => [
['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1],
],
'signal_timeline' => [],
],
];
};
};
$scope = [
'department_id' => 6,
'lane_id' => 7,
'vehicle_type_id' => 2,
'config_source' => 'draft',
'config_version_id' => 90,
'hardware_mode' => 'studio',
];
$initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]);
$allowedPath = array_values(array_filter(
$initial['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
$rows = [[
'path_signature' => $allowedPath['path_signature'],
'result_signature' => $allowedPath['result_signature'],
'answers' => $allowedPath['answers'],
'result' => ['allowed' => true],
'scope' => $scope,
'confirmed_at' => '2026-05-27 10:00:00',
'confirmed_by' => 9,
]];
$confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [
'scope' => $scope,
'confirmation_rows' => $rows,
]);
$changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [
'scope' => $scope,
'confirmation_rows' => $rows,
]);
$confirmedAllowed = array_values(array_filter(
$confirmed['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
$staleAllowed = array_values(array_filter(
$changed['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
expect($confirmedAllowed['confirmation_status'])->toBe('confirmed')
->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1)
->and($staleAllowed['confirmation_status'])->toBe('stale')
->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.')
->and($changed['summary']['confirmations']['stale'])->toBe(1);
});
it('truncates path outcome projection when the state cap is reached', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array {
@@ -1,5 +1,79 @@
<?php
app_require('classes/object_property.php');
app_require('objects/department_lanes_o.php');
app_require('modules/selfserve/classes/selfserve_lane.php');
app_require('modules/selfserve/classes/selfserve_wash_flow.php');
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\classes\selfserve_wash_flow;
use modules\selfserve\helpers\selfserve_lane_relay;
class SelfserveWashCompletionRelayValueFake extends \classes\object_property
{
public function __construct(private readonly mixed $storedValue) {}
public function value(): mixed
{
return $this->storedValue;
}
}
class SelfserveWashCompletionDepartmentLaneFake extends \objects\department_lanes_o
{
public function __construct(string $machineRelayId = 'relay-machine', string $cleanerRelayId = 'relay-cleaner')
{
$this->relay_machine_id = new SelfserveWashCompletionRelayValueFake($machineRelayId);
$this->relay_machine_cleaner_id = new SelfserveWashCompletionRelayValueFake($cleanerRelayId);
$this->relay_machine_program_picker_id = new SelfserveWashCompletionRelayValueFake('');
}
public function exists(): bool
{
return true;
}
}
class SelfserveWashCompletionRelayLaneFake extends selfserve_lane
{
/** @var selfserve_lane_relay[] */
public array $statusReads = [];
/** @var array<int,array{0:selfserve_lane_relay,1:bool}> */
public array $relayWrites = [];
public function __construct(bool $reportedOn)
{
$this->id = 77;
$this->reportedOn = $reportedOn;
$this->department_lane = new SelfserveWashCompletionDepartmentLaneFake();
}
private bool $reportedOn;
public function getRelayStatus(selfserve_lane_relay $relay): array
{
$this->statusReads[] = $relay;
return ['on' => $this->reportedOn];
}
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool
{
$this->relayWrites[] = [$relay, $on];
return true;
}
}
class SelfserveWashCompletionFlowHarness extends selfserve_wash_flow
{
public function __construct() {}
public function turnOffConfiguredRelay(selfserve_lane $lane, selfserve_lane_relay $relay): void
{
$this->turnOffRelayIfConfigured($lane, $relay);
}
}
it('forces machine and cleaner relays off when a self-serve wash session is completed', function (): void {
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
@@ -10,14 +84,27 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
expect($methodOffset)->not->toBeFalse();
$methodBody = substr($washFlow, (int)$methodOffset, 1500);
expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);');
expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);');
expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);');
expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);');
$helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfiguredAndOn');
$helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfigured');
expect($helperOffset)->not->toBeFalse();
$helperBody = substr($washFlow, (int)$helperOffset, 1500);
expect($helperBody)->toContain('$status = $lane->getRelayStatus($relay);');
expect($helperBody)->toContain("if ((bool)(\$status['on'] ?? false) !== true)");
expect($helperBody)->toContain('$lane->setRelayStatusHard($relay, false);');
expect($helperBody)->not->toContain('$lane->getRelayStatus($relay)');
});
it('always dispatches completion relay off for configured machine relays without a status precheck', function (): void {
$lane = new SelfserveWashCompletionRelayLaneFake(reportedOn: false);
$flow = new SelfserveWashCompletionFlowHarness();
$flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE);
$flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE_CLEANER);
expect($lane->statusReads)->toBe([])
->and($lane->relayWrites)->toBe([
[selfserve_lane_relay::MACHINE, false],
[selfserve_lane_relay::MACHINE_CLEANER, false],
]);
});
@@ -0,0 +1,25 @@
<?php
use objects\subuser_grants_o;
it('keeps default subuser grant permissions JSON encodable', function (): void {
$encoded = json_encode(subuser_grants_o::defaultPermissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
expect($encoded)->toBeString();
expect(json_decode($encoded, true))
->toContain('VEHICLES_LIST')
->toContain('SELFSERVE_ADD')
->toContain('BOOKINGS_LIST');
});
it('normalizes stored subuser grant permission payloads', function (): void {
expect(subuser_grants_o::normalizePermissionsValue(0))->toBe([]);
expect(subuser_grants_o::normalizePermissionsValue('0'))->toBe([]);
expect(subuser_grants_o::normalizePermissionsValue('["vehicles_list","BOOKINGS_ADD"]'))
->toBe(['VEHICLES_LIST', 'BOOKINGS_ADD']);
expect(subuser_grants_o::normalizePermissionsValue([
'VEHICLES_LIST' => true,
'BOOKINGS_DELETE' => false,
'UNKNOWN_PERMISSION' => true,
]))->toBe(['VEHICLES_LIST']);
});
@@ -0,0 +1,20 @@
<?php
use objects\subusers_o;
it('accepts subuser passwords that match the shared policy', function (): void {
subusers_o::assertValidPassword('Test1234');
expect(true)->toBeTrue();
});
it('rejects subuser passwords that do not match the shared policy', function (string $password): void {
expect(fn () => subusers_o::assertValidPassword($password))
->toThrow(Exception::class, 'Password must be between 8 and 255 characters long');
})->with([
'too short' => ['Tes123'],
'missing uppercase' => ['test1234'],
'missing lowercase' => ['TEST1234'],
'missing number' => ['TestPassword'],
'too long' => [str_repeat('A', 256) . 'a1'],
]);
@@ -9,6 +9,9 @@ it('exposes chauffeur management endpoints on the subusers route', function ():
expect($normalized)->toContain("\$this->post('/subusers/invite', function () {");
expect($normalized)->toContain("\$this->post('/subusers/invite/resend', function () {");
expect($normalized)->toContain("\$this->get('/superuser/subusers', function () {");
expect($normalized)->toContain("\$this->post('/superuser/subusers/invite', function () {");
expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {");
expect($normalized)->toContain("\$this->put('/subusers', function () {");
expect($normalized)->toContain("\$this->put('/subusers/me', function () {");
});
@@ -31,6 +34,20 @@ it('includes grant management fields in the subusers payload builder', function
expect($normalized)->toContain("'access_state' =>");
});
it('resolves subuser customer names without external lookups during list requests', function (): void {
$routeFile = app_path('routes/subusersRoute.php');
expect(is_file($routeFile))->toBeTrue();
$code = (string)file_get_contents($routeFile);
$normalized = preg_replace('/\s+/', ' ', $code);
expect($normalized)->toContain('getCustomerNames($customerNumbers, false)');
expect($normalized)->toContain('$customerName = $this->resolveCustomerName($customerNumber);');
expect($normalized)->toContain('buildSubuserManagementPayload($subuser, $customerNumber, $customerName)');
expect($normalized)->not->toContain("'customer_name' => (new users_o())->getCustomerName(\$customerNumber)");
expect($normalized)->not->toContain("'name' => (new users_o())->getCustomerName((int)\$grant['billing_customer_number'])");
});
it('links grant disable operations to SUBUSERS_DELETE for own-customer managers', function (): void {
$routeFile = app_path('routes/subusersRoute.php');
expect(is_file($routeFile))->toBeTrue();
@@ -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; }