Skip to content

Commit 77ca554

Browse files
authored
[Woo POS] Coupons: Disallow adding duplicate coupons to cart (#15551)
2 parents ba4bb40 + d004acc commit 77ca554

File tree

6 files changed

+148
-43
lines changed

6 files changed

+148
-43
lines changed

WooCommerce/Classes/POS/Models/Cart.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ extension Cart {
4949
case .variableParentProduct:
5050
return
5151
case .coupon(let coupon):
52-
let couponItem = Cart.CouponItem(id: UUID(), code: coupon.code, summary: coupon.summary)
52+
let couponItem = Cart.CouponItem(id: coupon.id, code: coupon.code, summary: coupon.summary)
5353
coupons.insert(couponItem, at: coupons.startIndex)
5454
}
5555
}

WooCommerce/Classes/POS/Presentation/Item Selector/POSItemActionHandler.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ extension POSItemActionHandler {
2929
break
3030
}
3131
}
32+
33+
func shouldSkipDuplicate(_ item: POSItem, itemListType: ItemListType, posModel: PointOfSaleAggregateModelProtocol) -> Bool {
34+
switch itemListType {
35+
case .coupons:
36+
return posModel.cart.coupons.contains(where: { $0.id == item.id })
37+
default:
38+
return false
39+
}
40+
}
3241
}
3342

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

4756
func handleTap(_ item: POSItem) {
57+
if shouldSkipDuplicate(item, itemListType: itemListType, posModel: posModel) {
58+
return
59+
}
4860
posModel.addToCart(item)
49-
5061
trackTapAnalytics(for: item, itemListType: itemListType, using: analytics)
5162
}
5263
}
@@ -70,6 +81,9 @@ final class SearchResultItemActionHandler: POSItemActionHandler {
7081
}
7182

7283
func handleTap(_ item: POSItem) {
84+
if shouldSkipDuplicate(item, itemListType: itemListType, posModel: posModel) {
85+
return
86+
}
7387
posModel.saveSearchTerm(searchTerm, for: itemListType.itemType)
7488

7589
posModel.addToCart(item)

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,8 @@
16211621
68600A912C65BC9C00252EDD /* PointOfSaleItemListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68600A902C65BC9C00252EDD /* PointOfSaleItemListEmptyView.swift */; };
16221622
68625DE62D4134D70042B231 /* DynamicVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68625DE52D4134D50042B231 /* DynamicVStack.swift */; };
16231623
68674D312B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68674D302B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift */; };
1624+
686A71B62DC9E5C10006E835 /* POSItemActionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */; };
1625+
686A71B82DC9EB710006E835 /* MockPOSSearchHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */; };
16241626
68709D3D2A2ED94900A7FA6C /* UpgradesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */; };
16251627
68709D402A2EE2DC00A7FA6C /* UpgradesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */; };
16261628
6879B8DB287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */; };
@@ -4838,6 +4840,8 @@
48384840
68600A902C65BC9C00252EDD /* PointOfSaleItemListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemListEmptyView.swift; sourceTree = "<group>"; };
48394841
68625DE52D4134D50042B231 /* DynamicVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicVStack.swift; sourceTree = "<group>"; };
48404842
68674D302B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptEligibilityUseCaseTests.swift; sourceTree = "<group>"; };
4843+
686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSItemActionHandlerTests.swift; sourceTree = "<group>"; };
4844+
686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSSearchHistoryService.swift; sourceTree = "<group>"; };
48414845
68709D3C2A2ED94900A7FA6C /* UpgradesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesView.swift; sourceTree = "<group>"; };
48424846
68709D3F2A2EE2DC00A7FA6C /* UpgradesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModel.swift; sourceTree = "<group>"; };
48434847
6879B8DA287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModelTests.swift; sourceTree = "<group>"; };
@@ -7439,6 +7443,7 @@
74397443
children = (
74407444
208628722D48E476003F45DC /* Payments Onboarding */,
74417445
026A502E2D2F8099002C42C2 /* Infinite Scroll */,
7446+
686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */,
74427447
);
74437448
path = Presentation;
74447449
sourceTree = "<group>";
@@ -7868,6 +7873,7 @@
78687873
02CD3BFC2C35D01600E575C4 /* Mocks */ = {
78697874
isa = PBXGroup;
78707875
children = (
7876+
686A71B72DC9EB6D0006E835 /* MockPOSSearchHistoryService.swift */,
78717877
01A3093B2DAE768000B672F6 /* MockPointOfSaleCouponService.swift */,
78727878
68503C352DA53E0800C07909 /* MockPointOfSaleCouponsController.swift */,
78737879
02CD3BFD2C35D04C00E575C4 /* MockCardPresentPaymentService.swift */,
@@ -17898,6 +17904,7 @@
1789817904
DE4D23A029B09D71003A4B5D /* WPComMagicLinkViewModelTests.swift in Sources */,
1789917905
02F5F80E246102240000613A /* FilterProductListViewModel+numberOfActiveFiltersTests.swift in Sources */,
1790017906
021AEF9C2407B07300029D28 /* ProductImageStatus+HelpersTests.swift in Sources */,
17907+
686A71B62DC9E5C10006E835 /* POSItemActionHandlerTests.swift in Sources */,
1790117908
024A543622BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift in Sources */,
1790217909
DE06D6602D64699D00419FFA /* AuthenticatedWebViewModelTests.swift in Sources */,
1790317910
B541B2132189E7FD008FE7C1 /* ScannerWooTests.swift in Sources */,
@@ -18180,6 +18187,7 @@
1818018187
DEBAB70D2A7A6F1100743185 /* MockStorePlanSynchronizer.swift in Sources */,
1818118188
77423F17251CF77E0016A083 /* ProductDownloadListViewModelTests.swift in Sources */,
1818218189
B53A569721123D3B000776C9 /* ResultsControllerUIKitTests.swift in Sources */,
18190+
686A71B82DC9EB710006E835 /* MockPOSSearchHistoryService.swift in Sources */,
1818318191
DE126D0D26CA4A0C007F901D /* ShippingLabelCustomsFormItemDetailsViewModelTests.swift in Sources */,
1818418192
262A0999262908A60033AD20 /* OrderAddOnListI1Tests.swift in Sources */,
1818518193
0234680A282CEA5F00CFC503 /* LegacyReceiptViewModelTests.swift in Sources */,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import protocol Yosemite.POSSearchHistoryProviding
2+
import enum Yosemite.POSItemType
3+
4+
struct MockPOSSearchHistoryService: POSSearchHistoryProviding {
5+
func saveSuccessfulSearch(term: String, for itemType: POSItemType) {}
6+
7+
func searchHistory(for itemType: POSItemType) -> [String] {
8+
return []
9+
}
10+
11+
func clearSearchHistory(for itemType: POSItemType) {}
12+
13+
func clearAllSearchHistory() {}
14+
}

WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -241,35 +241,6 @@ struct PointOfSaleAggregateModelTests {
241241
#expect(sut.cart.purchasableItems.count == 2)
242242
#expect(sut.cart.coupons.count == 0)
243243
}
244-
245-
@available(iOS 17.0, *)
246-
@Test(.disabled(
247-
"""
248-
This test doesn't currently work; analytics extensions are not thread-safe,
249-
and using the MainActor means the assert happens too early. I don't think
250-
we want the addToCart to be async, but that would be one way to fix it.
251-
"""))
252-
func addToCart_tracks_analytics_event() async throws {
253-
// Given
254-
let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
255-
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
256-
couponsController: MockPointOfSaleCouponsController(),
257-
couponsSearchController: MockPointOfSaleCouponsController(),
258-
cardPresentPaymentService: MockCardPresentPaymentService(),
259-
orderController: MockPointOfSaleOrderController(),
260-
analytics: analytics,
261-
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
262-
searchHistoryService: MockPOSSearchHistoryService(),
263-
popularItemsController: MockPointOfSalePopularItemsController())
264-
let item = makePurchasableItem()
265-
266-
// When
267-
sut.addToCart(item)
268-
269-
// Then
270-
let event = try #require(analyticsProvider.receivedEvents.first)
271-
#expect(event == "item_added_to_cart")
272-
}
273244
}
274245

275246
struct OrderTests {
@@ -1184,15 +1155,3 @@ private func makeLoadedOrderState(cartTotal: String = "",
11841155
order
11851156
)
11861157
}
1187-
1188-
private struct MockPOSSearchHistoryService: POSSearchHistoryProviding {
1189-
func saveSuccessfulSearch(term: String, for itemType: POSItemType) {}
1190-
1191-
func searchHistory(for itemType: POSItemType) -> [String] {
1192-
return []
1193-
}
1194-
1195-
func clearSearchHistory(for itemType: POSItemType) {}
1196-
1197-
func clearAllSearchHistory() {}
1198-
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Testing
2+
import Foundation
3+
import enum Yosemite.POSItem
4+
import enum Yosemite.POSItemType
5+
import protocol Yosemite.POSSearchHistoryProviding
6+
@testable import WooCommerce
7+
8+
struct POSItemActionHandlerTests {
9+
@available(iOS 17.0, *)
10+
@Test func handleTap_when_attempt_to_add_duplicated_coupons_in_list_then_does_not_add_it_to_cart() async throws {
11+
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
12+
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
13+
couponsController: MockPointOfSaleCouponsController(),
14+
couponsSearchController: MockPointOfSaleCouponsController(),
15+
cardPresentPaymentService: MockCardPresentPaymentService(),
16+
orderController: MockPointOfSaleOrderController(),
17+
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
18+
searchHistoryService: MockPOSSearchHistoryService(),
19+
popularItemsController: MockPointOfSalePopularItemsController())
20+
let sut = StandardPOSItemActionHandler(posModel: aggregateModel,
21+
itemListType: .coupons(search: false))
22+
23+
let coupon = makeCouponItem(code: "DISCOUNT!")
24+
25+
sut.handleTap(coupon)
26+
sut.handleTap(coupon)
27+
sut.handleTap(coupon)
28+
29+
#expect(aggregateModel.cart.coupons.count == 1)
30+
}
31+
32+
@available(iOS 17.0, *)
33+
@Test func handleTap_when_attempt_to_add_duplicated_coupons_in_search_then_does_not_add_it_to_cart() async throws {
34+
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
35+
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
36+
couponsController: MockPointOfSaleCouponsController(),
37+
couponsSearchController: MockPointOfSaleCouponsController(),
38+
cardPresentPaymentService: MockCardPresentPaymentService(),
39+
orderController: MockPointOfSaleOrderController(),
40+
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
41+
searchHistoryService: MockPOSSearchHistoryService(),
42+
popularItemsController: MockPointOfSalePopularItemsController())
43+
let sut = SearchResultItemActionHandler(posModel: aggregateModel,
44+
searchTerm: "",
45+
itemListType: .coupons(search: true))
46+
47+
let coupon = makeCouponItem(code: "DISCOUNT!")
48+
49+
sut.handleTap(coupon)
50+
sut.handleTap(coupon)
51+
sut.handleTap(coupon)
52+
53+
#expect(aggregateModel.cart.coupons.count == 1)
54+
}
55+
56+
@available(iOS 17.0, *)
57+
@Test func handleTap_when_attempt_to_add_duplicated_products_in_list_then_adds_them_to_cart() async throws {
58+
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
59+
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
60+
couponsController: MockPointOfSaleCouponsController(),
61+
couponsSearchController: MockPointOfSaleCouponsController(),
62+
cardPresentPaymentService: MockCardPresentPaymentService(),
63+
orderController: MockPointOfSaleOrderController(),
64+
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
65+
searchHistoryService: MockPOSSearchHistoryService(),
66+
popularItemsController: MockPointOfSalePopularItemsController())
67+
let sut = StandardPOSItemActionHandler(posModel: aggregateModel,
68+
itemListType: .products(search: false))
69+
70+
let product = makeProductItem()
71+
72+
sut.handleTap(product)
73+
sut.handleTap(product)
74+
sut.handleTap(product)
75+
76+
#expect(aggregateModel.cart.purchasableItems.count == 3)
77+
}
78+
79+
@available(iOS 17.0, *)
80+
@Test func handleTap_when_attempt_to_add_duplicated_products_in_search_then_adds_them_to_cart() async throws {
81+
let aggregateModel = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(),
82+
purchasableItemsSearchController: MockPointOfSalePurchasableItemsSearchController(),
83+
couponsController: MockPointOfSaleCouponsController(),
84+
couponsSearchController: MockPointOfSaleCouponsController(),
85+
cardPresentPaymentService: MockCardPresentPaymentService(),
86+
orderController: MockPointOfSaleOrderController(),
87+
collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker(),
88+
searchHistoryService: MockPOSSearchHistoryService(),
89+
popularItemsController: MockPointOfSalePopularItemsController())
90+
let sut = SearchResultItemActionHandler(posModel: aggregateModel,
91+
searchTerm: "",
92+
itemListType: .coupons(search: true))
93+
94+
let product = makeProductItem()
95+
96+
sut.handleTap(product)
97+
sut.handleTap(product)
98+
sut.handleTap(product)
99+
100+
#expect(aggregateModel.cart.purchasableItems.count == 3)
101+
}
102+
}
103+
104+
private func makeCouponItem(code: String = "") -> POSItem {
105+
return .coupon(.init(id: UUID(), code: code))
106+
}
107+
108+
private func makeProductItem() -> POSItem {
109+
return .simpleProduct(.init(id: UUID(), name: "some product name", formattedPrice: "$10.00", productID: 123, price: "10"))
110+
}

0 commit comments

Comments
 (0)