Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ extension Storage.GeneralStoreSettings {
lastSelectedOrderStatus: NullableCopiableProp<String> = .copy,
favoriteProductIDs: CopiableProp<[Int64]> = .copy,
searchTermsByKey: CopiableProp<[String: [String]]> = .copy,
isPOSTabVisible: NullableCopiableProp<Bool> = .copy
isPOSTabVisible: NullableCopiableProp<Bool> = .copy,
posLastFullSyncDate: NullableCopiableProp<Date> = .copy
) -> Storage.GeneralStoreSettings {
let storeID = storeID ?? self.storeID
let isTelemetryAvailable = isTelemetryAvailable ?? self.isTelemetryAvailable
Expand All @@ -137,6 +138,7 @@ extension Storage.GeneralStoreSettings {
let favoriteProductIDs = favoriteProductIDs ?? self.favoriteProductIDs
let searchTermsByKey = searchTermsByKey ?? self.searchTermsByKey
let isPOSTabVisible = isPOSTabVisible ?? self.isPOSTabVisible
let posLastFullSyncDate = posLastFullSyncDate ?? self.posLastFullSyncDate

return Storage.GeneralStoreSettings(
storeID: storeID,
Expand All @@ -158,7 +160,8 @@ extension Storage.GeneralStoreSettings {
lastSelectedOrderStatus: lastSelectedOrderStatus,
favoriteProductIDs: favoriteProductIDs,
searchTermsByKey: searchTermsByKey,
isPOSTabVisible: isPOSTabVisible
isPOSTabVisible: isPOSTabVisible,
posLastFullSyncDate: posLastFullSyncDate
)
}
}
12 changes: 10 additions & 2 deletions Modules/Sources/Storage/Model/GeneralStoreSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
///
public var isPOSTabVisible: Bool?

/// Last time a POS catalog full sync was completed for this store.
///
public var posLastFullSyncDate: Date?

public init(storeID: String? = nil,
isTelemetryAvailable: Bool = false,
telemetryLastReportedTime: Date? = nil,
Expand All @@ -105,7 +109,8 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
lastSelectedOrderStatus: String? = nil,
favoriteProductIDs: [Int64] = [],
searchTermsByKey: [String: [String]] = [:],
isPOSTabVisible: Bool? = nil) {
isPOSTabVisible: Bool? = nil,
posLastFullSyncDate: Date? = nil) {
self.storeID = storeID
self.isTelemetryAvailable = isTelemetryAvailable
self.telemetryLastReportedTime = telemetryLastReportedTime
Expand All @@ -126,6 +131,7 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
self.favoriteProductIDs = favoriteProductIDs
self.searchTermsByKey = searchTermsByKey
self.isPOSTabVisible = isPOSTabVisible
self.posLastFullSyncDate = posLastFullSyncDate
}

public func erasingSelectedTaxRateID() -> GeneralStoreSettings {
Expand All @@ -147,7 +153,8 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable {
lastSelectedOrderStatus: lastSelectedOrderStatus,
favoriteProductIDs: favoriteProductIDs,
searchTermsByKey: searchTermsByKey,
isPOSTabVisible: isPOSTabVisible)
isPOSTabVisible: isPOSTabVisible,
posLastFullSyncDate: posLastFullSyncDate)
}
}

Expand Down Expand Up @@ -182,6 +189,7 @@ extension GeneralStoreSettings {
self.searchTermsByKey = try container.decodeIfPresent([String: [String]].self, forKey: .searchTermsByKey) ?? [:]

self.isPOSTabVisible = try container.decodeIfPresent(Bool.self, forKey: .isPOSTabVisible)
self.posLastFullSyncDate = try container.decodeIfPresent(Date.self, forKey: .posLastFullSyncDate)

// Decode new properties with `decodeIfPresent` and provide a default value if necessary.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public protocol SiteSpecificAppSettingsStoreMethodsProtocol {
// Search history methods
func getSearchTerms(for itemType: POSItemType, siteID: Int64) -> [String]
func setSearchTerms(_ terms: [String], for itemType: POSItemType, siteID: Int64)

// POS catalog sync timestamp methods
func getPOSLastFullSyncDate(for siteID: Int64) -> Date?
func setPOSLastFullSyncDate(_ date: Date?, for siteID: Int64)
}

/// Methods for managing site-specific app settings
Expand Down Expand Up @@ -98,6 +102,20 @@ extension SiteSpecificAppSettingsStoreMethods {
}
}

// MARK: - POS Catalog Sync Timestamps
extension SiteSpecificAppSettingsStoreMethods {
func getPOSLastFullSyncDate(for siteID: Int64) -> Date? {
let storeSettings = getStoreSettings(for: siteID)
return storeSettings.posLastFullSyncDate
}

func setPOSLastFullSyncDate(_ date: Date?, for siteID: Int64) {
let storeSettings = getStoreSettings(for: siteID)
let updatedSettings = storeSettings.copy(posLastFullSyncDate: date)
setStoreSettings(settings: updatedSettings, for: siteID)
}
}

// MARK: - Constants
private enum Constants {
static let generalStoreSettingsFileName = "general-store-settings.plist"
Expand Down
63 changes: 63 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation
import Storage

public protocol POSCatalogSyncCoordinatorProtocol {
/// Performs a full catalog sync for the specified site
/// - Parameter siteID: The site ID to sync catalog for
/// - Returns: The synced catalog containing products and variations
func performFullSync(for siteID: Int64) async throws -> POSCatalog

/// Determines if a full sync should be performed based on the age of the last sync
/// - Parameters:
/// - siteID: The site ID to check
/// - maxAge: Maximum age before a sync is considered stale
/// - Returns: True if a sync should be performed
func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) -> Bool
}

public final class POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
private let syncService: POSCatalogFullSyncServiceProtocol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could be named fullSyncService to differentiate from incremental sync when both syncs are supported.

private let settingsStore: SiteSpecificAppSettingsStoreMethodsProtocol

public init(syncService: POSCatalogFullSyncServiceProtocol,
settingsStore: SiteSpecificAppSettingsStoreMethodsProtocol? = nil) {
self.syncService = syncService
self.settingsStore = settingsStore ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage())
}

public func performFullSync(for siteID: Int64) async throws -> POSCatalog {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think we could remove the POSCatalog as the return value, and in the protocol. In POSCatalogFullSyncService.startFullSync, the returned catalog is based on the API response, not the final catalog in the database which is the source of truth for POS. As we discovered on the Large fun testing test site, the API response could include duplicate items.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, agreed. I wasn't sure whether it would break anything for you if I did that, but if you're happy I'll take it out.

DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")

let catalog = try await syncService.startFullSync(for: siteID)

// Record the sync timestamp
settingsStore.setPOSLastFullSyncDate(Date(), for: siteID)

DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)")
return catalog
}

public func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) -> Bool {
guard let lastSyncDate = lastFullSyncDate(for: siteID) else {
DDLogInfo("📋 POSCatalogSyncCoordinator: No previous sync found for site \(siteID), sync needed")
return true
}

let age = Date().timeIntervalSince(lastSyncDate)
let shouldSync = age > maxAge

if shouldSync {
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync needed")
} else {
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync not needed")
}

return shouldSync
}

// MARK: - Private

private func lastFullSyncDate(for siteID: Int64) -> Date? {
return settingsStore.getPOSLastFullSyncDate(for: siteID)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@testable import Yosemite
import Foundation
import Storage

final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStoreMethodsProtocol {
Expand Down Expand Up @@ -27,6 +28,13 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor
var spySetSearchTermsSiteID: Int64?
var mockSearchTerms: [POSItemType: [String]] = [:]

// POS sync timestamp properties
var storedDates: [Int64: Date] = [:]
private(set) var getPOSLastFullSyncDateCallCount = 0
private(set) var setPOSLastFullSyncDateCallCount = 0
private(set) var lastSetSiteID: Int64?
private(set) var lastSetDate: Date?

func getStoreSettings(for siteID: Int64) -> GeneralStoreSettings {
getStoreSettingsCalled = true
return storeSettings
Expand Down Expand Up @@ -83,4 +91,16 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor
spySetSearchTermsSiteID = siteID
mockSearchTerms[itemType] = terms
}

func getPOSLastFullSyncDate(for siteID: Int64) -> Date? {
getPOSLastFullSyncDateCallCount += 1
return storedDates[siteID]
}

func setPOSLastFullSyncDate(_ date: Date?, for siteID: Int64) {
setPOSLastFullSyncDateCallCount += 1
lastSetSiteID = siteID
lastSetDate = date
storedDates[siteID] = date
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,144 @@ struct SiteSpecificAppSettingsStoreMethodsTests {
#expect(retrievedVariationTerms == variationTerms)
#expect(retrievedCouponTerms == couponTerms)
}


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: could remove extra empty lines


// MARK: - POS Last Full Sync Date Tests

@Test func getPOSLastFullSyncDate_returns_nil_when_no_date_exists() {
// When
let syncDate = sut.getPOSLastFullSyncDate(for: siteID)

// Then
#expect(syncDate == nil)
}

@Test func getPOSLastFullSyncDate_returns_saved_date() throws {
// Given
let expectedDate = Date()
let storeSettings = GeneralStoreSettings(posLastFullSyncDate: expectedDate)
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: storeSettings])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

// When
let syncDate = sut.getPOSLastFullSyncDate(for: siteID)

// Then
#expect(syncDate == expectedDate)
}

@Test func setPOSLastFullSyncDate_saves_date_successfully() throws {
// Given
let dateToSave = Date()
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: GeneralStoreSettings()])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

// When
sut.setPOSLastFullSyncDate(dateToSave, for: siteID)

// Then
let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)
#expect(savedData.storeSettingsBySite[siteID]?.posLastFullSyncDate == dateToSave)
}

@Test func setPOSLastFullSyncDate_can_set_nil_date() throws {
// Given
let existingDate = Date()
let storeSettings = GeneralStoreSettings(posLastFullSyncDate: existingDate)
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: storeSettings])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

// When
sut.setPOSLastFullSyncDate(nil, for: siteID)

// Then
let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)
#expect(savedData.storeSettingsBySite[siteID]?.posLastFullSyncDate == nil)
}

@Test func setPOSLastFullSyncDate_preserves_other_settings() throws {
// Given
let existingStoreID = "existing-store"
let existingTerms = ["existing", "terms"]
let storeSettings = GeneralStoreSettings(
storeID: existingStoreID,
searchTermsByKey: ["product_search_terms": existingTerms]
)
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: storeSettings])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

let dateToSave = Date()

// When
sut.setPOSLastFullSyncDate(dateToSave, for: siteID)

// Then
let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)
let savedSettings = savedData.storeSettingsBySite[siteID]
#expect(savedSettings?.posLastFullSyncDate == dateToSave)
#expect(savedSettings?.storeID == existingStoreID)
#expect(savedSettings?.searchTermsByKey["product_search_terms"] == existingTerms)
}

@Test func setPOSLastFullSyncDate_preserves_dates_for_other_sites() throws {
// Given
let otherSiteID: Int64 = 456
let otherSiteDate = Date().addingTimeInterval(-3600)
let otherSiteSettings = GeneralStoreSettings(posLastFullSyncDate: otherSiteDate)
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [otherSiteID: otherSiteSettings])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

let newDate = Date()

// When
sut.setPOSLastFullSyncDate(newDate, for: siteID)

// Then
let savedData: GeneralStoreSettingsBySite = try fileStorage.data(for: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)
#expect(savedData.storeSettingsBySite[siteID]?.posLastFullSyncDate == newDate)
#expect(savedData.storeSettingsBySite[otherSiteID]?.posLastFullSyncDate == otherSiteDate)
}

@Test func getPOSLastFullSyncDate_handles_different_sites_independently() throws {
// Given
let siteA: Int64 = 123
let siteB: Int64 = 456
let dateA = Date()
let dateB = Date().addingTimeInterval(-3600)

let storeSettingsA = GeneralStoreSettings(posLastFullSyncDate: dateA)
let storeSettingsB = GeneralStoreSettings(posLastFullSyncDate: dateB)
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [
siteA: storeSettingsA,
siteB: storeSettingsB
])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

// When
let retrievedDateA = sut.getPOSLastFullSyncDate(for: siteA)
let retrievedDateB = sut.getPOSLastFullSyncDate(for: siteB)

// Then
#expect(retrievedDateA == dateA)
#expect(retrievedDateB == dateB)
}

@Test func resetStoreSettings_clears_pos_sync_date() throws {
// Given
let syncDate = Date()
let storeSettings = GeneralStoreSettings(posLastFullSyncDate: syncDate)
let existingData = GeneralStoreSettingsBySite(storeSettingsBySite: [siteID: storeSettings])
try fileStorage.write(existingData, to: SiteSpecificAppSettingsStoreMethods.defaultGeneralStoreSettingsFileURL)

// When
sut.resetStoreSettings()

// Then
#expect(fileStorage.deleteIsHit == true)
let retrievedDate = sut.getPOSLastFullSyncDate(for: siteID)
#expect(retrievedDate == nil)
}
}

// MARK: - Mock FileStorage
Expand Down
Loading