Enhance MinIO handling in replication management and update legacy test bootstrap. Add MinIO replication logic, legacy setup cleanup, and include necessary tests for improved MinIO interaction and error tolerance.

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