Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a | ||
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
1beca924fc | ||
|
|
cea469c95a |
@@ -9448,6 +9448,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.
|
||||
|
||||
@@ -134,7 +134,7 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
public function send_message(string $string, string $module = null): void
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
// Format the message if a module is provided
|
||||
@@ -147,7 +147,7 @@ class slack implements notification_i
|
||||
|
||||
public function send_customer_registration_notification(int $customer_number): self
|
||||
{
|
||||
$webhook = trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return $this;
|
||||
}
|
||||
@@ -160,6 +160,52 @@ class slack implements notification_i
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized customer-registration test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_customer_registration_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_customer_registration_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack customer registration test webhook sent successfully.'
|
||||
: 'Slack customer registration test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function get_customer_registration_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
public function is_webhook_send_successful(string $result): bool
|
||||
{
|
||||
return !str_starts_with($result, 'Failed to send message:');
|
||||
}
|
||||
|
||||
public function format_customer_registration(int $customer_number): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
@@ -178,4 +224,10 @@ class slack implements notification_i
|
||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
||||
. "Open in Superuser: $customerUrl";
|
||||
}
|
||||
|
||||
public function format_customer_registration_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Customer registration notifications are configured correctly.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace email\templates;
|
||||
|
||||
use email\helpers\email_template;use objects\users_o;
|
||||
use email\helpers\email_template;
|
||||
use objects\users_o;
|
||||
|
||||
class email_template_new_customer
|
||||
{
|
||||
@@ -52,6 +53,7 @@ class email_template_new_customer
|
||||
*/
|
||||
public function generate_html(): string
|
||||
{
|
||||
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
||||
ob_start();
|
||||
# Start of the html
|
||||
?>
|
||||
@@ -73,7 +75,7 @@ class email_template_new_customer
|
||||
|
||||
<!-- Intro -->
|
||||
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
||||
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash.
|
||||
Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
|
||||
</p>
|
||||
|
||||
<!-- You can now wash your trucks -->
|
||||
@@ -185,4 +187,19 @@ class email_template_new_customer
|
||||
# End of the html
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
private function getCustomerRegistrationLabel(): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
|
||||
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
|
||||
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
|
||||
|
||||
$customer->getCustomerEcocomicData($this->customer_number);
|
||||
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
|
||||
if ($corporate_identification_number !== '') {
|
||||
$customer_label .= ' (' . $corporate_identification_number . ')';
|
||||
}
|
||||
|
||||
return $customer_label;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +525,12 @@ 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
|
||||
{
|
||||
if ($customer_number !== null && !isset($this->id)) {
|
||||
$this->getUserByCustomerNumber($customer_number);
|
||||
}
|
||||
|
||||
// Check if the customer number is set
|
||||
if (!isset($this->customer_number) && $customer_number === null) {
|
||||
return $this;
|
||||
@@ -531,7 +542,9 @@ class users_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
$cachedCustomer = $this->getCached('economic_customer');
|
||||
$cachedCustomer = isset($this->id) && $this->id > 0
|
||||
? $this->getCached('economic_customer')
|
||||
: null;
|
||||
if (is_object($cachedCustomer)) {
|
||||
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
||||
if ($cachedCustomerNumber === $customer_number) {
|
||||
@@ -717,7 +730,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 +1119,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;
|
||||
|
||||
@@ -9448,6 +9448,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.
|
||||
@@ -11657,6 +11665,23 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
||||
|
||||
/slack/config/test:
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Test Slack customer registration webhook
|
||||
operationId: testSlackCustomerRegistrationWebhook
|
||||
responses:
|
||||
'200':
|
||||
description: Slack customer registration webhook test completed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||
'400':
|
||||
description: Slack customer registration webhook URL is not configured
|
||||
'502':
|
||||
description: Slack customer registration webhook test failed
|
||||
|
||||
/backups/config:
|
||||
get:
|
||||
tags: [Config]
|
||||
@@ -15182,6 +15207,14 @@ components:
|
||||
example: https://hooks.slack.com/services/...
|
||||
required: [module, variable, type, value]
|
||||
|
||||
SlackConfigTestResult:
|
||||
type: object
|
||||
properties:
|
||||
configured: { type: boolean }
|
||||
sent: { type: boolean }
|
||||
message: { type: string }
|
||||
required: [configured, sent, message]
|
||||
|
||||
BackupsConfigEntry:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15458,6 +15491,14 @@ components:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
||||
required: [data]
|
||||
|
||||
SlackConfigTestResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
||||
required: [data]
|
||||
|
||||
BackupsConfigListResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||
@@ -18433,10 +18474,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);
|
||||
@@ -448,22 +440,66 @@ class authRoute
|
||||
);
|
||||
}
|
||||
|
||||
// Get the CVR company information used for the e-conomic customer payload.
|
||||
$companyInformation = null;
|
||||
try {
|
||||
$companyInformation = (new virkdata())->getCompanyInformation((string)$cvr, '', []);
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_FAILED', [
|
||||
'phase' => 'cvr_lookup',
|
||||
'cvr' => (string)$cvr,
|
||||
'requestedCustomerNumber' => $companyPhone,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$name = trim((string)($companyInformation->name ?? ''));
|
||||
if ($name === '') {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', [
|
||||
'phase' => 'cvr_lookup',
|
||||
'cvr' => (string)$cvr,
|
||||
'requestedCustomerNumber' => $companyPhone,
|
||||
]);
|
||||
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($localUserExists) {
|
||||
$response->error('Company phone number already registered', 400);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
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 +531,7 @@ class authRoute
|
||||
);
|
||||
}
|
||||
|
||||
$this->bootstrapLocalCustomerOrFail($companyPhone);
|
||||
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
|
||||
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||
$response->success($result, 201);
|
||||
});
|
||||
@@ -758,6 +794,49 @@ 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
|
||||
): ?object {
|
||||
// The upstream POST can commit before the client receives a validation/transport error.
|
||||
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
||||
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 findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||
{
|
||||
foreach ($customers as $customer) {
|
||||
@@ -785,19 +864,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.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -221,6 +221,35 @@ class moduleConfigRoute
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack config > TEST */
|
||||
$this->post('/slack/config/test', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$result = (new slack())->test_customer_registration_webhook();
|
||||
if (($result['configured'] ?? false) !== true) {
|
||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration webhook URL is not configured');
|
||||
$response->error($result['message'] ?? 'Slack customer registration webhook URL is not configured.', 400);
|
||||
}
|
||||
|
||||
if (($result['sent'] ?? false) !== true) {
|
||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration test webhook failed');
|
||||
$response->error($result['message'] ?? 'Slack test message failed.', 502);
|
||||
}
|
||||
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG_TEST', 'Successfully tested Slack customer registration webhook');
|
||||
$response->success($result);
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Test Slack config'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/backups/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('backups_config');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
return [
|
||||
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/NewCustomerEmailTemplateTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
it('renders the new customer welcome template without unselected user access or leaked output', function (): void {
|
||||
$result = run_legacy_script('tests/auth/NewCustomerEmailTemplateTest.php');
|
||||
|
||||
expect($result['exitCode'])->toBe(0, $result['output']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -6,9 +6,11 @@ it('registers Slack module config endpoints and customer registration webhook co
|
||||
|
||||
expect($routeContent)->not->toBeFalse()
|
||||
->and($routeContent)->toContain('/slack/config')
|
||||
->and($routeContent)->toContain('/slack/config/test')
|
||||
->and($routeContent)->toContain("requirePermission('slack_config')")
|
||||
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
||||
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()");
|
||||
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()")
|
||||
->and($routeContent)->toContain("(new slack())->test_customer_registration_webhook()");
|
||||
|
||||
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
|
||||
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
|
||||
@@ -24,11 +26,15 @@ it('registers Slack module config endpoints and customer registration webhook co
|
||||
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
||||
->and($slackClassContent)->not->toBeFalse()
|
||||
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
||||
->and($slackClassContent)->toContain('test_customer_registration_webhook')
|
||||
->and($slackClassContent)->toContain('format_customer_registration_test')
|
||||
->and($slackClassContent)->toContain('format_customer_registration')
|
||||
->and($authRouteContent)->not->toBeFalse()
|
||||
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
||||
->and($openApiContent)->not->toBeFalse()
|
||||
->and($openApiContent)->toContain('/slack/config')
|
||||
->and($openApiContent)->toContain('/slack/config/test')
|
||||
->and($openApiContent)->toContain('SlackConfigListResponse')
|
||||
->and($openApiContent)->toContain('SlackConfigTestResponse')
|
||||
->and($openApiContent)->toContain('SlackConfigEntry');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/slack.php');
|
||||
|
||||
use classes\slack;
|
||||
|
||||
final class SlackCustomerRegistrationWebhookFake extends slack
|
||||
{
|
||||
public array $messages = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $webhook,
|
||||
private readonly string $sendResult = 'Message sent successfully. Response: ok'
|
||||
) {
|
||||
// Skip parent config loading for unit isolation.
|
||||
}
|
||||
|
||||
protected function get_customer_registration_webhook_url(): string
|
||||
{
|
||||
return $this->webhook;
|
||||
}
|
||||
|
||||
public function send_webhook_message(string $message, string $webhook): string
|
||||
{
|
||||
$this->messages[] = [
|
||||
'message' => $message,
|
||||
'webhook' => $webhook,
|
||||
];
|
||||
|
||||
return $this->sendResult;
|
||||
}
|
||||
}
|
||||
|
||||
it('does not send customer registration test notifications without a saved webhook', function (): void {
|
||||
$slack = new SlackCustomerRegistrationWebhookFake('');
|
||||
|
||||
$result = $slack->test_customer_registration_webhook();
|
||||
|
||||
expect($result)
|
||||
->toBe([
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||
])
|
||||
->and($slack->messages)->toBe([])
|
||||
->and($slack->get_log())->toBe([]);
|
||||
});
|
||||
|
||||
it('sends customer registration test notifications to the saved webhook', function (): void {
|
||||
$slack = new SlackCustomerRegistrationWebhookFake('https://hooks.slack.test/services/secret-token');
|
||||
|
||||
$result = $slack->test_customer_registration_webhook();
|
||||
|
||||
expect($result)
|
||||
->toBe([
|
||||
'configured' => true,
|
||||
'sent' => true,
|
||||
'message' => 'Slack test message sent successfully.',
|
||||
])
|
||||
->and($slack->messages)->toHaveCount(1)
|
||||
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/secret-token')
|
||||
->and($slack->messages[0]['message'])->toContain('Truck Wash Slack test')
|
||||
->and($slack->messages[0]['message'])->toContain('Customer registration notifications are configured correctly.')
|
||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully')
|
||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
||||
});
|
||||
|
||||
it('reports customer registration test notification failures without exposing the webhook', function (): void {
|
||||
$slack = new SlackCustomerRegistrationWebhookFake(
|
||||
'https://hooks.slack.test/services/secret-token',
|
||||
'Failed to send message: cURL error for https://hooks.slack.test/services/secret-token'
|
||||
);
|
||||
|
||||
$result = $slack->test_customer_registration_webhook();
|
||||
|
||||
expect($result)
|
||||
->toBe([
|
||||
'configured' => true,
|
||||
'sent' => false,
|
||||
'message' => 'Slack test message failed.',
|
||||
])
|
||||
->and($slack->messages)->toHaveCount(1)
|
||||
->and(json_encode($result, JSON_UNESCAPED_SLASHES))->not->toContain('secret-token')
|
||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed')
|
||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
namespace {
|
||||
if (!defined('WD')) {
|
||||
define('WD', dirname(__DIR__, 2));
|
||||
}
|
||||
}
|
||||
|
||||
namespace objects {
|
||||
class users_o
|
||||
{
|
||||
public static array $calls = [];
|
||||
private bool $selected = false;
|
||||
public object $economic_customer;
|
||||
|
||||
public function getUserByCustomerNumber(int $customer_number): self
|
||||
{
|
||||
self::$calls[] = 'select:' . $customer_number;
|
||||
$this->selected = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomerName(int $customer_number): ?string
|
||||
{
|
||||
if (!$this->selected) {
|
||||
throw new \RuntimeException('Customer name requested before local customer selection.');
|
||||
}
|
||||
|
||||
self::$calls[] = 'name:' . $customer_number;
|
||||
|
||||
return 'KING FOOD DANMARK A/S';
|
||||
}
|
||||
|
||||
public function getCustomerEcocomicData(?int $customer_number = null): self
|
||||
{
|
||||
if (!$this->selected) {
|
||||
throw new \RuntimeException('Economic customer requested before local customer selection.');
|
||||
}
|
||||
|
||||
self::$calls[] = 'economic:' . (int)$customer_number;
|
||||
$this->economic_customer = (object)[
|
||||
'corporateIdentificationNumber' => '12345678',
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require_once WD . '/modules/email/helpers/email_template.php';
|
||||
require_once WD . '/modules/email/templates/email_template_new_customer.php';
|
||||
|
||||
function assert_true(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup_buffers_to(int $base_level): string
|
||||
{
|
||||
$output = '';
|
||||
while (ob_get_level() > $base_level) {
|
||||
$output .= (string)ob_get_clean();
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
$base_level = ob_get_level();
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
$html = (new \email\templates\email_template_new_customer(
|
||||
12345678,
|
||||
'https://truckwash.io/auth/password-reset/mock-token',
|
||||
))->generate_html();
|
||||
$leaked_output = cleanup_buffers_to($base_level);
|
||||
|
||||
assert_true($leaked_output === '', 'Template generation must not leak buffered HTML output.');
|
||||
assert_true(
|
||||
str_contains($html, 'Tak for din registrering af KING FOOD DANMARK A/S (12345678) som kunde hos Truck Wash.'),
|
||||
'Template must render the selected customer name and CVR in the welcome intro.'
|
||||
);
|
||||
assert_true(
|
||||
\objects\users_o::$calls === ['select:12345678', 'name:12345678', 'economic:12345678'],
|
||||
'Template must select the local customer before reading customer details.'
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$leaked_output = cleanup_buffers_to($base_level);
|
||||
fwrite(STDERR, $leaked_output);
|
||||
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\033[32m[PASS]\033[0m New customer email template renders without leaked output.\n";
|
||||
exit(0);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -133,9 +142,14 @@ namespace classes {
|
||||
public static int $mock_zipcode = 2630;
|
||||
public static string $mock_city = 'Taastrup';
|
||||
public static string $mock_website = 'https://demo.test';
|
||||
public static ?\RuntimeException $mock_exception = null;
|
||||
|
||||
public function getCompanyInformation($cvr, $endpoint, $data): object
|
||||
{
|
||||
if (self::$mock_exception !== null) {
|
||||
throw self::$mock_exception;
|
||||
}
|
||||
|
||||
$result = new \stdClass();
|
||||
$result->name = self::$mock_name;
|
||||
$result->address = self::$mock_address;
|
||||
@@ -214,6 +228,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 +238,7 @@ namespace objects {
|
||||
{
|
||||
self::$mock_existing_customer_numbers = [];
|
||||
self::$mock_importable_customer_numbers = [];
|
||||
self::$mock_external_lookup_enabled = true;
|
||||
self::$interaction_log = [];
|
||||
}
|
||||
|
||||
@@ -242,7 +258,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 +274,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;
|
||||
@@ -391,6 +424,58 @@ namespace {
|
||||
'expected_error' => 'Parameter cvr must be at least 8 characters long',
|
||||
'expected_status' => 400,
|
||||
],
|
||||
[
|
||||
'name' => 'CVR lookup failure returns validation error without creating customer',
|
||||
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||
'setup' => static function (): void {
|
||||
\classes\virkdata::$mock_exception = new \RuntimeException('An error occurred');
|
||||
},
|
||||
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
|
||||
'expected_status' => 400,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup failures must not create e-conomic customers.');
|
||||
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup failures must not send welcome emails.');
|
||||
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup failures must not send superuser notifications.');
|
||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup failures must not send Slack customer registration notifications.');
|
||||
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup failures should be logged for diagnostics.');
|
||||
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_FAILED', 'CVR lookup failure should use the lookup failure log event.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'CVR lookup without company name returns validation error without creating customer',
|
||||
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||
'setup' => static function (): void {
|
||||
\classes\virkdata::$mock_name = '';
|
||||
},
|
||||
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
|
||||
'expected_status' => 400,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup responses without a company name must not create e-conomic customers.');
|
||||
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup responses without a company name must not send welcome emails.');
|
||||
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup responses without a company name must not send superuser notifications.');
|
||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup responses without a company name must not send Slack customer registration notifications.');
|
||||
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup responses without a company name should be logged for diagnostics.');
|
||||
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', 'CVR lookup response without a company name should use the invalid response log event.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'CVR lookup failure takes precedence over local customer number collision',
|
||||
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||
'setup' => static function (): void {
|
||||
\classes\virkdata::$mock_exception = new \RuntimeException('An error occurred');
|
||||
\objects\users_o::$mock_existing_customer_numbers = [12345678];
|
||||
},
|
||||
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
|
||||
'expected_status' => 400,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup failures with local collisions must not create e-conomic customers.');
|
||||
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup failures with local collisions must not send welcome emails.');
|
||||
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup failures with local collisions must not send superuser notifications.');
|
||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup failures with local collisions must not send Slack customer registration notifications.');
|
||||
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup failures with local collisions should be logged once.');
|
||||
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_FAILED', 'CVR lookup failure should not be masked by the local duplicate check.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'Existing company phone with local customer stays blocked',
|
||||
'params' => $baseParams,
|
||||
@@ -509,6 +594,88 @@ 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' => 'Generic create failure recovers a confirmed just-created e-conomic customer',
|
||||
'params' => $baseParams,
|
||||
'setup' => static function (): void {
|
||||
\classes\economic::$mock_create_exception = new \RuntimeException('e-conomic request failed with HTTP 400: Validation failed. | details={"httpStatusCode":400}');
|
||||
\classes\economic::$mock_collection_after_create_exception = [
|
||||
(object)[
|
||||
'customerNumber' => 12345678,
|
||||
'name' => 'Recovered After Generic Create Failure',
|
||||
'email' => 'test@test.com',
|
||||
],
|
||||
];
|
||||
\objects\users_o::$mock_importable_customer_numbers = [12345678];
|
||||
},
|
||||
'expected_success' => (object)[
|
||||
'customerNumber' => 12345678,
|
||||
'name' => 'Recovered After Generic Create Failure',
|
||||
'email' => 'test@test.com',
|
||||
],
|
||||
'expected_status' => 200,
|
||||
'assert' => static function (): void {
|
||||
assert_true(count(\classes\economic::$create_calls) === 1, 'Generic create recovery must still record the attempted create call.');
|
||||
assert_true(count(\classes\economic::$search_calls) === 2, 'Generic create recovery must confirm the customer by searching e-conomic again.');
|
||||
assert_true(count(\classes\email::$sent) === 2, 'Generic create recovery must send two welcome emails.');
|
||||
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Generic create recovery must notify opted-in superusers once.');
|
||||
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Generic create recovery must notify Slack once.');
|
||||
},
|
||||
],
|
||||
[
|
||||
'name' => 'Fresh create mismatch returns conflict without local bootstrap or email',
|
||||
'params' => $baseParams,
|
||||
@@ -544,6 +711,7 @@ namespace {
|
||||
\classes\virkdata::$mock_zipcode = 2630;
|
||||
\classes\virkdata::$mock_city = 'Taastrup';
|
||||
\classes\virkdata::$mock_website = 'https://demo.test';
|
||||
\classes\virkdata::$mock_exception = null;
|
||||
\objects\users_o::reset();
|
||||
\objects\logs_o::reset();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user