Add new table and enhance studio layout logic

Introduce `department_selfserve_studio_layouts` table for department-specific layouts and implement advanced auto-layout functionality in the DepartmentSelfServeStudio module. Added custom node definitions, updated styling, and integrated new logics for sorting and visualizing nodes in the Vue Flow interface.
This commit is contained in:
Jeppe Bundgaard
2026-04-28 14:21:31 +02:00
parent 72df2244ca
commit 206b487fa2
14 changed files with 2496 additions and 1 deletions
@@ -114,6 +114,18 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_session_events_session (session_id),
INDEX idx_selfserve_wash_session_events_type (event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
user_id INT NULL,
layout_json JSON NOT 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_department_selfserve_studio_layouts_department_user (department_id, user_id),
INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
@@ -273,6 +273,7 @@ class selfserve_config_versioning
}
$conditionIds = [];
$conditionParents = [];
foreach ($conditions as $condition) {
$id = (int)($condition['id'] ?? 0);
if ($id <= 0) {
@@ -281,9 +282,20 @@ class selfserve_config_versioning
}
$conditionIds[$id] = true;
$parentId = $this->nullableInt($condition['condition_id'] ?? null);
$conditionParents[$id] = $parentId;
}
foreach ($conditionParents as $id => $parentId) {
if ($parentId !== null && !isset($conditionIds[$parentId])) {
$warnings[] = 'Condition ' . $id . ' references parent condition ' . $parentId . ' that may be defined later or missing.';
$errors[] = 'Condition ' . $id . ' references unknown parent condition_id ' . $parentId;
}
if ($parentId === $id) {
$errors[] = 'Condition ' . $id . ' cannot reference itself as parent condition.';
}
}
foreach ($this->detectConditionCycles($conditionParents) as $cycle) {
$errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle);
}
foreach ($rules as $rule) {
@@ -421,4 +433,41 @@ class selfserve_config_versioning
$intValue = (int)$value;
return $intValue <= 0 ? null : $intValue;
}
/**
* @param array<int,int|null> $parents
* @return array<int,array<int,int>>
*/
protected function detectConditionCycles(array $parents): array
{
$cycles = [];
$seenCycleKeys = [];
foreach (array_keys($parents) as $startId) {
$path = [];
$indexById = [];
$currentId = (int)$startId;
while ($currentId > 0 && array_key_exists($currentId, $parents)) {
if (isset($indexById[$currentId])) {
$cycle = array_slice($path, $indexById[$currentId]);
$cycle[] = $currentId;
$keyNodes = $cycle;
sort($keyNodes);
$key = implode(':', $keyNodes);
if (!isset($seenCycleKeys[$key])) {
$seenCycleKeys[$key] = true;
$cycles[] = $cycle;
}
break;
}
$indexById[$currentId] = count($path);
$path[] = $currentId;
$currentId = (int)($parents[$currentId] ?? 0);
}
}
return $cycles;
}
}
File diff suppressed because it is too large Load Diff
+398
View File
@@ -5198,6 +5198,207 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
/department/selfserve/studio/graph:
get:
tags:
- Self-Serve
summary: Get all-in-one self-serve studio graph
description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products.
operationId: getSelfserveStudioGraph
parameters:
- name: department
in: query
required: true
schema:
type: integer
responses:
'200':
description: Studio graph returned
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioGraph'
put:
tags:
- Self-Serve
summary: Bulk save all-in-one self-serve studio graph changes
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior.
operationId: saveSelfserveStudioGraph
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioGraphSaveRequest'
responses:
'200':
description: Studio graph saved
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioGraph'
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/layout:
put:
tags:
- Self-Serve
summary: Save self-serve studio canvas layout
description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior.
operationId: saveSelfserveStudioLayout
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest'
responses:
'200':
description: Layout saved
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioLayout'
/department/selfserve/studio/validate:
post:
tags:
- Self-Serve
summary: Validate self-serve studio graph
operationId: validateSelfserveStudioGraph
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department]
properties:
department:
type: integer
responses:
'200':
description: Validation result
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioValidation'
/department/selfserve/studio/simulate:
post:
tags:
- Self-Serve
summary: Simulate self-serve studio runtime
operationId: simulateSelfserveStudioGraph
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department, lane_id, reg]
properties:
department: { type: integer }
lane_id: { type: integer }
reg: { type: string }
customer_number: { type: integer, nullable: true }
vehicle_type_id: { type: integer, nullable: true }
responses:
'200':
description: Simulator result
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
/department/selfserve/studio/publish:
post:
tags:
- Self-Serve
summary: Publish self-serve studio draft
operationId: publishSelfserveStudioDraft
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department]
properties:
department: { type: integer }
responses:
'200':
description: Published version
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveConfigVersion'
/department/selfserve/studio/rollback:
post:
tags:
- Self-Serve
summary: Roll back self-serve studio to an earlier version
operationId: rollbackSelfserveStudioDraft
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department, target_version_id]
properties:
department: { type: integer }
target_version_id: { type: integer }
responses:
'200':
description: Rollback version
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveConfigVersion'
/department/selfserve/studio/gateway-action:
post:
tags:
- Self-Serve
summary: Run permission-gated edge gateway action from studio
operationId: runSelfserveStudioGatewayAction
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department, gateway_id, action]
properties:
department: { type: integer }
gateway_id: { type: integer }
action:
type: string
enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings]
confirm:
type: boolean
description: Required for dangerous gateway actions such as uninstall and credential rotation.
operation_id: { type: integer, nullable: true }
request:
type: object
additionalProperties: true
bindings:
type: array
items:
type: object
additionalProperties: true
responses:
'200':
description: Gateway action result
content:
application/json:
schema:
type: object
additionalProperties: true
# Products Endpoints
/products:
get:
@@ -14705,6 +14906,203 @@ components:
enum:
- MACHINE
SelfserveStudioNode:
type: object
required: [id, position, data]
properties:
id:
type: string
type:
type: string
nullable: true
position:
type: object
required: [x, y]
properties:
x: { type: number }
y: { type: number }
data:
type: object
additionalProperties: true
properties:
kind:
type: string
enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint]
object_id:
oneOf:
- type: integer
- type: string
nullable: true
label:
type: string
raw:
type: object
additionalProperties: true
SelfserveStudioEdge:
type: object
required: [id, source, target]
properties:
id: { type: string }
source: { type: string }
target: { type: string }
type: { type: string, nullable: true }
label: { type: string, nullable: true }
data:
type: object
additionalProperties: true
SelfserveStudioLayout:
type: object
properties:
nodes:
type: object
additionalProperties:
type: object
properties:
x: { type: number }
y: { type: number }
viewport:
type: object
additionalProperties: true
runtime_affecting:
type: boolean
enum: [false]
SelfserveStudioValidation:
type: object
properties:
valid:
type: boolean
errors:
type: array
items: { type: string }
warnings:
type: array
items: { type: string }
items:
type: array
items:
type: object
properties:
severity:
type: string
enum: [error, warning]
message:
type: string
stats:
type: object
additionalProperties: true
validated_at:
type: string
format: date-time
SelfserveConfigVersion:
type: object
properties:
id: { type: integer }
department_id: { type: integer }
status:
type: string
enum: [DRAFT, PUBLISHED, ARCHIVED]
version_number: { type: integer }
config:
type: object
additionalProperties: true
validation_result:
$ref: '#/components/schemas/SelfserveStudioValidation'
source_version_id: { type: integer, nullable: true }
created_by: { type: integer, nullable: true }
published_at: { type: string, nullable: true }
created_at: { type: string, nullable: true }
updated_at: { type: string, nullable: true }
SelfserveStudioGraph:
type: object
required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions]
properties:
nodes:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioNode'
edges:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioEdge'
lookups:
type: object
additionalProperties: true
validation:
$ref: '#/components/schemas/SelfserveStudioValidation'
layout:
$ref: '#/components/schemas/SelfserveStudioLayout'
versions:
type: array
items:
$ref: '#/components/schemas/SelfserveConfigVersion'
active_config:
type: object
nullable: true
additionalProperties: true
draft:
type: object
additionalProperties: true
simulator_defaults:
type: object
additionalProperties: true
gateway_workspace:
type: object
additionalProperties: true
permissions:
type: object
additionalProperties:
type: boolean
meta:
type: object
additionalProperties: true
SelfserveStudioGraphOperation:
type: object
properties:
action:
type: string
enum: [create, update, delete, connect, disconnect, reorder]
entity:
type: string
enum: [question, condition, rule, task]
id:
type: integer
nullable: true
source:
type: string
nullable: true
target:
type: string
nullable: true
data:
type: object
additionalProperties: true
items:
type: array
items:
type: object
additionalProperties: true
SelfserveStudioGraphSaveRequest:
type: object
required: [department]
properties:
department: { type: integer }
operations:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioGraphOperation'
nodes:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioNode'
layout:
$ref: '#/components/schemas/SelfserveStudioLayout'
SelfserveStudioLayoutSaveRequest:
type: object
required: [department, layout]
properties:
department: { type: integer }
layout:
$ref: '#/components/schemas/SelfserveStudioLayout'
SelfserveMachineType:
type: object
properties:
@@ -0,0 +1,225 @@
<?php
namespace routes;
use classes\authentication;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_studio_graph;
use modules\selfserve\classes\selfserve_wash_flow;
use objects\logs_o;
use traits\route_t;
class departmentSelfserveStudioRoute
{
use route_t;
public function run(): void
{
$this->get('/department/selfserve/studio/graph', function (): void {
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId, ['view_all_department_selfserve_config_versions']);
$service = new selfserve_studio_graph();
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GET_STUDIO_GRAPH', 'Fetched self-serve studio graph');
$response->success($service->buildGraph($departmentId, (int)$user->id, $this->studioPermissions()));
}, [
'list_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph',
'view_all_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph across departments',
]);
$this->put('/department/selfserve/studio/graph', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$payload = self::getParametersAsArray();
$service = new selfserve_studio_graph();
$graph = $service->applyGraphSave($departmentId, $payload, (int)$user->id, $this->studioPermissions());
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_GRAPH', 'Saved self-serve studio graph');
$response->success($graph);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Create, update, delete, connect, and reorder self-serve studio graph objects',
]);
$this->put('/department/selfserve/studio/layout', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'layout']);
self::requireType(self::getParameter('layout'), self::TYPE_ARRAY());
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$layout = (new selfserve_studio_graph())->saveLayout($departmentId, (int)$user->id, (array)self::getParameter('layout'));
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_LAYOUT', 'Saved self-serve studio layout');
$response->success($layout);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout',
]);
$this->post('/department/selfserve/studio/validate', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
$validation = (new selfserve_studio_graph())->validatePayload($departmentId, self::getParametersAsArray());
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'VALIDATE_STUDIO_GRAPH', 'Validated self-serve studio graph');
$response->success($validation);
}, [
'edit_department_selfserve_config_versions' => 'Validate the self-serve studio graph',
]);
$this->post('/department/selfserve/studio/simulate', function (): void {
global $response;
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
self::requireParameters(['department', 'lane_id', 'reg']);
$departmentId = (int)self::getParameter('department');
$laneId = (int)self::getParameter('lane_id');
self::requireParameterIntPositive($laneId, 'lane_id');
$this->assertDepartmentAccess($user, $departmentId);
try {
$result = (new selfserve_wash_flow())->previewVehicleEligibility(
$laneId,
(string)self::getParameter('reg'),
self::isParametersSet(['customer_number']) ? (int)self::getParameter('customer_number') : null,
self::isParametersSet(['vehicle_type_id']) ? (int)self::getParameter('vehicle_type_id') : null
);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SIMULATE_STUDIO_GRAPH', 'Simulated self-serve studio graph');
$response->success($result);
} catch (\Throwable $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator',
]);
$this->post('/department/selfserve/studio/publish', function (): void {
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph');
$response->success($published);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'publish_department_selfserve_config_versions' => 'Publish the self-serve studio draft',
]);
$this->post('/department/selfserve/studio/rollback', function (): void {
global $response;
$user = $this->requireStudioUser('rollback_department_selfserve_config_versions');
self::requireParameters(['department', 'target_version_id']);
$departmentId = (int)self::getParameter('department');
$targetVersionId = (int)self::getParameter('target_version_id');
self::requireParameterIntPositive($targetVersionId, 'target_version_id');
$this->assertDepartmentAccess($user, $departmentId);
try {
$rolledBack = (new selfserve_config_versioning())->rollbackToVersion($departmentId, $targetVersionId, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'ROLLBACK_STUDIO_GRAPH', 'Rolled back self-serve studio graph');
$response->success($rolledBack);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'rollback_department_selfserve_config_versions' => 'Rollback the self-serve studio draft to an earlier version',
]);
$this->post('/department/selfserve/studio/gateway-action', function (): void {
global $response;
$user = $this->requireStudioUser('modules_shelly_config');
self::requireParameters(['department', 'gateway_id', 'action']);
$departmentId = (int)self::getParameter('department');
$gatewayId = (int)self::getParameter('gateway_id');
self::requireParameterIntPositive($gatewayId, 'gateway_id');
$this->assertDepartmentAccess($user, $departmentId);
$action = strtolower((string)self::getParameter('action'));
$confirmed = filter_var(self::getParameter('confirm'), FILTER_VALIDATE_BOOLEAN);
if (in_array($action, ['uninstall', 'rotate_credentials'], true) && $confirmed !== true) {
$response->error('This gateway action requires explicit confirmation.', 428);
}
try {
$payload = self::getParametersAsArray();
$result = (new selfserve_studio_graph())->runGatewayAction($departmentId, $gatewayId, $action, $payload, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GATEWAY_STUDIO_ACTION', 'Ran self-serve studio gateway action: ' . $action);
$response->success($result);
} catch (\Throwable $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'modules_shelly_config' => 'Run permission-gated self-serve studio edge gateway actions',
]);
}
private function requireStudioUser(string $permission): object
{
global $response;
$this->requirePermission($permission);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
return $user;
}
/**
* @param array<int,string> $bypassPermissions
*/
private function assertDepartmentAccess(object $user, int $departmentId, array $bypassPermissions = []): void
{
$authorizedDepartmentIds = $user->getGroup()->getDepartments();
if (in_array($departmentId, $authorizedDepartmentIds, true)) {
return;
}
foreach ($bypassPermissions as $permission) {
if ($this->hasPermission($permission)) {
return;
}
}
$this->forbidDepartmentAccess($departmentId, $bypassPermissions);
}
/**
* @return array<string,bool>
*/
private function studioPermissions(): array
{
return [
'can_view' => $this->hasPermission('list_department_selfserve_config_versions'),
'can_edit' => $this->hasPermission('edit_department_selfserve_config_versions'),
'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'),
'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'),
'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'),
'modules_shelly_config' => $this->hasPermission('modules_shelly_config'),
'can_manage_gateways' => $this->hasPermission('modules_shelly_config'),
'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'),
'can_run_live_lane_actions' => $this->hasPermission('modules_selfserve_sessions_force_stop'),
];
}
}
@@ -0,0 +1,23 @@
<?php
usesApiSuite();
it('creates a comprehensive self-serve API scenario with demo relays', function (): void {
$scenario = api_fixtures()->createSelfServeScenario();
expect($scenario['relay_ids']['machine'])->toStartWith('demo-')
->and($scenario['relay_ids']['entry'])->toStartWith('demo-')
->and($scenario['lane']['relay_machine_id'])->toStartWith('demo-')
->and($scenario['session']['status'])->toBe('MACHINE_STARTED')
->and($scenario['session']['metadata_json']['relay_ids']['machine'])->toBe($scenario['relay_ids']['machine'])
->and($scenario['tasks'])->toHaveCount(2)
->and($scenario['events'])->toHaveCount(3);
$lane = api_fixtures()->fetchRowById('department_lanes', (int)$scenario['lane']['id']);
$session = api_fixtures()->fetchRowById('selfserve_wash_sessions', (int)$scenario['session']['id']);
expect($lane)->not->toBeNull()
->and($lane['relay_machine_id'])->toBe($scenario['relay_ids']['machine'])
->and($session)->not->toBeNull()
->and($session['reg'])->toBe($scenario['vehicle']['reg']);
});
@@ -0,0 +1,7 @@
<?php
usesApiSuite();
it('did not record any real Shelly request attempts during self-serve API tests', function (): void {
expect(shelly_test_guard_entries())->toBe([]);
});
@@ -82,6 +82,24 @@ it('fails validation when typed task gates reference unknown entities', function
expect(implode("\n", $validation['errors']))->toContain('requires gate_ref_id');
});
it('fails validation when nested conditions form cycles', function (): void {
$service = selfserve_config_versioning_without_constructor();
$validation = $service->validateConfig([
'questions' => [],
'conditions' => [
['id' => 10, 'condition_id' => 12],
['id' => 11, 'condition_id' => 10],
['id' => 12, 'condition_id' => 11],
],
'rules' => [],
'tasks' => [],
]);
expect($validation['valid'])->toBeFalse();
expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected');
});
it('encodes config json payloads with apostrophes before persistence', function (): void {
$version = new class extends selfserve_config_versions_o {
/** @var array<string,mixed> */
@@ -64,6 +64,22 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin
expect($summaryPathBlock)->toContain('name: vehicle_type');
});
it('documents the all-in-one self-serve studio replacement API', function (): void {
$content = selfserve_openapi_content_or_skip();
expect($content)->toContain('/department/selfserve/studio/graph:');
expect($content)->toContain('/department/selfserve/studio/layout:');
expect($content)->toContain('/department/selfserve/studio/validate:');
expect($content)->toContain('/department/selfserve/studio/simulate:');
expect($content)->toContain('/department/selfserve/studio/publish:');
expect($content)->toContain('/department/selfserve/studio/rollback:');
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
expect($content)->toContain('SelfserveStudioGraph:');
expect($content)->toContain('SelfserveStudioGraphSaveRequest:');
expect($content)->toContain('SelfserveStudioLayout:');
expect($content)->toContain('runSelfserveStudioGatewayAction');
});
it('defines reusable self-serve wash and machine type schemas', function (): void {
$content = selfserve_openapi_content_or_skip();
@@ -47,6 +47,29 @@ it('wires self-serve config draft/publish/rollback lifecycle endpoints', functio
expect($configRoute)->toContain('rollback_department_selfserve_config_versions');
});
it('wires the all-in-one self-serve studio replacement endpoints', function (): void {
$studioRoute = file_get_contents(app_path('routes/departmentSelfserveStudioRoute.php'));
$studioGraph = file_get_contents(app_path('modules/selfserve/classes/selfserve_studio_graph.php'));
expect($studioRoute)->not->toBeFalse();
expect($studioRoute)->toContain('/department/selfserve/studio/graph');
expect($studioRoute)->toContain('/department/selfserve/studio/layout');
expect($studioRoute)->toContain('/department/selfserve/studio/validate');
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
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('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('buildGatewayWorkspace');
expect($studioGraph)->toContain('runGatewayAction');
expect($studioGraph)->toContain('layout_affects_runtime');
expect($studioGraph)->toContain('resolved');
});
it('wires machine wash included minutes into self-serve module config', function (): void {
$selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php'));
@@ -17,3 +17,12 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func
expect($bootstrapContent)->toContain("'wash_started_at'");
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at');
});
it('creates canvas-only self-serve studio layout 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_studio_layouts');
expect($bootstrapContent)->toContain('layout_json JSON NOT NULL');
expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user');
});
@@ -0,0 +1,149 @@
<?php
app_require('modules/selfserve/classes/selfserve_studio_graph.php');
use modules\selfserve\classes\selfserve_studio_graph;
function selfserve_studio_graph_without_constructor(): selfserve_studio_graph
{
$reflection = new ReflectionClass(selfserve_studio_graph::class);
return $reflection->newInstanceWithoutConstructor();
}
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
$service = selfserve_studio_graph_without_constructor();
$graph = $service->buildGraphFromConfig([
'questions' => [
['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => 10, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1],
],
'conditions' => [
['id' => 10, 'name' => 'Trailer present', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2],
],
'rules' => [
['id' => 20, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1, 'name' => 'Mirror answer'],
],
'tasks' => [
['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1],
],
], [
'lookups' => [
'departments' => [['id' => 2, 'label' => 'Roskilde']],
'lanes' => [['id' => 7, 'label' => 'Lane 7']],
'products' => [['id' => 3, 'label' => 'Forvogn']],
'machine_types' => [],
'vehicle_types' => [['id' => 3, 'product' => 3, 'label' => 'Forvogn', 'source' => 'products']],
'labels' => [
'departments' => ['2' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'products' => ['3' => 'Forvogn'],
'vehicle_types' => ['3' => 'Forvogn'],
'questions' => ['1' => 'Are mirrors folded?'],
'conditions' => ['10' => 'Trailer present'],
'tasks' => ['30' => 'Fold mirrors'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 50,
'label' => 'Gateway A',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'relay-1', 'label' => 'Machine relay'],
],
],
],
'relays' => [
['relay_id' => 'relay-1', 'name' => 'Machine relay'],
],
'lanes' => [
[
'id' => 7,
'relay_slots' => [
['relay_id' => 'relay-1', 'slot' => 'MACHINE'],
],
],
],
],
]);
$nodeIds = array_column($graph['nodes'], 'id');
$edgeIds = array_column($graph['edges'], 'id');
expect($nodeIds)->toContain('question:1');
expect($nodeIds)->toContain('condition:10');
expect($nodeIds)->toContain('rule:20');
expect($nodeIds)->toContain('task:30');
expect($nodeIds)->toContain('vehicle_type:3');
expect($nodeIds)->toContain('gateway:50');
expect($nodeIds)->toContain('relay:relay-1');
expect($edgeIds)->toContain('question-gate:10:1');
expect($edgeIds)->toContain('task-gate:question:1:30');
expect($edgeIds)->toContain('scope:vehicle_type:3:question:1');
expect($edgeIds)->toContain('scope:vehicle_type:3:condition:10');
expect($edgeIds)->toContain('scope:vehicle_type:3:task:30');
expect($edgeIds)->toContain('gateway-binding:50:relay-1:0');
expect($edgeIds)->toContain('relay-lane:relay-1:7:MACHINE');
});
it('derives studio vehicle type lookup rows from selectable wash products', function (): void {
$service = selfserve_studio_graph_without_constructor();
$method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts');
$vehicleTypes = $method->invoke($service, [
['id' => 3, 'name' => 'Forvogn', 'description' => 'Front vehicle', 'is_wash' => 1, 'subscription_allowed' => 1, 'order_priority' => 10],
['id' => 4, 'name' => 'Trækker', 'description' => 'Tractor unit', 'is_wash' => '1', 'subscription_allowed' => '1', 'order_priority' => 20],
['id' => 5, 'name' => 'Addon', 'description' => '', 'is_wash' => 0, 'subscription_allowed' => 1, 'order_priority' => 30],
['id' => 6, 'name' => 'Internal wash', 'description' => '', 'is_wash' => 1, 'subscription_allowed' => 0, 'order_priority' => 40],
]);
expect(array_column($vehicleTypes, 'id'))->toBe([3, 4]);
expect(array_column($vehicleTypes, 'label'))->toBe(['Forvogn', 'Trækker']);
expect($vehicleTypes[0]['product'])->toBe(3);
expect($vehicleTypes[0]['product_id'])->toBe(3);
expect($vehicleTypes[0]['source'])->toBe('products');
});
it('applies saved layout without changing graph semantics', function (): void {
$service = selfserve_studio_graph_without_constructor();
$graph = $service->buildGraphFromConfig([
'questions' => [
['id' => 1, 'question' => 'Question', 'order_priority' => 1],
],
'conditions' => [],
'rules' => [],
'tasks' => [],
], [
'lookups' => [
'labels' => [],
],
], [
'nodes' => [
'question:1' => ['x' => 123, 'y' => 456],
],
]);
$questionNode = array_values(array_filter(
$graph['nodes'],
static fn(array $node): bool => ($node['id'] ?? null) === 'question:1'
))[0] ?? null;
expect($questionNode)->not->toBeNull();
expect($questionNode['position'])->toBe(['x' => 123.0, 'y' => 456.0]);
});
it('keeps layout loading compatible with native PDO named placeholders', function (): void {
$method = new ReflectionMethod(selfserve_studio_graph::class, 'loadLayout');
$source = implode('', array_slice(
file((string)$method->getFileName()) ?: [],
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
expect($source)->not->toContain('user_id = :user_id OR user_id IS NULL');
expect($source)->not->toContain('user_id = :user_id THEN 0 ELSE 1');
expect($source)->toContain(':user_id_filter');
expect($source)->toContain(':user_id_sort');
});
@@ -0,0 +1,92 @@
<?php
app_require('classes/shelly.php');
use classes\shelly;
class ShellyRealRequestGuardHarness extends shelly
{
public function __construct()
{
}
}
function restore_shelly_guard_env_for_test(string $key, string|false $previous): void
{
if ($previous === false) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv($key . '=' . $previous);
$_ENV[$key] = $previous;
$_SERVER[$key] = $previous;
}
it('blocks and records test-mode Shelly POST requests before cURL can run', function (): void {
$previousBlock = getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY');
$previousLog = getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG');
$logPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-post-' . uniqid('', true) . '.jsonl';
try {
putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1');
putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $logPath);
shelly::resetBlockedRequestLog();
$client = new ShellyRealRequestGuardHarness();
expect(fn() => $client->sendPostRequest('/v2/devices/api/set/switch', [
'id' => 'real-relay',
'on' => true,
]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode');
$entries = shelly::blockedRequestLog();
expect($entries)->toHaveCount(1)
->and($entries[0]['method'])->toBe('POST')
->and($entries[0]['endpoint'])->toBe('/v2/devices/api/set/switch')
->and($entries[0]['data'])->toMatchArray(['id' => 'real-relay', 'on' => true]);
$logLines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
expect($logLines)->not->toBeFalse();
$logged = json_decode((string)$logLines[0], true);
expect($logged)->toMatchArray([
'method' => 'POST',
'endpoint' => '/v2/devices/api/set/switch',
]);
} finally {
shelly::resetBlockedRequestLog();
@unlink($logPath);
restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock);
restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog);
}
});
it('blocks and records test-mode Shelly GET requests before cURL can run', function (): void {
$previousBlock = getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY');
$previousLog = getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG');
$logPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-get-' . uniqid('', true) . '.jsonl';
try {
putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1');
putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $logPath);
shelly::resetBlockedRequestLog();
$client = new ShellyRealRequestGuardHarness();
expect(fn() => $client->sendGetRequest('/device/all_status', [
'show_info' => 'true',
]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode');
$entries = shelly::blockedRequestLog();
expect($entries)->toHaveCount(1)
->and($entries[0]['method'])->toBe('GET')
->and($entries[0]['endpoint'])->toBe('/device/all_status')
->and($entries[0]['data'])->toMatchArray(['show_info' => 'true']);
} finally {
shelly::resetBlockedRequestLog();
@unlink($logPath);
restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock);
restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog);
}
});
@@ -0,0 +1,5 @@
<?php
it('did not record any real Shelly request attempts during self-serve tests', function (): void {
expect(shelly_test_guard_entries())->toBe([]);
});