diff --git a/dataset/data/coupons/coupon_preset_spring15.json b/dataset/data/coupons/coupon_preset_spring15.json new file mode 100644 index 00000000..a9417448 --- /dev/null +++ b/dataset/data/coupons/coupon_preset_spring15.json @@ -0,0 +1,7 @@ +{ + "code": "SPRING15", + "maxUsesNumber": 1000000, + "maxUsesPerUser": 1000000, + "expirationDate": "2030-01-01T00:00:00Z", + "promotionId": "promotion-acme-preset-spring-15-percent" +} diff --git a/dataset/data/coupons/coupon_preset_welcome20.json b/dataset/data/coupons/coupon_preset_welcome20.json new file mode 100644 index 00000000..f97e0068 --- /dev/null +++ b/dataset/data/coupons/coupon_preset_welcome20.json @@ -0,0 +1,7 @@ +{ + "code": "WELCOME20", + "maxUsesNumber": 1000000, + "maxUsesPerUser": 1000000, + "expirationDate": "2030-01-01T00:00:00Z", + "promotionId": "promotion-acme-preset-welcome-20-off" +} diff --git a/dataset/data/coupons/coupon_qa_10_off.json b/dataset/data/coupons/coupon_qa_10_off.json new file mode 100644 index 00000000..9bea0b8a --- /dev/null +++ b/dataset/data/coupons/coupon_qa_10_off.json @@ -0,0 +1,7 @@ +{ + "code": "QA10OFF", + "maxUsesNumber": 1000000, + "maxUsesPerUser": 1000000, + "expirationDate": "2030-01-01T00:00:00Z", + "promotionId": "promotion-acme-qa-10-percent-off-subtotal" +} diff --git a/dataset/data/coupons/coupon_qa_expired.json b/dataset/data/coupons/coupon_qa_expired.json new file mode 100644 index 00000000..b1116fb2 --- /dev/null +++ b/dataset/data/coupons/coupon_qa_expired.json @@ -0,0 +1,7 @@ +{ + "code": "QAEXPIRED", + "maxUsesNumber": 1000000, + "maxUsesPerUser": 1000000, + "expirationDate": "2020-01-01T00:00:00Z", + "promotionId": "promotion-acme-qa-expired-5-off" +} diff --git a/dataset/data/coupons/coupon_qa_lowercase.json b/dataset/data/coupons/coupon_qa_lowercase.json new file mode 100644 index 00000000..68ed5dae --- /dev/null +++ b/dataset/data/coupons/coupon_qa_lowercase.json @@ -0,0 +1,7 @@ +{ + "code": "agenttestlc062", + "maxUsesNumber": 1000000, + "maxUsesPerUser": 1000000, + "expirationDate": "2030-01-01T00:00:00Z", + "promotionId": "promotion-acme-qa-lowercase-5-percent" +} diff --git a/dataset/data/coupons/coupon_qa_min_subtotal.json b/dataset/data/coupons/coupon_qa_min_subtotal.json new file mode 100644 index 00000000..df540768 --- /dev/null +++ b/dataset/data/coupons/coupon_qa_min_subtotal.json @@ -0,0 +1,7 @@ +{ + "code": "QAMINSUB100K", + "maxUsesNumber": 1000000, + "maxUsesPerUser": 1000000, + "expirationDate": "2030-01-01T00:00:00Z", + "promotionId": "promotion-acme-qa-min-subtotal-gate" +} diff --git a/dataset/data/prices/smartphones/sale_001.json b/dataset/data/prices/smartphones/sale_001.json new file mode 100644 index 00000000..91951d95 --- /dev/null +++ b/dataset/data/prices/smartphones/sale_001.json @@ -0,0 +1,27 @@ +{ + "productId": "sale-001", + "product": { + "id": "sale-001", + "name": "QA Sale Test Product" + }, + "prices": [ + { + "catalog": "catalog-acme-electronics", + "currency": "USD", + "list": 29.99, + "sale": 19.99, + "minQuantity": 1, + "pricelistId": "pricelist-acme-usd", + "productId": "sale-001" + }, + { + "catalog": "catalog-acme-electronics", + "currency": "EUR", + "list": 27.99, + "sale": 18.99, + "minQuantity": 1, + "pricelistId": "pricelist-acme-eur", + "productId": "sale-001" + } + ] +} diff --git a/dataset/data/product_inventories/smartphones/sale_001_ffc_usa_northgate_logistics_hub.json b/dataset/data/product_inventories/smartphones/sale_001_ffc_usa_northgate_logistics_hub.json new file mode 100644 index 00000000..a53afce0 --- /dev/null +++ b/dataset/data/product_inventories/smartphones/sale_001_ffc_usa_northgate_logistics_hub.json @@ -0,0 +1,8 @@ +{ + "productId": "sale-001", + "sku": "SALE-001", + "productName": "QA Sale Test Product", + "fulfillmentCenterId": "ffc-usa-northgate-logistics-hub", + "fulfillmentCenterName": "NorthGate Logistics Hub - USA", + "inStockQuantity": 74085 +} diff --git a/dataset/data/products/smartphones/sale_001.json b/dataset/data/products/smartphones/sale_001.json new file mode 100644 index 00000000..e8aa474a --- /dev/null +++ b/dataset/data/products/smartphones/sale_001.json @@ -0,0 +1,32 @@ +{ + "id": "sale-001", + "code": "SALE-001", + "catalogId": "catalog-acme-electronics", + "categoryId": "category-acme-electronics-smartphones", + "name": "QA Sale Test Product", + "vendor": "ACME", + "productType": "Physical", + "seoObjectType": "Product", + "seoInfos": [ + { + "id": "seo-info-sale-001", + "isActive": true, + "languageCode": "en-US", + "pageTitle": "QA Sale Test Product", + "semanticUrl": "qa-sale-test-product", + "storeId": "store-acme" + } + ], + "properties": [ + { + "id": "catalog-property-general-brand", + "name": "Brand", + "valueType": "ShortText", + "values": [ + { + "value": "ACME" + } + ] + } + ] +} diff --git a/dataset/data/promotions/cart_preset_spring_15_percent.json b/dataset/data/promotions/cart_preset_spring_15_percent.json new file mode 100644 index 00000000..d9cfa92f --- /dev/null +++ b/dataset/data/promotions/cart_preset_spring_15_percent.json @@ -0,0 +1,39 @@ +{ + "id": "promotion-acme-preset-spring-15-percent", + "name": "Spring Sale - 15% Off", + "localizedLabel": { + "values": { + "en-US": "15% Off - en" + } + }, + "description": "Save 15% on your entire order this spring.", + "type": "DynamicPromotion", + "isActive": true, + "isPublic": true, + "endDate": "2030-06-30T23:59:59Z", + "storeIds": ["store-acme"], + "dynamicExpression": { + "all": true, + "not": false, + "id": "PromotionConditionAndRewardTree", + "children": [ + { + "id": "BlockCustomerCondition", + "children": [ + { + "id": "ConditionIsEveryone" + } + ] + }, + { + "id": "BlockReward", + "children": [ + { + "id": "RewardCartGetOfRelSubtotal", + "amount": 15 + } + ] + } + ] + } +} diff --git a/dataset/data/promotions/cart_preset_welcome_20_off.json b/dataset/data/promotions/cart_preset_welcome_20_off.json new file mode 100644 index 00000000..2061d724 --- /dev/null +++ b/dataset/data/promotions/cart_preset_welcome_20_off.json @@ -0,0 +1,38 @@ +{ + "id": "promotion-acme-preset-welcome-20-off", + "name": "Welcome Offer - $20 Off", + "localizedLabel": { + "values": { + "en-US": "$20 Off - en" + } + }, + "description": "Enjoy $20 off your order as a welcome gift.", + "type": "DynamicPromotion", + "isActive": true, + "isPublic": true, + "storeIds": ["store-acme"], + "dynamicExpression": { + "all": true, + "not": false, + "id": "PromotionConditionAndRewardTree", + "children": [ + { + "id": "BlockCustomerCondition", + "children": [ + { + "id": "ConditionIsEveryone" + } + ] + }, + { + "id": "BlockReward", + "children": [ + { + "id": "RewardCartGetOfAbsSubtotal", + "amount": 20 + } + ] + } + ] + } +} diff --git a/dataset/data/promotions/cart_qa_10_percent_off_subtotal.json b/dataset/data/promotions/cart_qa_10_percent_off_subtotal.json new file mode 100644 index 00000000..4826f5ae --- /dev/null +++ b/dataset/data/promotions/cart_qa_10_percent_off_subtotal.json @@ -0,0 +1,38 @@ +{ + "id": "promotion-acme-qa-10-percent-off-subtotal", + "name": "QA 10% Off Subtotal", + "localizedLabel": { + "values": { + "en-US": "10% Off - en" + } + }, + "description": "QA automation: 10% off the cart subtotal.", + "type": "DynamicPromotion", + "isActive": true, + "isPublic": true, + "storeIds": ["store-acme"], + "dynamicExpression": { + "all": true, + "not": false, + "id": "PromotionConditionAndRewardTree", + "children": [ + { + "id": "BlockCustomerCondition", + "children": [ + { + "id": "ConditionIsEveryone" + } + ] + }, + { + "id": "BlockReward", + "children": [ + { + "id": "RewardCartGetOfRelSubtotal", + "amount": 10 + } + ] + } + ] + } +} diff --git a/dataset/data/promotions/cart_qa_expired_5_off.json b/dataset/data/promotions/cart_qa_expired_5_off.json new file mode 100644 index 00000000..9de78a3b --- /dev/null +++ b/dataset/data/promotions/cart_qa_expired_5_off.json @@ -0,0 +1,38 @@ +{ + "id": "promotion-acme-qa-expired-5-off", + "name": "QA Expired Coupon", + "localizedLabel": { + "values": { + "en-US": "$5 Off - en" + } + }, + "description": "QA automation: promotion whose coupon is already expired.", + "type": "DynamicPromotion", + "isActive": true, + "isPublic": true, + "storeIds": ["store-acme"], + "dynamicExpression": { + "all": true, + "not": false, + "id": "PromotionConditionAndRewardTree", + "children": [ + { + "id": "BlockCustomerCondition", + "children": [ + { + "id": "ConditionIsEveryone" + } + ] + }, + { + "id": "BlockReward", + "children": [ + { + "id": "RewardCartGetOfAbsSubtotal", + "amount": 5 + } + ] + } + ] + } +} diff --git a/dataset/data/promotions/cart_qa_lowercase_5_percent.json b/dataset/data/promotions/cart_qa_lowercase_5_percent.json new file mode 100644 index 00000000..458c7f7b --- /dev/null +++ b/dataset/data/promotions/cart_qa_lowercase_5_percent.json @@ -0,0 +1,38 @@ +{ + "id": "promotion-acme-qa-lowercase-5-percent", + "name": "QA Lowercase 5% Off", + "localizedLabel": { + "values": { + "en-US": "5% Off - en" + } + }, + "description": "QA automation: 5% off used to verify coupon-code case fidelity (VCST-5233).", + "type": "DynamicPromotion", + "isActive": true, + "isPublic": true, + "storeIds": ["store-acme"], + "dynamicExpression": { + "all": true, + "not": false, + "id": "PromotionConditionAndRewardTree", + "children": [ + { + "id": "BlockCustomerCondition", + "children": [ + { + "id": "ConditionIsEveryone" + } + ] + }, + { + "id": "BlockReward", + "children": [ + { + "id": "RewardCartGetOfRelSubtotal", + "amount": 5 + } + ] + } + ] + } +} diff --git a/dataset/data/promotions/cart_qa_min_subtotal_gate.json b/dataset/data/promotions/cart_qa_min_subtotal_gate.json new file mode 100644 index 00000000..3681a406 --- /dev/null +++ b/dataset/data/promotions/cart_qa_min_subtotal_gate.json @@ -0,0 +1,48 @@ +{ + "id": "promotion-acme-qa-min-subtotal-gate", + "name": "QA Min-Subtotal Gate", + "localizedLabel": { + "values": { + "en-US": "$50 Off over $100k - en" + } + }, + "description": "QA automation: coupon-gated $50 off, gated behind an unreachable $100,000 subtotal minimum. Non-public so it never surfaces in promotionCoupons/preset tests.", + "type": "DynamicPromotion", + "isActive": true, + "isPublic": false, + "storeIds": ["store-acme"], + "dynamicExpression": { + "all": true, + "not": false, + "id": "PromotionConditionAndRewardTree", + "children": [ + { + "id": "BlockCustomerCondition", + "children": [ + { + "id": "ConditionIsEveryone" + } + ] + }, + { + "id": "BlockCartCondition", + "children": [ + { + "id": "ConditionCartSubtotalLeast", + "subTotal": 100000.0, + "compareCondition": "AtLeast" + } + ] + }, + { + "id": "BlockReward", + "children": [ + { + "id": "RewardCartGetOfAbsSubtotal", + "amount": 50 + } + ] + } + ] + } +} diff --git a/dataset/data/promotions/cart_subtotal_100_of_for_coupon.json b/dataset/data/promotions/cart_subtotal_100_of_for_coupon.json index e219371c..7a7d02de 100644 --- a/dataset/data/promotions/cart_subtotal_100_of_for_coupon.json +++ b/dataset/data/promotions/cart_subtotal_100_of_for_coupon.json @@ -1,8 +1,14 @@ { "id": "promotion-acme-100-off-for-cart-subtotal-by-coupon", "name": "$100 off for cart subtotal by coupon", + "localizedLabel": { + "values": { + "en-US": "$100 Off - en" + } + }, "type": "DynamicPromotion", "isActive": true, + "isPublic": true, "storeIds": ["store-acme"], "dynamicExpression": { "all": true, diff --git a/dataset/data/promotions/cart_subtotal_100_off_for_4000.json b/dataset/data/promotions/cart_subtotal_100_off_for_4000.json index 3a645cbc..e599871a 100644 --- a/dataset/data/promotions/cart_subtotal_100_off_for_4000.json +++ b/dataset/data/promotions/cart_subtotal_100_off_for_4000.json @@ -1,8 +1,14 @@ { "id": "promotion-acme-100-off-for-cart-subtotal", "name": "$100 off for cart subtotal", + "localizedLabel": { + "values": { + "en-US": "$100 Off - en" + } + }, "type": "DynamicPromotion", "isActive": true, + "isPublic": true, "storeIds": ["store-acme"], "dynamicExpression": { "all": true, diff --git a/dataset/data/promotions/gift_for_cart_20_items.json b/dataset/data/promotions/gift_for_cart_20_items.json index c69738d0..04f74f58 100644 --- a/dataset/data/promotions/gift_for_cart_20_items.json +++ b/dataset/data/promotions/gift_for_cart_20_items.json @@ -1,8 +1,14 @@ { "id": "promotion-acme-gift-for-20-cart-items", "name": "Gift for 20 cart items", + "localizedLabel": { + "values": { + "en-US": "Free Gift - en" + } + }, "type": "DynamicPromotion", "isActive": true, + "isPublic": true, "storeIds": ["store-acme"], "dynamicExpression": { "all": true, diff --git a/dataset/data/promotions/smartphone-samsung-galaxy-a57-5g_50_off.json b/dataset/data/promotions/smartphone-samsung-galaxy-a57-5g_50_off.json index df7c9af2..d0a032e4 100644 --- a/dataset/data/promotions/smartphone-samsung-galaxy-a57-5g_50_off.json +++ b/dataset/data/promotions/smartphone-samsung-galaxy-a57-5g_50_off.json @@ -1,8 +1,14 @@ { "id": "promotion-acme-50-off-smartphone-samsung-galaxy-a57-5g", "name": "$50 off for Samsung Galaxy A57 5G", + "localizedLabel": { + "values": { + "en-US": "$50 Off - en" + } + }, "type": "DynamicPromotion", "isActive": true, + "isPublic": true, "storeIds": ["store-acme"], "dynamicExpression": { "all": true, diff --git a/gql/fragments/cart.graphql b/gql/fragments/cart.graphql index 231580f9..1794fdbf 100644 --- a/gql/fragments/cart.graphql +++ b/gql/fragments/cart.graphql @@ -15,6 +15,12 @@ fragment CartFragment on CartType { subTotalDiscount { ...MoneyFragment } + discountTotal { + ...MoneyFragment + } + taxTotal { + ...MoneyFragment + } shippingTotal { ...MoneyFragment } diff --git a/gql/fragments/promotion_coupon.graphql b/gql/fragments/promotion_coupon.graphql new file mode 100644 index 00000000..5380a194 --- /dev/null +++ b/gql/fragments/promotion_coupon.graphql @@ -0,0 +1,8 @@ +fragment PromotionCouponFragment on PromotionCouponType { + id + endDate + label + name + description + couponCode +} diff --git a/gql/operations/__init__.py b/gql/operations/__init__.py index 05ddb624..ac5523ee 100644 --- a/gql/operations/__init__.py +++ b/gql/operations/__init__.py @@ -4,6 +4,7 @@ from .page_context_operations import PageContextOperations from .pickup_location_operations import PickupLocationOperations from .product_operations import ProductOperations +from .promotion_coupon_operations import PromotionCouponOperations from .quote_operations import QuoteOperations from .save_for_later_operations import SaveForLaterOperations from .seo_operations import SeoOperations @@ -17,6 +18,7 @@ "PageContextOperations", "PickupLocationOperations", "ProductOperations", + "PromotionCouponOperations", "QuoteOperations", "SaveForLaterOperations", "SeoOperations", diff --git a/gql/operations/cart_operations.py b/gql/operations/cart_operations.py index 6e28c067..9be0d852 100644 --- a/gql/operations/cart_operations.py +++ b/gql/operations/cart_operations.py @@ -351,6 +351,35 @@ def remove_coupon( ) return Cart.model_validate(result["data"]["removeCoupon"]) + def validate_coupon( + self, + store_id: str, + user_id: str, + cart_id: str, + currency_code: str, + coupon: str, + culture_name: str | None = None, + ) -> bool: + # fmt: off + query = gql(""" + query ValidateCoupon($storeId: String!, $cartId: String!, $userId: String!, $currencyCode: String!, $cultureName: String, $coupon: String!) { + validateCoupon(storeId: $storeId, cartId: $cartId, userId: $userId, currencyCode: $currencyCode, cultureName: $cultureName, coupon: $coupon) + } + """) + # fmt: on + result = self._client.execute( + self._build_query(query), + variables={ + "storeId": store_id, + "cartId": cart_id, + "userId": user_id, + "currencyCode": currency_code, + "coupon": coupon, + **({"cultureName": culture_name} if culture_name else {}), + }, + ) + return bool(result["data"]["validateCoupon"]) + def clear_cart( self, cart_id: str, diff --git a/gql/operations/promotion_coupon_operations.py b/gql/operations/promotion_coupon_operations.py new file mode 100644 index 00000000..cb2f6816 --- /dev/null +++ b/gql/operations/promotion_coupon_operations.py @@ -0,0 +1,54 @@ +from gql.types.promotion_coupon import PromotionCoupon + +from .base_operations import BaseOperations, gql + + +class PromotionCouponOperations(BaseOperations): + def get_promotion_coupons( + self, + store_id: str, + user_id: str, + culture_name: str, + sort: str | None = None, + first: int | None = None, + after: str | None = None, + ) -> list[PromotionCoupon]: + # fmt: off + query = gql(""" + query GetPromotionCoupons( + $storeId: String!, + $userId: String!, + $cultureName: String!, + $sort: String, + $first: Int, + $after: String, + ) { + promotionCoupons( + storeId: $storeId, + userId: $userId, + cultureName: $cultureName, + sort: $sort, + first: $first, + after: $after, + ) { + totalCount + items { + ...PromotionCouponFragment + } + } + } + """) + # fmt: on + result = self._client.execute( + self._build_query(query), + variables={ + "storeId": store_id, + "userId": user_id, + "cultureName": culture_name, + "sort": sort, + "first": first, + "after": after, + }, + ) + items = result["data"]["promotionCoupons"]["items"] or [] + return [PromotionCoupon.model_validate(item) for item in items] diff --git a/gql/types/__init__.py b/gql/types/__init__.py index 840cfcfa..8f247204 100644 --- a/gql/types/__init__.py +++ b/gql/types/__init__.py @@ -30,6 +30,7 @@ from .product import Product from .product_configuration import ProductConfiguration from .product_pickup_location import ProductPickupLocation +from .promotion_coupon import PromotionCoupon from .role import Role from .seo_info import SeoInfo from .shipment import Shipment @@ -72,6 +73,7 @@ "Product", "ProductConfiguration", "ProductPickupLocation", + "PromotionCoupon", "Role", "SeoInfo", "Shipment", diff --git a/gql/types/cart.py b/gql/types/cart.py index a4f76b24..67ac9b48 100644 --- a/gql/types/cart.py +++ b/gql/types/cart.py @@ -18,6 +18,8 @@ class Cart(GqlModel): total: Money sub_total: Money sub_total_discount: Money + discount_total: Money | None = None + tax_total: Money | None = None shipping_total: Money items_count: int items_quantity: int diff --git a/gql/types/promotion_coupon.py b/gql/types/promotion_coupon.py new file mode 100644 index 00000000..a4737cad --- /dev/null +++ b/gql/types/promotion_coupon.py @@ -0,0 +1,10 @@ +from .base import GqlModel + + +class PromotionCoupon(GqlModel): + id: str + end_date: str | None = None + label: str | None = None + name: str | None = None + description: str | None = None + coupon_code: str diff --git a/page_objects/components/__init__.py b/page_objects/components/__init__.py index d4943f69..4e222534 100644 --- a/page_objects/components/__init__.py +++ b/page_objects/components/__init__.py @@ -5,11 +5,13 @@ from .add_to_wishlists_modal import AddToWishlistsModal from .address import Address from .address_form import AddressForm +from .cart_coupon_section import CartCouponSection from .category_view_switcher import CategoryViewSwitcher from .checkboxes_filter import CheckboxesFilter from .chip import Chip from .clear_cart_modal import ClearCartModal from .component import Component +from .coupon_item import CouponItem from .currency_selector import CurrencySelector from .delete_wishlist_modal import DeleteWishlistModal from .dropdown_filter import DropdownFilter @@ -42,11 +44,13 @@ "AddToWishlistsModal", "Address", "AddressForm", + "CartCouponSection", "CategoryViewSwitcher", "CheckboxesFilter", "Chip", "ClearCartModal", "Component", + "CouponItem", "CurrencySelector", "DeleteWishlistModal", "DropdownFilter", diff --git a/page_objects/components/cart_coupon_section.py b/page_objects/components/cart_coupon_section.py new file mode 100644 index 00000000..6222a4f7 --- /dev/null +++ b/page_objects/components/cart_coupon_section.py @@ -0,0 +1,63 @@ +import re + +from playwright.sync_api import Locator + +from .component import Component + + +class CartCouponSection(Component): + + @property + def preset_cards(self) -> Locator: + return self._root.locator(".coupon-card:has(input[readonly])") + + def first_preset_code(self) -> str: + aria = self.preset_cards.first.get_by_role("button").first.get_attribute("aria-label") or "" + match = re.search(r"Apply coupon\s+(.+)$", aria, re.IGNORECASE) + return match.group(1).strip() if match else "" + + def apply_preset(self, code: str) -> None: + self._root.get_by_role( + "button", name=re.compile(rf"Apply coupon\s+{re.escape(code)}\b", re.IGNORECASE) + ).first.click() + + @property + def custom_code_card(self) -> Locator: + return self._root.locator(".coupon-card:has(input:not([readonly]))") + + @property + def custom_code_input(self) -> Locator: + return self.custom_code_card.locator("input").first + + @property + def apply_button(self) -> Locator: + return self.custom_code_card.get_by_role("button").first + + @property + def applied_cards(self) -> Locator: + return self._root.locator(".coupon-card--applied") + + @property + def applied_code_input(self) -> Locator: + """Readonly code field of the currently applied preset card. + + The code field keeps its value in both the default and applied + states, so the applied coupon is identified by its code rather than + by the promotion's display name (which the applied card's button + drops, and whose text is edit-prone). Pair with ``expect(...) + .to_have_value(code)`` so the assertion polls out the non-atomic + switch between two applied coupons. + """ + return self.applied_cards.first.locator("input[readonly]").first + + @property + def view_all_link(self) -> Locator: + return self._root.locator(".coupons-section__link") + + @property + def remove_button(self) -> Locator: + return self._root.get_by_role("button", name=re.compile("remove coupon", re.IGNORECASE)).first + + @property + def error_message(self) -> Locator: + return self._root.locator(".coupon-card__error, [role='alert']").first diff --git a/page_objects/components/coupon_item.py b/page_objects/components/coupon_item.py new file mode 100644 index 00000000..9eb76836 --- /dev/null +++ b/page_objects/components/coupon_item.py @@ -0,0 +1,24 @@ +from playwright.sync_api import Locator + +from .component import Component + + +class CouponItem(Component): + """A single promotion-coupon card (``.coupon-item``). + + Rendered on the account coupons page and, in themes that surface preset + coupons, inside the cart coupon section. The coupon UI is class-based in + vc-frontend, so locators here use the ``coupon-item__*`` BEM classes rather + than ``data-test-id`` attributes. + """ + + @property + def code_button(self) -> Locator: + return self._root.locator(".coupon-item__code-button") + + @property + def code_value(self) -> Locator: + return self._root.locator(".coupon-item__code-value") + + def code(self) -> str: + return self.code_value.inner_text().strip() diff --git a/page_objects/pages/__init__.py b/page_objects/pages/__init__.py index bce0a7b2..2fc715c0 100644 --- a/page_objects/pages/__init__.py +++ b/page_objects/pages/__init__.py @@ -1,3 +1,4 @@ +from .account_coupons import AccountCouponsPage from .account_list_details import AccountListDetailsPage from .account_lists import AccountListsPage from .account_saved_for_later import AccountSavedForLaterPage @@ -13,6 +14,7 @@ from .sign_in import SignInPage __all__ = [ + "AccountCouponsPage", "AccountListDetailsPage", "AccountListsPage", "AccountSavedForLaterPage", diff --git a/page_objects/pages/account_coupons.py b/page_objects/pages/account_coupons.py new file mode 100644 index 00000000..353ff2fd --- /dev/null +++ b/page_objects/pages/account_coupons.py @@ -0,0 +1,22 @@ +from playwright.sync_api import Locator + +from page_objects.layouts.main import MainLayout + + +class AccountCouponsPage(MainLayout): + """The account promotion-coupons page (``/account/coupons``). + + The coupon UI is class-based in vc-frontend, so locators here use CSS + classes (``.coupon-item``) rather than ``data-test-id`` attributes. + """ + + @property + def url(self) -> str: + return f"{self._global_settings.frontend_base_url}/account/coupons" + + @property + def cards(self) -> Locator: + return self._page.locator(".coupon-item") + + def navigate(self) -> None: + self._page.goto(url=self.url, wait_until="load") diff --git a/page_objects/pages/cart.py b/page_objects/pages/cart.py index 51673c40..e917dcbf 100644 --- a/page_objects/pages/cart.py +++ b/page_objects/pages/cart.py @@ -1,5 +1,6 @@ from playwright.sync_api import Locator +from page_objects.components.cart_coupon_section import CartCouponSection from page_objects.components.line_item import LineItem from page_objects.components.payment_details_section import PaymentDetailsSection from page_objects.components.shipping_details_section import ShippingDetailsSection @@ -11,17 +12,18 @@ class CartPage(MainLayout): def url(self) -> str: return f"{self._global_settings.frontend_base_url}/cart" + @property + def coupon_section(self) -> CartCouponSection: + # The coupon UI is class-based (not data-test-id) in vc-frontend. + return CartCouponSection(root=self._page.locator(".coupons-section")) + @property def shipping_details_section(self) -> ShippingDetailsSection: - return ShippingDetailsSection( - root=self._page.locator("[data-test-id='shipping-details-section']") - ) + return ShippingDetailsSection(root=self._page.locator("[data-test-id='shipping-details-section']")) @property def payment_details_section(self) -> PaymentDetailsSection: - return PaymentDetailsSection( - root=self._page.locator("[data-test-id='payment-details-section']") - ) + return PaymentDetailsSection(root=self._page.locator("[data-test-id='payment-details-section']")) @property def line_items(self) -> Locator: @@ -43,6 +45,11 @@ def place_order_button(self) -> Locator: def shipping_cost_label(self) -> Locator: return self._page.locator("[data-test-id='shipping-cost-label']") + # Order-summary total value span has no class hook, so key on data-test-id. + @property + def grand_total_label(self) -> Locator: + return self._page.locator("[data-test-id='cart-total-label']") + def find_line_item(self, sku: str) -> LineItem: return LineItem(root=self._page.locator(f"[data-product-sku='{sku}']")) diff --git a/tests/constants.py b/tests/constants.py index b518dbd4..53bb854b 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -17,3 +17,29 @@ ) TEST_CART_ADDRESS = CartAddress.model_validate(TEST_ADDRESS.model_dump()) + +# --- Coupon / promotion test data (seeded via dataset) --- +# SALE-001: seeded on sale (list $29.99 / sale $19.99) so the percentage-coupon +# test can prove the discount is computed on the sale price, not the list price. +# The test reads the actual list/sale prices off the cart line item. +SALE_PRODUCT_ID = "sale-001" + +# QA10OFF: 10%-of-subtotal cart coupon (RewardCartGetOfRelSubtotal, amount 10). +PERCENTAGE_COUPON_CODE = "QA10OFF" +PERCENTAGE_PCT = 10 + +# WELCOME20: $20 absolute-off cart coupon (RewardCartGetOfAbsSubtotal, amount 20). +FIXED_COUPON_CODE = "WELCOME20" +FIXED_COUPON_AMOUNT = 20 + +# Stored lowercase; guards coupon-code case fidelity (VCST-5233). +LOWERCASE_COUPON_CODE = "agenttestlc062" + +# Coupon whose expiration date is in the past. +EXPIRED_COUPON_CODE = "QAEXPIRED" + +# QAMINSUB100K: attached to a coupon-gated promotion whose $100,000 min-subtotal +# condition is unreachable for a normal cart. Proves an unmet condition leaves +# the coupon recorded but not applied (no discount). The promotion is non-public +# so it never surfaces in promotionCoupons / preset assertions. +MIN_SUBTOTAL_COUPON_CODE = "QAMINSUB100K" diff --git a/tests/e2e/test_account_coupons.py b/tests/e2e/test_account_coupons.py new file mode 100644 index 00000000..9c8ff3bc --- /dev/null +++ b/tests/e2e/test_account_coupons.py @@ -0,0 +1,60 @@ +from typing import Any + +import allure +import pytest +from core.global_settings import GlobalSettings +from page_objects.components import CouponItem +from page_objects.pages import AccountCouponsPage +from playwright.sync_api import Page, expect + +_USERNAME = "acme_store_employee_1@acme.com" + + +@pytest.fixture +def browser_context_args(browser_context_args: dict[Any, Any]) -> dict[Any, Any]: + """Grant clipboard access so copy-to-clipboard assertions can read it back.""" + return {**browser_context_args, "permissions": ["clipboard-read", "clipboard-write"]} + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@allure.feature("Account / Coupons (E2E)") +@allure.title("Account coupons page lists available coupons") +def test_account_coupons_list_renders(page: Page, global_settings: GlobalSettings) -> None: + coupons_page = AccountCouponsPage(global_settings=global_settings, page=page) + + with allure.step("Open the account coupons page"): + coupons_page.navigate() + + with allure.step("At least one coupon card is rendered"): + expect(coupons_page.cards.first).to_be_visible() + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@allure.feature("Account / Coupons (E2E)") +@allure.title("Clicking a coupon card copies its code to the clipboard") +def test_account_coupon_copy_to_clipboard(page: Page, global_settings: GlobalSettings) -> None: + coupons_page = AccountCouponsPage(global_settings=global_settings, page=page) + + with allure.step("Open the account coupons page"): + coupons_page.navigate() + expect(coupons_page.cards.first).to_be_visible() + + card = CouponItem(root=coupons_page.cards.first) + displayed = card.code() + + with allure.step("Click the coupon code button to copy it"): + card.code_button.click() + + with allure.step("Clipboard contents match the displayed code"): + # The copy handler writes via the async Clipboard API, which may not have + # resolved the instant the click returns; poll briefly for the write. + clipboard = "" + for _ in range(10): + clipboard = page.evaluate("() => navigator.clipboard.readText()").strip() + if clipboard: + break + page.wait_for_timeout(200) + assert clipboard != "", "Clipboard was empty after clicking the copy button" + assert clipboard == displayed.strip() diff --git a/tests/e2e/test_cart_coupon.py b/tests/e2e/test_cart_coupon.py new file mode 100644 index 00000000..e521ea9a --- /dev/null +++ b/tests/e2e/test_cart_coupon.py @@ -0,0 +1,260 @@ +import re +from decimal import Decimal +from collections.abc import Callable + +import allure +import pytest +from core.global_settings import GlobalSettings +from page_objects.pages import CartPage +from playwright.sync_api import Page, expect + +from tests.constants import ( + EXPIRED_COUPON_CODE, + FIXED_COUPON_CODE, + LOWERCASE_COUPON_CODE, + PERCENTAGE_COUPON_CODE, +) + +_USERNAME = "acme_store_employee_1@acme.com" +_PRODUCT_ID = "smartphone-apple-iphone-17-256gb-black" +_QUANTITY = 2 +_INVALID_CODE = "NOPE-INVALID-123" + +_MONEY_RE = re.compile(r"-?[\d,]+\.\d{2}") +_NON_EMPTY = re.compile(r"\S") + + +def _amount(text: str | None) -> Decimal: + """Parse the first currency-looking number out of a label's text.""" + match = _MONEY_RE.search(text or "") + return Decimal(match.group().replace(",", "")) if match else Decimal(0) + + +def _open_cart(cart_page: CartPage, page: Page) -> None: + """Open the cart and wait for it to fully settle before asserting. + + The cart is a single-page checkout: on a fresh cart it auto-initializes + shipment then payment (AddOrUpdateCartShipment -> AddOrUpdateCartPayment), + a cascade that keeps re-rendering the coupon section for several seconds + after the load event. Reading during that window is what made the preset + and coupon assertions flake on faster machines. Waiting for the network to + go idle lets the whole cascade quiesce, so assertions read a stable cart. + """ + cart_page.navigate() + expect(cart_page.line_items).to_be_visible() + page.wait_for_load_state("networkidle") + + +def _apply_and_settle(cart_page: CartPage, page: Page, action: Callable[[], None], field: str = "addCoupon") -> None: + """Trigger a coupon mutation, wait for it to commit, then reopen the settled cart. + + Awaiting the mutation's own GraphQL response guarantees it committed + server-side before the reopen (so the reload can't cancel the in-flight + request), and reopening reads the authoritative cart: once the coupon is + persisted and the promotion re-evaluated, a fresh load's GetFullCart returns + the final state and shipment/payment are already initialized, so they do not + re-fire to clobber it. This is what a single post-click assertion could not + guarantee, because a concurrent background write could land stale last. + """ + with page.expect_response( + lambda r: "/graphql" in r.url and r.request.method == "POST" and field in (r.request.post_data or "") + ): + action() + _open_cart(cart_page, page) + + +@pytest.mark.e2e +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("Anonymous shopper sees only the custom-code field and can apply a coupon there") +def test_cart_coupon_anonymous_custom_code(page: Page, global_settings: GlobalSettings) -> None: + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart as an anonymous shopper"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + total_label = cart_page.grand_total_label + before_total_text = total_label.inner_text() + + with allure.step("Only the custom-code field is shown — no presets, no view-all link"): + expect(section.custom_code_input).to_be_visible() + expect(section.preset_cards).to_have_count(0) + expect(section.view_all_link).to_have_count(0) + + with allure.step(f"Apply '{LOWERCASE_COUPON_CODE}' via the custom-code field"): + section.custom_code_input.fill(LOWERCASE_COUPON_CODE) + _apply_and_settle(cart_page, page, section.apply_button.click) + + with allure.step("The coupon validates true and the grand total decreases"): + expect(section.remove_button).to_be_visible() + expect(total_label).not_to_have_text(before_total_text) + assert _amount(total_label.inner_text()) < _amount(before_total_text) + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("Preset coupon cards render with a localized label and promotion name") +def test_cart_coupon_presets_render(page: Page, global_settings: GlobalSettings) -> None: + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart and confirm the seeded item is shown"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + + with allure.step("The first preset card shows a label, a name, and an apply code"): + first = section.preset_cards.first + expect(first).to_be_visible() + expect(first.locator(".coupon-card__label")).to_have_text(_NON_EMPTY) + expect(first.locator(".coupon-card__name")).to_have_text(_NON_EMPTY) + assert section.first_preset_code() != "" + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("Clicking a preset validates the coupon (green check); clicking another switches it") +def test_cart_coupon_preset_apply_and_switch(page: Page, global_settings: GlobalSettings) -> None: + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + total_label = cart_page.grand_total_label + before_total_text = total_label.inner_text() + + with allure.step(f"Click the '{PERCENTAGE_COUPON_CODE}' preset — the coupon validates true"): + _apply_and_settle(cart_page, page, lambda: section.apply_preset(PERCENTAGE_COUPON_CODE)) + expect(section.applied_cards).to_have_count(1) + expect(section.applied_code_input).to_have_value(PERCENTAGE_COUPON_CODE) + expect(total_label).not_to_have_text(before_total_text) + assert _amount(total_label.inner_text()) < _amount(before_total_text) + + with allure.step(f"Click the '{FIXED_COUPON_CODE}' preset — the applied coupon switches to it"): + _apply_and_settle(cart_page, page, lambda: section.apply_preset(FIXED_COUPON_CODE)) + # Exactly one applied card, now carrying the fixed coupon's code — + # inherently proves the percentage coupon is no longer applied. + expect(section.applied_cards).to_have_count(1) + expect(section.applied_code_input).to_have_value(FIXED_COUPON_CODE) + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("Removing an applied preset coupon restores the cart") +def test_cart_coupon_preset_remove_restores_cart(page: Page, global_settings: GlobalSettings) -> None: + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + total_label = cart_page.grand_total_label + before_total_text = total_label.inner_text() + + with allure.step(f"Apply the '{PERCENTAGE_COUPON_CODE}' preset and confirm it is applied"): + _apply_and_settle(cart_page, page, lambda: section.apply_preset(PERCENTAGE_COUPON_CODE)) + expect(section.applied_cards).to_have_count(1) + expect(section.applied_code_input).to_have_value(PERCENTAGE_COUPON_CODE) + expect(total_label).not_to_have_text(before_total_text) + assert _amount(total_label.inner_text()) < _amount(before_total_text) + + with allure.step("Remove the preset and confirm the applied state and total are restored"): + _apply_and_settle(cart_page, page, section.remove_button.click, field="removeCoupon") + expect(section.applied_cards).to_have_count(0) + expect(total_label).to_have_text(before_total_text) + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("The 'View all' link navigates to the account coupons page") +def test_cart_coupon_view_all_navigates(page: Page, global_settings: GlobalSettings) -> None: + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + + with allure.step("The link points at the account coupons page"): + link = section.view_all_link.first + expect(link).to_be_visible() + assert (link.get_attribute("href") or "").endswith("/account/coupons") + + # The link opens in a new tab (target="_blank"), so capture the popup page + # rather than expecting the cart tab itself to navigate. + with allure.step("Clicking it opens the account coupons page in a new tab"): + with page.context.expect_page() as new_page_info: + link.click() + new_page = new_page_info.value + new_page.wait_for_load_state("load") + expect(new_page).to_have_url(re.compile(r"/account/coupons")) + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("Removing an applied custom-code coupon restores the cart") +def test_cart_coupon_remove_restores_cart(page: Page, global_settings: GlobalSettings) -> None: + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + total_label = cart_page.grand_total_label + before_total_text = total_label.inner_text() + + with allure.step(f"Apply '{LOWERCASE_COUPON_CODE}' via the custom-code field"): + section.custom_code_input.fill(LOWERCASE_COUPON_CODE) + _apply_and_settle(cart_page, page, section.apply_button.click) + expect(section.remove_button).to_be_visible() + + with allure.step("Remove the coupon and confirm entry state and total are restored"): + _apply_and_settle(cart_page, page, section.remove_button.click, field="removeCoupon") + expect(section.remove_button).to_be_hidden() + expect(section.apply_button).to_be_visible() + expect(total_label).to_have_text(before_total_text) + + +@pytest.mark.e2e +@pytest.mark.with_user(_USERNAME) +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@pytest.mark.parametrize( + "code, case", + [ + pytest.param(_INVALID_CODE, "unknown", id="unknown"), + pytest.param(EXPIRED_COUPON_CODE, "expired", id="expired"), + ], +) +@allure.feature("Cart / Coupons (E2E)") +@allure.title("A rejected coupon code ({case}) shows an error and does not discount the cart") +def test_cart_coupon_bad_code_rejected(page: Page, global_settings: GlobalSettings, code: str, case: str) -> None: + # Both an unknown code and a found-but-expired code are rejected identically + # at the UI ("This code is not valid"); the semantic difference between them + # is covered at the API layer by test_validate_coupon_valid_and_invalid. + cart_page = CartPage(global_settings=global_settings, page=page) + + with allure.step("Open the cart"): + _open_cart(cart_page, page) + + section = cart_page.coupon_section + before_total_text = cart_page.grand_total_label.inner_text() + + with allure.step(f"Enter the {case} code '{code}' and apply"): + section.custom_code_input.fill(code) + section.apply_button.click() + + with allure.step("An inline 'not valid' error is shown and the grand total is unchanged"): + expect(section.error_message).to_be_visible() + expect(section.error_message).to_contain_text(re.compile("not valid", re.IGNORECASE)) + expect(cart_page.grand_total_label).to_have_text(before_total_text) diff --git a/tests/graphql/test_cart_coupon.py b/tests/graphql/test_cart_coupon.py index 68f8777b..f8a6483d 100644 --- a/tests/graphql/test_cart_coupon.py +++ b/tests/graphql/test_cart_coupon.py @@ -1,12 +1,81 @@ +from decimal import Decimal + import allure import pytest from core.clients import GraphQLClient from gql.operations import CartOperations +from gql.types import Cart +from tests.constants import ( + EXPIRED_COUPON_CODE, + FIXED_COUPON_AMOUNT, + FIXED_COUPON_CODE, + LOWERCASE_COUPON_CODE, + MIN_SUBTOTAL_COUPON_CODE, + PERCENTAGE_COUPON_CODE, + PERCENTAGE_PCT, + SALE_PRODUCT_ID, +) from tests.context import Context _PRODUCT_ID = "smartphone-apple-iphone-17-256gb-black" _QUANTITY = 3 _COUPON_CODE = "COUPON-100-OFF" +_SALE_QUANTITY = 2 +_UNKNOWN_COUPON_CODE = "NO-SUCH-COUPON-QA-ZZZ" + + +def _add_coupon(cart_ops: CartOperations, ctx: Context, code: str) -> Cart: + return cart_ops.add_coupon( + store_id=ctx.store_id, + user_id=ctx.user_id, + currency_code=ctx.currency_code, + culture_name=ctx.culture_name, + code=code, + ) + + +def _remove_coupon(cart_ops: CartOperations, ctx: Context, code: str) -> Cart: + return cart_ops.remove_coupon( + store_id=ctx.store_id, + user_id=ctx.user_id, + currency_code=ctx.currency_code, + culture_name=ctx.culture_name, + code=code, + ) + + +def _validate_coupon(cart_ops: CartOperations, ctx: Context, cart_id: str, code: str) -> bool: + return cart_ops.validate_coupon( + store_id=ctx.store_id, + user_id=ctx.user_id, + cart_id=cart_id, + currency_code=ctx.currency_code, + culture_name=ctx.culture_name, + coupon=code, + ) + + +def _discount(cart: Cart) -> Decimal: + """``discount_total.amount`` with a None-narrowing guard (the field is Optional).""" + assert cart.discount_total is not None, "Cart is missing discount_total" + return cart.discount_total.amount + + +def _assert_totals_consistent(cart: Cart) -> None: + assert cart.tax_total is not None, "Cart is missing tax_total" + + sub_total = cart.sub_total.amount + discount_total = _discount(cart) + tax_total = cart.tax_total.amount + shipping = cart.shipping_total.amount + grand_total = cart.total.amount + + expected = sub_total - discount_total + tax_total + shipping + assert abs(grand_total - expected) <= Decimal("0.01"), ( + f"Totals inconsistent: subTotal={sub_total} " + f"discountTotal={discount_total} taxTotal={tax_total} " + f"shipping={shipping} grandTotal={grand_total}" + ) @pytest.mark.graphql @@ -17,13 +86,7 @@ def test_cart_coupon(graphql_client: GraphQLClient, ctx: Context) -> None: cart_ops = CartOperations(client=graphql_client) with allure.step(f"Add coupon {_COUPON_CODE} to cart"): - cart = cart_ops.add_coupon( - store_id=ctx.store_id, - user_id=ctx.user_id, - culture_name=ctx.culture_name, - currency_code=ctx.currency_code, - code=_COUPON_CODE, - ) + cart = _add_coupon(cart_ops, ctx, _COUPON_CODE) with allure.step(f"Verify coupon {_COUPON_CODE} is applied successfully"): assert cart is not None @@ -31,17 +94,165 @@ def test_cart_coupon(graphql_client: GraphQLClient, ctx: Context) -> None: coupon = cart.coupons[0] assert coupon.code == _COUPON_CODE - assert coupon.is_applied_successfully == True + assert coupon.is_applied_successfully is True with allure.step(f"Remove coupon {_COUPON_CODE} from cart"): - cart = cart_ops.remove_coupon( - store_id=ctx.store_id, - user_id=ctx.user_id, - culture_name=ctx.culture_name, - currency_code=ctx.currency_code, - code=_COUPON_CODE, - ) + cart = _remove_coupon(cart_ops, ctx, _COUPON_CODE) with allure.step("Verify cart has no coupons"): assert cart is not None assert cart.coupons is not None and len(cart.coupons) == 0 + + +@pytest.mark.graphql +@pytest.mark.with_cart([(_PRODUCT_ID, 1)]) +@allure.feature("Cart / Coupons (GraphQL)") +@allure.title("Validate a valid, an expired, and an unknown coupon") +def test_validate_coupon_valid_and_invalid(graphql_client: GraphQLClient, ctx: Context, with_cart: Cart) -> None: + cart_ops = CartOperations(client=graphql_client) + assert with_cart is not None + + with allure.step(f"Valid coupon '{PERCENTAGE_COUPON_CODE}' validates as true"): + assert _validate_coupon(cart_ops, ctx, with_cart.id, PERCENTAGE_COUPON_CODE) is True + + with allure.step(f"Expired coupon '{EXPIRED_COUPON_CODE}' validates as false"): + assert _validate_coupon(cart_ops, ctx, with_cart.id, EXPIRED_COUPON_CODE) is False + + with allure.step(f"Unknown coupon '{_UNKNOWN_COUPON_CODE}' validates as false"): + assert _validate_coupon(cart_ops, ctx, with_cart.id, _UNKNOWN_COUPON_CODE) is False + + +@pytest.mark.graphql +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (GraphQL)") +@allure.title("Switching the cart coupon keeps only the last applied code") +def test_cart_coupon_single_slot_last_wins(graphql_client: GraphQLClient, ctx: Context) -> None: + cart_ops = CartOperations(client=graphql_client) + + with allure.step(f"Apply first coupon '{_COUPON_CODE}'"): + cart = _add_coupon(cart_ops, ctx, _COUPON_CODE) + assert any(c.code == _COUPON_CODE and c.is_applied_successfully for c in cart.coupons) + + with allure.step(f"Switch to '{PERCENTAGE_COUPON_CODE}' (remove then add)"): + _remove_coupon(cart_ops, ctx, _COUPON_CODE) + cart = _add_coupon(cart_ops, ctx, PERCENTAGE_COUPON_CODE) + + with allure.step("Only the last coupon remains and is applied"): + assert [c.code for c in cart.coupons] == [PERCENTAGE_COUPON_CODE] + assert all(c.is_applied_successfully for c in cart.coupons) + assert _discount(cart) > 0 + _assert_totals_consistent(cart) + + +@pytest.mark.graphql +@pytest.mark.with_cart([(SALE_PRODUCT_ID, _SALE_QUANTITY)]) +@allure.feature("Cart / Coupons (GraphQL)") +@allure.title("Percentage coupon discounts the sale price, not the list price") +def test_cart_coupon_percentage_on_sale_price(graphql_client: GraphQLClient, ctx: Context, with_cart: Cart) -> None: + cart_ops = CartOperations(client=graphql_client) + assert with_cart is not None + + line = next(i for i in with_cart.items if i.product_id == SALE_PRODUCT_ID) + assert line.sale_price is not None and line.list_price is not None + sale_unit = line.sale_price.amount + list_unit = line.list_price.amount + + with allure.step("Seeded line item is on sale (sale price below list price)"): + assert sale_unit < list_unit + + baseline_discount = _discount(with_cart) + + with allure.step(f"Apply percentage coupon '{PERCENTAGE_COUPON_CODE}'"): + cart = _add_coupon(cart_ops, ctx, PERCENTAGE_COUPON_CODE) + assert any(c.code == PERCENTAGE_COUPON_CODE and c.is_applied_successfully for c in cart.coupons) + + applied_line = next(i for i in cart.items if i.product_id == SALE_PRODUCT_ID) + coupon_discount = _discount(cart) - baseline_discount + pct = Decimal(PERCENTAGE_PCT) / Decimal(100) + expected_on_sale = (sale_unit * applied_line.quantity * pct).quantize(Decimal("0.01")) + expected_on_list = (list_unit * applied_line.quantity * pct).quantize(Decimal("0.01")) + + with allure.step("Coupon discount equals the percentage of the SALE price"): + assert abs(coupon_discount - expected_on_sale) <= Decimal( + "0.02" + ), f"coupon_discount={coupon_discount} expected_on_sale={expected_on_sale}" + + with allure.step("Coupon discount is strictly less than the list-price basis"): + assert coupon_discount < expected_on_list + + with allure.step("Cart totals remain consistent (BL-CHK-006)"): + _assert_totals_consistent(cart) + + +@pytest.mark.graphql +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (GraphQL)") +@allure.title("Fixed-amount coupon discounts the cart by its absolute value") +def test_cart_coupon_fixed_amount(graphql_client: GraphQLClient, ctx: Context, with_cart: Cart) -> None: + cart_ops = CartOperations(client=graphql_client) + assert with_cart is not None + baseline_discount = _discount(with_cart) + fixed_amount = Decimal(FIXED_COUPON_AMOUNT) + + assert with_cart.sub_total.amount > fixed_amount + + with allure.step(f"Apply fixed-amount coupon '{FIXED_COUPON_CODE}'"): + cart = _add_coupon(cart_ops, ctx, FIXED_COUPON_CODE) + assert any(c.code == FIXED_COUPON_CODE and c.is_applied_successfully for c in cart.coupons) + + with allure.step("Discount equals the coupon's absolute amount"): + coupon_discount = _discount(cart) - baseline_discount + assert abs(coupon_discount - fixed_amount) <= Decimal( + "0.01" + ), f"coupon_discount={coupon_discount} expected={fixed_amount}" + + with allure.step("Cart totals remain consistent (BL-CHK-006)"): + _assert_totals_consistent(cart) + + +@pytest.mark.graphql +@pytest.mark.with_cart([(_PRODUCT_ID, _QUANTITY)]) +@allure.feature("Cart / Coupons (GraphQL)") +@allure.title("Lowercase coupon applied via any input case preserves its stored case") +def test_cart_coupon_lowercase_code_roundtrip(graphql_client: GraphQLClient, ctx: Context) -> None: + cart_ops = CartOperations(client=graphql_client) + + with allure.step(f"Apply the exact lowercase code '{LOWERCASE_COUPON_CODE}'"): + cart = _add_coupon(cart_ops, ctx, LOWERCASE_COUPON_CODE) + applied = next((c for c in cart.coupons if c.code.lower() == LOWERCASE_COUPON_CODE), None) + assert applied is not None + assert applied.is_applied_successfully is True + assert applied.code == LOWERCASE_COUPON_CODE, f"Stored code was not preserved lowercase: {applied.code!r}" + + with allure.step("Re-apply with an upper-cased input; matching is case-insensitive"): + _remove_coupon(cart_ops, ctx, LOWERCASE_COUPON_CODE) + cart = _add_coupon(cart_ops, ctx, LOWERCASE_COUPON_CODE.upper()) + applied = next((c for c in cart.coupons if c.code.lower() == LOWERCASE_COUPON_CODE), None) + assert applied is not None + assert applied.is_applied_successfully is True + + +@pytest.mark.graphql +@pytest.mark.with_cart([(_PRODUCT_ID, 1)]) +@allure.feature("Cart / Coupons (GraphQL)") +@allure.title("A coupon gated by an unmet cart-subtotal condition is not honored") +def test_cart_coupon_unmet_condition_not_applied(graphql_client: GraphQLClient, ctx: Context, with_cart: Cart) -> None: + cart_ops = CartOperations(client=graphql_client) + assert with_cart is not None + baseline_discount = _discount(with_cart) + + with allure.step(f"Apply '{MIN_SUBTOTAL_COUPON_CODE}' to a cart far below its $100k minimum"): + cart = _add_coupon(cart_ops, ctx, MIN_SUBTOTAL_COUPON_CODE) + + # Verified live: the backend records the coupon on the cart but flags it as + # not applied (is_applied_successfully False) because the min-subtotal + # condition is unmet — the discount total stays at its baseline. + with allure.step("The coupon is recorded on the cart but not applied successfully"): + applied = next((c for c in cart.coupons if c.code == MIN_SUBTOTAL_COUPON_CODE), None) + assert applied is not None + assert applied.is_applied_successfully is False + + with allure.step("The coupon grants no discount and totals stay consistent (BL-CHK-006)"): + assert cart.discount_total is not None + assert cart.discount_total.amount == baseline_discount + _assert_totals_consistent(cart) diff --git a/tests/graphql/test_promotion_coupons.py b/tests/graphql/test_promotion_coupons.py new file mode 100644 index 00000000..4331ceb8 --- /dev/null +++ b/tests/graphql/test_promotion_coupons.py @@ -0,0 +1,95 @@ +import allure +import pytest +from core.clients import GraphQLClient +from gql.operations import PromotionCouponOperations +from tests.constants import PERCENTAGE_COUPON_CODE +from tests.context import Context + +_USERNAME = "acme_store_employee_1@acme.com" +_PERCENTAGE_LOCALIZED_LABEL = "10% Off - en" +_END_DATED_COUPON_CODE = "SPRING15" +_END_DATE_YEAR = "2030" + + +@pytest.mark.graphql +@pytest.mark.with_user(_USERNAME) +@allure.feature("Account / Coupons (GraphQL)") +@allure.title("promotionCoupons returns store coupons with resolved labels and end dates") +def test_promotion_coupons_returns_store_coupons(graphql_client: GraphQLClient, ctx: Context) -> None: + ops = PromotionCouponOperations(client=graphql_client) + coupons = ops.get_promotion_coupons( + store_id=ctx.store_id, user_id=ctx.user_id, culture_name=ctx.culture_name, first=100 + ) + + with allure.step(f"Every coupon exposes a code and a label, and '{PERCENTAGE_COUPON_CODE}' is present"): + assert coupons + assert all(c.coupon_code for c in coupons) + assert all(c.label for c in coupons) + codes = {c.coupon_code.upper() for c in coupons} + assert PERCENTAGE_COUPON_CODE.upper() in codes + + with allure.step(f"'{PERCENTAGE_COUPON_CODE}' resolves its localized (en-US) label, distinct from the name"): + percentage = next((c for c in coupons if c.coupon_code.upper() == PERCENTAGE_COUPON_CODE.upper()), None) + assert percentage is not None, f"'{PERCENTAGE_COUPON_CODE}' was not returned by promotionCoupons" + assert percentage.label == _PERCENTAGE_LOCALIZED_LABEL + assert percentage.label != percentage.name + + with allure.step(f"'{_END_DATED_COUPON_CODE}' returns an end date (ISO datetime in {_END_DATE_YEAR})"): + end_dated = next((c for c in coupons if c.coupon_code.upper() == _END_DATED_COUPON_CODE.upper()), None) + assert end_dated is not None, f"'{_END_DATED_COUPON_CODE}' was not returned by promotionCoupons" + assert end_dated.end_date is not None + assert end_dated.end_date.startswith(_END_DATE_YEAR) + + +@pytest.mark.graphql +@pytest.mark.with_user(_USERNAME) +@allure.feature("Account / Coupons (GraphQL)") +@allure.title("promotionCoupons honors the first limit and the name sort order") +def test_promotion_coupons_pagination_and_sort(graphql_client: GraphQLClient, ctx: Context) -> None: + ops = PromotionCouponOperations(client=graphql_client) + all_coupons = ops.get_promotion_coupons( + store_id=ctx.store_id, user_id=ctx.user_id, culture_name=ctx.culture_name, first=100 + ) + + all_ids = {c.id for c in all_coupons} + all_codes = {c.coupon_code for c in all_coupons} + + with allure.step("first=1 returns exactly one coupon drawn from the full set"): + page = ops.get_promotion_coupons( + store_id=ctx.store_id, user_id=ctx.user_id, culture_name=ctx.culture_name, first=1 + ) + assert len(page) == 1 + assert page[0].id in all_ids + + with allure.step("Sorting by name asc and desc yields exact reverses of one another"): + asc = ops.get_promotion_coupons( + store_id=ctx.store_id, user_id=ctx.user_id, culture_name=ctx.culture_name, first=100, sort="name:asc" + ) + desc = ops.get_promotion_coupons( + store_id=ctx.store_id, user_id=ctx.user_id, culture_name=ctx.culture_name, first=100, sort="name:desc" + ) + asc_names = [c.name for c in asc] + desc_names = [c.name for c in desc] + + assert {c.coupon_code for c in asc} == all_codes + assert {c.coupon_code for c in desc} == all_codes + # Compare the sort-key (name) sequence, not coupon codes: this stays + # correct when two coupons share a name (a stable sort keeps tied items + # in the same relative order both ways, so a code-level reverse check + # would false-fail). asc must be desc reversed by name. + assert asc_names == desc_names[::-1] + if len(all_codes) > 1: + assert asc_names != desc_names + + +@pytest.mark.graphql +@allure.feature("Account / Coupons (GraphQL)") +@allure.title("promotionCoupons denies anonymous access") +def test_promotion_coupons_anonymous_denied(graphql_client: GraphQLClient, ctx: Context) -> None: + ops = PromotionCouponOperations(client=graphql_client) + + with allure.step("Anonymous promotionCoupons raises an Unauthorized GraphQL error"): + with pytest.raises(ValueError, match="(?i)anonymous access denied|unauthorized"): + ops.get_promotion_coupons( + store_id=ctx.store_id, user_id=ctx.user_id, culture_name=ctx.culture_name, first=100 + )