Compare commits

..
19 changed files with 395 additions and 192 deletions
+27 -8
View File
@@ -6266,6 +6266,33 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/order-bookings/completion-confirmation/resend:
post:
tags:
- Bookings
summary: Resend order booking completion confirmation
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
operationId: resendOrderBookingCompletionConfirmation
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id: {type: integer}
responses:
'200':
description: Completion confirmation resent successfully
content:
application/json:
schema: {}
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
/order-bookings/complete:
post:
tags:
@@ -9421,14 +9448,6 @@ paths:
license_plate:
type: string
description: Required for START command
wash_type:
type: string
enum: [Manual, Machine]
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
wash_mode:
type: string
enum: [manual, machine]
description: Lowercase alias for wash_type accepted by backend clients.
customer_number:
type: integer
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
+7 -1
View File
@@ -133,8 +133,14 @@ class attachments implements attachments_i
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{
$options = $this->normalizeAttachmentOptions($options);
$rawType = trim($type, '`');
$objectTypes = array_values(array_unique([
$rawType,
'`' . $rawType . '`',
]));
return (new object_attachments_o())->getFieldsWhereIn([
'object_type' => $type,
'object_type' => $objectTypes,
'object_id' => $object_ids,
'deleted_at' => null
], $options);
+68 -2
View File
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
{
if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [
self::recordFakeDelivery([
'to' => $to,
'recipient_name' => $recipient_name,
'subject' => $subject,
'message' => $message,
'html' => $html,
];
]);
return;
}
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
public static function resetFakeDeliveries(): void
{
self::$fake_deliveries = [];
$path = self::getFakeDeliveriesPath();
if ($path !== null && is_file($path)) {
unlink($path);
}
}
public static function syncFakeDeliveries(): void
{
$path = self::getFakeDeliveriesPath();
if ($path === null || !is_file($path)) {
self::$fake_deliveries = [];
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
self::$fake_deliveries = [];
return;
}
$deliveries = [];
foreach ($lines as $line) {
$delivery = json_decode($line, true);
if (is_array($delivery)) {
$deliveries[] = $delivery;
}
}
self::$fake_deliveries = $deliveries;
}
private static function recordFakeDelivery(array $delivery): void
{
self::$fake_deliveries[] = $delivery;
$path = self::getFakeDeliveriesPath();
if ($path === null) {
return;
}
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private static function getFakeDeliveriesPath(): ?string
{
if (!self::isFakeDeliveryEnabled()) {
return null;
}
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
if ($configuredPath !== '') {
return $configuredPath;
}
if (getenv('RUN_API_TESTS') !== '1') {
return null;
}
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
}
private static function isFakeDeliveryEnabled(): bool
+5 -1
View File
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
*/
public function download(string $file): string
{
if ($this->shouldUseLocalTestStorage()) {
return $this->getLocalTestObjectPath($file);
}
$path = '/tmp/' . $file;
$result = self::getS3Client()->getObject([
self::getS3Client()->getObject([
'Bucket' => self::getBucket(),
'Key' => $file,
'SaveAs' => $path
@@ -7,7 +7,6 @@ class selfserve_lane_command_arguments
public ?string $license_plate = null;
public ?int $customer_number = null;
public ?int $subuser_id = null;
public ?string $wash_mode = null;
public bool $defer_relay_side_effects = false;
/**
@@ -33,22 +32,6 @@ class selfserve_lane_command_arguments
return $this;
}
public function setWashMode(?string $wash_mode): self
{
$normalized = strtolower(trim((string)$wash_mode));
if ($wash_mode === null || $normalized === '') {
$this->wash_mode = null;
return $this;
}
if (!in_array($normalized, ['manual', 'machine'], true)) {
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
}
$this->wash_mode = $normalized;
return $this;
}
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{
$this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -67,12 +50,6 @@ class selfserve_lane_command_arguments
if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
}
if (array_key_exists('wash_type', $params)) {
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
}
if (array_key_exists('wash_mode', $params)) {
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
}
if (array_key_exists('defer_relay_side_effects', $params)) {
$this->setDeferRelaySideEffects(filter_var(
$params['defer_relay_side_effects'],
@@ -171,12 +171,12 @@ trait selfserve_lane_command_t
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
* If machine relay is not configured, this is a no-op.
*/
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
protected function setMachineRelayStatusForWashStart(): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
return;
}
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
if ($this->isMachineWashSelectedAndAvailableForStart()) {
try {
$this->setMachineRelayStatusHard(true);
} catch (\Throwable) {
@@ -194,39 +194,38 @@ trait selfserve_lane_command_t
/**
* Keep the program picker relay aligned with the selected wash mode at START.
* It is ON only when the customer explicitly selected machine wash.
* It is ON only when the active self-serve session is allowed to start machine wash.
*/
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
protected function setProgramPickerRelayStatusForWashStart(): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
$shouldEnable = $this->isMachineWashSelectedAndAvailableForStart();
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
$this->setMachineProgramPickerRelayStatusHard($this->isMachineServiceSelectedForWashStart());
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
protected function isMachineWashSelectedAndAvailableForStart(): bool
{
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
if (!$this->isMachineServiceSelectedForWashStart()) {
return false;
}
@@ -249,39 +248,6 @@ trait selfserve_lane_command_t
}
}
protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if (!$this->isMachineWashModeSelectedForStart($arguments)) {
return false;
}
return $this->isMachineServiceSelectedForWashStart();
}
protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
{
return $this->isExplicitMachineWashModeSelectedForStart($arguments)
&& $this->isMachineServiceSelectedForWashStart();
}
protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
}
protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
return false;
}
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
return true;
}
return $this->isMachineServiceSelectedForWashStart();
}
protected function isMachineServiceSelectedForWashStart(): bool
{
try {
@@ -383,24 +349,17 @@ trait selfserve_lane_command_t
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
{
if ($arguments->defer_relay_side_effects) {
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart();
return;
}
$this->turnOnCleanerRelayForWashStart();
$this->setProgramPickerRelayStatusForWashStart($arguments);
$this->setMachineRelayStatusForWashStart($arguments);
$this->setProgramPickerRelayStatusForWashStart();
$this->setMachineRelayStatusForWashStart();
}
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
protected function resolveSelfServeActionWashModeForStart(): string
{
if ($arguments !== null && in_array($arguments->wash_mode, [
selfserve_studio_actions::MODE_MANUAL,
selfserve_studio_actions::MODE_MACHINE,
], true)) {
return $arguments->wash_mode;
}
if ($this->isMachineServiceSelectedForWashStart()) {
return selfserve_studio_actions::MODE_MACHINE;
}
@@ -675,7 +634,7 @@ trait selfserve_lane_command_t
$this->runRelaySideEffectsForWashStart($arguments);
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart($arguments),
$this->resolveSelfServeActionWashModeForStart(),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
+27 -8
View File
@@ -6266,6 +6266,33 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/order-bookings/completion-confirmation/resend:
post:
tags:
- Bookings
summary: Resend order booking completion confirmation
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
operationId: resendOrderBookingCompletionConfirmation
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id: {type: integer}
responses:
'200':
description: Completion confirmation resent successfully
content:
application/json:
schema: {}
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
/order-bookings/complete:
post:
tags:
@@ -9421,14 +9448,6 @@ paths:
license_plate:
type: string
description: Required for START command
wash_type:
type: string
enum: [Manual, Machine]
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
wash_mode:
type: string
enum: [manual, machine]
description: Lowercase alias for wash_type accepted by backend clients.
customer_number:
type: integer
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
@@ -392,6 +392,37 @@ class orderBookingRoute
]
);
$this->post('/order-bookings/completion-confirmation/resend', function () {
global $response;
$object = self::getTargetObject();
if (!$object || !$object->exists()) {
$response->error('Order booking does not exist.', 400);
}
self::requirePermission('complete_bookings');
self::requireDepartmentAccess((int)$object->department->value());
if (!$object->hasTransaction()) {
$response->error('Order booking has not been completed yet.', 409);
}
if (!$object->getOrder()->hasWashCertificateAttached()) {
$response->error('Order booking completion confirmation is not available yet.', 409);
}
(new email())->sendWashCertificateEmailToCustomer($object);
$response->success([
'message' => 'Completion confirmation resent successfully.',
'booking' => $object->asArray(),
]);
},
[
'complete_bookings' => 'Permission for department admins to resend order booking completion confirmation emails.'
]
);
$this->post('/order-bookings/complete', function () {
// Require the user to be logged in
global $response;
@@ -2,6 +2,9 @@
declare(strict_types=1);
use classes\email;
use classes\pdf_store;
putenv('EMAIL_FAKE_MODE=1');
usesApiSuite();
@@ -76,3 +79,97 @@ it('requires department access when resending order booking confirmations', func
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . $department['id']]);
});
it('allows department admins to resend order booking completion confirmations', function (): void {
email::resetFakeDeliveries();
$customer = api_fixtures()->createUser([
'display_name' => 'Resend Completion Confirmation Customer',
'email' => 'resend-completion-confirmation@example.test',
]);
$department = api_fixtures()->createDepartment([
'name' => 'Resend Completion Confirmation Department',
]);
$cashier = api_fixtures()->createUser([
'display_name' => 'Resend Completion Confirmation Cashier',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
$booking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'reference' => 'RESEND-COMPLETION',
'reg_1' => 'DONE1',
'order_id' => $order['id'],
]);
api_fixtures()->createOrderAttachment([
'order_id' => $order['id'],
'content' => json_encode([
'document' => 'completion-confirmation-test.pdf',
'other' => 'wash_certificate',
], JSON_THROW_ON_ERROR),
]);
(new pdf_store())->createObject('completion-confirmation-test.pdf', '%PDF-1.4 test completion confirmation');
$session = api_fixtures()->createUserSession([
'complete_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->post('/order-bookings/completion-confirmation/resend', [
'id' => $booking['id'],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('message', 'Completion confirmation resent successfully.')
->toHaveKey('booking')
->and($response->data()['booking'])
->toBeArray()
->and($response->data()['booking']['id'] ?? null)
->toBe($booking['id'])
->and(email::$fake_deliveries)
->toHaveCount(1)
->and(email::$fake_deliveries[0]['to'] ?? null)
->toBe('resend-completion-confirmation@example.test')
->and(email::$fake_deliveries[0]['subject'] ?? '')
->toContain('RESEND-COMPLETION');
});
it('requires department access when resending order booking completion confirmations', function (): void {
$customer = api_fixtures()->createUser([
'display_name' => 'Resend Completion Confirmation Foreign Customer',
]);
$department = api_fixtures()->createDepartment([
'name' => 'Resend Completion Confirmation Foreign Department',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
]);
$booking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'order_id' => $order['id'],
]);
$session = api_fixtures()->createUserSession([
'complete_bookings',
]);
$response = api_client()->post('/order-bookings/completion-confirmation/resend', [
'id' => $booking['id'],
], $session['headers']);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . $department['id']]);
});
@@ -76,7 +76,6 @@ it('allows the customer self-serve start sequence without department access', fu
'lane_id' => $laneId,
'command' => 'START',
'license_plate' => $reg,
'wash_type' => 'Manual',
'defer_relay_side_effects' => true,
], $headers)
->assertStatus(200)
@@ -132,7 +131,6 @@ it('marks the active customer session relay-enabled after machine relay enable',
'lane_id' => $laneId,
'command' => 'START',
'license_plate' => $reg,
'wash_type' => 'Machine',
'defer_relay_side_effects' => true,
], $headers)
->assertStatus(200)
@@ -64,7 +64,6 @@ it('allows customer self-serve permission to execute START without department ac
'lane_id' => (int)$scenario['lane']['id'],
'command' => 'START',
'license_plate' => (string)$scenario['vehicle']['reg'],
'wash_type' => 'Manual',
'defer_relay_side_effects' => true,
], api_fixtures()->bearerHeaders($token));
@@ -171,7 +170,6 @@ it('still allows elevated operators with department access to execute lane comma
'lane_id' => (int)$scenario['lane']['id'],
'command' => 'START',
'license_plate' => 'OP' . (int)$scenario['lane']['id'],
'wash_type' => 'Manual',
'defer_relay_side_effects' => true,
], $session['headers']);
@@ -96,6 +96,10 @@ final class ApiClient
$decoded = json_decode($body, true);
if (getenv('EMAIL_FAKE_MODE') === '1' && class_exists(\classes\email::class)) {
\classes\email::syncFakeDeliveries();
}
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
}
}
@@ -0,0 +1,16 @@
<?php
it('wires the order booking completion confirmation resend endpoint', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
expect(is_file($routeFile))->toBeTrue();
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
expect($routeCode)
->toContain("$" . "this->post('/order-bookings/completion-confirmation/resend', function () {")
->toContain("self::requirePermission('complete_bookings');")
->toContain('self::requireDepartmentAccess((int)$object->department->value());')
->toContain('(new email())->sendWashCertificateEmailToCustomer($object);')
->toContain("'Completion confirmation resent successfully.'");
});
@@ -0,0 +1,49 @@
<?php
app_require('classes/email.php');
use classes\email;
it('syncs fake email deliveries written by another process', function (): void {
$previousFakeMode = getenv('EMAIL_FAKE_MODE');
$previousFakePath = getenv('EMAIL_FAKE_DELIVERIES_PATH');
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-fake-email-sync-' . bin2hex(random_bytes(4)) . '.jsonl';
putenv('EMAIL_FAKE_MODE=1');
putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $path);
email::resetFakeDeliveries();
try {
file_put_contents($path, json_encode([
'to' => 'customer@example.test',
'recipient_name' => 'Customer',
'subject' => 'Subject',
'message' => '',
'html' => '<p>Body</p>',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
email::syncFakeDeliveries();
expect(email::$fake_deliveries)
->toHaveCount(1)
->and(email::$fake_deliveries[0]['to'] ?? null)
->toBe('customer@example.test')
->and(email::$fake_deliveries[0]['subject'] ?? null)
->toBe('Subject');
} finally {
email::resetFakeDeliveries();
if (is_file($path)) {
unlink($path);
}
if ($previousFakeMode === false) {
putenv('EMAIL_FAKE_MODE');
} else {
putenv('EMAIL_FAKE_MODE=' . $previousFakeMode);
}
if ($previousFakePath === false) {
putenv('EMAIL_FAKE_DELIVERIES_PATH');
} else {
putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $previousFakePath);
}
}
});
@@ -53,23 +53,20 @@ class SelfserveLaneStartEntranceTimeoutHarness
$this->relayEvents[] = 'cleaner:on';
}
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void
{
unset($arguments);
$this->programPickerRelayCalls++;
$this->relayEvents[] = 'program_picker:selected_service';
}
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
protected function setProgramPickerRelayStatusForWashStart(): void
{
unset($arguments);
$this->programPickerRelayCalls++;
$this->relayEvents[] = 'program_picker:eligibility_sync';
}
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
protected function setMachineRelayStatusForWashStart(): void
{
unset($arguments);
$this->machineRelayCalls++;
$this->relayEvents[] = 'machine:sync';
}
@@ -110,30 +107,14 @@ it('parses deferred relay side effects on start command arguments', function ():
$arguments = (new selfserve_lane_command_arguments())->setParameters([
'license_plate' => 'ab12345',
'customer_number' => 12345679,
'wash_type' => 'Manual',
'defer_relay_side_effects' => true,
]);
expect($arguments->license_plate)->toBe('AB12345');
expect($arguments->customer_number)->toBe(12345679);
expect($arguments->wash_mode)->toBe('manual');
expect($arguments->defer_relay_side_effects)->toBeTrue();
});
it('parses wash mode aliases on start command arguments', function (): void {
$arguments = (new selfserve_lane_command_arguments())->setParameters([
'wash_mode' => 'machine',
]);
expect($arguments->wash_mode)->toBe('machine');
});
it('rejects invalid wash types on start command arguments', function (): void {
expect(fn() => (new selfserve_lane_command_arguments())->setParameters([
'wash_type' => 'automatic',
]))->toThrow(\InvalidArgumentException::class, 'Invalid wash type: automatic');
});
it('only syncs program picker from selected service when start asks to defer machine side effects', function (): void {
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
@@ -138,8 +138,6 @@ it('documents property gate lane commands and sanitized gate failure responses',
expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE');
expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE');
expect($commandPathBlock)->toContain('wash_type:');
expect($commandPathBlock)->toContain('wash_mode:');
expect($commandPathBlock)->toContain('Command execution failed');
expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.');
@@ -41,8 +41,6 @@ class SelfserveProgramPickerSelectionHarness
public object $department_lane;
public int $licensePlateReads = 0;
public int $customerNumberReads = 0;
public int $availabilityChecks = 0;
public bool $machineAvailable = true;
/** @var array<int,bool> */
public array $programPickerWrites = [];
/** @var array<string,mixed> */
@@ -81,84 +79,29 @@ class SelfserveProgramPickerSelectionHarness
return true;
}
public function runDeferredStartRelaySideEffects(?string $washType = null): void
public function runDeferredStartRelaySideEffects(): void
{
$arguments = (new selfserve_lane_command_arguments())
->setDeferRelaySideEffects(true)
->setWashMode($washType);
$arguments = (new selfserve_lane_command_arguments())->setDeferRelaySideEffects(true);
$this->runRelaySideEffectsForWashStart($arguments);
}
public function runNormalStartProgramPickerRelay(?string $washType = null): void
{
$arguments = (new selfserve_lane_command_arguments())->setWashMode($washType);
$this->setProgramPickerRelayStatusForWashStart($arguments);
}
public function resolveStartWashMode(?string $washType = null): string
{
$arguments = (new selfserve_lane_command_arguments())->setWashMode($washType);
return $this->resolveSelfServeActionWashModeForStart($arguments);
}
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
unset($arguments);
$this->availabilityChecks++;
return $this->machineAvailable;
}
}
it('turns off the program picker on deferred start when the frontend selected manual wash', function (): void {
$lane = new SelfserveProgramPickerSelectionHarness();
$lane->setSelectedServices([]);
$lane->runDeferredStartRelaySideEffects('Manual');
$lane->runDeferredStartRelaySideEffects();
expect($lane->programPickerWrites)->toBe([false]);
});
it('does not let backend machine eligibility override a frontend manual wash selection', function (): void {
$lane = new SelfserveProgramPickerSelectionHarness();
$lane->setSelectedServices(['MACHINE']);
$lane->setSelectedServices([]);
$lane->runDeferredStartRelaySideEffects('Manual');
$lane->runDeferredStartRelaySideEffects();
expect($lane->licensePlateReads)->toBe(0)
->and($lane->customerNumberReads)->toBe(0)
->and($lane->programPickerWrites)->toBe([false])
->and($lane->resolveStartWashMode('Manual'))->toBe('manual');
});
it('does not infer program picker enablement from machine service without a customer machine selection', function (): void {
$lane = new SelfserveProgramPickerSelectionHarness();
$lane->setSelectedServices(['MACHINE']);
$lane->runDeferredStartRelaySideEffects();
$lane->runNormalStartProgramPickerRelay();
expect($lane->programPickerWrites)->toBe([false, false])
->and($lane->availabilityChecks)->toBe(0);
});
it('keeps normal start program picker off when the customer selected manual wash', function (): void {
$lane = new SelfserveProgramPickerSelectionHarness();
$lane->setSelectedServices(['MACHINE']);
$lane->runNormalStartProgramPickerRelay('Manual');
expect($lane->programPickerWrites)->toBe([false])
->and($lane->availabilityChecks)->toBe(0);
});
it('honors a frontend machine wash selection when machine service is selected', function (): void {
$lane = new SelfserveProgramPickerSelectionHarness();
$lane->setSelectedServices(['MACHINE']);
$lane->runDeferredStartRelaySideEffects('Machine');
$lane->runNormalStartProgramPickerRelay('Machine');
expect($lane->programPickerWrites)->toBe([true, true])
->and($lane->availabilityChecks)->toBe(1)
->and($lane->resolveStartWashMode('Machine'))->toBe('machine');
->and($lane->programPickerWrites)->toBe([false]);
});
@@ -0,0 +1,38 @@
<?php
use classes\pdf_store;
it('falls back to local test storage when MinIO config values are empty', function (): void {
global $MINIO;
$previousRunApiTests = getenv('RUN_API_TESTS');
$previousMinio = $MINIO ?? null;
putenv('RUN_API_TESTS=1');
$MINIO = [
'endpoint' => null,
'access_key' => null,
'secret_key' => null,
];
try {
$file = 'minio-local-test-' . bin2hex(random_bytes(4)) . '.pdf';
$store = new pdf_store();
expect($store->createObject($file, 'local-pdf-content'))->toBeTrue();
$path = $store->download($file);
expect(is_file($path))->toBeTrue()
->and(file_get_contents($path))->toBe('local-pdf-content');
} finally {
if (isset($path) && is_file($path)) {
unlink($path);
}
if ($previousRunApiTests === false) {
putenv('RUN_API_TESTS');
} else {
putenv('RUN_API_TESTS=' . $previousRunApiTests);
}
$MINIO = $previousMinio;
}
});
+3 -3
View File
@@ -76,7 +76,7 @@ trait minio_t
public function getEndpoint(): string
{
global $MINIO;
return $MINIO['endpoint'];
return is_array($MINIO ?? null) ? (string)($MINIO['endpoint'] ?? '') : '';
}
/**
@@ -86,7 +86,7 @@ trait minio_t
public function getAccessKey(): string
{
global $MINIO;
return $MINIO['access_key'];
return is_array($MINIO ?? null) ? (string)($MINIO['access_key'] ?? '') : '';
}
/**
@@ -96,7 +96,7 @@ trait minio_t
public function getSecretKey(): string
{
global $MINIO;
return $MINIO['secret_key'];
return is_array($MINIO ?? null) ? (string)($MINIO['secret_key'] ?? '') : '';
}
/**