Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 | ||
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a |
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
||||
|
||||
# Copy Nginx configuration file
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -47,6 +47,7 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
|
||||
+10
-1
@@ -128,7 +128,16 @@ tar \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
||||
{
|
||||
$session = $reg !== null
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||
@@ -438,7 +438,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
if (!$session->markCompletedIfOpen($orderId)) {
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
if ($disableRelays) {
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
}
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||
|
||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
||||
|
||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
||||
|
||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ Public methods:
|
||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
||||
|
||||
Key implementation details:
|
||||
|
||||
|
||||
@@ -538,7 +538,8 @@ trait selfserve_lane_command_t
|
||||
$this->id,
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Session completion must not block STOP flow.
|
||||
|
||||
@@ -18,13 +18,18 @@ class customer_password_reset_keys_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
const TOKEN_LENGTH = 32;
|
||||
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
$this->setTable('customer_password_reset_keys');
|
||||
}
|
||||
|
||||
private function validTokenWhereClause(): string
|
||||
{
|
||||
return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new customer reset key
|
||||
@@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db
|
||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
// Query the database for a valid token
|
||||
$current_time = date('Y-m-d H:i:s');
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1";
|
||||
// Query the database for a valid token using the same clock that writes created_at.
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows === 0) {
|
||||
return null;
|
||||
@@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db
|
||||
*/
|
||||
public function isValidToken(): bool
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$created_at = strtotime($this->created_at->value());
|
||||
$current_time = time();
|
||||
return (
|
||||
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
||||
($this->deleted_at->value() === null
|
||||
);
|
||||
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
return $result->num_rows > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +527,10 @@ class users_o extends db
|
||||
|
||||
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;
|
||||
@@ -538,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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -440,13 +440,36 @@ 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 ?? '');
|
||||
try {
|
||||
$result = $economic->createCustomer(
|
||||
(int)$companyPhone,
|
||||
@@ -462,8 +485,7 @@ class authRoute
|
||||
$economic,
|
||||
(string)$cvr,
|
||||
$companyPhone,
|
||||
(string)$invoiceEmail,
|
||||
$exception
|
||||
(string)$invoiceEmail
|
||||
);
|
||||
|
||||
if ($recoveredCustomer !== null) {
|
||||
@@ -548,7 +570,8 @@ class authRoute
|
||||
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
||||
|
||||
$subject = 'Adgangskode nulstilling';
|
||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i 1 time.";
|
||||
$valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600);
|
||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i $valid_hours timer.";
|
||||
|
||||
try {
|
||||
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
||||
@@ -788,13 +811,10 @@ class authRoute
|
||||
economic $economic,
|
||||
string $cvr,
|
||||
int $customerNumber,
|
||||
string $invoiceEmail,
|
||||
Exception $exception
|
||||
string $invoiceEmail
|
||||
): ?object {
|
||||
if (!$this->isRecoverableEconomicDuplicateError($exception)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -818,15 +838,6 @@ class authRoute
|
||||
return $matchingEconomicCustomer;
|
||||
}
|
||||
|
||||
private function isRecoverableEconomicDuplicateError(Exception $exception): bool
|
||||
{
|
||||
$message = strtolower($exception->getMessage());
|
||||
|
||||
return str_contains($message, 'already exists')
|
||||
|| str_contains($message, 'already exist')
|
||||
|| str_contains($message, 'duplicate');
|
||||
}
|
||||
|
||||
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||
{
|
||||
foreach ($customers as $customer) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,105 @@
|
||||
<?php
|
||||
|
||||
use objects\customer_password_reset_keys_o;
|
||||
|
||||
app_require('objects/customer_password_reset_keys_o.php');
|
||||
|
||||
if (!class_exists('PasswordResetTokenExpiryFakeResult')) {
|
||||
class PasswordResetTokenExpiryFakeResult
|
||||
{
|
||||
public int $num_rows;
|
||||
|
||||
public function __construct(private readonly array $rows)
|
||||
{
|
||||
$this->num_rows = count($rows);
|
||||
}
|
||||
|
||||
public function fetch_assoc(): ?array
|
||||
{
|
||||
return $this->rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('PasswordResetTokenExpiryFakeDb')) {
|
||||
class PasswordResetTokenExpiryFakeDb
|
||||
{
|
||||
public array $queries = [];
|
||||
|
||||
public function __construct(private readonly array $results)
|
||||
{
|
||||
}
|
||||
|
||||
public function escape_string(string $string): string
|
||||
{
|
||||
return addslashes($string);
|
||||
}
|
||||
|
||||
public function query(string $sql): PasswordResetTokenExpiryFakeResult
|
||||
{
|
||||
$this->queries[] = $sql;
|
||||
|
||||
return $this->results[count($this->queries) - 1] ?? new PasswordResetTokenExpiryFakeResult([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
||||
class PasswordResetTokenExpiryProbe extends customer_password_reset_keys_o
|
||||
{
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function forceSelectedId(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousDb !== null) {
|
||||
$GLOBALS['db'] = $this->previousDb;
|
||||
return;
|
||||
}
|
||||
|
||||
unset($GLOBALS['db']);
|
||||
});
|
||||
|
||||
it('keeps password reset tokens valid for 72 hours', function (): void {
|
||||
expect(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS)->toBe(72 * 60 * 60);
|
||||
});
|
||||
|
||||
it('looks up reset tokens using the database 72 hour validity window', function (): void {
|
||||
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
||||
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
||||
]);
|
||||
|
||||
$token = str_repeat('a', customer_password_reset_keys_o::TOKEN_LENGTH);
|
||||
$probe = new PasswordResetTokenExpiryProbe();
|
||||
|
||||
$found = $probe->findValidByToken($token);
|
||||
|
||||
expect($found)->toBe($probe)
|
||||
->and($probe->id)->toBe(42)
|
||||
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)')
|
||||
->and($GLOBALS['db']->queries[0])->not->toContain("DATE_SUB('");
|
||||
});
|
||||
|
||||
it('uses the same database 72 hour window for the selected token guard', function (): void {
|
||||
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
||||
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
||||
]);
|
||||
|
||||
$probe = new PasswordResetTokenExpiryProbe();
|
||||
$probe->forceSelectedId(42);
|
||||
|
||||
expect($probe->isValidToken())->toBeTrue()
|
||||
->and($GLOBALS['db']->queries[0])->toContain('id = 42')
|
||||
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)');
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
function phpFpmWorkerConfigRepoRoot(): string
|
||||
{
|
||||
$configuredRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
|
||||
if (is_string($configuredRoot) && $configuredRoot !== '') {
|
||||
return rtrim($configuredRoot, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
$appRoot = defined('WD') ? WD : dirname(__DIR__, 3);
|
||||
|
||||
return dirname($appRoot, 3);
|
||||
}
|
||||
|
||||
function phpFpmWorkerConfigRepoPath(string $relative): string
|
||||
{
|
||||
return phpFpmWorkerConfigRepoRoot() . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative);
|
||||
}
|
||||
|
||||
function phpFpmWorkerConfigValue(string $poolConfig, string $key): ?int
|
||||
{
|
||||
if (!preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(\d+)\s*$/m', $poolConfig, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
it('configures PHP-FPM with multiple warm request workers', function (): void {
|
||||
$poolPath = phpFpmWorkerConfigRepoPath('services/php/php-fpm-pool.conf');
|
||||
expect(is_file($poolPath))->toBeTrue();
|
||||
|
||||
$poolConfig = (string)file_get_contents($poolPath);
|
||||
|
||||
expect($poolConfig)->toContain('[www]')
|
||||
->and($poolConfig)->toContain('pm = dynamic')
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_children'))->toBeGreaterThanOrEqual(8)
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.start_servers'))->toBeGreaterThanOrEqual(4)
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.min_spare_servers'))->toBeGreaterThanOrEqual(4)
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_spare_servers'))->toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('copies the worker pool config into every API PHP image', function (): void {
|
||||
$copyInstruction = 'COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf';
|
||||
$dockerfiles = [
|
||||
phpFpmWorkerConfigRepoPath('Dockerfile'),
|
||||
phpFpmWorkerConfigRepoPath('Dockerfile.coolify-api'),
|
||||
phpFpmWorkerConfigRepoPath('services/php/Dockerfile'),
|
||||
];
|
||||
|
||||
foreach ($dockerfiles as $dockerfile) {
|
||||
expect(is_file($dockerfile))->toBeTrue();
|
||||
expect((string)file_get_contents($dockerfile))->toContain($copyInstruction);
|
||||
}
|
||||
});
|
||||
@@ -79,6 +79,8 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
|
||||
|
||||
expect($washFlow)->not->toBeFalse();
|
||||
expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);');
|
||||
expect($washFlow)->toContain('bool $disableRelays = true');
|
||||
expect($washFlow)->toContain('if ($disableRelays) {');
|
||||
|
||||
$methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash');
|
||||
expect($methodOffset)->not->toBeFalse();
|
||||
@@ -108,3 +110,22 @@ it('always dispatches completion relay off for configured machine relays without
|
||||
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normal STOP completion skips duplicate completion relay cleanup after STOP already disabled relays', function (): void {
|
||||
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
|
||||
|
||||
expect($commandTrait)->not->toBeFalse();
|
||||
$methodOffset = strpos($commandTrait, 'protected function completeLatestSessionForStop(): void');
|
||||
expect($methodOffset)->not->toBeFalse();
|
||||
|
||||
$methodBody = substr($commandTrait, (int)$methodOffset, 1500);
|
||||
expect($methodBody)->toContain(<<<'PHP'
|
||||
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
||||
$this->id,
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
);
|
||||
PHP);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -142,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;
|
||||
@@ -419,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,
|
||||
@@ -591,6 +648,34 @@ namespace {
|
||||
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,
|
||||
@@ -626,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();
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ WORKDIR /var/www/html
|
||||
|
||||
# Copy and enable entrypoint that installs Composer deps on first run
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[www]
|
||||
pm = dynamic
|
||||
pm.max_children = 8
|
||||
pm.start_servers = 4
|
||||
pm.min_spare_servers = 4
|
||||
pm.max_spare_servers = 8
|
||||
pm.max_requests = 500
|
||||
Reference in New Issue
Block a user