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:
@@ -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
@@ -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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user