Skip to content

Commit 0d7a052

Browse files
authored
[POS as a tab i2] Integrate with core API to enable feature switch from ineligible UI (#15923)
2 parents d7f96c9 + f8f740a commit 0d7a052

File tree

8 files changed

+253
-15
lines changed

8 files changed

+253
-15
lines changed

Modules/Sources/Networking/Remote/SiteSettingsRemote.swift

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import Foundation
22

3+
/// Protocol for SiteSettingsRemote to enable testing.
4+
public protocol SiteSettingsRemoteProtocol {
5+
func setFeature(for siteID: Int64, feature: SiteSettingsFeature, enabled: Bool) async throws -> Bool
6+
}
7+
38
/// Features that can be enabled/disabled in core, under WC Settings > Advanced > Features.
49
public enum SiteSettingsFeature {
510
case pointOfSale
@@ -114,16 +119,49 @@ public class SiteSettingsRemote: Remote {
114119
let mapper = SiteSettingMapper(siteID: siteID, settingsGroup: .advanced)
115120
let response = try await enqueue(request, mapper: mapper)
116121
switch response.value {
117-
case "yes":
122+
case Constants.featureEnabledValue:
123+
return true
124+
case Constants.featureDisabledValue:
125+
return false
126+
default:
127+
throw SiteSettingsRemoteError.invalidResponse
128+
}
129+
}
130+
131+
/// Enables or disables a specific feature in the site WC settings.
132+
///
133+
/// - Parameters:
134+
/// - siteID: Site for which we'll update the feature status.
135+
/// - feature: The feature to enable or disable.
136+
/// - enabled: Whether the feature should be enabled (true) or disabled (false).
137+
/// - Returns: A boolean indicating the updated feature status.
138+
/// - Throws: Error if the request fails.
139+
///
140+
public func setFeature(for siteID: Int64, feature: SiteSettingsFeature, enabled: Bool) async throws -> Bool {
141+
let value = enabled ? Constants.featureEnabledValue : Constants.featureDisabledValue
142+
let parameters: [String: Any] = [Constants.valueParameter: value]
143+
let path = Constants.siteSettingsPath + Constants.advancedSettingsGroup + "/woocommerce_feature_\(feature.rawValue)_enabled"
144+
let request = JetpackRequest(wooApiVersion: .mark3,
145+
method: .put,
146+
siteID: siteID,
147+
path: path,
148+
parameters: parameters,
149+
availableAsRESTRequest: true)
150+
let mapper = SiteSettingMapper(siteID: siteID, settingsGroup: .advanced)
151+
let response = try await enqueue(request, mapper: mapper)
152+
switch response.value {
153+
case Constants.featureEnabledValue:
118154
return true
119-
case "no":
155+
case Constants.featureDisabledValue:
120156
return false
121157
default:
122158
throw SiteSettingsRemoteError.invalidResponse
123159
}
124160
}
125161
}
126162

163+
extension SiteSettingsRemote: SiteSettingsRemoteProtocol {}
164+
127165
public extension SiteSettingsFeature {
128166
var rawValue: String {
129167
switch self {
@@ -142,6 +180,8 @@ private extension SiteSettingsRemote {
142180
static let productSettingsGroup: String = "products"
143181
static let advancedSettingsGroup: String = "advanced"
144182
static let valueParameter: String = "value"
183+
static let featureEnabledValue: String = "yes"
184+
static let featureDisabledValue: String = "no"
145185
}
146186
}
147187

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
import Networking
3+
4+
/// Protocol for POS site setting service that manages WooCommerce feature flags.
5+
public protocol POSSiteSettingServiceProtocol {
6+
/// Enables or disables a specific feature in the site WC settings.
7+
/// - Parameters:
8+
/// - siteID: The site ID for which to update the feature setting.
9+
/// - feature: The feature to enable or disable.
10+
/// - enabled: Whether the feature should be enabled (true) or disabled (false).
11+
/// - Returns: A boolean indicating the updated feature status.
12+
/// - Throws: Error if the request fails or the setting cannot be updated.
13+
func setFeature(siteID: Int64, feature: SiteSettingsFeature, enabled: Bool) async throws -> Bool
14+
}
15+
16+
/// Service for managing Point of Sale related site settings.
17+
public final class POSSiteSettingService: POSSiteSettingServiceProtocol {
18+
private let remote: SiteSettingsRemoteProtocol
19+
20+
public init(credentials: Credentials?) {
21+
let network = AlamofireNetwork(credentials: credentials)
22+
self.remote = SiteSettingsRemote(network: network)
23+
}
24+
25+
/// Test-friendly initializer that accepts a remote implementation.
26+
/// - Parameter remote: The remote service implementation to use for network requests.
27+
init(remote: SiteSettingsRemoteProtocol) {
28+
self.remote = remote
29+
}
30+
31+
public func setFeature(siteID: Int64, feature: SiteSettingsFeature, enabled: Bool) async throws -> Bool {
32+
try await remote.setFeature(for: siteID, feature: feature, enabled: enabled)
33+
}
34+
}

Modules/Tests/NetworkingTests/Remote/SiteSettingsRemoteTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,48 @@ final class SiteSettingsRemoteTests: XCTestCase {
228228
XCTAssertEqual(error as? NetworkError, .unacceptableStatusCode(statusCode: 500))
229229
}
230230
}
231+
232+
// MARK: - `setFeature`
233+
234+
func test_setFeature_enables_feature_when_enabled_is_true() async throws {
235+
// Given
236+
let remote = SiteSettingsRemote(network: network)
237+
network.simulateResponse(requestUrlSuffix: "settings/advanced/woocommerce_feature_point_of_sale_enabled",
238+
filename: "settings-advanced-feature-pos-enabled")
239+
240+
// When
241+
let isEnabled = try await remote.setFeature(for: sampleSiteID, feature: .pointOfSale, enabled: true)
242+
243+
// Then
244+
XCTAssertTrue(isEnabled)
245+
}
246+
247+
func test_setFeature_disables_feature_when_enabled_is_false() async throws {
248+
// Given
249+
let remote = SiteSettingsRemote(network: network)
250+
network.simulateResponse(requestUrlSuffix: "settings/advanced/woocommerce_feature_point_of_sale_enabled",
251+
filename: "settings-advanced-feature-pos-disabled")
252+
253+
// When
254+
let isEnabled = try await remote.setFeature(for: sampleSiteID, feature: .pointOfSale, enabled: false)
255+
256+
// Then
257+
XCTAssertFalse(isEnabled)
258+
}
259+
260+
func test_setFeature_throws_error_when_network_fails() async {
261+
// Given
262+
let remote = SiteSettingsRemote(network: network)
263+
let error = NetworkError.unacceptableStatusCode(statusCode: 500)
264+
network.simulateError(requestUrlSuffix: "settings/advanced/woocommerce_feature_point_of_sale_enabled",
265+
error: error)
266+
267+
// When/Then
268+
do {
269+
_ = try await remote.setFeature(for: sampleSiteID, feature: .pointOfSale, enabled: true)
270+
XCTFail("Expected error to be thrown")
271+
} catch {
272+
XCTAssertEqual(error as? NetworkError, .unacceptableStatusCode(statusCode: 500))
273+
}
274+
}
231275
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Networking
2+
3+
final class MockSiteSettingsRemote: SiteSettingsRemoteProtocol {
4+
var setFeatureCalled: Bool = false
5+
var spySetFeatureSiteID: Int64?
6+
var spySetFeatureFeature: SiteSettingsFeature?
7+
var spySetFeatureEnabled: Bool?
8+
var setFeatureResult: Result<Bool, Error> = .success(true)
9+
10+
func setFeature(for siteID: Int64, feature: SiteSettingsFeature, enabled: Bool) async throws -> Bool {
11+
setFeatureCalled = true
12+
spySetFeatureSiteID = siteID
13+
spySetFeatureFeature = feature
14+
spySetFeatureEnabled = enabled
15+
16+
switch setFeatureResult {
17+
case .success(let result):
18+
return result
19+
case .failure(let error):
20+
throw error
21+
}
22+
}
23+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
import Testing
3+
@testable import Yosemite
4+
@testable import Networking
5+
6+
struct POSSiteSettingServiceTests {
7+
private let sut: POSSiteSettingService
8+
private let mockRemote: MockSiteSettingsRemote
9+
private let sampleSiteID: Int64 = 123
10+
11+
init() {
12+
let mockRemote = MockSiteSettingsRemote()
13+
self.mockRemote = mockRemote
14+
self.sut = POSSiteSettingService(remote: mockRemote)
15+
}
16+
17+
@Test(arguments: [true, false])
18+
func setFeature_calls_remote_with_correct_parameters(featureEnabled: Bool) async throws {
19+
// Given
20+
let feature = SiteSettingsFeature.pointOfSale
21+
mockRemote.setFeatureResult = .success(true)
22+
23+
// When
24+
let result = try await sut.setFeature(siteID: sampleSiteID, feature: feature, enabled: featureEnabled)
25+
26+
// Then
27+
#expect(mockRemote.setFeatureCalled == true)
28+
#expect(mockRemote.spySetFeatureSiteID == sampleSiteID)
29+
#expect(mockRemote.spySetFeatureFeature == feature)
30+
#expect(mockRemote.spySetFeatureEnabled == featureEnabled)
31+
#expect(result == true)
32+
}
33+
34+
@Test(arguments: [true, false])
35+
func setFeature_returns_false_when_remote_returns_false(remoteFeatureEnabled: Bool) async throws {
36+
// Given
37+
let feature = SiteSettingsFeature.pointOfSale
38+
mockRemote.setFeatureResult = .success(remoteFeatureEnabled)
39+
40+
// When
41+
let result = try await sut.setFeature(siteID: sampleSiteID, feature: feature, enabled: true)
42+
43+
// Then
44+
#expect(result == remoteFeatureEnabled)
45+
}
46+
47+
@Test func setFeature_throws_error_when_remote_throws_error() async throws {
48+
// Given
49+
let feature = SiteSettingsFeature.pointOfSale
50+
let expectedError = SiteSettingsRemoteError.invalidResponse
51+
mockRemote.setFeatureResult = .failure(expectedError)
52+
53+
// When/Then
54+
await #expect(performing: {
55+
try await sut.setFeature(siteID: sampleSiteID, feature: feature, enabled: true)
56+
}, throws: { error in
57+
return error as? SiteSettingsRemoteError == expectedError
58+
})
59+
}
60+
}

WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ struct POSIneligibleView: View {
5151
}
5252
}
5353
} label: {
54-
Text(Localization.refreshEligibility)
54+
Text(reason.refreshEligibilityTitle)
5555
}
5656
.buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isLoading))
5757
.renderedIf(reason.shouldShowRetryButton)
@@ -92,9 +92,9 @@ struct POSIneligibleView: View {
9292
"If there is still an issue, please contact support for assistance.",
9393
comment: "Suggestion for missing WooCommerce plugin: install plugin")
9494
case .featureSwitchDisabled:
95-
return NSLocalizedString("pos.ineligible.suggestion.featureSwitchDisabled",
95+
return NSLocalizedString("pos.ineligible.suggestion.featureSwitchDisabled.2",
9696
value: "Point of Sale must be enabled to proceed. " +
97-
"Please enable the POS feature from your WordPress admin under WooCommerce settings > Advanced > Features.",
97+
"You can enable the POS feature below or from your WordPress admin under WooCommerce settings > Advanced > Features.",
9898
comment: "Suggestion for disabled feature switch: enable feature in WooCommerce settings")
9999
case let .unsupportedCurrency(supportedCurrencies):
100100
let currencyList = supportedCurrencies.map { $0.rawValue }
@@ -128,12 +128,6 @@ private extension POSIneligibleView {
128128
comment: "Title shown in POS ineligible view"
129129
)
130130

131-
static let refreshEligibility = NSLocalizedString(
132-
"pos.ineligible.refresh.button.title",
133-
value: "Retry",
134-
comment: "Button title to refresh POS eligibility check"
135-
)
136-
137131
static let dismiss = NSLocalizedString(
138132
"pos.ineligible.dismiss.button.title",
139133
value: "Exit POS",
@@ -157,6 +151,28 @@ private extension POSIneligibleReason {
157151
return true
158152
}
159153
}
154+
155+
var refreshEligibilityTitle: String {
156+
switch self {
157+
case .featureSwitchDisabled:
158+
return NSLocalizedString(
159+
"pos.ineligible.enable.pos.feature.and.refresh.button.title",
160+
value: "Enable POS & Retry",
161+
comment: "Button title to enable the POS feature switch and refresh POS eligibility check"
162+
)
163+
case .unsupportedIOSVersion,
164+
.unsupportedWooCommerceVersion,
165+
.siteSettingsNotAvailable,
166+
.wooCommercePluginNotFound,
167+
.unsupportedCurrency,
168+
.selfDeallocated:
169+
return NSLocalizedString(
170+
"pos.ineligible.refresh.button.title",
171+
value: "Retry",
172+
comment: "Button title to refresh POS eligibility check"
173+
)
174+
}
175+
}
160176
}
161177

162178
#if DEBUG

WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import enum Yosemite.FeatureFlagAction
1313
import enum Yosemite.SettingAction
1414
import protocol Yosemite.POSSystemStatusServiceProtocol
1515
import class Yosemite.POSSystemStatusService
16+
import protocol Yosemite.POSSiteSettingServiceProtocol
17+
import class Yosemite.POSSiteSettingService
18+
import enum Networking.SiteSettingsFeature
1619

1720
/// Represents the reasons why a site may be ineligible for POS.
1821
enum POSIneligibleReason: Equatable {
@@ -50,6 +53,7 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
5053
private let stores: StoresManager
5154
private let featureFlagService: FeatureFlagService
5255
private let systemStatusService: POSSystemStatusServiceProtocol
56+
private let siteSettingService: POSSiteSettingServiceProtocol
5357

5458
init(siteID: Int64,
5559
userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom,
@@ -58,14 +62,16 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
5862
stores: StoresManager = ServiceLocator.stores,
5963
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
6064
systemStatusService: POSSystemStatusServiceProtocol = POSSystemStatusService(credentials: ServiceLocator.stores.sessionManager.defaultCredentials,
61-
storageManager: ServiceLocator.storageManager)) {
65+
storageManager: ServiceLocator.storageManager),
66+
siteSettingService: POSSiteSettingServiceProtocol = POSSiteSettingService(credentials: ServiceLocator.stores.sessionManager.defaultCredentials)) {
6267
self.siteID = siteID
6368
self.userInterfaceIdiom = userInterfaceIdiom
6469
self.siteSettings = siteSettings
6570
self.eligibilityService = eligibilityService
6671
self.stores = stores
6772
self.featureFlagService = featureFlagService
6873
self.systemStatusService = systemStatusService
74+
self.siteSettingService = siteSettingService
6975
}
7076

7177
/// Checks the initial visibility of the POS tab without dependance on network requests.
@@ -133,8 +139,7 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol {
133139
case .unsupportedWooCommerceVersion, .wooCommercePluginNotFound:
134140
return await checkEligibility()
135141
case .featureSwitchDisabled:
136-
// TODO: WOOMOB-759 - enable feature switch via API and check eligibility again
137-
// For now, just checks eligibility again.
142+
_ = try await siteSettingService.setFeature(siteID: siteID, feature: .pointOfSale, enabled: true)
138143
return await checkEligibility()
139144
case .selfDeallocated:
140145
return await checkEligibility()

WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ struct POSTabEligibilityCheckerTests {
1212
private var siteSettings: MockSelectedSiteSettings!
1313
private var eligibilityService: MockPOSEligibilityService!
1414
private var mockSystemStatusService: MockPOSSystemStatusService!
15+
private var mockSiteSettingService: MockPOSSiteSettingService!
1516
private let siteID: Int64 = 2
1617

1718
init() async throws {
@@ -21,6 +22,7 @@ struct POSTabEligibilityCheckerTests {
2122
eligibilityService = MockPOSEligibilityService()
2223
siteSettings = MockSelectedSiteSettings()
2324
mockSystemStatusService = MockPOSSystemStatusService()
25+
mockSiteSettingService = MockPOSSiteSettingService()
2426
setupWooCommerceVersion()
2527
}
2628

@@ -622,7 +624,8 @@ struct POSTabEligibilityCheckerTests {
622624
let checker = POSTabEligibilityChecker(siteID: siteID,
623625
siteSettings: siteSettings,
624626
stores: stores,
625-
systemStatusService: mockSystemStatusService)
627+
systemStatusService: mockSystemStatusService,
628+
siteSettingService: mockSiteSettingService)
626629

627630
// When
628631
let result = try await checker.refreshEligibility(ineligibleReason: .featureSwitchDisabled)
@@ -873,3 +876,16 @@ private final class MockPOSSystemStatusService: POSSystemStatusServiceProtocol {
873876
}
874877
}
875878
}
879+
880+
private final class MockPOSSiteSettingService: POSSiteSettingServiceProtocol {
881+
var setFeatureResult: Result<Bool, Error> = .success(true)
882+
883+
func setFeature(siteID: Int64, feature: SiteSettingsFeature, enabled: Bool) async throws -> Bool {
884+
switch setFeatureResult {
885+
case .success(let result):
886+
return result
887+
case .failure(let error):
888+
throw error
889+
}
890+
}
891+
}

0 commit comments

Comments
 (0)