Files
api/services/nginx/app/tests/Api/OrderItemsApiTest.php
T
2026-08-13 15:55:58 +00:00

939 lines
36 KiB
PHP

<?php
declare(strict_types=1);
usesApiSuite();
function create_order_item_rule_fixture(array $customerAttributes = []): array
{
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
foreach ($customerAttributes as $attribute) {
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
}
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'RULE-CHECK',
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
return [
'customer' => $customer,
'department' => $department,
'order' => $order,
'session' => $session,
];
}
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
{
return api_client()->post('/order/items', array_merge([
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $overrides), $headers);
}
function configure_customer_rule_product(string $attribute, int $productId): void
{
new \classes\customer_rule_product_restriction_service();
$db = api_test_runtime()->db();
$safeAttribute = $db->real_escape_string($attribute);
$result = $db->query(
"SELECT id FROM customer_rule_product_collections
WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
);
$collectionId = $result && $result->num_rows > 0 ? (int)$result->fetch_assoc()['id'] : 0;
if ($collectionId < 1) {
$db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', 'API exact restriction', 0)"
);
$collectionId = (int)$db->insert_id;
}
$db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
);
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
'collection_id' => $collectionId,
'product_id' => $productId,
]);
}
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
VALUES (?, 0, ?, ?)'
);
$productIdText = (string)$productId;
$statement->bind_param('isi', $userId, $productIdText, $percentage);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('price_overrides', [
'user_id' => $userId,
'is_category' => 0,
'product_or_category_id' => $productIdText,
]);
}
function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'NOTE-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => \classes\order_item_reason_policy::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => ' ',
'extra_sale_reason_code' => 'customer_request',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Graffiti removal on left side',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Extra sale reason code is required for this product');
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Graffiti removal on left side',
'reason_code' => 'customer_approved_extra_work',
'reason_comment' => 'Graffiti removal on left side',
'extra_sale_reason_code' => 'customer_request',
'extra_sale_comment' => 'Graffiti removal on left side',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
expect($response->data()['reason_code'] ?? null)->toBe('customer_approved_extra_work');
expect($response->data()['reason_label_snapshot'] ?? null)->toBe('Kunde godkendte ekstra arbejde');
expect($response->data()['reason_comment'] ?? null)->toBe('Graffiti removal on left side');
expect($response->data()['extra_sale_reason_code'] ?? null)->toBe('customer_request');
expect($response->data()['extra_sale_comment'] ?? null)->toBe('Graffiti removal on left side');
});
it('requires valid approved reason data for audited add-on order items', function (array $payload, string $message): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Reason Required Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'REASON-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => \classes\order_item_reason_policy::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', array_merge([
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Extra wash work',
], $payload), $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage($message);
})->with([
'missing code' => [[], 'Reason code is required for this product'],
'invalid code' => [['reason_code' => 'not_approved', 'reason_comment' => 'Extra wash work'], 'Reason code is invalid for this product'],
'deprecated code' => [['reason_code' => 'legacy_note_only', 'reason_comment' => 'Extra wash work'], 'Reason code is deprecated for this product'],
'missing comment' => [['reason_code' => 'customer_approved_extra_work', 'reason_comment' => ' ', 'notes' => ''], 'Reason comment is required for this product'],
]);
it('requires reason data when editing audited add-on order items', function (): void {
api_test_covers('PUT /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Reason Edit Customer']);
$department = api_fixtures()->createDepartment();
$product = api_fixtures()->createProduct([
'id' => \classes\order_item_reason_policy::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'REASON-EDIT',
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$created = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Initial reason',
'reason_code' => 'customer_approved_extra_work',
'reason_comment' => 'Initial reason',
], $session['headers'])->data();
api_client()->put('/order/items', [
'id' => $created['id'],
'price' => 299,
'notes' => 'Updated text only',
'reference' => '',
'quantity' => 1,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Reason code is required for this product');
api_client()->put('/order/items', [
'id' => $created['id'],
'price' => 299,
'notes' => 'Updated text with reason',
'reference' => '',
'quantity' => 1,
'reason_code' => 'quality_rework',
'reason_comment' => 'Updated text with reason',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('rejects unapproved and comment-missing extraordinary chemistry reasons', function (): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Audit Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'AUDIT-REASON',
]);
$product = api_fixtures()->createProduct([
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Extra time',
'extra_sale_reason_code' => 'manual_override',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Extra sale reason code is not approved');
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Extra time',
'extra_sale_reason_code' => 'other',
'extra_sale_comment' => ' ',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Extra sale comment is required for this reason');
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Extra time',
'extra_sale_reason_code' => 'other',
'extra_sale_comment' => 'Customer approved a non-standard extra wash.',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['extra_sale_reason_code'] ?? null)->toBe('other');
expect($response->data()['extra_sale_comment'] ?? null)->toBe('Customer approved a non-standard extra wash.');
});
it('uses a product fixed price instead of the best discount when adding an order item', function (): void {
api_test_covers('POST /order/items', 'pricing');
api_test_covers('GET /products', 'pricing');
$customer = api_fixtures()->createUser(['display_name' => 'Fixed Price Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Fixed Price Cashier']);
$category = api_fixtures()->createCategory(['name' => 'Fixed Price Category']);
$product = api_fixtures()->createProduct([
'name' => 'Fixed Price Product',
'price' => 1000,
'category' => $category['id'],
'apply_category_discount' => 1,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 1,
'product_or_category_id' => (string)$category['id'],
'percentage' => 80,
]);
api_fixtures()->createPriceOverride([
'user_id' => $customer['id'],
'is_category' => 0,
'product_or_category_id' => (string)$product['id'],
'percentage' => 10,
'fixed_price' => 350,
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE',
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->data()['price'] ?? null)->toBe(350);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['price'] ?? null)->toBe(350);
});
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
api_test_covers('POST /order/items', 'customer_rules');
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ONLY-TANK',
]);
$washProduct = api_fixtures()->createProduct([
'name' => 'Forvogn',
'price' => 649,
'category' => 4,
]);
$tankCleaningProduct = api_fixtures()->createProduct([
'name' => 'Saebe/kemi, 1-4 spulehoveder',
'price' => 299,
'category' => 5,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
configure_customer_rule_product('onlyTankCleaning', (int)$washProduct['id']);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $washProduct['id'],
'quantity' => 1,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $tankCleaningProduct['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
});
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
api_test_covers('POST /order/items', 'customer_rules');
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'REGULAR-WASH',
]);
$washProduct = api_fixtures()->createProduct([
'name' => 'Forvogn',
'price' => 649,
'category' => 4,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $washProduct['id'],
'quantity' => 1,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
});
it('does not allow clearing notes for order items whose product requires notes', function (): void {
api_test_covers('PUT /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'NOTE-EDIT',
]);
$product = api_fixtures()->createProduct([
'id' => 902702,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 199,
'requires_note' => 1,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => $cashier['id'],
'price' => 199,
'quantity' => 1,
'notes' => 'Initial note',
'extra_sale_reason_code' => 'customer_request',
]);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client()
->put('/order/items', [
'id' => $orderItem['id'],
'price' => 199,
'quantity' => 1,
'reference' => '',
'notes' => '',
'extra_sale_reason_code' => 'customer_request',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
api_client()
->put('/order/items', [
'id' => $orderItem['id'],
'price' => 199,
'quantity' => 1,
'reference' => '',
'notes' => 'Initial note',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Extra sale reason code is required for this product');
});
it('allows empty notes for primary products that do not require a note', function (): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Empty Notes Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Empty Notes Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'EMPTY-NOTES',
]);
// Primary product with requires_note = 0 and not the extraordinary chemistry product.
$product = api_fixtures()->createProduct([
'id' => 1701,
'name' => 'Standard Wash',
'price' => 929,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_order_items', 'department_access_' . $department['id']]);
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'price' => 929,
'notes' => '',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['product_id'] ?? null)->toBe(1701);
expect($response->data()['notes'] ?? null)->toBe('');
expect($response->data()['reason_code'] ?? 'not_set')->toBe('not_set');
});
it('still rejects empty notes for products whose requires_note flag is enabled', function (): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Notes Required Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Notes Required Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'NOTES-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => 1702,
'name' => 'Requires-Note Wash',
'price' => 199,
'requires_note' => 1,
]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'price' => 199,
'notes' => ' ',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
});
it('returns the extraordinary chemistry product with requires_note enabled', function (): void {
api_test_covers('GET /products', 'happy');
$product = api_fixtures()->createProduct([
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get('/products?id=' . $product['id'], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['requires_note'] ?? null)->toBeTrue();
expect($response->data()['requires_extra_sale_audit'] ?? null)->toBeTrue();
});
it('blocks standalone category 8 products for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Extra detergent',
'category' => 8,
'price' => 50,
]);
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'notes' => 'Addon customer rule check',
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('allows standalone category 8 products when the customer is not restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture();
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary unrestricted truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Unrestricted additional service',
'category' => 8,
'price' => 50,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('blocks configured related addon order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary related truck wash',
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Related extra brush',
'category' => 8,
'price' => 35,
]);
$primaryItem = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $primaryProduct['id'],
'cashier_id' => $cashier['id'],
'price' => 200,
]);
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('still blocks related add-ons covered by a specific customer product rule', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictInteriorCleaning']);
$cashier = api_fixtures()->createUser(['display_name' => 'Specific Related Rule Cashier']);
$primaryProduct = api_fixtures()->createProduct(['name' => 'Primary truck wash', 'price' => 200]);
$interiorProduct = api_fixtures()->createProduct(['name' => 'Indvendig vask Forvogn', 'category' => 4, 'price' => 35]);
$primaryItem = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $primaryProduct['id'],
'cashier_id' => $cashier['id'],
'price' => 200,
]);
configure_customer_rule_product('restrictInteriorCleaning', (int)$interiorProduct['id']);
post_order_item($fixture['order'], $interiorProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('does not infer additional-service restrictions from category 4, product names, or existing order items', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$primaryProduct = api_fixtures()->createProduct([
'name' => 'Primary wash',
'category' => 4,
'price' => 200,
]);
$namedAddonProduct = api_fixtures()->createProduct([
'name' => 'Trailer add-on',
'category' => 4,
'price' => 35,
]);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
post_order_item($fixture['order'], $namedAddonProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('does not infer additional-service restrictions from the legacy Tillægsydelser category after migration', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
$category = api_fixtures()->createCategory(['name' => 'Tillægsydelser']);
$product = api_fixtures()->createProduct([
'name' => 'Legacy additional service',
'category' => $category['id'],
'price' => 35,
]);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('validates that related order items exist, are active, and belong to the target order', function (): void {
api_test_covers('POST /order/items', 'related-item-validation');
$fixture = create_order_item_rule_fixture();
$cashier = api_fixtures()->createUser(['display_name' => 'Related Item Cashier']);
$product = api_fixtures()->createProduct(['name' => 'Related item validation product', 'price' => 35]);
$otherOrder = api_fixtures()->createOrder([
'customer_id' => $fixture['customer']['customer_number'],
'department_id' => $fixture['department']['id'],
'cashier_id' => $cashier['id'],
]);
$validParent = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $product['id'],
'cashier_id' => $cashier['id'],
'price' => 35,
]);
$otherOrderParent = api_fixtures()->createOrderItem([
'order_id' => $otherOrder['id'],
'product_id' => $product['id'],
'cashier_id' => $cashier['id'],
'price' => 35,
]);
$deletedParent = api_fixtures()->createOrderItem([
'order_id' => $fixture['order']['id'],
'product_id' => $product['id'],
'cashier_id' => $cashier['id'],
'price' => 35,
'deleted_at' => '2026-07-15 12:00:00',
]);
foreach ([$deletedParent['id'], 999999999] as $missingParentId) {
post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
'related_item_id' => $missingParentId,
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Related order item not found');
}
post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
'related_item_id' => $otherOrderParent['id'],
])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Related order item must belong to the same order');
$response = post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
'related_item_id' => $validParent['id'],
]);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['related_item_id'] ?? 0))->toBe((int)$validParent['id']);
});
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture([$attribute]);
$product = api_fixtures()->createProduct($productAttributes);
configure_customer_rule_product($attribute, (int)$product['id']);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
})->with([
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
]);
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
$nonTankProduct = api_fixtures()->createProduct([
'name' => 'Exterior truck wash',
'price' => 180,
]);
$tankProduct = api_fixtures()->createProduct([
'name' => 'Tank cleaning',
'category' => 5,
'price' => 300,
]);
configure_customer_rule_product('onlyTankCleaning', (int)$nonTankProduct['id']);
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
api_test_covers('GET /products', 'happy');
api_test_covers('POST /order/items', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Custom Pricing Products',
'custom_pricing_only' => 1,
]);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
$product = api_fixtures()->createProduct([
'name' => 'Custom Pricing Missing Product',
'category' => $category['id'],
'price' => 12345,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
$session = api_fixtures()->createUserSession([
'list_products',
'add_order_items',
'department_access_' . (int)$department['id'],
]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->body)->not->toContain('12345');
expect($productResponse->body)->not->toContain('3333');
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$otherDepartment['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'CUSTOM-ONLY-ORDER',
]);
$orderItem = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$orderItem
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
});