Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1beca924fc | ||
|
|
cea469c95a |
+8
-27
@@ -6266,33 +6266,6 @@ 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:
|
||||
@@ -9448,6 +9421,14 @@ 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.
|
||||
|
||||
@@ -133,14 +133,8 @@ 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' => $objectTypes,
|
||||
'object_type' => $type,
|
||||
'object_id' => $object_ids,
|
||||
'deleted_at' => null
|
||||
], $options);
|
||||
|
||||
@@ -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::recordFakeDelivery([
|
||||
self::$fake_deliveries[] = [
|
||||
'to' => $to,
|
||||
'recipient_name' => $recipient_name,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'html' => $html,
|
||||
]);
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,72 +225,6 @@ 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
|
||||
|
||||
@@ -47,12 +47,8 @@ class pdf_store implements minio_pdfs_i
|
||||
*/
|
||||
public function download(string $file): string
|
||||
{
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
return $this->getLocalTestObjectPath($file);
|
||||
}
|
||||
|
||||
$path = '/tmp/' . $file;
|
||||
self::getS3Client()->getObject([
|
||||
$result = self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $file,
|
||||
'SaveAs' => $path
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,22 @@ 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;
|
||||
@@ -50,6 +67,12 @@ 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(): void
|
||||
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
||||
return;
|
||||
}
|
||||
if ($this->isMachineWashSelectedAndAvailableForStart()) {
|
||||
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
|
||||
try {
|
||||
$this->setMachineRelayStatusHard(true);
|
||||
} catch (\Throwable) {
|
||||
@@ -194,38 +194,39 @@ trait selfserve_lane_command_t
|
||||
|
||||
/**
|
||||
* Keep the program picker relay aligned with the selected wash mode at START.
|
||||
* It is ON only when the active self-serve session is allowed to start machine wash.
|
||||
* It is ON only when the customer explicitly selected machine wash.
|
||||
*/
|
||||
protected function setProgramPickerRelayStatusForWashStart(): void
|
||||
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$shouldEnable = $this->isMachineWashSelectedAndAvailableForStart();
|
||||
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
||||
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
|
||||
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void
|
||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->setMachineProgramPickerRelayStatusHard($this->isMachineServiceSelectedForWashStart());
|
||||
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function isMachineWashSelectedAndAvailableForStart(): bool
|
||||
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
if (!$this->isMachineServiceSelectedForWashStart()) {
|
||||
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -248,6 +249,39 @@ 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 {
|
||||
@@ -349,17 +383,24 @@ trait selfserve_lane_command_t
|
||||
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
||||
{
|
||||
if ($arguments->defer_relay_side_effects) {
|
||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart();
|
||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->turnOnCleanerRelayForWashStart();
|
||||
$this->setProgramPickerRelayStatusForWashStart();
|
||||
$this->setMachineRelayStatusForWashStart();
|
||||
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
||||
$this->setMachineRelayStatusForWashStart($arguments);
|
||||
}
|
||||
|
||||
protected function resolveSelfServeActionWashModeForStart(): string
|
||||
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): 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;
|
||||
}
|
||||
@@ -634,7 +675,7 @@ trait selfserve_lane_command_t
|
||||
$this->runRelaySideEffectsForWashStart($arguments);
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
$this->resolveSelfServeActionWashModeForStart(),
|
||||
$this->resolveSelfServeActionWashModeForStart($arguments),
|
||||
[
|
||||
'customer_number' => (int)$customer_number,
|
||||
'reg' => $license_plate,
|
||||
|
||||
@@ -6266,33 +6266,6 @@ 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:
|
||||
@@ -9448,6 +9421,14 @@ 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,37 +392,6 @@ 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,9 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\email;
|
||||
use classes\pdf_store;
|
||||
|
||||
putenv('EMAIL_FAKE_MODE=1');
|
||||
|
||||
usesApiSuite();
|
||||
@@ -79,97 +76,3 @@ 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,6 +76,7 @@ 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)
|
||||
@@ -131,6 +132,7 @@ 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,6 +64,7 @@ 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));
|
||||
|
||||
@@ -170,6 +171,7 @@ 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,10 +96,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<?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.'");
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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,20 +53,23 @@ class SelfserveLaneStartEntranceTimeoutHarness
|
||||
$this->relayEvents[] = 'cleaner:on';
|
||||
}
|
||||
|
||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void
|
||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
unset($arguments);
|
||||
$this->programPickerRelayCalls++;
|
||||
$this->relayEvents[] = 'program_picker:selected_service';
|
||||
}
|
||||
|
||||
protected function setProgramPickerRelayStatusForWashStart(): void
|
||||
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
unset($arguments);
|
||||
$this->programPickerRelayCalls++;
|
||||
$this->relayEvents[] = 'program_picker:eligibility_sync';
|
||||
}
|
||||
|
||||
protected function setMachineRelayStatusForWashStart(): void
|
||||
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
unset($arguments);
|
||||
$this->machineRelayCalls++;
|
||||
$this->relayEvents[] = 'machine:sync';
|
||||
}
|
||||
@@ -107,14 +110,30 @@ 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,6 +138,8 @@ 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.');
|
||||
|
||||
|
||||
+63
-6
@@ -41,6 +41,8 @@ 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> */
|
||||
@@ -79,29 +81,84 @@ class SelfserveProgramPickerSelectionHarness
|
||||
return true;
|
||||
}
|
||||
|
||||
public function runDeferredStartRelaySideEffects(): void
|
||||
public function runDeferredStartRelaySideEffects(?string $washType = null): void
|
||||
{
|
||||
$arguments = (new selfserve_lane_command_arguments())->setDeferRelaySideEffects(true);
|
||||
$arguments = (new selfserve_lane_command_arguments())
|
||||
->setDeferRelaySideEffects(true)
|
||||
->setWashMode($washType);
|
||||
$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();
|
||||
$lane->runDeferredStartRelaySideEffects('Manual');
|
||||
|
||||
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([]);
|
||||
$lane->setSelectedServices(['MACHINE']);
|
||||
|
||||
$lane->runDeferredStartRelaySideEffects();
|
||||
$lane->runDeferredStartRelaySideEffects('Manual');
|
||||
|
||||
expect($lane->licensePlateReads)->toBe(0)
|
||||
->and($lane->customerNumberReads)->toBe(0)
|
||||
->and($lane->programPickerWrites)->toBe([false]);
|
||||
->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');
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
});
|
||||
@@ -76,7 +76,7 @@ trait minio_t
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
global $MINIO;
|
||||
return is_array($MINIO ?? null) ? (string)($MINIO['endpoint'] ?? '') : '';
|
||||
return $MINIO['endpoint'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ trait minio_t
|
||||
public function getAccessKey(): string
|
||||
{
|
||||
global $MINIO;
|
||||
return is_array($MINIO ?? null) ? (string)($MINIO['access_key'] ?? '') : '';
|
||||
return $MINIO['access_key'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +96,7 @@ trait minio_t
|
||||
public function getSecretKey(): string
|
||||
{
|
||||
global $MINIO;
|
||||
return is_array($MINIO ?? null) ? (string)($MINIO['secret_key'] ?? '') : '';
|
||||
return $MINIO['secret_key'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user