Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d605eca574 | ||
|
|
7e85c74e60 | ||
|
|
67d62eff70 | ||
|
|
8ebbd52a99 | ||
|
|
6d6cc501db | ||
|
|
ce999afbb3 | ||
|
|
cb34b030c8 |
@@ -164,7 +164,30 @@ jobs:
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
composer_install() {
|
||||
install_mode="$1"
|
||||
max_attempts="$2"
|
||||
attempt=1
|
||||
while :; do
|
||||
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
composer_install --prefer-dist 3 || {
|
||||
echo "Composer dist install failed; retrying with --prefer-source." >&2
|
||||
composer_install --prefer-source 2
|
||||
}
|
||||
|
||||
- name: Verify edge gateway test files
|
||||
run: >
|
||||
|
||||
@@ -6266,6 +6266,33 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/order-bookings/completion-confirmation/resend:
|
||||
post:
|
||||
tags:
|
||||
- Bookings
|
||||
summary: Resend order booking completion confirmation
|
||||
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
|
||||
operationId: resendOrderBookingCompletionConfirmation
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: {type: integer}
|
||||
responses:
|
||||
'200':
|
||||
description: Completion confirmation resent successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
|
||||
/order-bookings/complete:
|
||||
post:
|
||||
tags:
|
||||
|
||||
+17
-2
@@ -68,6 +68,22 @@ retry_command() {
|
||||
done
|
||||
}
|
||||
|
||||
composer_install() {
|
||||
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
|
||||
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
|
||||
|
||||
if retry_command "$dist_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
|
||||
retry_command "$source_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
@@ -112,8 +128,7 @@ 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 \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
composer_install
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
|
||||
@@ -133,8 +133,14 @@ class attachments implements attachments_i
|
||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||
{
|
||||
$options = $this->normalizeAttachmentOptions($options);
|
||||
$rawType = trim($type, '`');
|
||||
$objectTypes = array_values(array_unique([
|
||||
$rawType,
|
||||
'`' . $rawType . '`',
|
||||
]));
|
||||
|
||||
return (new object_attachments_o())->getFieldsWhereIn([
|
||||
'object_type' => $type,
|
||||
'object_type' => $objectTypes,
|
||||
'object_id' => $object_ids,
|
||||
'deleted_at' => null
|
||||
], $options);
|
||||
|
||||
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||
{
|
||||
if (self::isFakeDeliveryEnabled()) {
|
||||
self::$fake_deliveries[] = [
|
||||
self::recordFakeDelivery([
|
||||
'to' => $to,
|
||||
'recipient_name' => $recipient_name,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'html' => $html,
|
||||
];
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
public static function resetFakeDeliveries(): void
|
||||
{
|
||||
self::$fake_deliveries = [];
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path !== null && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function syncFakeDeliveries(): void
|
||||
{
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null || !is_file($path)) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveries = [];
|
||||
foreach ($lines as $line) {
|
||||
$delivery = json_decode($line, true);
|
||||
if (is_array($delivery)) {
|
||||
$deliveries[] = $delivery;
|
||||
}
|
||||
}
|
||||
|
||||
self::$fake_deliveries = $deliveries;
|
||||
}
|
||||
|
||||
private static function recordFakeDelivery(array $delivery): void
|
||||
{
|
||||
self::$fake_deliveries[] = $delivery;
|
||||
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function getFakeDeliveriesPath(): ?string
|
||||
{
|
||||
if (!self::isFakeDeliveryEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
|
||||
if ($configuredPath !== '') {
|
||||
return $configuredPath;
|
||||
}
|
||||
|
||||
if (getenv('RUN_API_TESTS') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR
|
||||
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
|
||||
}
|
||||
|
||||
private static function isFakeDeliveryEnabled(): bool
|
||||
|
||||
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
|
||||
*/
|
||||
public function download(string $file): string
|
||||
{
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
return $this->getLocalTestObjectPath($file);
|
||||
}
|
||||
|
||||
$path = '/tmp/' . $file;
|
||||
$result = self::getS3Client()->getObject([
|
||||
self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $file,
|
||||
'SaveAs' => $path
|
||||
|
||||
@@ -6266,6 +6266,33 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/order-bookings/completion-confirmation/resend:
|
||||
post:
|
||||
tags:
|
||||
- Bookings
|
||||
summary: Resend order booking completion confirmation
|
||||
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
|
||||
operationId: resendOrderBookingCompletionConfirmation
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: {type: integer}
|
||||
responses:
|
||||
'200':
|
||||
description: Completion confirmation resent successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
|
||||
/order-bookings/complete:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@@ -392,6 +392,37 @@ class orderBookingRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/order-bookings/completion-confirmation/resend', function () {
|
||||
global $response;
|
||||
|
||||
$object = self::getTargetObject();
|
||||
if (!$object || !$object->exists()) {
|
||||
$response->error('Order booking does not exist.', 400);
|
||||
}
|
||||
|
||||
self::requirePermission('complete_bookings');
|
||||
self::requireDepartmentAccess((int)$object->department->value());
|
||||
|
||||
if (!$object->hasTransaction()) {
|
||||
$response->error('Order booking has not been completed yet.', 409);
|
||||
}
|
||||
|
||||
if (!$object->getOrder()->hasWashCertificateAttached()) {
|
||||
$response->error('Order booking completion confirmation is not available yet.', 409);
|
||||
}
|
||||
|
||||
(new email())->sendWashCertificateEmailToCustomer($object);
|
||||
|
||||
$response->success([
|
||||
'message' => 'Completion confirmation resent successfully.',
|
||||
'booking' => $object->asArray(),
|
||||
]);
|
||||
},
|
||||
[
|
||||
'complete_bookings' => 'Permission for department admins to resend order booking completion confirmation emails.'
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/order-bookings/complete', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\email;
|
||||
use classes\pdf_store;
|
||||
|
||||
putenv('EMAIL_FAKE_MODE=1');
|
||||
|
||||
usesApiSuite();
|
||||
@@ -76,3 +79,97 @@ it('requires department access when resending order booking confirmations', func
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . $department['id']]);
|
||||
});
|
||||
|
||||
it('allows department admins to resend order booking completion confirmations', function (): void {
|
||||
email::resetFakeDeliveries();
|
||||
|
||||
$customer = api_fixtures()->createUser([
|
||||
'display_name' => 'Resend Completion Confirmation Customer',
|
||||
'email' => 'resend-completion-confirmation@example.test',
|
||||
]);
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Resend Completion Confirmation Department',
|
||||
]);
|
||||
$cashier = api_fixtures()->createUser([
|
||||
'display_name' => 'Resend Completion Confirmation Cashier',
|
||||
]);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'department_id' => $department['id'],
|
||||
]);
|
||||
$booking = api_fixtures()->createOrderBooking([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'department' => $department['id'],
|
||||
'reference' => 'RESEND-COMPLETION',
|
||||
'reg_1' => 'DONE1',
|
||||
'order_id' => $order['id'],
|
||||
]);
|
||||
api_fixtures()->createOrderAttachment([
|
||||
'order_id' => $order['id'],
|
||||
'content' => json_encode([
|
||||
'document' => 'completion-confirmation-test.pdf',
|
||||
'other' => 'wash_certificate',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
(new pdf_store())->createObject('completion-confirmation-test.pdf', '%PDF-1.4 test completion confirmation');
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'complete_bookings',
|
||||
'department_access_' . $department['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->post('/order-bookings/completion-confirmation/resend', [
|
||||
'id' => $booking['id'],
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('message', 'Completion confirmation resent successfully.')
|
||||
->toHaveKey('booking')
|
||||
->and($response->data()['booking'])
|
||||
->toBeArray()
|
||||
->and($response->data()['booking']['id'] ?? null)
|
||||
->toBe($booking['id'])
|
||||
->and(email::$fake_deliveries)
|
||||
->toHaveCount(1)
|
||||
->and(email::$fake_deliveries[0]['to'] ?? null)
|
||||
->toBe('resend-completion-confirmation@example.test')
|
||||
->and(email::$fake_deliveries[0]['subject'] ?? '')
|
||||
->toContain('RESEND-COMPLETION');
|
||||
});
|
||||
|
||||
it('requires department access when resending order booking completion confirmations', function (): void {
|
||||
$customer = api_fixtures()->createUser([
|
||||
'display_name' => 'Resend Completion Confirmation Foreign Customer',
|
||||
]);
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Resend Completion Confirmation Foreign Department',
|
||||
]);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
]);
|
||||
$booking = api_fixtures()->createOrderBooking([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'department' => $department['id'],
|
||||
'order_id' => $order['id'],
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'complete_bookings',
|
||||
]);
|
||||
|
||||
$response = api_client()->post('/order-bookings/completion-confirmation/resend', [
|
||||
'id' => $booking['id'],
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . $department['id']]);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,10 @@ final class ApiClient
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if (getenv('EMAIL_FAKE_MODE') === '1' && class_exists(\classes\email::class)) {
|
||||
\classes\email::syncFakeDeliveries();
|
||||
}
|
||||
|
||||
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('wires the order booking completion confirmation resend endpoint', function (): void {
|
||||
$routeFile = app_path('routes/orderBookingRoute.php');
|
||||
|
||||
expect(is_file($routeFile))->toBeTrue();
|
||||
|
||||
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
|
||||
|
||||
expect($routeCode)
|
||||
->toContain("$" . "this->post('/order-bookings/completion-confirmation/resend', function () {")
|
||||
->toContain("self::requirePermission('complete_bookings');")
|
||||
->toContain('self::requireDepartmentAccess((int)$object->department->value());')
|
||||
->toContain('(new email())->sendWashCertificateEmailToCustomer($object);')
|
||||
->toContain("'Completion confirmation resent successfully.'");
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/email.php');
|
||||
|
||||
use classes\email;
|
||||
|
||||
it('syncs fake email deliveries written by another process', function (): void {
|
||||
$previousFakeMode = getenv('EMAIL_FAKE_MODE');
|
||||
$previousFakePath = getenv('EMAIL_FAKE_DELIVERIES_PATH');
|
||||
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-fake-email-sync-' . bin2hex(random_bytes(4)) . '.jsonl';
|
||||
|
||||
putenv('EMAIL_FAKE_MODE=1');
|
||||
putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $path);
|
||||
email::resetFakeDeliveries();
|
||||
|
||||
try {
|
||||
file_put_contents($path, json_encode([
|
||||
'to' => 'customer@example.test',
|
||||
'recipient_name' => 'Customer',
|
||||
'subject' => 'Subject',
|
||||
'message' => '',
|
||||
'html' => '<p>Body</p>',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
email::syncFakeDeliveries();
|
||||
|
||||
expect(email::$fake_deliveries)
|
||||
->toHaveCount(1)
|
||||
->and(email::$fake_deliveries[0]['to'] ?? null)
|
||||
->toBe('customer@example.test')
|
||||
->and(email::$fake_deliveries[0]['subject'] ?? null)
|
||||
->toBe('Subject');
|
||||
} finally {
|
||||
email::resetFakeDeliveries();
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
if ($previousFakeMode === false) {
|
||||
putenv('EMAIL_FAKE_MODE');
|
||||
} else {
|
||||
putenv('EMAIL_FAKE_MODE=' . $previousFakeMode);
|
||||
}
|
||||
if ($previousFakePath === false) {
|
||||
putenv('EMAIL_FAKE_DELIVERIES_PATH');
|
||||
} else {
|
||||
putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $previousFakePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use classes\pdf_store;
|
||||
|
||||
it('falls back to local test storage when MinIO config values are empty', function (): void {
|
||||
global $MINIO;
|
||||
|
||||
$previousRunApiTests = getenv('RUN_API_TESTS');
|
||||
$previousMinio = $MINIO ?? null;
|
||||
putenv('RUN_API_TESTS=1');
|
||||
$MINIO = [
|
||||
'endpoint' => null,
|
||||
'access_key' => null,
|
||||
'secret_key' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$file = 'minio-local-test-' . bin2hex(random_bytes(4)) . '.pdf';
|
||||
$store = new pdf_store();
|
||||
|
||||
expect($store->createObject($file, 'local-pdf-content'))->toBeTrue();
|
||||
|
||||
$path = $store->download($file);
|
||||
|
||||
expect(is_file($path))->toBeTrue()
|
||||
->and(file_get_contents($path))->toBe('local-pdf-content');
|
||||
} finally {
|
||||
if (isset($path) && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
if ($previousRunApiTests === false) {
|
||||
putenv('RUN_API_TESTS');
|
||||
} else {
|
||||
putenv('RUN_API_TESTS=' . $previousRunApiTests);
|
||||
}
|
||||
$MINIO = $previousMinio;
|
||||
}
|
||||
});
|
||||
@@ -76,7 +76,7 @@ trait minio_t
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
global $MINIO;
|
||||
return $MINIO['endpoint'];
|
||||
return is_array($MINIO ?? null) ? (string)($MINIO['endpoint'] ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ trait minio_t
|
||||
public function getAccessKey(): string
|
||||
{
|
||||
global $MINIO;
|
||||
return $MINIO['access_key'];
|
||||
return is_array($MINIO ?? null) ? (string)($MINIO['access_key'] ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +96,7 @@ trait minio_t
|
||||
public function getSecretKey(): string
|
||||
{
|
||||
global $MINIO;
|
||||
return $MINIO['secret_key'];
|
||||
return is_array($MINIO ?? null) ? (string)($MINIO['secret_key'] ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user