Skip to content

[Woo POS] Coupons: Disallow adding duplicate coupons to cart #15551

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion WooCommerce/Classes/POS/Models/Cart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ extension Cart {
case .variableParentProduct:
return
case .coupon(let coupon):
let couponItem = Cart.CouponItem(id: UUID(), code: coupon.code, summary: coupon.summary)
let couponItem = Cart.CouponItem(id: coupon.id, code: coupon.code, summary: coupon.summary)
coupons.insert(couponItem, at: coupons.startIndex)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ extension POSItemActionHandler {
break
}
}

func shouldSkipDuplicate(_ item: POSItem, itemListType: ItemListType, posModel: PointOfSaleAggregateModelProtocol) -> Bool {
switch itemListType {
case .coupons:
return posModel.cart.coupons.contains(where: { $0.id == item.id })
default:
return false
}
}
}

/// Standard handler for handling item taps without any special context
Expand All @@ -45,8 +54,10 @@ final class StandardPOSItemActionHandler: POSItemActionHandler {
}

func handleTap(_ item: POSItem) {
if shouldSkipDuplicate(item, itemListType: itemListType, posModel: posModel) {
return
}
posModel.addToCart(item)

trackTapAnalytics(for: item, itemListType: itemListType, using: analytics)
}
}
Expand All @@ -70,6 +81,9 @@ final class SearchResultItemActionHandler: POSItemActionHandler {
}

func handleTap(_ item: POSItem) {
if shouldSkipDuplicate(item, itemListType: itemListType, posModel: posModel) {
return
}
posModel.saveSearchTerm(searchTerm, for: itemListType.itemType)

posModel.addToCart(item)
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,8 @@
68600A912C65BC9C00252EDD /* PointOfSaleItemListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68600A902C65BC9C00252EDD /* PointOfSaleItemListEmptyView.swift */; };
68625DE62D4134D70042B231 /* DynamicVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68625DE52D4134D50042B231 /* DynamicVStack.swift */; };
68674D312B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68674D302B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift */; };
686A71B62DC9E5C10006E835 /* POSItemActionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */; };
686A71B82DC9EB710006E835 /* MockPOSSearchHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */; };
68709D3D2A2ED94900A7FA6C /* UpgradesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */; };
68709D402A2EE2DC00A7FA6C /* UpgradesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */; };
6879B8DB287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */; };
Expand Down Expand Up @@ -4832,6 +4834,8 @@
68600A902C65BC9C00252EDD /* PointOfSaleItemListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemListEmptyView.swift; sourceTree = "<group>"; };
68625DE52D4134D50042B231 /* DynamicVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicVStack.swift; sourceTree = "<group>"; };
68674D302B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptEligibilityUseCaseTests.swift; sourceTree = "<group>"; };
686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSItemActionHandlerTests.swift; sourceTree = "<group>"; };
686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSSearchHistoryService.swift; sourceTree = "<group>"; };
68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesView.swift; sourceTree = "<group>"; };
68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModel.swift; sourceTree = "<group>"; };
6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7433,6 +7437,7 @@
children = (
208628722D48E476003F45DC /* Payments Onboarding */,
026A502E2D2F8099002C42C2 /* Infinite Scroll */,
686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */,
);
path = Presentation;
sourceTree = "<group>";
Expand Down Expand Up @@ -7862,6 +7867,7 @@
02CD3BFC2C35D01600E575C4 /* Mocks */ = {
isa = PBXGroup;
children = (
686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */,
01A3093B2DAE768000B672F6 /* MockPointOfSaleCouponService.swift */,
68503C352DA53E0800C07909 /* MockPointOfSaleCouponsController.swift */,
02CD3BFD2C35D04C00E575C4 /* MockCardPresentPaymentService.swift */,
Expand Down Expand Up @@ -17886,6 +17892,7 @@
DE4D23A029B09D71003A4B5D /* WPComMagicLinkViewModelTests.swift in Sources */,
02F5F80E246102240000613A /* FilterProductListViewModel+numberOfActiveFiltersTests.swift in Sources */,
021AEF9C2407B07300029D28 /* ProductImageStatus+HelpersTests.swift in Sources */,
686A71B62DC9E5C10006E835 /* POSItemActionHandlerTests.swift in Sources */,
024A543622BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift in Sources */,
DE06D6602D64699D00419FFA /* AuthenticatedWebViewModelTests.swift in Sources */,
B541B2132189E7FD008FE7C1 /* ScannerWooTests.swift in Sources */,
Expand Down Expand Up @@ -18168,6 +18175,7 @@
DEBAB70D2A7A6F1100743185 /* MockStorePlanSynchronizer.swift in Sources */,
77423F17251CF77E0016A083 /* ProductDownloadListViewModelTests.swift in Sources */,
B53A569721123D3B000776C9 /* ResultsControllerUIKitTests.swift in Sources */,
686A71B82DC9EB710006E835 /* MockPOSSearchHistoryService.swift in Sources */,
DE126D0D26CA4A0C007F901D /* ShippingLabelCustomsFormItemDetailsViewModelTests.swift in Sources */,
262A0999262908A60033AD20 /* OrderAddOnListI1Tests.swift in Sources */,
0234680A282CEA5F00CFC503 /* LegacyReceiptViewModelTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import protocol Yosemite.POSSearchHistoryProviding
import enum Yosemite.POSItemType

struct MockPOSSearchHistoryService: POSSearchHistoryProviding {
func saveSuccessfulSearch(term: String, for itemType: POSItemType) {}

func searchHistory(for itemType: POSItemType) -> [String] {
return []
}

func clearSearchHistory(for itemType: POSItemType) {}

func clearAllSearchHistory() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,35 +241,6 @@ struct PointOfSaleAggregateModelTests {
#expect(sut.cart.purchasableItems.count == 2)
#expect(sut.cart.coupons.count == 0)
}

@available(iOS 17.0, *)
@Test(.disabled(
"""
This test doesn't currently work; analytics extensions are not thread-safe,
and using the MainActor means the assert happens too early. I don't think
we want the addToCart to be async, but that would be one way to fix it.
"""))
func addToCart_tracks_analytics_event() async throws {
// Given
let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
couponsController: MockPointOfSaleCouponsController(),
couponsSearchController: MockPointOfSaleCouponsController(),
cardPresentPaymentService: MockCardPresentPaymentService(),
orderController: MockPointOfSaleOrderController(),
analytics: analytics,
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
searchHistoryService: MockPOSSearchHistoryService(),
popularItemsController: MockPointOfSalePopularItemsController())
let item = makePurchasableItem()

// When
sut.addToCart(item)

// Then
let event = try #require(analyticsProvider.receivedEvents.first)
#expect(event == "item_added_to_cart")
}
}

struct OrderTests {
Expand Down Expand Up @@ -1184,15 +1155,3 @@ private func makeLoadedOrderState(cartTotal: String = "",
order
)
}

private struct MockPOSSearchHistoryService: POSSearchHistoryProviding {
func saveSuccessfulSearch(term: String, for itemType: POSItemType) {}

func searchHistory(for itemType: POSItemType) -> [String] {
return []
}

func clearSearchHistory(for itemType: POSItemType) {}

func clearAllSearchHistory() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Testing
import Foundation
import enum Yosemite.POSItem
import enum Yosemite.POSItemType
import protocol Yosemite.POSSearchHistoryProviding
@testable import WooCommerce

struct POSItemActionHandlerTests {
@available(iOS 17.0, *)
@Test func handleTap_when_attempt_to_add_duplicated_coupons_in_list_then_does_not_add_it_to_cart() async throws {
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
couponsController: MockPointOfSaleCouponsController(),
couponsSearchController: MockPointOfSaleCouponsController(),
cardPresentPaymentService: MockCardPresentPaymentService(),
orderController: MockPointOfSaleOrderController(),
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
searchHistoryService: MockPOSSearchHistoryService(),
popularItemsController: MockPointOfSalePopularItemsController())
let sut = StandardPOSItemActionHandler(posModel: aggregateModel,
itemListType: .coupons(search: false))

let coupon = makeCouponItem(code: "DISCOUNT!")

sut.handleTap(coupon)
sut.handleTap(coupon)
sut.handleTap(coupon)

#expect(aggregateModel.cart.coupons.count == 1)
}

@available(iOS 17.0, *)
@Test func handleTap_when_attempt_to_add_duplicated_coupons_in_search_then_does_not_add_it_to_cart() async throws {
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
couponsController: MockPointOfSaleCouponsController(),
couponsSearchController: MockPointOfSaleCouponsController(),
cardPresentPaymentService: MockCardPresentPaymentService(),
orderController: MockPointOfSaleOrderController(),
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
searchHistoryService: MockPOSSearchHistoryService(),
popularItemsController: MockPointOfSalePopularItemsController())
let sut = SearchResultItemActionHandler(posModel: aggregateModel,
searchTerm: "",
itemListType: .coupons(search: true))

let coupon = makeCouponItem(code: "DISCOUNT!")

sut.handleTap(coupon)
sut.handleTap(coupon)
sut.handleTap(coupon)

#expect(aggregateModel.cart.coupons.count == 1)
}

@available(iOS 17.0, *)
@Test func handleTap_when_attempt_to_add_duplicated_products_in_list_then_adds_them_to_cart() async throws {
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
couponsController: MockPointOfSaleCouponsController(),
couponsSearchController: MockPointOfSaleCouponsController(),
cardPresentPaymentService: MockCardPresentPaymentService(),
orderController: MockPointOfSaleOrderController(),
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
searchHistoryService: MockPOSSearchHistoryService(),
popularItemsController: MockPointOfSalePopularItemsController())
let sut = StandardPOSItemActionHandler(posModel: aggregateModel,
itemListType: .products(search: false))

let product = makeProductItem()

sut.handleTap(product)
sut.handleTap(product)
sut.handleTap(product)

#expect(aggregateModel.cart.purchasableItems.count == 3)
}

@available(iOS 17.0, *)
@Test func handleTap_when_attempt_to_add_duplicated_products_in_search_then_adds_them_to_cart() async throws {
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
couponsController: MockPointOfSaleCouponsController(),
couponsSearchController: MockPointOfSaleCouponsController(),
cardPresentPaymentService: MockCardPresentPaymentService(),
orderController: MockPointOfSaleOrderController(),
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
searchHistoryService: MockPOSSearchHistoryService(),
popularItemsController: MockPointOfSalePopularItemsController())
let sut = SearchResultItemActionHandler(posModel: aggregateModel,
searchTerm: "",
itemListType: .coupons(search: true))

let product = makeProductItem()

sut.handleTap(product)
sut.handleTap(product)
sut.handleTap(product)

#expect(aggregateModel.cart.purchasableItems.count == 3)
}
}

private func makeCouponItem(code: String = "") -> POSItem {
return .coupon(.init(id: UUID(), code: code))
}

private func makeProductItem() -> POSItem {
return .simpleProduct(.init(id: UUID(), name: "some product name", formattedPrice: "$10.00", productID: 123, price: "10"))
}