Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
d605eca574 | ||
|
|
7e85c74e60 | ||
|
|
67d62eff70 | ||
|
|
8ebbd52a99 | ||
|
|
6d6cc501db | ||
|
|
ce999afbb3 | ||
|
|
cb34b030c8 |
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,8 +48,8 @@ class selfserve_studio_graph
|
||||
{
|
||||
private const DEFAULT_PATH_MAX_STATES = 2048;
|
||||
private const MAX_PATH_MAX_STATES = 2048;
|
||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
|
||||
private const MAX_PATH_SAMPLE_LIMIT = 200;
|
||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
|
||||
private const MAX_PATH_SAMPLE_LIMIT = 2048;
|
||||
|
||||
/** @var array<string,array<int,string>> */
|
||||
private array $columnCache = [];
|
||||
|
||||
@@ -127,40 +127,47 @@ class users_o extends db
|
||||
|
||||
private function importCustomerFromExternalSource(int $customer_number): object|bool
|
||||
{
|
||||
global $db;
|
||||
// Get the customer data from the external source
|
||||
$economic = new economicCustomers();
|
||||
$customer_data = $economic->getCustomerId($customer_number);
|
||||
// DEBUG: Return the customer data
|
||||
// Check if the customer exists
|
||||
if ($customer_data) {
|
||||
// Avoid SQL injection
|
||||
$customer_number = $db->escape_string($customer_data->customerNumber);
|
||||
// Double check if the customer exists
|
||||
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
$this->id = $result->fetch_assoc()['id'];
|
||||
$this->getObjectProperties();
|
||||
} else {
|
||||
// Import the customer
|
||||
$this->add($customer_number, '', 0);
|
||||
// Nullify the password
|
||||
$this->password->nullify();
|
||||
// If the customer has an email address, save it
|
||||
if (isset($customer_data->email)) {
|
||||
$this->email->set($customer_data->email);
|
||||
}
|
||||
// If the customer has a name, save it as the display name
|
||||
if (isset($customer_data->name)) {
|
||||
$this->display_name->set($customer_data->name);
|
||||
}
|
||||
}
|
||||
return $this->importCustomerFromEconomicCustomerData($customer_data);
|
||||
}
|
||||
// Else return false
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function importCustomerFromEconomicCustomerData(object $customer_data): users_o|bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($customer_data->customerNumber) || !is_numeric($customer_data->customerNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$customer_number = $db->escape_string((string)$customer_data->customerNumber);
|
||||
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
$this->id = (int)$result->fetch_assoc()['id'];
|
||||
$this->getObjectProperties();
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->add($customer_number, '', 0);
|
||||
$this->password->nullify();
|
||||
|
||||
if (isset($customer_data->email)) {
|
||||
$this->email->set($customer_data->email);
|
||||
}
|
||||
|
||||
if (isset($customer_data->name)) {
|
||||
$this->display_name->set($customer_data->name);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -258,7 +265,7 @@ class users_o extends db
|
||||
* @param int|null $user_id The user id to add the attribute to
|
||||
* @throws Exception If the user is not selected, and the user_id is null
|
||||
*/
|
||||
public function addAttribute(string $attribute, int $user_id = null): void
|
||||
public function addAttribute(string $attribute, ?int $user_id = null): void
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -272,7 +279,7 @@ class users_o extends db
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
public function deleteAttribute(string $attribute, int $user_id = null): void
|
||||
public function deleteAttribute(string $attribute, ?int $user_id = null): void
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -304,7 +311,7 @@ class users_o extends db
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
public function doesUserHaveAttribute(string $attribute, int $user_id = null): bool
|
||||
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -518,7 +525,7 @@ class users_o extends db
|
||||
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
||||
}
|
||||
|
||||
public function getCustomerEcocomicData(int $customer_number = null): users_o
|
||||
public function getCustomerEcocomicData(?int $customer_number = null): users_o
|
||||
{
|
||||
// Check if the customer number is set
|
||||
if (!isset($this->customer_number) && $customer_number === null) {
|
||||
@@ -717,7 +724,7 @@ class users_o extends db
|
||||
$this->permissions = $perms;
|
||||
}
|
||||
|
||||
public function getUserAttributes(int $user_id = null): array
|
||||
public function getUserAttributes(?int $user_id = null): array
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -1106,7 +1113,7 @@ class users_o extends db
|
||||
* Set the password for the user
|
||||
* @throws Exception If the user is not selected
|
||||
*/
|
||||
public function setPassword(string $password = null): void
|
||||
public function setPassword(?string $password = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
global $db;
|
||||
|
||||
@@ -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:
|
||||
@@ -18414,10 +18441,10 @@ components:
|
||||
path_sample_limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 200
|
||||
maximum: 2048
|
||||
default: 2048
|
||||
nullable: true
|
||||
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
|
||||
description: Optional cap for returned path rows. Omitted returns every projected terminal path within the state cap; larger values are capped at 2048.
|
||||
|
||||
SelfserveStudioPathOutcomesResponse:
|
||||
type: object
|
||||
|
||||
@@ -409,15 +409,7 @@ class authRoute
|
||||
* Check if the cvr already exists
|
||||
*/
|
||||
$economic = new economic();
|
||||
$economic_response = ($economic->customers->customers->search([
|
||||
'corporateIdentificationNumber' => (string)$cvr,
|
||||
], [
|
||||
'skipPages' => 0,
|
||||
'pageSize' => 1, // Since the limit is 1000, we need to set the page size to 1000.
|
||||
])->collection);
|
||||
if (!is_array($economic_response)) {
|
||||
$economic_response = [];
|
||||
}
|
||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, (string)$cvr);
|
||||
|
||||
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
||||
@@ -455,15 +447,37 @@ class authRoute
|
||||
// Get the CVR company information used for the e-conomic customer payload.
|
||||
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
|
||||
$name = (string)($companyInformation->name ?? '');
|
||||
$result = $economic->createCustomer(
|
||||
(int)$companyPhone,
|
||||
$name,
|
||||
(int)$cvr,
|
||||
(string)$invoiceEmail,
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
);
|
||||
try {
|
||||
$result = $economic->createCustomer(
|
||||
(int)$companyPhone,
|
||||
$name,
|
||||
(int)$cvr,
|
||||
(string)$invoiceEmail,
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
||||
$economic,
|
||||
(string)$cvr,
|
||||
$companyPhone,
|
||||
(string)$invoiceEmail,
|
||||
$exception
|
||||
);
|
||||
|
||||
if ($recoveredCustomer !== null) {
|
||||
$response->success($recoveredCustomer, 200);
|
||||
}
|
||||
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_FAILED', [
|
||||
'phase' => 'create',
|
||||
'cvr' => (string)$cvr,
|
||||
'requestedCustomerNumber' => $companyPhone,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$response->error('Failed to create customer in e-conomic.', 502);
|
||||
}
|
||||
|
||||
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
||||
@@ -495,7 +509,7 @@ class authRoute
|
||||
);
|
||||
}
|
||||
|
||||
$this->bootstrapLocalCustomerOrFail($companyPhone);
|
||||
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
|
||||
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||
$response->success($result, 201);
|
||||
});
|
||||
@@ -758,6 +772,61 @@ class authRoute
|
||||
return count($rows) > 0;
|
||||
}
|
||||
|
||||
private function searchEconomicCustomersByCvr(economic $economic, string $cvr): array
|
||||
{
|
||||
$economic_response = ($economic->customers->customers->search([
|
||||
'corporateIdentificationNumber' => $cvr,
|
||||
], [
|
||||
'skipPages' => 0,
|
||||
'pageSize' => 1,
|
||||
])->collection);
|
||||
|
||||
return is_array($economic_response) ? $economic_response : [];
|
||||
}
|
||||
|
||||
private function recoverRegistrationAfterCreateFailure(
|
||||
economic $economic,
|
||||
string $cvr,
|
||||
int $customerNumber,
|
||||
string $invoiceEmail,
|
||||
Exception $exception
|
||||
): ?object {
|
||||
if (!$this->isRecoverableEconomicDuplicateError($exception)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
||||
} catch (Exception $searchException) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_RECOVERY_SEARCH_FAILED', [
|
||||
'phase' => 'create_recovery',
|
||||
'cvr' => $cvr,
|
||||
'requestedCustomerNumber' => $customerNumber,
|
||||
'message' => $searchException->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $customerNumber);
|
||||
if ($matchingEconomicCustomer === null || $this->localCustomerNumberExists($customerNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
||||
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
||||
|
||||
return $matchingEconomicCustomer;
|
||||
}
|
||||
|
||||
private function isRecoverableEconomicDuplicateError(Exception $exception): bool
|
||||
{
|
||||
$message = strtolower($exception->getMessage());
|
||||
|
||||
return str_contains($message, 'already exists')
|
||||
|| str_contains($message, 'already exist')
|
||||
|| str_contains($message, 'duplicate');
|
||||
}
|
||||
|
||||
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||
{
|
||||
foreach ($customers as $customer) {
|
||||
@@ -785,19 +854,46 @@ class authRoute
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function bootstrapLocalCustomerOrFail(int $customerNumber): users_o
|
||||
private function bootstrapLocalCustomerOrFail(int $customerNumber, ?object $economicCustomer = null): users_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
|
||||
if (method_exists($customer, 'exists') && $customer->exists()) {
|
||||
return $customer;
|
||||
$customer = new users_o();
|
||||
try {
|
||||
$customer = $customer->getUserByCustomerNumber($customerNumber);
|
||||
if (method_exists($customer, 'exists') && $customer->exists()) {
|
||||
return $customer;
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_LOOKUP_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
$economicCustomer !== null
|
||||
&& $this->extractEconomicCustomerNumber($economicCustomer) === $customerNumber
|
||||
&& method_exists($customer, 'importCustomerFromEconomicCustomerData')
|
||||
) {
|
||||
try {
|
||||
$importedCustomer = $customer->importCustomerFromEconomicCustomerData($economicCustomer);
|
||||
if (is_object($importedCustomer) && method_exists($importedCustomer, 'exists') && $importedCustomer->exists()) {
|
||||
return $importedCustomer;
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_SNAPSHOT_BOOTSTRAP_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
]);
|
||||
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
||||
throw new Exception('Customer was created in e-conomic but could not be imported locally.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1670,9 +1670,13 @@ class moduleSelfServeRoute
|
||||
}
|
||||
|
||||
if ($allow_customer_self_serve) {
|
||||
$customer_allowed = $requires_active_wash
|
||||
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
|
||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
if ($requires_active_wash && $allow_department_active_wash) {
|
||||
$customer_allowed = $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
|
||||
} else {
|
||||
$customer_allowed = $requires_active_wash
|
||||
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, false)
|
||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
if ($customer_allowed) {
|
||||
return;
|
||||
@@ -1827,7 +1831,17 @@ class moduleSelfServeRoute
|
||||
|
||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true);
|
||||
if ($this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($customer_number <= 0 || !$this->isOwnCustomerContext($customer_number)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
return $department_id > 0
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveOperationalSelfServeLane(
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -295,6 +295,9 @@ it('wires self-serve property gate command permissions', function (): void {
|
||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
|
||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
||||
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
|
||||
expect($moduleSelfServeRoute)->toContain('$this->canCustomerUsePropertyGateForLane($lane, $customer_number)');
|
||||
expect($moduleSelfServeRoute)->toContain('$this->isOwnCustomerContext($customer_number)');
|
||||
expect($moduleSelfServeRoute)->toContain('$this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number)');
|
||||
|
||||
expect($commandTrait)->not->toBeFalse();
|
||||
expect($commandTrait)->toContain('Failed to open property access gate.');
|
||||
|
||||
@@ -26,6 +26,54 @@ function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_har
|
||||
return $reflection->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
function selfserve_question_tree_simulator(int $questionCount): callable
|
||||
{
|
||||
return function (array $overrides) use ($questionCount): array {
|
||||
$answers = [];
|
||||
foreach ($overrides as $entry) {
|
||||
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
||||
}
|
||||
|
||||
$questions = [];
|
||||
foreach (range(1, $questionCount) as $questionId) {
|
||||
$questions[] = [
|
||||
'id' => $questionId,
|
||||
'node_id' => 'question:' . $questionId,
|
||||
'label' => 'Question ' . $questionId,
|
||||
'visible' => true,
|
||||
'answer' => $answers[$questionId] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$complete = count($answers) === $questionCount;
|
||||
$allowed = $complete && !in_array(false, $answers, true);
|
||||
|
||||
return [
|
||||
'allowed' => $allowed,
|
||||
'questions' => [],
|
||||
'tasks' => $allowed ? [
|
||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
||||
] : [],
|
||||
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
||||
'debug' => [
|
||||
'questions' => $questions,
|
||||
'tasks' => [
|
||||
[
|
||||
'id' => 41,
|
||||
'node_id' => 'task:41',
|
||||
'label' => 'Start machine',
|
||||
'active' => $allowed,
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['start'],
|
||||
'order_priority' => 1,
|
||||
],
|
||||
],
|
||||
'signal_timeline' => [],
|
||||
],
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
|
||||
@@ -1321,52 +1369,20 @@ it('truncates path outcome projection when the state cap is reached', function (
|
||||
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
||||
});
|
||||
|
||||
it('applies default caps for wide question trees and reports progress', function (): void {
|
||||
it('returns more than 200 projected path cases by default', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = function (array $overrides): array {
|
||||
$answers = [];
|
||||
foreach ($overrides as $entry) {
|
||||
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
||||
}
|
||||
$projection = $service->projectPathOutcomesFromSimulator(selfserve_question_tree_simulator(8));
|
||||
|
||||
$questions = [];
|
||||
foreach (range(1, 12) as $questionId) {
|
||||
$questions[] = [
|
||||
'id' => $questionId,
|
||||
'node_id' => 'question:' . $questionId,
|
||||
'label' => 'Question ' . $questionId,
|
||||
'visible' => true,
|
||||
'answer' => $answers[$questionId] ?? null,
|
||||
];
|
||||
}
|
||||
expect($projection['truncated'])->toBeFalse()
|
||||
->and($projection['summary']['state_count'])->toBe(511)
|
||||
->and($projection['summary']['terminal_path_count'])->toBe(256)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(256)
|
||||
->and($projection['paths'])->toHaveCount(256);
|
||||
});
|
||||
|
||||
$complete = count($answers) === 12;
|
||||
$allowed = $complete && !in_array(false, $answers, true);
|
||||
|
||||
return [
|
||||
'allowed' => $allowed,
|
||||
'questions' => [],
|
||||
'tasks' => $allowed ? [
|
||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
||||
] : [],
|
||||
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
||||
'debug' => [
|
||||
'questions' => $questions,
|
||||
'tasks' => [
|
||||
[
|
||||
'id' => 41,
|
||||
'node_id' => 'task:41',
|
||||
'label' => 'Start machine',
|
||||
'active' => $allowed,
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['start'],
|
||||
'order_priority' => 1,
|
||||
],
|
||||
],
|
||||
'signal_timeline' => [],
|
||||
],
|
||||
];
|
||||
};
|
||||
it('applies the default state cap for wide question trees and reports progress', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = selfserve_question_tree_simulator(12);
|
||||
|
||||
$progressEvents = [];
|
||||
$projection = $service->projectPathOutcomesFromSimulator($simulate, [
|
||||
@@ -1385,10 +1401,10 @@ it('applies default caps for wide question trees and reports progress', function
|
||||
->and($projection['summary']['question_count'])->toBe(12)
|
||||
->and($projection['summary']['terminal_path_count'])->toBe(1023)
|
||||
->and($projection['summary']['outcome_count'])->toBe(2)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(200)
|
||||
->and($projection['summary']['path_sample_count'])->toBe(1023)
|
||||
->and($projection['progress']['complete'])->toBeFalse()
|
||||
->and($projection['progress']['percent'])->toBe(99)
|
||||
->and($projection['paths'])->toHaveCount(200)
|
||||
->and($projection['paths'])->toHaveCount(1023)
|
||||
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
||||
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
||||
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -69,6 +69,8 @@ namespace classes {
|
||||
{
|
||||
public static array $mock_collection = [];
|
||||
public static ?object $mock_create_response = null;
|
||||
public static ?\RuntimeException $mock_create_exception = null;
|
||||
public static array $mock_collection_after_create_exception = [];
|
||||
public static array $search_calls = [];
|
||||
public static array $create_calls = [];
|
||||
|
||||
@@ -78,6 +80,8 @@ namespace classes {
|
||||
{
|
||||
self::$mock_collection = [];
|
||||
self::$mock_create_response = null;
|
||||
self::$mock_create_exception = null;
|
||||
self::$mock_collection_after_create_exception = [];
|
||||
self::$search_calls = [];
|
||||
self::$create_calls = [];
|
||||
}
|
||||
@@ -113,6 +117,11 @@ namespace classes {
|
||||
'company_information' => $companyInformation,
|
||||
];
|
||||
|
||||
if (self::$mock_create_exception !== null) {
|
||||
self::$mock_collection = self::$mock_collection_after_create_exception;
|
||||
throw self::$mock_create_exception;
|
||||
}
|
||||
|
||||
$response = self::$mock_create_response ?? (object)[
|
||||
'customerNumber' => (int)$number,
|
||||
];
|
||||
@@ -214,6 +223,7 @@ namespace objects {
|
||||
{
|
||||
public static array $mock_existing_customer_numbers = [];
|
||||
public static array $mock_importable_customer_numbers = [];
|
||||
public static bool $mock_external_lookup_enabled = true;
|
||||
public static array $interaction_log = [];
|
||||
|
||||
public int $id = 0;
|
||||
@@ -223,6 +233,7 @@ namespace objects {
|
||||
{
|
||||
self::$mock_existing_customer_numbers = [];
|
||||
self::$mock_importable_customer_numbers = [];
|
||||
self::$mock_external_lookup_enabled = true;
|
||||
self::$interaction_log = [];
|
||||
}
|
||||
|
||||
@@ -242,7 +253,8 @@ namespace objects {
|
||||
self::$interaction_log[] = 'bootstrap:' . $customerNumber;
|
||||
|
||||
$existsLocally = in_array($customerNumber, self::$mock_existing_customer_numbers, true);
|
||||
$canImport = in_array($customerNumber, self::$mock_importable_customer_numbers, true);
|
||||
$canImport = self::$mock_external_lookup_enabled
|
||||
&& in_array($customerNumber, self::$mock_importable_customer_numbers, true);
|
||||
|
||||
if ($existsLocally || $canImport) {
|
||||
$this->id = $customerNumber;
|
||||
@@ -257,6 +269,22 @@ namespace objects {
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function importCustomerFromEconomicCustomerData(object $customerData): self|bool
|
||||
{
|
||||
$customerNumber = (int)($customerData->customerNumber ?? 0);
|
||||
if ($customerNumber <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$interaction_log[] = 'snapshot-import:' . $customerNumber;
|
||||
$this->id = $customerNumber;
|
||||
$this->exists = true;
|
||||
self::$mock_existing_customer_numbers[] = $customerNumber;
|
||||
self::$mock_existing_customer_numbers = array_values(array_unique(self::$mock_existing_customer_numbers));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->exists;
|
||||
@@ -509,6 +537,60 @@ namespace {
|
||||
assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Fresh registration Slack notification must use the created customer number.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'Successful registration falls back to the create response when immediate import lookup misses',
|
||||
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
|
||||
'setup' => static function (): void {
|
||||
\classes\economic::$mock_create_response = (object)[
|
||||
'customerNumber' => 12345678,
|
||||
'name' => 'Mock Company',
|
||||
'email' => 'test@test.com',
|
||||
];
|
||||
\objects\users_o::$mock_external_lookup_enabled = false;
|
||||
},
|
||||
'expected_success' => (object)[
|
||||
'customerNumber' => 12345678,
|
||||
'name' => 'Mock Company',
|
||||
'email' => 'test@test.com',
|
||||
],
|
||||
'expected_status' => 201,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
|
||||
assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Fresh registration must try the standard local bootstrap first.');
|
||||
assert_true(\objects\users_o::$interaction_log[1] === 'snapshot-import:12345678', 'Fresh registration must import from the create response when the immediate lookup misses.');
|
||||
assert_true(count(\classes\email::$sent) === 2, 'Snapshot fallback registration must send two welcome emails.');
|
||||
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Snapshot fallback registration must notify opted-in superusers once.');
|
||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Snapshot fallback registration must notify Slack once.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'Duplicate create response recovers a just-created e-conomic customer and sends notifications',
|
||||
'params' => $baseParams,
|
||||
'setup' => static function (): void {
|
||||
\classes\economic::$mock_create_exception = new \RuntimeException('e-conomic request failed with HTTP 400: Customer already exists');
|
||||
\classes\economic::$mock_collection_after_create_exception = [
|
||||
(object)[
|
||||
'customerNumber' => 12345678,
|
||||
'name' => 'Recovered After Create',
|
||||
'email' => 'test@test.com',
|
||||
],
|
||||
];
|
||||
\objects\users_o::$mock_importable_customer_numbers = [12345678];
|
||||
},
|
||||
'expected_success' => (object)[
|
||||
'customerNumber' => 12345678,
|
||||
'name' => 'Recovered After Create',
|
||||
'email' => 'test@test.com',
|
||||
],
|
||||
'expected_status' => 200,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 1, 'Recovery must still record the attempted create call.');
|
||||
assert_true(count(\classes\economic::$search_calls) === 2, 'Recovery must verify the duplicate by searching e-conomic again.');
|
||||
assert_true(count(\classes\email::$sent) === 2, 'Duplicate create recovery must send two welcome emails.');
|
||||
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Duplicate create recovery must notify opted-in superusers once.');
|
||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Duplicate create recovery must notify Slack once.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'Fresh create mismatch returns conflict without local bootstrap or email',
|
||||
'params' => $baseParams,
|
||||
|
||||
@@ -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'] ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user