Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard e6a18ce5d8 Add Slack customer registration webhook test endpoint 2026-06-11 15:06:31 +02:00
Jeppe B b5c24ef80a Merge pull request #280 from copenhagentruckwash/fix/self-serve-path-outcome-case-limit
Fix self-serve path outcome case limit
2026-06-11 14:57:16 +02:00
Jeppe Bundgaard df7153a5ba Fix self-serve path outcome case limit 2026-06-11 14:46:27 +02:00
Jeppe Bundgaard d06c78119b Fix customer registration duplicate recovery 2026-06-11 12:04:56 +02:00
Jeppe B bdb1a0074b Merge pull request #279 from copenhagentruckwash/fix/self-serve-customer-property-gates
Allow customers to open property gates for active washes
2026-06-10 22:00:06 +02:00
10 changed files with 531 additions and 109 deletions
+54 -2
View File
@@ -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.";
}
}
@@ -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 = [];
+39 -32
View File
@@ -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;
+36 -3
View File
@@ -11665,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]
@@ -15190,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:
@@ -15466,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'
@@ -18441,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
+106 -23
View File
@@ -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,36 @@ 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
);
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 +508,7 @@ class authRoute
);
}
$this->bootstrapLocalCustomerOrFail($companyPhone);
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
$response->success($result, 201);
});
@@ -758,6 +771,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 +841,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');
@@ -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');
});
@@ -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,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,