- Refactor machine_1 drawing logic: optimize highlighted button rendering and deferred processing.

- Add branding management feature: API routes, payload handling, and OpenAPI schema updates.
- Implement department branding logic: CRUD operations, validation, and permissions.
- Add order deletion confirmation support with conflict handling and OpenAPI schema updates.
- Enhance tests and API methods for improved order handling and branding workflows.
This commit is contained in:
Jeppe Bundgaard
2026-05-07 10:44:00 +02:00
parent a71bde3211
commit 22fcb5cd0e
12 changed files with 785 additions and 71 deletions
@@ -74,10 +74,9 @@ class machine_1 extends dynamicimages_image
$this->drawProgramWheel();
$this->drawThumb();
$this->drawStepThumb();
$this->drawHighlightedResetButton();
$this->drawHighlightedButtons();
$deferredStartButtons = $this->drawHighlightedButtonSequence();
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
$this->drawHighlightedStartButton();
$this->drawDeferredHighlightedButtons($deferredStartButtons);
$this->drawCropOutLine(true);
}
@@ -194,6 +193,161 @@ class machine_1 extends dynamicimages_image
return false;
}
private function normalizeHighlightedButtonToken(mixed $button): int|string|null
{
if (is_string($button)) {
$trimmed = trim($button);
$specialButton = strtolower($trimmed);
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
return $specialButton;
}
if ($trimmed !== '' && ctype_digit($trimmed)) {
$button = (int)$trimmed;
}
}
if (is_int($button)) {
return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null;
}
if (is_numeric($button) && (int)$button == $button) {
$button = (int)$button;
return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null;
}
return null;
}
private function getOrderedHighlightedButtonTokens(): array
{
$tokens = [];
foreach ($this->highlighted_buttons as $button) {
$token = $this->normalizeHighlightedButtonToken($button);
if ($token !== null) {
$tokens[] = $token;
}
}
return $tokens;
}
private function getRegularButtonCoordinates(int $buttonIndex): ?array
{
if ($buttonIndex < 0 || $buttonIndex >= ($this->button_rows * $this->button_columns)) {
return null;
}
$row = intdiv($buttonIndex, $this->button_columns);
$col = $buttonIndex % $this->button_columns;
$x = 2815 + ($col * ($this->button_highlight_size + $this->button_columns_spacing));
$y = 1265 + ($row * ($this->button_highlight_size + $this->button_rows_spacing));
if ($row === $this->button_rows - 1) {
$y += $this->button_last_row_spacing_buffer;
}
return [
'x' => $x,
'y' => $y,
'size' => $this->button_highlight_size,
];
}
private function getHighlightedButtonCoordinates(int|string $button): ?array
{
if ($button === self::BUTTON_RESET) {
return [
'x' => 1736,
'y' => 599,
'size' => $this->reset_button_highlight_size,
];
}
if ($button === self::BUTTON_START) {
return [
'x' => 4965,
'y' => 1980,
'size' => $this->start_button_highlight_size,
];
}
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
}
/**
* @throws \Exception
*/
private function buildHighlightedButtonDraw(int|string $button): ?array
{
$coordinates = $this->getHighlightedButtonCoordinates($button);
if ($coordinates === null) {
return null;
}
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
$tmp->resize($coordinates['size'], $coordinates['size']);
$tmp = $this->drawStepCounterOnButton($tmp);
if ($this->only_generate_current_step && $this->button_counter != $this->current_step + 1) {
if (method_exists($tmp, 'clearMemoryImage')) {
$tmp->clearMemoryImage();
}
return null;
}
return [
'asset' => $tmp,
'x' => $coordinates['x'],
'y' => $coordinates['y'],
];
}
/**
* @throws \Exception
*/
private function drawHighlightedButtonDraw(array $draw): void
{
$tmp = $draw['asset'];
$this->drawAsset($tmp, (int)$draw['x'], (int)$draw['y']);
if (method_exists($tmp, 'clearMemoryImage')) {
$tmp->clearMemoryImage();
}
}
/**
* @return array<int,array{asset: dynamicimages_asset, x: int, y: int}>
* @throws \Exception
*/
public function drawHighlightedButtonSequence(): array
{
$deferredStartButtons = [];
foreach ($this->getOrderedHighlightedButtonTokens() as $button) {
$draw = $this->buildHighlightedButtonDraw($button);
if ($draw === null) {
continue;
}
if ($button === self::BUTTON_START) {
$deferredStartButtons[] = $draw;
continue;
}
$this->drawHighlightedButtonDraw($draw);
}
return $deferredStartButtons;
}
/**
* @param array<int,array{asset: dynamicimages_asset, x: int, y: int}> $deferredStartButtons
* @throws \Exception
*/
public function drawDeferredHighlightedButtons(array $deferredStartButtons): void
{
foreach ($deferredStartButtons as $draw) {
$this->drawHighlightedButtonDraw($draw);
}
}
/**
* @throws \Exception
*/
@@ -545,12 +699,8 @@ class machine_1 extends dynamicimages_image
$rad = deg2rad($angle);
$stepX = $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2);
$stepY = $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2);
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
$tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE));
$tmp->resize($stepSize, $stepSize);
$tmp = $this->drawStepCounterOnButton($tmp);
if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) {
return;
}
$this->drawAsset($tmp, $stepX, $stepY);
// Free memory used by temporary asset image, if any
if (method_exists($tmp, 'clearMemoryImage')) {
+75
View File
@@ -154,6 +154,80 @@ class orders_o extends db
// Save the object
}
/**
* Return the data needed to decide whether deletion needs explicit confirmation.
*
* @return array{
* requires_confirmation: bool,
* protected_reasons: array<int, string>,
* order_item_count: int,
* attachment_count: int,
* completed_at: mixed
* }
* @throws Exception
*/
public function getDeleteProtectionSummary(): array
{
self::requireSelected();
$completedAt = $this->completed_at->value();
$orderItemCount = $this->countActiveOrderItems();
$attachmentCount = $this->countActiveOrderAttachments();
$protectedReasons = [];
if ($completedAt !== null) {
$protectedReasons[] = 'completed';
}
if ($orderItemCount > 0) {
$protectedReasons[] = 'order_items';
}
if ($attachmentCount > 0) {
$protectedReasons[] = 'attachments';
}
return [
'requires_confirmation' => count($protectedReasons) > 0,
'protected_reasons' => $protectedReasons,
'order_item_count' => $orderItemCount,
'attachment_count' => $attachmentCount,
'completed_at' => $completedAt,
];
}
/**
* @throws Exception
*/
private function countActiveOrderItems(): int
{
self::requireSelected();
global $db;
$orderId = (int)$this->id;
$result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL");
if ($result && $row = $result->fetch_assoc()) {
return (int)($row['total'] ?? 0);
}
return 0;
}
/**
* @throws Exception
*/
private function countActiveOrderAttachments(): int
{
self::requireSelected();
global $db;
$orderId = (int)$this->id;
$result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL");
if ($result && $row = $result->fetch_assoc()) {
return (int)($row['total'] ?? 0);
}
return 0;
}
/**
* @throws Exception If the order is not selected
* This function is called when the order object is changed.
@@ -278,6 +352,7 @@ class orders_o extends db
}
// Set the completed_at property to the current timestamp
$this->completed_at->set(date('Y-m-d H:i:s'));
$this->setPendingHandheldIndicator(false);
$washCertificateCreated = $this->completeWashCertificateIfNeeded(
$operator,
(string)$this->completed_at->value()
+100 -3
View File
@@ -3146,12 +3146,55 @@ paths:
required: true
schema:
type: integer
- name: confirmed
in: query
required: false
description: Must be true to delete an order that is completed or has active items or attachments.
schema:
type: boolean
responses:
'200':
description: Order deleted successfully
content:
application/json:
schema: {}
'409':
description: Order deletion requires explicit confirmation
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
data:
type: object
properties:
message:
type: string
example: Order deletion requires confirmation
requires_confirmation:
type: boolean
example: true
protected_reasons:
type: array
items:
type: string
enum: [completed, order_items, attachments]
order_item_count:
type: integer
example: 2
attachment_count:
type: integer
example: 1
completed_at:
type: string
nullable: true
meta:
type: object
includes:
type: object
'401':
$ref: '#/components/responses/Unauthorized'
'404':
@@ -11125,12 +11168,23 @@ paths:
name: {type: string}
description: {type: string}
cvr: {type: integer}
address: {type: string, nullable: true}
phone_country_code: {type: integer, nullable: true}
phone: {type: integer, nullable: true}
email: {type: string, nullable: true}
website: {type: string, nullable: true}
banner: {type: string, nullable: true}
logo: {type: string, nullable: true}
favicon: {type: string, nullable: true}
signature: {type: string, nullable: true}
responses:
'200':
description: Branding option added successfully
content:
application/json:
schema: {}
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
put:
@@ -11148,17 +11202,30 @@ paths:
required: [id]
properties:
id: {type: integer}
name: {type: string}
description: {type: string}
cvr: {type: integer}
name: {type: string, nullable: true}
description: {type: string, nullable: true}
cvr: {type: integer, nullable: true}
address: {type: string, nullable: true}
phone_country_code: {type: integer, nullable: true}
phone: {type: integer, nullable: true}
email: {type: string, nullable: true}
website: {type: string, nullable: true}
banner: {type: string, nullable: true}
logo: {type: string, nullable: true}
favicon: {type: string, nullable: true}
signature: {type: string, nullable: true}
responses:
'200':
description: Branding option updated successfully
content:
application/json:
schema: {}
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/roles:
get:
@@ -11332,6 +11399,36 @@ paths:
application/json:
schema: {}
/superuser/department/branding:
put:
tags:
- Departments
summary: Set department branding
description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id.
operationId: setDepartmentBranding
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department_id, branding_id]
properties:
department_id: {type: integer}
branding_id: {type: integer, nullable: true}
responses:
'200':
description: Department branding updated successfully
content:
application/json:
schema: {}
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/department/prices:
get:
tags:
+91 -43
View File
@@ -11,6 +11,87 @@ class BrandingRoute
{
use route_t;
private const BRANDING_FIELDS = [
'name' => 'string',
'description' => 'string',
'cvr' => 'int',
'address' => 'string',
'phone_country_code' => 'int',
'phone' => 'int',
'email' => 'string',
'website' => 'string',
'banner' => 'string',
'logo' => 'string',
'favicon' => 'string',
'signature' => 'string',
];
private function readBrandingPayload(array $requiredFields = []): array
{
global $response;
$payload = [];
foreach (self::BRANDING_FIELDS as $field => $type) {
if (!self::isParametersSet([$field])) {
continue;
}
$value = self::getParameter($field);
if ($value === '') {
$value = null;
}
if ($value === null) {
if (in_array($field, $requiredFields, true)) {
$response->error($field . ' is required', 400);
}
$payload[$field] = null;
continue;
}
if ($type === 'int') {
if (is_int($value)) {
$payload[$field] = $value;
continue;
}
if (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) {
$payload[$field] = (int)$value;
continue;
}
$response->error($field . ' must be an integer', 400);
}
if (!is_string($value)) {
$response->error($field . ' must be a string', 400);
}
$payload[$field] = $value;
}
foreach ($requiredFields as $requiredField) {
if (!array_key_exists($requiredField, $payload)) {
$response->error($requiredField . ' is required', 400);
}
}
return $payload;
}
private function applyBrandingPayload(branding_o $branding, array $payload): void
{
foreach ($payload as $field => $value) {
if (!array_key_exists($field, self::BRANDING_FIELDS) || !property_exists($branding, $field)) {
continue;
}
$branding->{$field}->set($value);
}
$branding->objectChanged();
}
public function run(): void
{
$this->get('/branding', function () {
@@ -35,7 +116,7 @@ class BrandingRoute
if ($branding->exists()) {
// Return the object as an array
$response->success(
(new branding_o())->select(self::getParameter('id'))->__toString()
$branding->asArray()
);
} else {
// Log the incident
@@ -77,25 +158,15 @@ class BrandingRoute
if ($user) {
// Check if the required parameters are set
self::requireParameters(['name', 'description', 'cvr']);
// Check if the parameters are of the correct type
self::requireType(self::getParameter('name'), self::TYPE_STRING());
self::requireType(self::getParameter('description'), self::TYPE_STRING());
self::requireType(self::getParameter('cvr'), self::TYPE_INT());
// Create the object
$branding = new branding_o();
// Add the object
$branding->add(
[
'name' => self::getParameter('name'),
'description' => self::getParameter('description'),
'cvr' => self::getParameter('cvr')
]
);
$branding->add($this->readBrandingPayload(['name', 'description', 'cvr']));
// Log the incident
(new logs_o())->add('branding', 'global', 1, $user->id, 'ADD_BRANDING_OPTION', 'User added a branding option');
// Return the object
$response->success(
$branding->__toString()
$branding->asArray()
);
} else {
// Log the incident
@@ -125,41 +196,18 @@ class BrandingRoute
$branding = new branding_o();
// Select the object
$branding->select(self::getParameter('id'));
// Check what the user wants to edit
if (!$branding->exists()) {
$response->error('Invalid id', 400);
}
// Option name
if (self::isParametersSet(['name'])) {
// Check if the parameters are of the correct type
self::requireType(self::getParameter('name'), self::TYPE_STRING());
// Set the name
$branding->name->set(
(string)self::getParameter('name')
);
}
// Option description
if (self::isParametersSet(['description'])) {
// Check if the parameters are of the correct type
self::requireType(self::getParameter('description'), self::TYPE_STRING());
// Set the description
$branding->description->set(
(string)self::getParameter('description')
);
}
// Option cvr
if (self::isParametersSet(['cvr'])) {
// Check if the parameters are of the correct type
self::requireType(self::getParameter('cvr'), self::TYPE_INT());
// Set the cvr value
$branding->cvr->set(
(int)self::getParameter('cvr')
);
}
$payload = $this->readBrandingPayload();
$this->applyBrandingPayload($branding, $payload);
// Log the incident
(new logs_o())->add('branding', 'global', 1, $user->id, 'EDIT_BRANDING_OPTION', 'User edited a branding option');
// Return the object
$response->success(
$branding->__toString()
$branding->asArray()
);
} else {
// Log the incident
@@ -173,4 +221,4 @@ class BrandingRoute
]
);
}
}
}
@@ -135,7 +135,7 @@ class departmentLanesRoute
* Query parameters:
* - department (int, required)
* - lane (int, required)
* - buttons (array|json|csv, optional) → highlighted button IDs (0-indexed)
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start"
* - current_step (int >= 0, optional) → current click/step indicator
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
+35 -2
View File
@@ -218,15 +218,48 @@ class ordersRoute
// Get the current order
$order = (new orders_o())->getOrderById((int)$id);
// Check if the order exists
if (!$order->exists()) {
if (!isset($order->id) || (int)$order->id < 1 || !$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((int)$order->department_id->value());
$confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true;
$deleteProtection = $order->getDeleteProtectionSummary();
if ($deleteProtection['requires_confirmation'] && !$confirmed) {
(new logs_o())->add(
'orders',
$order->department_id->value(),
1,
$user->id,
'DELETE_ORDER_CONFIRMATION_REQUIRED',
'Order deletion requires confirmation (ID: ' . $id
. '; reasons: ' . implode(',', $deleteProtection['protected_reasons'])
. '; order_items: ' . $deleteProtection['order_item_count']
. '; attachments: ' . $deleteProtection['attachment_count'] . ')'
);
$response->error([
'message' => 'Order deletion requires confirmation',
...$deleteProtection,
], 409);
}
// Delete the order
$order->delete();
// Log the incident
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')');
if ($deleteProtection['requires_confirmation']) {
(new logs_o())->add(
'orders',
$order->department_id->value(),
1,
$user->id,
'DELETE_ORDER_CONFIRMED',
'Successfully deleted a protected order after confirmation (ID: ' . $id
. '; reasons: ' . implode(',', $deleteProtection['protected_reasons'])
. '; order_items: ' . $deleteProtection['order_item_count']
. '; attachments: ' . $deleteProtection['attachment_count'] . ')'
);
} else {
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')');
}
// Return a success message
$response->success(['message' => 'Order deleted successfully']);
} else {
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use objects\branding_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
@@ -50,6 +51,63 @@ class superuserDepartmentRoute
'superuser_fetch_department' => 'Fetch department'
]);
$this->put('/superuser/department/branding', function () {
global $response;
$this->requirePermission('superuser_set_department_branding');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['department_id', 'branding_id']);
$departmentId = self::getParameter('department_id');
if (!is_int($departmentId) && !(is_string($departmentId) && preg_match('/^\d+$/', $departmentId) === 1)) {
$response->error('Department ID must be a number', 400);
}
$departmentId = (int)$departmentId;
if ($departmentId <= 0) {
$response->error('Department ID must be a positive number', 400);
}
$department = (new departments_o())->selectId($departmentId);
if (!$department->exists()) {
$response->error('Department not found', 404);
}
$brandingId = self::getParameter('branding_id');
if ($brandingId === '' || $brandingId === null || $brandingId === 0 || $brandingId === '0') {
$department->branding->set(null);
} else {
if (!is_int($brandingId) && !(is_string($brandingId) && preg_match('/^\d+$/', $brandingId) === 1)) {
$response->error('Branding ID must be a number', 400);
}
$brandingId = (int)$brandingId;
if ($brandingId <= 0) {
$response->error('Branding ID must be a positive number', 400);
}
$branding = (new branding_o())->select($brandingId);
if (!$branding->exists()) {
$response->error('Branding not found', 404);
}
$department->branding->set($brandingId);
}
$department->objectChanged();
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'Successfully set department branding');
$response->success(
(new departments_o())->getDepartmentById($departmentId, true)
);
}, [
'superuser_set_department_branding' => 'Set department branding'
]);
$this->post('/superuser/department/prices', function () {
// Require the user to be logged in
global $response;
@@ -237,4 +295,4 @@ class superuserDepartmentRoute
]);
}
}
}
+82 -2
View File
@@ -798,6 +798,86 @@ it('deletes orders through the real endpoint', function (): void {
expect($row['deleted_at'] ?? null)->not->toBeNull();
});
it('requires explicit confirmation before deleting protected orders', function (): void {
api_test_covers('DELETE /orders', 'failure');
api_test_covers('DELETE /orders', 'happy');
$department = api_fixtures()->createDepartment();
$customer = api_fixtures()->createUser();
$cashier = api_fixtures()->createUser(['display_name' => 'Protected Delete Cashier']);
$session = api_fixtures()->createUserSession([
'delete_order',
'department_access_' . $department['id'],
]);
$cases = [
'completed' => function () use ($department, $customer, $cashier): array {
return api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'completed_at' => date('Y-m-d H:i:s'),
]);
},
'order_items' => function () use ($department, $customer, $cashier): array {
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => 53,
'cashier_id' => $cashier['id'],
'quantity' => 1,
]);
return $order;
},
'attachments' => function () use ($department, $customer, $cashier): array {
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
api_fixtures()->createOrderAttachment([
'order_id' => $order['id'],
]);
return $order;
},
];
foreach ($cases as $expectedReason => $createProtectedOrder) {
$order = $createProtectedOrder();
$unconfirmed = api_client()->delete('/orders', [
'id' => $order['id'],
], $session['headers']);
$unconfirmed
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Order deletion requires confirmation');
expect($unconfirmed->data()['requires_confirmation'] ?? null)->toBeTrue();
expect($unconfirmed->data()['protected_reasons'] ?? [])->toContain($expectedReason);
expect(api_fixtures()->fetchRowById('orders', (int)$order['id'])['deleted_at'] ?? null)->toBeNull();
$confirmed = api_client()->delete('/orders', [
'id' => $order['id'],
'confirmed' => 'true',
], $session['headers']);
$confirmed
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order deleted successfully');
expect(api_fixtures()->fetchRowById('orders', (int)$order['id'])['deleted_at'] ?? null)->not->toBeNull();
}
});
it('rejects invalid order delete requests', function (): void {
api_test_covers('DELETE /orders', 'failure');
@@ -820,8 +900,8 @@ it('rejects invalid order delete requests', function (): void {
], $session['headers']);
$missingOrder
->assertStatus(500)
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessageContains('must not be accessed before initialization');
->assertMessage('Order not found');
});
@@ -16,6 +16,10 @@ return [
'PUT /orders',
'PUT /numberplatescanners',
'DELETE /orders',
'GET /branding',
'POST /branding',
'PUT /branding',
'PUT /superuser/department/branding',
'POST /bird/voice/calls/webhook/inbound',
],
'manual_operations' => [
@@ -141,6 +141,32 @@ final class ApiFixtures
return ['id' => $departmentId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createBranding(array $attributes = []): array
{
$brandingId = $this->insertRow('branding', [
'name' => (string)($attributes['name'] ?? ('API Brand ' . $this->uniqueSuffix())),
'description' => (string)($attributes['description'] ?? 'API branding'),
'cvr' => (int)($attributes['cvr'] ?? 41004355),
'address' => $attributes['address'] ?? null,
'phone_country_code' => $attributes['phone_country_code'] ?? null,
'phone' => $attributes['phone'] ?? null,
'email' => $attributes['email'] ?? null,
'website' => $attributes['website'] ?? null,
'banner' => $attributes['banner'] ?? null,
'logo' => $attributes['logo'] ?? null,
'favicon' => $attributes['favicon'] ?? null,
'signature' => $attributes['signature'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('branding', $brandingId));
return array_merge(['id' => $brandingId], $this->fetchRowById('branding', $brandingId) ?? []);
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
@@ -729,6 +755,34 @@ final class ApiFixtures
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createOrderAttachment(array $attributes): array
{
$orderId = (int)($attributes['order_id'] ?? 0);
if ($orderId <= 0) {
throw new RuntimeException('Order attachments require order_id.');
}
$attachmentId = $this->insertRow('object_attachments', [
'object_type' => 'orders',
'object_id' => $orderId,
'content' => $attributes['content'] ?? '{"document":"api-test.pdf","other":"api-test.pdf"}',
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('object_attachments', $attachmentId));
return [
'id' => $attachmentId,
'order_id' => $orderId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
@@ -1,11 +1,72 @@
<?php
it('allows studio lane dynamic image previews to override the saved image id', function (): void {
$content = file_get_contents(app_path('routes/departmentLanesRoute.php'));
namespace classes {
if (!class_exists('classes\\db')) {
class db {}
}
expect($content)->not->toBeFalse();
expect($content)->toContain("isRequestParameterSet('dynamic_image_id')");
expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')");
expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)");
expect($content)->toContain("switch (\$dynamic_image_id)");
});
if (!class_exists('classes\\object_property')) {
class object_property
{
public function __construct(...$args) {}
public function value() { return null; }
public function set($v) {}
}
}
}
namespace traits {
if (!trait_exists('traits\\db_object_t')) {
trait db_object_t {}
}
}
namespace {
app_require('objects/department_selfserve_tasks_o.php');
}
namespace {
use objects\department_selfserve_tasks_o;
it('allows studio lane dynamic image previews to override the saved image id', function (): void {
$content = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain("isRequestParameterSet('dynamic_image_id')");
expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')");
expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)");
expect($content)->toContain("switch (\$dynamic_image_id)");
});
it('accepts ordered dynamic image button tokens including reset start and zero', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput('["reset",0,2,"start",5]'))->toBe([
'reset',
0,
2,
'start',
5,
]);
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('ordered highlighted button IDs');
expect($route)->toContain('"reset", or "start"');
});
it('renders machine one dynamic image steps from the ordered button payload', function (): void {
$content = file_get_contents(app_path('modules/dynamicimages/images/machine_1.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();');
expect($content)->toContain('getOrderedHighlightedButtonTokens');
expect($content)->toContain('normalizeHighlightedButtonToken');
expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)');
$thumbOffset = strpos($content, 'public function drawStepThumb');
expect($thumbOffset)->not->toBeFalse();
$thumbBody = substr($content, (int)$thumbOffset, 1800);
expect($thumbBody)->toContain('self::IMAGE_BUTTON_HIGHLIGHTED_BLUE');
expect($thumbBody)->not->toContain('drawStepCounterOnButton');
expect($thumbBody)->not->toContain('only_generate_current_step');
});
}