diff --git a/Networking/Networking/Model/Product/ProductImage.swift b/Networking/Networking/Model/Product/ProductImage.swift index fbdbe6c0f3d..05b998b6192 100644 --- a/Networking/Networking/Model/Product/ProductImage.swift +++ b/Networking/Networking/Model/Product/ProductImage.swift @@ -3,7 +3,7 @@ import Codegen /// Represents a ProductImage entity. /// -public struct ProductImage: Codable, Equatable, Sendable, GeneratedCopiable, GeneratedFakeable { +public struct ProductImage: Codable, Sendable, GeneratedCopiable, GeneratedFakeable { public let imageID: Int64 public let dateCreated: Date // gmt public let dateModified: Date? // gmt @@ -55,6 +55,20 @@ public struct ProductImage: Codable, Equatable, Sendable, GeneratedCopiable, Gen } } +extension ProductImage: Equatable { + public static func == (lhs: ProductImage, rhs: ProductImage) -> Bool { + // Convert timestamps to integers to ignore fractional seconds, ensuring date comparisons + // are accurate to the nearest second and avoiding test discrepancies from millisecond differences. + let lhsTimestamp = Int(lhs.dateCreated.timeIntervalSince1970) + let rhsTimestamp = Int(rhs.dateCreated.timeIntervalSince1970) + + return lhs.imageID == rhs.imageID && + lhsTimestamp == rhsTimestamp && + lhs.src == rhs.src && + lhs.name == rhs.name && + lhs.alt == rhs.alt + } +} /// Defines all the ProductImage CodingKeys. /// diff --git a/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift b/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift index 72557e165a8..084d39884dc 100644 --- a/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift +++ b/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift @@ -7,6 +7,8 @@ import protocol Yosemite.StoresManager import enum Yosemite.ProductImageStatus import enum Yosemite.ProductImageAssetType import enum Yosemite.ProductOrVariationID +import class Networking.ProductImageStatusStorage +import protocol Experiments.FeatureFlagService /// Information about a background product image upload error. struct ProductImageUploadErrorInfo { @@ -87,12 +89,65 @@ protocol ProductImageUploaderProtocol { /// Supports background image upload and product images update after the user leaves the product form. final class ProductImageUploader: ProductImageUploaderProtocol { + + let imageStatusStorage: ProductImageStatusStorage + var errors: AnyPublisher { - errorsSubject.eraseToAnyPublisher() + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + return imageStatusStorage.errorsPublisher + .flatMap { errorItems in + errorItems.publisher + .compactMap { errorItem in + guard let productOrVariationID = errorItem.productOrVariationID, + let assetType = errorItem.assetType else { return nil } + + // Create key to check against excluded keys + let key = Key(siteID: errorItem.siteID, + productOrVariationID: productOrVariationID, + isLocalID: productOrVariationID.id == 0) + + // Skip error if it's for a product being edited + guard !self.statusUpdatesExcludedProductKeys.contains(key) else { + return nil + } + + return ProductImageUploadErrorInfo( + siteID: errorItem.siteID, + productOrVariationID: productOrVariationID, + error: .failedUploadingImage(asset: assetType, error: errorItem.error) + ) + } + } + .eraseToAnyPublisher() + } else { + return errorsSubject + .filter { info in + let key = Key(siteID: info.siteID, + productOrVariationID: info.productOrVariationID, + isLocalID: info.productOrVariationID.id == 0) + return !self.statusUpdatesExcludedProductKeys.contains(key) + } + .eraseToAnyPublisher() + } } var activeUploads: AnyPublisher<[ProductImageUploaderKey], Never> { - $activeUploadsPublisher.eraseToAnyPublisher() + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + return imageStatusStorage.statusesPublisher + .map { statuses in + statuses.compactMap { status -> ProductImageUploaderKey? in + if status.isUploading { + return ProductImageUploaderKey(siteID: status.siteID, + productOrVariationID: status.productOrVariationID, + isLocalID: status.isLocalID) + } + return nil + } + } + .eraseToAnyPublisher() + } else { + return $activeUploadsPublisher.eraseToAnyPublisher() + } } typealias Key = ProductImageUploaderKey @@ -108,12 +163,20 @@ final class ProductImageUploader: ProductImageUploaderProtocol { @Published private var activeUploadsPublisher: [ProductImageUploaderKey] = [] private let stores: StoresManager + private let featureFlagService: FeatureFlagService private let imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol + private var cancellables = Set() + init(stores: StoresManager = ServiceLocator.stores, - imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol = ProductImagesProductIDUpdater()) { + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol = ProductImagesProductIDUpdater(), + imageStatusStorage: ProductImageStatusStorage = ProductImageStatusStorage()) { self.stores = stores + self.featureFlagService = featureFlagService self.imagesProductIDUpdater = imagesProductIDUpdater + self.imageStatusStorage = imageStatusStorage + // Observe when the app enters background. NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), @@ -170,9 +233,17 @@ final class ProductImageUploader: ProductImageUploaderProtocol { } func sendBackgroundUploadNoticeIfNeeded(key: ProductImageUploaderKey, using noticePresenter: NoticePresenter) { - if activeUploadsPublisher.contains(key) { - let notice = Notice(title: Localization.backgroundUploadNoticeTitle) - noticePresenter.enqueue(notice: notice) + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + let statuses = imageStatusStorage.getAllStatuses(for: key.siteID, productID: key.productOrVariationID) + if statuses.contains(where: { $0.isUploading }) { + let notice = Notice(title: Localization.backgroundUploadNoticeTitle) + noticePresenter.enqueue(notice: notice) + } + } else { + if activeUploadsPublisher.contains(key) { + let notice = Notice(title: Localization.backgroundUploadNoticeTitle) + noticePresenter.enqueue(notice: notice) + } } } @@ -243,18 +314,34 @@ final class ProductImageUploader: ProductImageUploaderProtocol { imageUploadSubscriptions = [] activeUploadsPublisher = [] + imageStatusStorage.clearAllStatuses() + actionHandlersByProduct = [:] imagesSaverByProduct = [:] } private func scheduleUploadInProgressNotificationIfNeeded() { - guard !activeUploadsPublisher.isEmpty else { return } + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + let statuses = imageStatusStorage.getAllStatuses() + let hasUploadingStatuses = statuses.contains { $0.isUploading } + + if hasUploadingStatuses { + let notification = LocalNotification(scenario: .productImageBackgroundUpload) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + Task { + await LocalNotificationScheduler(pushNotesManager: ServiceLocator.pushNotesManager).schedule(notification: notification, + trigger: trigger, remoteFeatureFlag: nil) + } + } + } else { + guard !activeUploadsPublisher.isEmpty else { return } - let notification = LocalNotification(scenario: .productImageBackgroundUpload) - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) - Task { - await LocalNotificationScheduler(pushNotesManager: ServiceLocator.pushNotesManager).schedule(notification: notification, - trigger: trigger, remoteFeatureFlag: nil) + let notification = LocalNotification(scenario: .productImageBackgroundUpload) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + Task { + await LocalNotificationScheduler(pushNotesManager: ServiceLocator.pushNotesManager).schedule(notification: notification, + trigger: trigger, remoteFeatureFlag: nil) + } } } @@ -282,12 +369,18 @@ private extension ProductImageUploader { let observationToken = actionHandler.addUpdateObserver(self) { [weak self] productImageStatuses in guard let self = self else { return } - if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload { - activeUploadsPublisher.append(key) - } else if activeUploadsPublisher.contains(key), !productImageStatuses.hasPendingUpload { - /// When all pending uploads are completed or removed, - /// remove the key from active uploads - removeProductFromActiveUploads(key: key) + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + // Update the states in userDefaultsStatuses + self.imageStatusStorage.appendStatuses(productImageStatuses, for: key.siteID, productID: key.productOrVariationID) + } + else { + if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload { + activeUploadsPublisher.append(key) + } else if activeUploadsPublisher.contains(key), !productImageStatuses.hasPendingUpload { + /// When all pending uploads are completed or removed, + /// remove the key from active uploads + removeProductFromActiveUploads(key: key) + } } } statusUpdatesSubscriptions.insert(observationToken) @@ -302,7 +395,13 @@ private extension ProductImageUploader { productOrVariationID: key.productOrVariationID, error: .failedUploadingImage(asset: asset, error: error)) if statusUpdatesExcludedProductKeys.contains(key) == false { - errorsSubject.send(infoError) + if !self.featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + // Only send the error directly if `backgroundProductImageUpload` feature flag is disabled + errorsSubject.send(infoError) + } + // To keep in mind + // Do not update storage here as the action handler will update its status + // which will trigger `observeStatusUpdates` to do the storage update } } } @@ -310,7 +409,15 @@ private extension ProductImageUploader { } func removeProductFromActiveUploads(key: Key) { - activeUploadsPublisher.removeAll(where: { $0 == key }) + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + imageStatusStorage.removeStatus(where: { status in + status.siteID == key.siteID && + status.productOrVariationID == key.productOrVariationID && + status.isUploading + }) + } else { + activeUploadsPublisher.removeAll(where: { $0 == key }) + } } } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index e612a1a20e1..a8dae1e55ff 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -23,6 +23,7 @@ final class MockFeatureFlagService: FeatureFlagService { var favoriteProducts: Bool var isProductGlobalUniqueIdentifierSupported: Bool var hideSitesInStorePicker: Bool + var backgroundProductImageUpload: Bool var notificationSettings: Bool var allowMerchantAIAPIKey: Bool @@ -47,6 +48,7 @@ final class MockFeatureFlagService: FeatureFlagService { favoriteProducts: Bool = false, isProductGlobalUniqueIdentifierSupported: Bool = false, hideSitesInStorePicker: Bool = false, + backgroundProductImageUpload: Bool = false, notificationSettings: Bool = false, allowMerchantAIAPIKey: Bool = false) { self.isInboxOn = isInboxOn @@ -70,6 +72,7 @@ final class MockFeatureFlagService: FeatureFlagService { self.favoriteProducts = favoriteProducts self.isProductGlobalUniqueIdentifierSupported = isProductGlobalUniqueIdentifierSupported self.hideSitesInStorePicker = hideSitesInStorePicker + self.backgroundProductImageUpload = backgroundProductImageUpload self.notificationSettings = notificationSettings self.allowMerchantAIAPIKey = allowMerchantAIAPIKey } @@ -118,6 +121,8 @@ final class MockFeatureFlagService: FeatureFlagService { return isProductGlobalUniqueIdentifierSupported case .hideSitesInStorePicker: return hideSitesInStorePicker + case .backgroundProductImageUpload: + return backgroundProductImageUpload case .notificationSettings: return notificationSettings case .allowMerchantAIAPIKey: diff --git a/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift b/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift index d5527014b9f..e2ed95874cc 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift @@ -64,7 +64,6 @@ extension MockProductImageUploader: ProductImageUploaderProtocol { } func sendBackgroundUploadNoticeIfNeeded(key: ProductImageUploaderKey, using noticePresenter: NoticePresenter) { - // no-op } func reset() { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift index 138d5e027f3..dd5373645c0 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift @@ -3,6 +3,7 @@ import Combine import Photos import XCTest import Yosemite +import Networking final class ProductImageUploaderTests: XCTestCase { private let siteID: Int64 = 134 @@ -10,13 +11,66 @@ final class ProductImageUploaderTests: XCTestCase { private var errorsSubscription: AnyCancellable? private var assetUploadSubscription: AnyCancellable? private var activeUploadsSubscription: AnyCancellable? + private var mockFeatureFlagService: MockFeatureFlagService! + private var storage: ProductImageStatusStorage! + private var testDefaults: UserDefaults! + private var testUserDefaultsName: String! + + override func setUp() { + super.setUp() + mockFeatureFlagService = MockFeatureFlagService() + + // Create a truly unique UserDefaults instance for each test run + testUserDefaultsName = "test.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testUserDefaultsName)! + storage = ProductImageStatusStorage(userDefaults: testDefaults, key: testUserDefaultsName) + } + + override func tearDown() { + // Cancel all subscriptions + errorsSubscription?.cancel() + errorsSubscription = nil + + assetUploadSubscription?.cancel() + assetUploadSubscription = nil + + activeUploadsSubscription?.cancel() + activeUploadsSubscription = nil + + // Clean up storage + storage.clearAllStatuses() + storage = nil + + mockFeatureFlagService = nil + + // Remove the UserDefaults suite + testDefaults.removeSuite(named: testUserDefaultsName) + testDefaults = nil + testUserDefaultsName = nil + + super.tearDown() + } + + private func createImageUploader(stores: StoresManager, + featureFlag: MockFeatureFlagService, + productIDUpdater: ProductImagesProductIDUpdaterProtocol = MockProductImagesProductIDUpdater()) -> ProductImageUploader { + return ProductImageUploader( + stores: stores, + featureFlagService: featureFlag, + imagesProductIDUpdater: productIDUpdater, + imageStatusStorage: storage + ) + } + + // MARK: - Tests with Feature Flag Disabled func test_hasUnsavedChangesOnImages_becomes_false_after_uploading_and_saving() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, + featureFlag: mockFeatureFlagService, + productIDUpdater: mockProductIDUpdater) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -54,7 +108,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_hasUnsavedChangesOnImages_stays_false_after_uploading_and_saving_successfully() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -113,7 +167,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_when_saving_product_twice_the_latest_images_are_saved() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -188,8 +242,7 @@ final class ProductImageUploaderTests: XCTestCase { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) let localProductID: Int64 = 0 let remoteProductID = productID.id let originalStatuses: [ProductImageStatus] = [.remote(image: ProductImage.fake(), siteID: siteID, productID: productID), @@ -224,8 +277,7 @@ final class ProductImageUploaderTests: XCTestCase { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) let localProductID: Int64 = 0 let nonExistentProductID: Int64 = 999 let remoteProductID = productID.id @@ -252,8 +304,7 @@ final class ProductImageUploaderTests: XCTestCase { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -295,7 +346,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_actionHandler_error_is_emitted_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -328,7 +379,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_savingProductImages_error_is_emitted_when_saving_images_fails() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -374,7 +425,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_errors_are_not_emitted_when_image_upload_succeeds() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -402,7 +453,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_error_is_emitted_after_stopEmittingErrors_with_a_different_product_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -439,7 +490,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_error_is_not_emitted_after_stopEmittingErrors_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -470,7 +521,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_calling_replaceLocalID_updates_excluded_product_from_status_updates() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let localProductID: Int64 = 0 let nonExistentProductID: Int64 = 999 let remoteProductID = productID @@ -508,7 +559,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_error_is_emitted_after_stop_and_startEmittingErrors_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -550,7 +601,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_image_upload_error_is_not_emitted_after_reset() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -592,7 +643,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_product_is_removed_from_activeUploads_when_upload_completes() { let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let key = ProductImageUploaderKey(siteID: siteID, productOrVariationID: productID, isLocalID: false) @@ -624,7 +675,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_product_is_removed_from_activeUploads_when_upload_is_cancelled() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let key = ProductImageUploaderKey(siteID: siteID, productOrVariationID: productID, isLocalID: false) @@ -658,7 +709,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_background_upload_notice_is_sent_when_there_are_active_uploads() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let key = ProductImageUploaderKey(siteID: siteID, productOrVariationID: productID, isLocalID: false) @@ -693,6 +744,288 @@ final class ProductImageUploaderTests: XCTestCase { imageUploader.sendBackgroundUploadNoticeIfNeeded(key: key, using: noticePresenter) XCTAssertTrue(isNoticeTriggered) } + + // MARK: - Tests with background image upload feature flag enabled + + func test_hasUnsavedChangesOnImages_becomes_false_after_uploading_and_saving_with_flag_enabled() throws { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let mockProductIDUpdater = MockProductImagesProductIDUpdater() + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + let actionHandler = imageUploader.actionHandler(key: key, originalStatuses: []) + let asset = PHAsset() + + // Initial state - no unsaved changes + XCTAssertFalse(imageUploader.hasUnsavedChangesOnImages(key: key, originalImages: []), + "Should not have unsaved changes initially") + + // When - Upload an image + let uploadedMedia = Media.fake().copy(mediaID: 645) + stores.whenReceivingAction(ofType: MediaAction.self) { action in + if case let .uploadMedia(_, _, _, _, _, onCompletion) = action { + onCompletion(.success(uploadedMedia)) + } + } + + actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .phAsset(asset: asset)) + + // Wait for the upload to be processed + let _ = waitFor { promise in + actionHandler.addUpdateObserver(self) { statuses in + if statuses.count > 0 { + promise(statuses) + } + } + } + + // Verify upload created unsaved changes + XCTAssertTrue(imageUploader.hasUnsavedChangesOnImages(key: key, originalImages: []), + "Should have unsaved changes after uploading an image") + + // When - Save the product with new images + stores.whenReceivingAction(ofType: ProductAction.self) { action in + if case let .updateProductImages(_, _, images, onCompletion) = action { + onCompletion(.success(.fake().copy(siteID: self.siteID, productID: self.productID.id, images: images))) + } + } + + let saveResult: Result<[ProductImage], Error> = waitFor { promise in + imageUploader.saveProductImagesWhenNoneIsPendingUploadAnymore(key: key) { result in + promise(result) + } + } + + // Then - Verify save succeeded and changes are no longer unsaved + XCTAssertTrue(saveResult.isSuccess, "Product save should succeed") + if case .success(let images) = saveResult { + XCTAssertEqual(images.count, 1, "Should have saved one image") + XCTAssertEqual(images.first?.imageID, uploadedMedia.mediaID, "Saved image should match uploaded media") + } + + XCTAssertFalse(imageUploader.hasUnsavedChangesOnImages(key: key, originalImages: [.fake().copy(imageID: 645)]), + "Should not have unsaved changes after saving") + } + + func test_error_is_published_through_storage_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + + let asset = ProductImageAssetType.phAsset(asset: PHAsset()) + let error = NSError(domain: "test", code: 123) + let expectedError = ProductImageUploadErrorInfo( + siteID: siteID, + productOrVariationID: productID, + error: .failedUploadingImage(asset: asset, error: error) + ) + + let errorsSubject = PassthroughSubject() + let mockImageUploader = MockProductImageUploader(errors: errorsSubject.eraseToAnyPublisher()) + + var receivedErrors: [ProductImageUploadErrorInfo] = [] + errorsSubscription = mockImageUploader.errors.sink { error in + receivedErrors.append(error) + } + + // When - simulate an error in the storage + errorsSubject.send(expectedError) + + // Then + XCTAssertEqual(receivedErrors.count, 1) + XCTAssertEqual(receivedErrors.first?.siteID, siteID) + XCTAssertEqual(receivedErrors.first?.productOrVariationID, productID) + if case let .failedUploadingImage(receivedAsset, receivedError) = receivedErrors.first?.error { + XCTAssertEqual(receivedAsset, asset) + XCTAssertEqual((receivedError as NSError).domain, error.domain) + XCTAssertEqual((receivedError as NSError).code, error.code) + } else { + XCTFail("Expected failedUploadingImage error") + } + } + + func test_activeUploads_are_tracked_through_storage_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) + + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + + // Ensure storage is empty at the start + XCTAssertEqual(storage.getAllStatuses().count, 0, "Storage should be empty at test start") + + // Set up subscription to track active uploads + var activeUploads: [ProductImageUploaderKey] = [] + activeUploadsSubscription = imageUploader.activeUploads.sink { keys in + activeUploads = keys + } + + // Initially there should be no active uploads + XCTAssertTrue(activeUploads.isEmpty) + + // When - Upload an image to create an active upload + let actionHandler = imageUploader.actionHandler(key: key, originalStatuses: []) + + // Configure the stores manager to not complete the upload immediately + var uploadCompletion: ((Result) -> Void)? + stores.whenReceivingAction(ofType: MediaAction.self) { action in + if case let .uploadMedia(_, _, _, _, _, onCompletion) = action { + uploadCompletion = onCompletion + // Don't call completion yet to keep the upload "in progress" + } + } + + // Start the upload + let mockAsset = PHAsset() + actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .phAsset(asset: mockAsset)) + + // Wait for the upload to be reflected in the active uploads + let uploadDetectedExpectation = expectation(description: "Upload detected in active uploads") + var uploadCheckCancellable: AnyCancellable? + uploadCheckCancellable = imageUploader.activeUploads + .sink { currentUploads in + if currentUploads.contains(key) { + uploadDetectedExpectation.fulfill() + uploadCheckCancellable?.cancel() + } + } + wait(for: [uploadDetectedExpectation], timeout: 3.0) + + // Verify the upload is being tracked + XCTAssertEqual(activeUploads.count, 1) + XCTAssertEqual(activeUploads.first?.siteID, key.siteID) + XCTAssertEqual(activeUploads.first?.productOrVariationID, key.productOrVariationID) + + // When - Complete the upload + uploadCompletion?(.success(.fake().copy(mediaID: 999))) + + // Then - Wait for the active upload to be removed + let uploadCompletedExpectation = expectation(description: "Upload removed from active uploads") + var completionCheckCancellable: AnyCancellable? + completionCheckCancellable = imageUploader.activeUploads + .sink { currentUploads in + if currentUploads.isEmpty { + uploadCompletedExpectation.fulfill() + completionCheckCancellable?.cancel() + } + } + wait(for: [uploadCompletedExpectation], timeout: 3.0) + + // Verify all uploads are finished + XCTAssertTrue(activeUploads.isEmpty) + } + + func test_background_upload_notice_is_sent_when_there_are_active_uploads_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let imageUploader = createImageUploader(stores: stores, + featureFlag: mockFeatureFlagService) + + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + + let noticePresenter = MockNoticePresenter() + var isNoticeTriggered = false + noticePresenter.onNoticeQueued = { _ in + isNoticeTriggered = true + } + + // Monitor active uploads + var activeUploads: [ProductImageUploaderKey] = [] + activeUploadsSubscription = imageUploader.activeUploads.sink { keys in + activeUploads = keys + } + + // Verify that there are no uploads at the beginning + XCTAssertEqual(storage.getAllStatuses().count, 0, "Storage should be empty at the beginning") + + // When - No active uploads + imageUploader.sendBackgroundUploadNoticeIfNeeded(key: key, using: noticePresenter) + + // Then + XCTAssertFalse(isNoticeTriggered, "No notice should be triggered when there are no active uploads") + + // Reset the notice flag + isNoticeTriggered = false + + // Create an uploading status directly in storage + let uploadingStatus = ProductImageStatus.uploading( + asset: .uiImage(image: .checkmark, filename: "test", altText: "alt_test"), + siteID: siteID, + productID: productID + ) + storage.addStatus(uploadingStatus) + + // Wait for the active upload to be registered + waitUntil(timeout: 3) { + activeUploads.contains(key) + } + + // When - With active uploads + imageUploader.sendBackgroundUploadNoticeIfNeeded(key: key, using: noticePresenter) + + // Then + XCTAssertTrue(isNoticeTriggered, "Notice should be triggered when there are active uploads") + } + + func test_reset_clears_storage_state_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) + + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + + // Configure the store to keep uploads in progress. Don't call completion to keep it in uploading state. + stores.whenReceivingAction(ofType: MediaAction.self) { action in + if case .uploadMedia = action { + } + } + + // Add a status to storage by uploading an image + let actionHandler = imageUploader.actionHandler(key: key, originalStatuses: []) + actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .uiImage(image: .checkmark, filename: "test", altText: "alt_test")) + + // Wait for the upload to be registered + let uploadStatus = waitFor { promise in + actionHandler.addUpdateObserver(self) { statuses in + if statuses.hasPendingUpload { + promise(statuses) + } + } + } + + // Verify we have a pending upload + XCTAssertTrue(uploadStatus.hasPendingUpload) + + // Set up subscription to track active uploads + var activeUploads: [ProductImageUploaderKey] = [] + activeUploadsSubscription = imageUploader.activeUploads.sink { keys in + activeUploads = keys + } + + // Wait for the active upload to be registered + waitUntil() { + activeUploads.contains { $0 == key } + } + + // When + imageUploader.reset() + + // Then - Active uploads should be cleared + waitUntil() { + activeUploads.isEmpty + } + } } extension ProductImageUploadErrorInfo: @retroactive Equatable {