Skip to content

Commit 93f5692

Browse files
committed
Add catalog sync coordinator
1 parent e4abd4d commit 93f5692

File tree

4 files changed

+285
-2
lines changed

4 files changed

+285
-2
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Foundation
2+
import Storage
3+
import CocoaLumberjackSwift
4+
5+
public protocol POSCatalogSyncCoordinatorProtocol {
6+
/// Performs a full catalog sync for the specified site
7+
/// - Parameter siteID: The site ID to sync catalog for
8+
/// - Returns: The synced catalog containing products and variations
9+
func performFullSync(for siteID: Int64) async throws -> POSCatalog
10+
11+
/// Determines if a full sync should be performed based on the age of the last sync
12+
/// - Parameters:
13+
/// - siteID: The site ID to check
14+
/// - maxAge: Maximum age before a sync is considered stale
15+
/// - Returns: True if a sync should be performed
16+
func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) -> Bool
17+
}
18+
19+
public final class POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
20+
private let syncService: POSCatalogFullSyncServiceProtocol
21+
private let settingsStore: SiteSpecificAppSettingsStoreMethodsProtocol
22+
23+
public init(syncService: POSCatalogFullSyncServiceProtocol,
24+
settingsStore: SiteSpecificAppSettingsStoreMethodsProtocol? = nil) {
25+
self.syncService = syncService
26+
self.settingsStore = settingsStore ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage())
27+
}
28+
29+
public func performFullSync(for siteID: Int64) async throws -> POSCatalog {
30+
DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")
31+
32+
let catalog = try await syncService.startFullSync(for: siteID)
33+
34+
// Record the sync timestamp
35+
settingsStore.setPOSLastFullSyncDate(Date(), for: siteID)
36+
37+
DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)")
38+
return catalog
39+
}
40+
41+
public func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) -> Bool {
42+
guard let lastSyncDate = lastFullSyncDate(for: siteID) else {
43+
DDLogInfo("📋 POSCatalogSyncCoordinator: No previous sync found for site \(siteID), sync needed")
44+
return true
45+
}
46+
47+
let age = Date().timeIntervalSince(lastSyncDate)
48+
let shouldSync = age > maxAge
49+
50+
if shouldSync {
51+
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync needed")
52+
} else {
53+
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync not needed")
54+
}
55+
56+
return shouldSync
57+
}
58+
59+
// MARK: - Private
60+
61+
private func lastFullSyncDate(for siteID: Int64) -> Date? {
62+
return settingsStore.getPOSLastFullSyncDate(for: siteID)
63+
}
64+
}

Modules/Tests/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor
2828
var spySetSearchTermsSiteID: Int64?
2929
var mockSearchTerms: [POSItemType: [String]] = [:]
3030

31+
// POS sync timestamp properties
32+
var storedDates: [Int64: Date] = [:]
33+
private(set) var getPOSLastFullSyncDateCallCount = 0
34+
private(set) var setPOSLastFullSyncDateCallCount = 0
35+
private(set) var lastSetSiteID: Int64?
36+
private(set) var lastSetDate: Date?
37+
3138
func getStoreSettings(for siteID: Int64) -> GeneralStoreSettings {
3239
getStoreSettingsCalled = true
3340
return storeSettings
@@ -86,8 +93,14 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor
8693
}
8794

8895
func getPOSLastFullSyncDate(for siteID: Int64) -> Date? {
89-
return Date.now
96+
getPOSLastFullSyncDateCallCount += 1
97+
return storedDates[siteID]
9098
}
9199

92-
func setPOSLastFullSyncDate(_ date: Date?, for siteID: Int64) { }
100+
func setPOSLastFullSyncDate(_ date: Date?, for siteID: Int64) {
101+
setPOSLastFullSyncDateCallCount += 1
102+
lastSetSiteID = siteID
103+
lastSetDate = date
104+
storedDates[siteID] = date
105+
}
93106
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import Foundation
2+
import Testing
3+
@testable import Yosemite
4+
import Storage
5+
6+
struct POSCatalogSyncCoordinatorTests {
7+
private let mockSyncService: MockPOSCatalogFullSyncService
8+
private let mockSettingsStore: MockSiteSpecificAppSettingsStoreMethods
9+
private let sut: POSCatalogSyncCoordinator
10+
private let sampleSiteID: Int64 = 134
11+
12+
init() {
13+
self.mockSyncService = MockPOSCatalogFullSyncService()
14+
self.mockSettingsStore = MockSiteSpecificAppSettingsStoreMethods()
15+
self.sut = POSCatalogSyncCoordinator(
16+
syncService: mockSyncService,
17+
settingsStore: mockSettingsStore
18+
)
19+
}
20+
21+
// MARK: - Full Sync Tests
22+
23+
@Test func performFullSync_delegates_to_sync_service() async throws {
24+
// Given
25+
let expectedCatalog = POSCatalog(
26+
products: [POSProduct.fake()],
27+
variations: [POSProductVariation.fake()]
28+
)
29+
mockSyncService.startFullSyncResult = expectedCatalog
30+
31+
// When
32+
let result = try await sut.performFullSync(for: sampleSiteID)
33+
34+
// Then
35+
#expect(result.products.count == expectedCatalog.products.count)
36+
#expect(result.variations.count == expectedCatalog.variations.count)
37+
#expect(mockSyncService.startFullSyncCallCount == 1)
38+
#expect(mockSyncService.lastSyncSiteID == sampleSiteID)
39+
}
40+
41+
@Test func performFullSync_stores_sync_timestamp() async throws {
42+
// Given
43+
let beforeSync = Date()
44+
let expectedCatalog = POSCatalog(products: [], variations: [])
45+
mockSyncService.startFullSyncResult = expectedCatalog
46+
47+
// When
48+
_ = try await sut.performFullSync(for: sampleSiteID)
49+
let afterSync = Date()
50+
51+
// Then
52+
#expect(mockSettingsStore.setPOSLastFullSyncDateCallCount == 1)
53+
#expect(mockSettingsStore.lastSetSiteID == sampleSiteID)
54+
55+
let storedDate = mockSettingsStore.lastSetDate
56+
#expect(storedDate != nil)
57+
#expect(storedDate! >= beforeSync)
58+
#expect(storedDate! <= afterSync)
59+
}
60+
61+
@Test func performFullSync_propagates_errors() async throws {
62+
// Given
63+
let expectedError = NSError(domain: "sync", code: 500, userInfo: [NSLocalizedDescriptionKey: "Sync failed"])
64+
mockSyncService.startFullSyncError = expectedError
65+
66+
// When/Then
67+
await #expect(throws: expectedError) {
68+
_ = try await sut.performFullSync(for: sampleSiteID)
69+
}
70+
71+
// Should not store timestamp on failure
72+
#expect(mockSettingsStore.setPOSLastFullSyncDateCallCount == 0)
73+
}
74+
75+
// MARK: - Should Sync Decision Tests
76+
77+
@Test func shouldPerformFullSync_returns_true_when_no_previous_sync() {
78+
// Given - no previous sync date stored
79+
mockSettingsStore.storedDates = [:]
80+
81+
// When
82+
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 3600)
83+
84+
// Then
85+
#expect(shouldSync == true)
86+
#expect(mockSettingsStore.getPOSLastFullSyncDateCallCount == 1)
87+
}
88+
89+
@Test func shouldPerformFullSync_returns_true_when_sync_is_stale() {
90+
// Given - previous sync was 2 hours ago
91+
let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60)
92+
mockSettingsStore.storedDates[sampleSiteID] = twoHoursAgo
93+
94+
// When - max age is 1 hour
95+
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
96+
97+
// Then
98+
#expect(shouldSync == true)
99+
}
100+
101+
@Test func shouldPerformFullSync_returns_false_when_sync_is_fresh() {
102+
// Given - previous sync was 30 minutes ago
103+
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
104+
mockSettingsStore.storedDates[sampleSiteID] = thirtyMinutesAgo
105+
106+
// When - max age is 1 hour
107+
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 60 * 60)
108+
109+
// Then
110+
#expect(shouldSync == false)
111+
}
112+
113+
@Test func shouldPerformFullSync_handles_different_sites_independently() {
114+
// Given
115+
let siteA: Int64 = 123
116+
let siteB: Int64 = 456
117+
let oneHourAgo = Date().addingTimeInterval(-60 * 60)
118+
119+
mockSettingsStore.storedDates[siteA] = oneHourAgo // Has previous sync
120+
// siteB has no previous sync
121+
122+
// When
123+
let shouldSyncA = sut.shouldPerformFullSync(for: siteA, maxAge: 2 * 60 * 60) // 2 hours
124+
let shouldSyncB = sut.shouldPerformFullSync(for: siteB, maxAge: 2 * 60 * 60) // 2 hours
125+
126+
// Then
127+
#expect(shouldSyncA == false) // Recent sync exists
128+
#expect(shouldSyncB == true) // No previous sync
129+
}
130+
131+
@Test func shouldPerformFullSync_with_zero_maxAge_always_returns_true() {
132+
// Given - previous sync was just now
133+
let justNow = Date()
134+
mockSettingsStore.storedDates[sampleSiteID] = justNow
135+
136+
// When - max age is 0 (always sync)
137+
let shouldSync = sut.shouldPerformFullSync(for: sampleSiteID, maxAge: 0)
138+
139+
// Then
140+
#expect(shouldSync == true)
141+
}
142+
}
143+
144+
// MARK: - Mock Services
145+
146+
final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol {
147+
var startFullSyncResult: POSCatalog = POSCatalog(products: [], variations: [])
148+
var startFullSyncError: Error?
149+
150+
private(set) var startFullSyncCallCount = 0
151+
private(set) var lastSyncSiteID: Int64?
152+
153+
func startFullSync(for siteID: Int64) async throws -> POSCatalog {
154+
startFullSyncCallCount += 1
155+
lastSyncSiteID = siteID
156+
157+
if let error = startFullSyncError {
158+
throw error
159+
}
160+
161+
return startFullSyncResult
162+
}
163+
}
164+

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ final class MainTabBarController: UITabBarController {
130130

131131
private var posEligibilityChecker: POSEntryPointEligibilityCheckerProtocol?
132132
private var posEligibilityCheckTask: Task<Void, Never>?
133+
private var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?
133134

134135
private var isPOSTabVisible: Bool = false
135136

@@ -685,6 +686,11 @@ private extension MainTabBarController {
685686
cachePOSTabVisibility(siteID: siteID, isPOSTabVisible: isPOSTabVisible)
686687
updateTabViewControllers(isPOSTabVisible: isPOSTabVisible)
687688
viewModel.loadHubMenuTabBadge()
689+
690+
// Trigger POS catalog sync if tab is visible and feature flag is enabled
691+
if isPOSTabVisible, ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) {
692+
await triggerPOSCatalogSyncIfNeeded(for: siteID)
693+
}
688694
}
689695
}
690696

@@ -760,6 +766,11 @@ private extension MainTabBarController {
760766
eligibilityChecker: posEligibilityChecker
761767
)
762768

769+
// Configure POS catalog sync coordinator for local catalog syncing
770+
if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) {
771+
posCatalogSyncCoordinator = createPOSCatalogSyncCoordinator(siteID: siteID)
772+
}
773+
763774
// Configure hub menu tab coordinator once per logged in session potentially with multiple sites.
764775
if hubMenuTabCoordinator == nil {
765776
let hubTabCoordinator = createHubMenuTabCoordinator()
@@ -781,6 +792,37 @@ private extension MainTabBarController {
781792
OrdersSplitViewWrapperController(siteID: siteID)
782793
}
783794

795+
func createPOSCatalogSyncCoordinator(siteID: Int64) -> POSCatalogSyncCoordinatorProtocol? {
796+
guard let credentials = ServiceLocator.stores.sessionManager.defaultCredentials,
797+
let syncService = POSCatalogFullSyncService(credentials: credentials, grdbManager: ServiceLocator.grdbManager)
798+
else {
799+
return nil
800+
}
801+
802+
return POSCatalogSyncCoordinator(syncService: syncService)
803+
}
804+
805+
func triggerPOSCatalogSyncIfNeeded(for siteID: Int64) async {
806+
guard let coordinator = posCatalogSyncCoordinator else {
807+
return
808+
}
809+
810+
// Check if sync is needed (older than 24 hours)
811+
let maxAge: TimeInterval = 24 * 60 * 60
812+
guard coordinator.shouldPerformFullSync(for: siteID, maxAge: maxAge) else {
813+
return
814+
}
815+
816+
// Perform background sync
817+
Task.detached {
818+
do {
819+
_ = try await coordinator.performFullSync(for: siteID)
820+
} catch {
821+
DDLogError("⚠️ POS catalog sync failed: \(error)")
822+
}
823+
}
824+
}
825+
784826
func createHubMenuTabCoordinator() -> HubMenuCoordinator {
785827
HubMenuCoordinator(tabContainerController: hubMenuContainerController,
786828
storesManager: stores,

0 commit comments

Comments
 (0)