Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e6d4156
Merge POS catalog sync tasks into one
staskus Oct 14, 2025
a2d4d6b
Move TimeProvider to WooFoundation
staskus Oct 14, 2025
31f5aec
Create BackgroundTaskSchedule to provide a prioritization mechanism
staskus Oct 14, 2025
dc026a8
Integrate BackgroundTaskSchedule into BackgroundTaskRefreshDispatcher…
staskus Oct 14, 2025
9a363ce
Added various BackgroundTaskScheduleTests to verify various scenarios
staskus Oct 14, 2025
c3aae83
Cancel previous tasks before scheduling new ones
staskus Oct 14, 2025
7abfa86
Only schedule ordersAndDashboardSync if feature flag is disabled
staskus Oct 14, 2025
4dd41e5
Merge branch 'trunk' into woomob-1095-woo-poslocal-catalog-bg-app-ref…
staskus Oct 15, 2025
b442512
Create a performSmartSync method that decides on incremental or full …
staskus Oct 15, 2025
b603561
Update POSCatalogSyncCoordinator.swift
staskus Oct 15, 2025
1ce075c
Improve catalog persistence logs to show number of products for a spe…
staskus Oct 16, 2025
6abb109
Update catalog logs to be clear that incremental catalog is persisted…
staskus Oct 16, 2025
fe57312
Update POSCatalogIncrementalSyncService logs for clarity
staskus Oct 16, 2025
ee7506d
Add UTC suffix to smart sync logs for clarity
staskus Oct 16, 2025
a58294f
Fix a typo in a BackgroundTaskSchedule comment
staskus Oct 16, 2025
14fd3ad
Only cancel the tasks that BackgroundTaskRefreshDispatcher handles
staskus Oct 16, 2025
571f3cf
Persist preferredTaskDate to avoid any issues with the app gets termi…
staskus Oct 16, 2025
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
@@ -1,5 +1,6 @@
import Foundation
import GameController
import WooFoundation

/// Parses GameController keyboard input into barcode scans.
/// This class handles the core logic for interpreting GameController GCKeyCode input as barcode data,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import GameController
import UIKit
import WooFoundation

/// An observer that processes UIKit UIPress events for barcode scanner input.
/// This class serves as a fallback for VoiceOver scenarios where GameController framework
Expand Down
5 changes: 5 additions & 0 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,11 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
// Simulates an incremental sync operation with a 0.5 second delay.
try await Task.sleep(nanoseconds: 500_000_000)
}

func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws {
// Simulates a smart sync operation with a 1 second delay.
try await Task.sleep(nanoseconds: 1_000_000_000)
}
}

#endif
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import Foundation

protocol TimeProvider {
public protocol TimeProvider {
func now() -> Date
func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer
}

struct DefaultTimeProvider: TimeProvider {
func now() -> Date {
public struct DefaultTimeProvider: TimeProvider {
public init() {}

public func now() -> Date {
Date()
}

func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer {
public func scheduleTimer(timeInterval: TimeInterval, target: Any, selector: Selector) -> Timer {
return Timer.scheduledTimer(timeInterval: timeInterval, target: target, selector: selector, userInfo: nil, repeats: false)
}
}
26 changes: 26 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public protocol POSCatalogSyncCoordinatorProtocol {
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
//periphery:ignore - remove ignore comment when incremental sync is integrated with POS
func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws

/// Performs a smart sync that decides between full and incremental sync based on the last full sync date
/// - Parameters:
/// - siteID: The site ID to sync catalog for
/// - fullSyncMaxAge: Maximum age before a full sync is triggered. If the last full sync is older than this,
/// performs full sync; otherwise, performs incremental sync
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws
}

public extension POSCatalogSyncCoordinatorProtocol {
Expand All @@ -28,6 +36,12 @@ public extension POSCatalogSyncCoordinatorProtocol {
func performIncrementalSync(for siteID: Int64) async throws {
try await performIncrementalSyncIfApplicable(for: siteID, maxAge: .zero)
}

/// Performs a smart sync with a default 24-hour threshold for full sync
func performSmartSync(for siteID: Int64) async throws {
let twentyFourHours: TimeInterval = 24 * 60 * 60
try await performSmartSync(for: siteID, fullSyncMaxAge: twentyFourHours)
}
}

public enum POSCatalogSyncError: Error, Equatable {
Expand Down Expand Up @@ -88,6 +102,18 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)")
}

public func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws {
let lastFullSync = await lastFullSyncDate(for: siteID) ?? Date(timeIntervalSince1970: 0)

if Date().timeIntervalSince(lastFullSync) >= fullSyncMaxAge {
DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing full sync for site \(siteID) (last full sync: \(lastFullSync))")
try await performFullSync(for: siteID)
} else {
DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing incremental sync for site \(siteID) (last full sync: \(lastFullSync))")
try await performIncrementalSync(for: siteID)
}
}

/// Determines if a full sync should be performed based on the age of the last sync
/// - Parameters:
/// - siteID: The site ID to check
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
var performFullSyncInvocationCount = 0
var performFullSyncSiteID: Int64?
var performFullSyncResult: Result<Void, Error> = .success(())
var lastSyncDate: Date?

var performSmartSyncInvocationCount = 0
var performSmartSyncSiteID: Int64?
var performSmartSyncFullSyncMaxAge: TimeInterval?
var performSmartSyncResult: Result<Void, Error> = .success(())

var onPerformFullSyncCalled: (() -> Void)?

Expand All @@ -23,4 +29,17 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
}

func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws {}

func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws {
performSmartSyncInvocationCount += 1
performSmartSyncSiteID = siteID
performSmartSyncFullSyncMaxAge = fullSyncMaxAge

switch performSmartSyncResult {
case .success:
return
case .failure(let error):
throw error
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Testing
import GameController
@testable import PointOfSale
import WooFoundation

struct GameControllerBarcodeParserTests {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
@testable import PointOfSale
import WooFoundation

final class MockTimer: Timer {
var isCancelled = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Testing
import GameController
import UIKit
@testable import PointOfSale
import WooFoundation

@MainActor
struct UIKitBarcodeObserverTests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,102 @@ struct POSCatalogSyncCoordinatorTests {
#expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID)
}

// MARK: - Smart Sync Tests

@Test func performSmartSync_performs_full_sync_when_last_full_sync_older_than_threshold() async throws {
// Given - last full sync was 25 hours ago (older than 24 hour threshold)
let twentyFiveHoursAgo = Date().addingTimeInterval(-25 * 60 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twentyFiveHoursAgo)
Copy link
Contributor

Choose a reason for hiding this comment

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

TIL about PersistedSite, just for context this seems to be the table that holds each site's ID and associated catalog and sync details, in GRDB?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, Persisted prefix is used for GRDB entities. I'm not sure if that's great naming. We discussed that we may move all the GRDB stuff into a separate module, maybe we could ditch the prefixes.


// When
try await sut.performSmartSync(for: sampleSiteID)

// Then - should perform full sync
#expect(mockSyncService.startFullSyncCallCount == 1)
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0)
}

@Test func performSmartSync_performs_incremental_sync_when_last_full_sync_within_threshold() async throws {
// Given - last full sync was 12 hours ago (within 24 hour threshold)
let twelveHoursAgo = Date().addingTimeInterval(-12 * 60 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twelveHoursAgo)

// When
try await sut.performSmartSync(for: sampleSiteID)

// Then - should perform incremental sync
#expect(mockSyncService.startFullSyncCallCount == 0)
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1)
}

@Test func performSmartSync_performs_full_sync_when_no_previous_sync() async throws {
// Given - no previous sync exists
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil)

// When
try await sut.performSmartSync(for: sampleSiteID)

// Then - should perform full sync
#expect(mockSyncService.startFullSyncCallCount == 1)
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0)
}

@Test func performSmartSync_respects_custom_fullSyncMaxAge() async throws {
// Given - last full sync was 2 hours ago
let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twoHoursAgo)

// When - using custom threshold of 1 hour
let oneHour: TimeInterval = 60 * 60
try await sut.performSmartSync(for: sampleSiteID, fullSyncMaxAge: oneHour)

// Then - should perform full sync because last sync is older than 1 hour
#expect(mockSyncService.startFullSyncCallCount == 1)
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0)
}

@Test func performSmartSync_performs_incremental_sync_with_custom_threshold() async throws {
// Given - last full sync was 30 minutes ago
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo)

// When - using custom threshold of 1 hour
let oneHour: TimeInterval = 60 * 60
try await sut.performSmartSync(for: sampleSiteID, fullSyncMaxAge: oneHour)

// Then - should perform incremental sync because last sync is within 1 hour
#expect(mockSyncService.startFullSyncCallCount == 0)
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1)
}

@Test func performSmartSync_propagates_full_sync_errors() async throws {
// Given - last full sync was 25 hours ago (should trigger full sync)
let twentyFiveHoursAgo = Date().addingTimeInterval(-25 * 60 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twentyFiveHoursAgo)

let expectedError = NSError(domain: "full_sync", code: 500, userInfo: [NSLocalizedDescriptionKey: "Full sync failed"])
mockSyncService.startFullSyncResult = .failure(expectedError)

// When/Then
await #expect(throws: expectedError) {
try await sut.performSmartSync(for: sampleSiteID)
}
}

@Test func performSmartSync_propagates_incremental_sync_errors() async throws {
// Given - last full sync was 12 hours ago (should trigger incremental sync)
let twelveHoursAgo = Date().addingTimeInterval(-12 * 60 * 60)
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twelveHoursAgo)

let expectedError = NSError(domain: "incremental_sync", code: 500, userInfo: [NSLocalizedDescriptionKey: "Incremental sync failed"])
mockIncrementalSyncService.startIncrementalSyncResult = .failure(expectedError)

// When/Then
await #expect(throws: expectedError) {
try await sut.performSmartSync(for: sampleSiteID)
}
}

// MARK: - Helper Methods

private func createSiteInDatabase(siteID: Int64, lastFullSyncDate: Date? = nil, lastIncrementalSyncDate: Date? = nil) throws {
Expand Down
Loading