From 1b1896e6edfbbbfa77f26097034acc06757c9f2b Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:21:48 +0000 Subject: [PATCH 01/24] Add trash status to ProductStatus enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Sources/Networking/Model/Product/ProductStatus.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/Sources/Networking/Model/Product/ProductStatus.swift b/Modules/Sources/Networking/Model/Product/ProductStatus.swift index 86d370a7b18..ab82ad7e652 100644 --- a/Modules/Sources/Networking/Model/Product/ProductStatus.swift +++ b/Modules/Sources/Networking/Model/Product/ProductStatus.swift @@ -10,6 +10,7 @@ public enum ProductStatus: Codable, Hashable, GeneratedFakeable { case privateStatus // `private` is a reserved keyword case autoDraft case importing // used for placeholder products from a product import or template + case trash case custom(String) // in case there are extensions modifying product statuses } @@ -34,6 +35,8 @@ extension ProductStatus: RawRepresentable { self = .autoDraft case Keys.importing: self = .importing + case Keys.trash: + self = .trash default: self = .custom(rawValue) } @@ -49,6 +52,7 @@ extension ProductStatus: RawRepresentable { case .privateStatus: return Keys.privateStatus case .autoDraft: return Keys.autoDraft case .importing: return Keys.importing + case .trash: return Keys.trash case .custom(let payload): return payload } } @@ -69,6 +73,8 @@ extension ProductStatus: RawRepresentable { return "Auto Draft" // We don't need to localize this now. case .importing: return "Importing" // We don't need to localize this now. + case .trash: + return "Trash" // We don't need to localize this now. case .custom(let payload): return payload // unable to localize at runtime. } @@ -85,4 +91,5 @@ private enum Keys { static let privateStatus = "private" static let autoDraft = "auto-draft" static let importing = "importing" + static let trash = "trash" } From 480aafd4e4242ed6b81ce81656e8cf4c5800758e Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:22:36 +0000 Subject: [PATCH 02/24] Add status field to POSProduct model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Modules/Sources/Networking/Model/POSProduct.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Modules/Sources/Networking/Model/POSProduct.swift b/Modules/Sources/Networking/Model/POSProduct.swift index dc0702cf2c9..4f37da22768 100644 --- a/Modules/Sources/Networking/Model/POSProduct.swift +++ b/Modules/Sources/Networking/Model/POSProduct.swift @@ -43,6 +43,12 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab public let stockQuantity: Decimal? public let stockStatusKey: String + public let statusKey: String + + public var productStatus: ProductStatus { + return ProductStatus(rawValue: statusKey) + } + public let variationIDs: [Int64] public init(siteID: Int64, @@ -61,6 +67,7 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab manageStock: Bool, stockQuantity: Decimal?, stockStatusKey: String, + statusKey: String, variationIDs: [Int64]) { self.siteID = siteID self.productID = productID @@ -85,6 +92,8 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab self.stockQuantity = stockQuantity self.stockStatusKey = stockStatusKey + self.statusKey = statusKey + self.variationIDs = variationIDs } @@ -129,6 +138,8 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab let stockQuantity = container.failsafeDecodeIfPresent(decimalForKey: .stockQuantity) let stockStatusKey = try container.decode(String.self, forKey: .stockStatusKey) + let statusKey = try container.decode(String.self, forKey: .statusKey) + let variationIDs = try container.decodeIfPresent([Int64].self, forKey: .variationIDs) ?? [] self.init(siteID: siteID, @@ -147,6 +158,7 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab manageStock: manageStock, stockQuantity: stockQuantity, stockStatusKey: stockStatusKey, + statusKey: statusKey, variationIDs: variationIDs) } @@ -180,6 +192,7 @@ private extension POSProduct { case manageStock = "manage_stock" case stockQuantity = "stock_quantity" case stockStatusKey = "stock_status" + case statusKey = "status" case variationIDs = "variations" } } From 2f415d44f55aae966b9eca746d2d4874c34cbdc1 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:23:31 +0000 Subject: [PATCH 03/24] Add statusKey column to product database schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Storage/GRDB/Migrations/V001InitialSchema.swift | 2 ++ Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift index f75a6d51a27..8d17ff63d43 100644 --- a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift +++ b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift @@ -51,6 +51,8 @@ struct V001InitialSchema { productTable.column("manageStock", .boolean).notNull() productTable.column("stockQuantity", .double) productTable.column("stockStatusKey", .text).notNull() + + productTable.column("statusKey", .text).notNull() } } diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index f126e09e2ba..fb6f36a1676 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -17,6 +17,7 @@ public struct PersistedProduct: Codable { public let manageStock: Bool public let stockQuantity: Decimal? public let stockStatusKey: String + public let statusKey: String public init(id: Int64, siteID: Int64, @@ -31,7 +32,8 @@ public struct PersistedProduct: Codable { parentID: Int64, manageStock: Bool, stockQuantity: Decimal?, - stockStatusKey: String) { + stockStatusKey: String, + statusKey: String) { self.id = id self.siteID = siteID self.name = name @@ -46,6 +48,7 @@ public struct PersistedProduct: Codable { self.manageStock = manageStock self.stockQuantity = stockQuantity self.stockStatusKey = stockStatusKey + self.statusKey = statusKey } } @@ -70,6 +73,7 @@ extension PersistedProduct: FetchableRecord, PersistableRecord { public static let manageStock = Column(CodingKeys.manageStock) public static let stockQuantity = Column(CodingKeys.stockQuantity) public static let stockStatusKey = Column(CodingKeys.stockStatusKey) + public static let statusKey = Column(CodingKeys.statusKey) } // Join table association (internal - used by 'images' through association) @@ -136,6 +140,7 @@ private extension PersistedProduct { case manageStock case stockQuantity case stockStatusKey + case statusKey } enum ProductType: String { From 39ad0c50fa2581c2cda506c58e4b846ac4b9ca08 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:24:02 +0000 Subject: [PATCH 04/24] Filter trashed/draft/pending/private products in POS catalog view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Sources/Storage/GRDB/Model/PersistedProduct.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index fb6f36a1676..88685a9933f 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -1,5 +1,6 @@ import Foundation import GRDB +import Networking // periphery:ignore - TODO: remove ignore when populating database public struct PersistedProduct: Codable { @@ -103,11 +104,20 @@ extension PersistedProduct: FetchableRecord, PersistableRecord { // MARK: - Point of Sale Requests public extension PersistedProduct { /// Returns a request for POS-supported products (simple and variable, non-downloadable) for a given site, ordered by name + /// Filters out products with trash, draft, pending, or private status to ensure only published and 3rd party custom status products are shown static func posProductsRequest(siteID: Int64) -> QueryInterfaceRequest { + let excludedStatuses = [ + ProductStatus.trash.rawValue, + ProductStatus.draft.rawValue, + ProductStatus.pending.rawValue, + ProductStatus.privateStatus.rawValue + ] + return PersistedProduct .filter(Columns.siteID == siteID) .filter([ProductType.simple.rawValue, ProductType.variable.rawValue].contains(Columns.productTypeKey)) .filter(Columns.downloadable == false) + .filter(!excludedStatuses.contains(Columns.statusKey)) .order(Columns.name.collating(.localizedCaseInsensitiveCompare)) } From 5cef067e70cb38f0703d684c3183c32e9bc0dfe3 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:24:46 +0000 Subject: [PATCH 05/24] Add support for fetching products with specific status in POSCatalogSyncRemote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Networking/Remote/POSCatalogSyncRemote.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 6017702eb30..34fc7913e6c 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -9,10 +9,11 @@ public protocol POSCatalogSyncRemoteProtocol { /// - modifiedAfter: Only products modified after this date will be returned. /// - siteID: Site ID to load products from. /// - pageNumber: Page number for pagination. + /// - includeStatus: Optional status to include (e.g., "trash" to fetch trashed products). /// - Returns: Paginated list of POS products. // TODO - remove the periphery ignore comment when the incremental sync is integrated with POS. // periphery:ignore - func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems + func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int, includeStatus: String?) async throws -> PagedItems /// Loads POS product variations modified after the specified date for incremental sync. /// @@ -109,18 +110,23 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// - modifiedAfter: Only products modified after this date will be returned. /// - siteID: Site ID to load products from. /// - pageNumber: Page number for pagination. + /// - includeStatus: Optional status to include (e.g., "trash" to fetch trashed products). /// - Returns: Paginated list of POS products. /// - public func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) + public func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int, includeStatus: String? = nil) async throws -> PagedItems { let path = Path.products - let parameters = [ + var parameters: [String: String] = [ ParameterKey.modifiedAfter: dateFormatter.string(from: modifiedAfter), ParameterKey.page: String(pageNumber), ParameterKey.perPage: String(Constants.defaultPageSize), ParameterKey.fields: POSProduct.requestFields.joined(separator: ",") ] + if let includeStatus = includeStatus { + parameters[ParameterKey.includeStatus] = includeStatus + } + let request = JetpackRequest( wooApiVersion: .mark3, method: .get, @@ -399,6 +405,7 @@ private extension POSCatalogSyncRemote { static let fields = "_fields" static let fullSyncFields = "fields" static let forceGenerate = "force_generate" + static let includeStatus = "include_status" } enum Path { From f4104373f542f6d06aa1693f2a5ce397980084d7 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:25:13 +0000 Subject: [PATCH 06/24] Fetch trashed products during incremental sync using modified_after parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../POSCatalogIncrementalSyncService.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index 86d2fa259b1..bd8d4610077 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -77,19 +77,33 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe private extension POSCatalogIncrementalSyncService { func loadCatalog(for siteID: Int64, modifiedAfter: Date, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog { let syncStartDate = Date.now - async let productsTask = batchedLoader.loadAll( + + // Fetch regular products (excluding trash) + async let regularProductsTask = batchedLoader.loadAll( + makeRequest: { pageNumber in + try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber, includeStatus: nil) + } + ) + + // Fetch trashed products separately to detect products moved to trash + async let trashedProductsTask = batchedLoader.loadAll( makeRequest: { pageNumber in - try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber) + try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber, includeStatus: "trash") } ) + async let variationsTask = batchedLoader.loadAll( makeRequest: { pageNumber in try await syncRemote.loadProductVariations(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber) } ) - let (products, variations) = try await (productsTask, variationsTask) - return POSCatalog(products: products, variations: variations, syncDate: syncStartDate) + let (regularProducts, trashedProducts, variations) = try await (regularProductsTask, trashedProductsTask, variationsTask) + + // Union regular and trashed products before persistence + let allProducts = regularProducts + trashedProducts + + return POSCatalog(products: allProducts, variations: variations, syncDate: syncStartDate) } } From 1b8ec6bc5d0488064fcdf17299e820efc002c566 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:26:36 +0000 Subject: [PATCH 07/24] Prevent scanning trashed/draft/pending/private products via barcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Storage/GRDB/Model/PersistedProduct.swift | 9 ++++----- .../Items/PointOfSaleBarcodeScanService.swift | 14 ++++++++++++++ .../PointOfSaleLocalBarcodeScanService.swift | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index 88685a9933f..29aa507407e 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -1,6 +1,5 @@ import Foundation import GRDB -import Networking // periphery:ignore - TODO: remove ignore when populating database public struct PersistedProduct: Codable { @@ -107,10 +106,10 @@ public extension PersistedProduct { /// Filters out products with trash, draft, pending, or private status to ensure only published and 3rd party custom status products are shown static func posProductsRequest(siteID: Int64) -> QueryInterfaceRequest { let excludedStatuses = [ - ProductStatus.trash.rawValue, - ProductStatus.draft.rawValue, - ProductStatus.pending.rawValue, - ProductStatus.privateStatus.rawValue + "trash", + "draft", + "pending", + "private" ] return PersistedProduct diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift index ff59863e051..34066b2615f 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift @@ -1,6 +1,7 @@ import Foundation import protocol Networking.ProductsRemoteProtocol import class Networking.ProductsRemote +import enum Networking.ProductStatus import class WooFoundation.CurrencySettings import class Networking.AlamofireNetwork import enum Networking.NetworkError @@ -58,6 +59,10 @@ public final class PointOfSaleBarcodeScanService: PointOfSaleBarcodeScanServiceP /// - Returns: A POSItem if found, or throws an error public func getItem(barcode: String) async throws(PointOfSaleBarcodeScanError) -> POSItem { let productOrVariation = try await loadPOSProduct(barcode: barcode) + + // Validate that the product status is allowed for POS + try validateProductStatus(productOrVariation, scannedCode: barcode) + return try await itemResolver.itemForProductOrVariation(productOrVariation, scannedCode: barcode) } @@ -71,4 +76,13 @@ public final class PointOfSaleBarcodeScanService: PointOfSaleBarcodeScanServiceP throw .loadingError(scannedCode: barcode, underlyingError: error) } } + + /// Validates that the product status is allowed for POS + /// Throws notFound error if product has a status that should be excluded from POS + private func validateProductStatus(_ product: POSProduct, scannedCode: String) throws(PointOfSaleBarcodeScanError) { + let excludedStatuses: [ProductStatus] = [.trash, .draft, .pending, .privateStatus] + if excludedStatuses.contains(product.productStatus) { + throw .notFound(scannedCode: scannedCode) + } + } } diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalBarcodeScanService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalBarcodeScanService.swift index e3f3a0bd647..74184b7e304 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalBarcodeScanService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalBarcodeScanService.swift @@ -1,5 +1,6 @@ import Foundation import protocol Storage.GRDBManagerProtocol +import enum Networking.ProductStatus import class WooFoundation.CurrencySettings /// Service for handling barcode scanning using local GRDB catalog @@ -67,6 +68,9 @@ public final class PointOfSaleLocalBarcodeScanService: PointOfSaleBarcodeScanSer do { let posProduct = try persistedProduct.toPOSProduct(db: grdbManager.databaseConnection) + // Validate that the product status is allowed for POS + try validateProductStatus(posProduct, scannedCode: scannedCode) + guard !posProduct.downloadable else { throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode, productName: posProduct.name) } @@ -103,6 +107,9 @@ public final class PointOfSaleLocalBarcodeScanService: PointOfSaleBarcodeScanSer let posVariation = try persistedVariation.toPOSProductVariation(db: grdbManager.databaseConnection) let parentPOSProduct = try parentProduct.toPOSProduct(db: grdbManager.databaseConnection) + // Validate that the parent product status is allowed for POS + try validateProductStatus(parentPOSProduct, scannedCode: scannedCode) + // Map to POSItem guard let mappedParent = itemMapper.mapProductsToPOSItems(products: [parentPOSProduct]).first, case .variableParentProduct(let variableParentProduct) = mappedParent, @@ -129,6 +136,15 @@ public final class PointOfSaleLocalBarcodeScanService: PointOfSaleBarcodeScanSer } return posVariation.name } + + /// Validates that the product status is allowed for POS + /// Throws notFound error if product has a status that should be excluded from POS + private func validateProductStatus(_ product: POSProduct, scannedCode: String) throws(PointOfSaleBarcodeScanError) { + let excludedStatuses: [ProductStatus] = [.trash, .draft, .pending, .privateStatus] + if excludedStatuses.contains(product.productStatus) { + throw .notFound(scannedCode: scannedCode) + } + } } private extension PointOfSaleLocalBarcodeScanService { From 6ab61874f1aacefa3a6064c44f64ff46415318a2 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:46:08 +0000 Subject: [PATCH 08/24] Update generated files for POSProduct status field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Modules/Sources/Fakes/Networking.generated.swift | 1 + .../Networking/Model/Copiable/Models+Copiable.generated.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index b67a0f1102d..8773b17d7ef 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -814,6 +814,7 @@ extension Networking.POSProduct { manageStock: .fake(), stockQuantity: .fake(), stockStatusKey: .fake(), + statusKey: .fake(), variationIDs: .fake() ) } diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index b02490bf551..294847b3868 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -1371,6 +1371,7 @@ extension Networking.POSProduct { manageStock: CopiableProp = .copy, stockQuantity: NullableCopiableProp = .copy, stockStatusKey: CopiableProp = .copy, + statusKey: CopiableProp = .copy, variationIDs: CopiableProp<[Int64]> = .copy ) -> Networking.POSProduct { let siteID = siteID ?? self.siteID @@ -1389,6 +1390,7 @@ extension Networking.POSProduct { let manageStock = manageStock ?? self.manageStock let stockQuantity = stockQuantity ?? self.stockQuantity let stockStatusKey = stockStatusKey ?? self.stockStatusKey + let statusKey = statusKey ?? self.statusKey let variationIDs = variationIDs ?? self.variationIDs return Networking.POSProduct( @@ -1408,6 +1410,7 @@ extension Networking.POSProduct { manageStock: manageStock, stockQuantity: stockQuantity, stockStatusKey: stockStatusKey, + statusKey: statusKey, variationIDs: variationIDs ) } From 330ad6328f2a3af0d3a082ddc3229f2c9730a88b Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 14 Nov 2025 16:56:21 +0000 Subject: [PATCH 09/24] Include status in persisted product --- .../Yosemite/Model/Storage/PersistedProduct+Conversions.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift index 16807f0a893..c1e58308e8a 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -19,7 +19,8 @@ extension PersistedProduct { parentID: posProduct.parentID, manageStock: posProduct.manageStock, stockQuantity: posProduct.stockQuantity, - stockStatusKey: posProduct.stockStatusKey + stockStatusKey: posProduct.stockStatusKey, + statusKey: posProduct.statusKey ) } @@ -41,6 +42,7 @@ extension PersistedProduct { manageStock: manageStock, stockQuantity: stockQuantity, stockStatusKey: stockStatusKey, + statusKey: statusKey, variationIDs: variationIDs ) } From 76320014a3f1234eec196aeb464034d3619c36dc Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 11:14:27 +0000 Subject: [PATCH 10/24] Add status field assertions in PersistedProductTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Storage/PersistedProductTests.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift index aeef327b164..06a01f3ab5e 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -28,6 +28,7 @@ struct PersistedProductTests { manageStock: true, stockQuantity: 5, stockStatusKey: "instock", + statusKey: "publish", variationIDs: [] ) @@ -49,6 +50,7 @@ struct PersistedProductTests { #expect(persisted.manageStock == posProduct.manageStock) #expect(persisted.stockQuantity == posProduct.stockQuantity) #expect(persisted.stockStatusKey == posProduct.stockStatusKey) + #expect(persisted.statusKey == posProduct.statusKey) } @Test("PersistedProduct toPOSProduct maps back with images and attributes") @@ -70,7 +72,8 @@ struct PersistedProductTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "outofstock" + stockStatusKey: "outofstock", + statusKey: "publish" ) let productImages = [ @@ -130,6 +133,7 @@ struct PersistedProductTests { #expect(posProduct.manageStock == persisted.manageStock) #expect(posProduct.stockQuantity == persisted.stockQuantity) #expect(posProduct.stockStatusKey == persisted.stockStatusKey) + #expect(posProduct.statusKey == persisted.statusKey) #expect(posProduct.images.count == 2) #expect(posProduct.attributes.count == 2) #expect(posProduct.attributesForVariations.count == 1) @@ -161,7 +165,8 @@ struct PersistedProductTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try product.insert(db) @@ -279,7 +284,8 @@ struct PersistedProductTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try product.insert(db) } @@ -386,7 +392,8 @@ struct PersistedProductTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try product.insert(db) @@ -510,6 +517,7 @@ struct PersistedProductTests { manageStock: true, stockQuantity: 50, stockStatusKey: "instock", + statusKey: "publish", variationIDs: [] ) From 2b9e060b22ea3b7272876b745bb4c5adc337efc0 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 11:15:04 +0000 Subject: [PATCH 11/24] Add trash filtering tests to PersistedProductTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Storage/PersistedProductTests.swift | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift index 06a01f3ab5e..f3991c9aefe 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -549,4 +549,230 @@ struct PersistedProductTests { #expect(colorAttr?.options == ["Red", "Blue"]) #expect(colorAttr?.variation == true) } + + @Test("posProductsRequest filters out trashed products") + func posProductsRequest_filters_out_trashed_products() throws { + // Given + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + let site = PersistedSite(id: 1) + try site.insert(db) + + // Insert a published product (should be included) + let publishedProduct = PersistedProduct( + id: 1, + siteID: 1, + name: "Published Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try publishedProduct.insert(db) + + // Insert a trashed product (should be filtered out) + let trashedProduct = PersistedProduct( + id: 2, + siteID: 1, + name: "Trashed Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "20.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "trash" + ) + try trashedProduct.insert(db) + } + + // When + let products = try db.read { db in + try PersistedProduct.posProductsRequest(siteID: 1).fetchAll(db) + } + + // Then + #expect(products.count == 1) + #expect(products.first?.name == "Published Product") + #expect(products.first?.statusKey == "publish") + } + + @Test("posProductsRequest filters out draft, pending, and private products") + func posProductsRequest_filters_out_draft_pending_private_products() throws { + // Given + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + let site = PersistedSite(id: 1) + try site.insert(db) + + // Insert a published product (should be included) + let publishedProduct = PersistedProduct( + id: 1, + siteID: 1, + name: "Published Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try publishedProduct.insert(db) + + // Insert draft product (should be filtered out) + let draftProduct = PersistedProduct( + id: 2, + siteID: 1, + name: "Draft Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "20.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "draft" + ) + try draftProduct.insert(db) + + // Insert pending product (should be filtered out) + let pendingProduct = PersistedProduct( + id: 3, + siteID: 1, + name: "Pending Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "30.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "pending" + ) + try pendingProduct.insert(db) + + // Insert private product (should be filtered out) + let privateProduct = PersistedProduct( + id: 4, + siteID: 1, + name: "Private Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "40.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "private" + ) + try privateProduct.insert(db) + } + + // When + let products = try db.read { db in + try PersistedProduct.posProductsRequest(siteID: 1).fetchAll(db) + } + + // Then + #expect(products.count == 1) + #expect(products.first?.name == "Published Product") + } + + @Test("posProductsRequest includes custom status products") + func posProductsRequest_includes_custom_status_products() throws { + // Given + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + let site = PersistedSite(id: 1) + try site.insert(db) + + // Insert a published product + let publishedProduct = PersistedProduct( + id: 1, + siteID: 1, + name: "Published Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try publishedProduct.insert(db) + + // Insert a product with custom status (should be included - 3rd party plugin) + let customStatusProduct = PersistedProduct( + id: 2, + siteID: 1, + name: "Custom Status Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "25.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "custom-status" + ) + try customStatusProduct.insert(db) + } + + // When + let products = try db.read { db in + try PersistedProduct.posProductsRequest(siteID: 1).fetchAll(db) + } + + // Then both products should be included (custom status is not explicitly excluded) + #expect(products.count == 2) + let productNames = Set(products.map { $0.name }) + #expect(productNames.contains("Published Product")) + #expect(productNames.contains("Custom Status Product")) + } } From 46f28b2480307d1b1a2e7a7e6fb0ca4b97d4a96f Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 11:16:38 +0000 Subject: [PATCH 12/24] Add barcode scanning status validation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Responses/pos-product-draft.json | 20 ++ .../Responses/pos-product-trashed.json | 20 ++ .../Responses/pos-products.json | 1 + .../PointOfSaleBarcodeScanServiceTests.swift | 22 +++ ...ntOfSaleLocalBarcodeScanServiceTests.swift | 181 +++++++++++++++++- 5 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 Modules/Tests/NetworkingTests/Responses/pos-product-draft.json create mode 100644 Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json diff --git a/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json b/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json new file mode 100644 index 00000000000..4a7834d1980 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json @@ -0,0 +1,20 @@ +{ + "id": 123, + "name": "Draft Product", + "type": "simple", + "sku": "DRAFT-SKU", + "global_unique_id": "123456789", + "price": "10.00", + "regular_price": "10.00", + "sale_price": null, + "on_sale": false, + "images": [], + "attributes": [], + "manage_stock": true, + "stock_quantity": 10, + "stock_status": "instock", + "status": "draft", + "downloadable": false, + "parent_id": 0, + "variations": [] +} diff --git a/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json b/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json new file mode 100644 index 00000000000..2a39d68e0ff --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json @@ -0,0 +1,20 @@ +{ + "id": 123, + "name": "Trashed Product", + "type": "simple", + "sku": "TRASHED-SKU", + "global_unique_id": "123456789", + "price": "10.00", + "regular_price": "10.00", + "sale_price": null, + "on_sale": false, + "images": [], + "attributes": [], + "manage_stock": true, + "stock_quantity": 10, + "stock_status": "instock", + "status": "trash", + "downloadable": false, + "parent_id": 0, + "variations": [] +} diff --git a/Modules/Tests/NetworkingTests/Responses/pos-products.json b/Modules/Tests/NetworkingTests/Responses/pos-products.json index 383eb0e058e..52161ff291a 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-products.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-products.json @@ -15,6 +15,7 @@ "manage_stock": true, "stock_quantity": 10, "stock_status": "instock", + "status": "publish", "downloadable": false, "parent_id": 0 } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleBarcodeScanServiceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleBarcodeScanServiceTests.swift index 84f7aa6dd57..5c427e2d85d 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleBarcodeScanServiceTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleBarcodeScanServiceTests.swift @@ -51,4 +51,26 @@ struct PointOfSaleBarcodeScanServiceTests { _ = try await sut.getItem(barcode: barcode) } } + + @Test func getItem_throws_notFound_when_product_has_trash_status() async throws { + // Given + let barcode = "123456789" + network.simulateResponse(requestUrlSuffix: "products", filename: "pos-product-trashed") + + // When/Then + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } + + @Test func getItem_throws_notFound_when_product_has_draft_status() async throws { + // Given + let barcode = "123456789" + network.simulateResponse(requestUrlSuffix: "products", filename: "pos-product-draft") + + // When/Then + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalBarcodeScanServiceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalBarcodeScanServiceTests.swift index 5e6db2e6f8f..847c3c986f7 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalBarcodeScanServiceTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalBarcodeScanServiceTests.swift @@ -45,7 +45,8 @@ struct PointOfSaleLocalBarcodeScanServiceTests { parentID: 0, manageStock: true, stockQuantity: 10, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(product) @@ -84,7 +85,8 @@ struct PointOfSaleLocalBarcodeScanServiceTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(parentProduct) @@ -149,7 +151,8 @@ struct PointOfSaleLocalBarcodeScanServiceTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(downloadableProduct) @@ -177,7 +180,8 @@ struct PointOfSaleLocalBarcodeScanServiceTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(groupedProduct) @@ -207,7 +211,8 @@ struct PointOfSaleLocalBarcodeScanServiceTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(variableParentProduct) @@ -219,6 +224,169 @@ struct PointOfSaleLocalBarcodeScanServiceTests { } } + @Test("Throws notFound when product has trash status") + func test_throws_not_found_for_trashed_product() async throws { + // Given + let barcode = "TRASHED-123" + let trashedProduct = PersistedProduct( + id: 30, + siteID: siteID, + name: "Trashed Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: barcode, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "trash" // Trashed product + ) + try await insertProduct(trashedProduct) + + // When/Then - Should throw notFound error for trashed product + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } + + @Test("Throws notFound when product has draft status") + func test_throws_not_found_for_draft_product() async throws { + // Given + let barcode = "DRAFT-123" + let draftProduct = PersistedProduct( + id: 31, + siteID: siteID, + name: "Draft Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: barcode, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "draft" // Draft product + ) + try await insertProduct(draftProduct) + + // When/Then - Should throw notFound error for draft product + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } + + @Test("Throws notFound when product has pending status") + func test_throws_not_found_for_pending_product() async throws { + // Given + let barcode = "PENDING-123" + let pendingProduct = PersistedProduct( + id: 32, + siteID: siteID, + name: "Pending Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: barcode, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "pending" // Pending product + ) + try await insertProduct(pendingProduct) + + // When/Then - Should throw notFound error for pending product + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } + + @Test("Throws notFound when product has private status") + func test_throws_not_found_for_private_product() async throws { + // Given + let barcode = "PRIVATE-123" + let privateProduct = PersistedProduct( + id: 33, + siteID: siteID, + name: "Private Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: barcode, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "private" // Private product + ) + try await insertProduct(privateProduct) + + // When/Then - Should throw notFound error for private product + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } + + @Test("Throws notFound when variation's parent product has trash status") + func test_throws_not_found_for_variation_with_trashed_parent() async throws { + // Given + let barcode = "VAR-TRASHED-PARENT" + + // Insert parent product with trash status + let parentProduct = PersistedProduct( + id: 40, + siteID: siteID, + name: "Trashed Parent", + productTypeKey: "variable", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "0.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "trash" // Parent is trashed + ) + try await insertProduct(parentProduct) + + // Insert variation + let variation = PersistedProductVariation( + id: 401, + siteID: siteID, + productID: 40, + sku: nil, + globalUniqueID: barcode, + price: "15.00", + downloadable: false, + fullDescription: nil, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try await insertVariation(variation) + + // When/Then - Should throw notFound error because parent is trashed + await #expect(throws: PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)) { + _ = try await sut.getItem(barcode: barcode) + } + } + @Test("Foreign key constraint prevents orphaned variations") func test_variations_cannot_be_orphaned() async throws { // Given @@ -239,7 +407,8 @@ struct PointOfSaleLocalBarcodeScanServiceTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(parentProduct) From 230d779fcd69a4855e1cda8ace3115ae7fb27d7c Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 11:18:52 +0000 Subject: [PATCH 13/24] Add includeStatus parameter tests to POSCatalogSyncRemoteTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests verifying include_status parameter is correctly added/omitted - Add test verifying status is included in _fields request parameter - Update MockPOSCatalogSyncRemote to support includeStatus parameter - Update test fixtures to include required statusKey field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Remote/POSCatalogSyncRemoteTests.swift | 42 +++++++++++++++++++ .../PersistedProductBarcodeQueryTests.swift | 9 ++-- ...tedProductVariationBarcodeQueryTests.swift | 12 ++++-- .../Mocks/MockPOSCatalogSyncRemote.swift | 5 ++- .../GRDBObservableDataSourceTests.swift | 3 +- .../PersistedProductVariationTests.swift | 12 ++++-- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index b3a12edb674..2912fbed1eb 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -133,6 +133,48 @@ struct POSCatalogSyncRemoteTests { #expect(pagedProducts.totalItems == nil) } + @Test func loadProducts_with_includeStatus_adds_parameter() async throws { + // Given + let remote = createRemote() + let modifiedAfter = Date(timeIntervalSince1970: 1692968400) + let pageNumber = 1 + + // When + _ = try? await remote.loadProducts(modifiedAfter: modifiedAfter, siteID: sampleSiteID, pageNumber: pageNumber, includeStatus: "trash") + + // Then + let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) + #expect(queryParametersDictionary["include_status"] as? String == "trash") + } + + @Test func loadProducts_without_includeStatus_omits_parameter() async throws { + // Given + let remote = createRemote() + let modifiedAfter = Date(timeIntervalSince1970: 1692968400) + let pageNumber = 1 + + // When + _ = try? await remote.loadProducts(modifiedAfter: modifiedAfter, siteID: sampleSiteID, pageNumber: pageNumber, includeStatus: nil) + + // Then + let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) + #expect(queryParametersDictionary["include_status"] == nil) + } + + @Test func loadProducts_includes_status_in_request_fields() async throws { + // Given + let remote = createRemote() + let modifiedAfter = Date() + + // When + _ = try? await remote.loadProducts(modifiedAfter: modifiedAfter, siteID: sampleSiteID, pageNumber: 1) + + // Then + let queryParametersDictionary = try #require(network.queryParametersDictionary as? [String: any Hashable]) + let fieldsString = try #require(queryParametersDictionary["_fields"] as? String) + #expect(fieldsString.contains("status")) + } + // MARK: - Product Variations Tests @Test func loadProductVariations_sets_correct_parameters() async throws { diff --git a/Modules/Tests/StorageTests/GRDB/PersistedProductBarcodeQueryTests.swift b/Modules/Tests/StorageTests/GRDB/PersistedProductBarcodeQueryTests.swift index 08bab734b2f..d170c80bfe3 100644 --- a/Modules/Tests/StorageTests/GRDB/PersistedProductBarcodeQueryTests.swift +++ b/Modules/Tests/StorageTests/GRDB/PersistedProductBarcodeQueryTests.swift @@ -36,7 +36,8 @@ struct PersistedProductBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(product) @@ -91,7 +92,8 @@ struct PersistedProductBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) // Insert product for other site @@ -109,7 +111,8 @@ struct PersistedProductBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(ourProduct) diff --git a/Modules/Tests/StorageTests/GRDB/PersistedProductVariationBarcodeQueryTests.swift b/Modules/Tests/StorageTests/GRDB/PersistedProductVariationBarcodeQueryTests.swift index 38641468837..b2d86ada821 100644 --- a/Modules/Tests/StorageTests/GRDB/PersistedProductVariationBarcodeQueryTests.swift +++ b/Modules/Tests/StorageTests/GRDB/PersistedProductVariationBarcodeQueryTests.swift @@ -38,7 +38,8 @@ struct PersistedProductVariationBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(parentProduct) @@ -108,7 +109,8 @@ struct PersistedProductVariationBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) let otherParentProduct = PersistedProduct( id: 20, @@ -124,7 +126,8 @@ struct PersistedProductVariationBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(ourParentProduct) try await insertProduct(otherParentProduct) @@ -191,7 +194,8 @@ struct PersistedProductVariationBarcodeQueryTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try await insertProduct(parentProduct) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index 721ac6d9ea7..364419e6ff0 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -71,7 +71,10 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { // MARK: - Protocol Methods - Incremental Sync - func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { + func loadProducts(modifiedAfter: Date, + siteID: Int64, + pageNumber: Int, + includeStatus: String?) async throws -> PagedItems { await loadIncrementalProductsCallCount.increment() lastIncrementalProductsModifiedAfter = modifiedAfter diff --git a/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift index 159a5ee62f4..b793b773487 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift @@ -740,7 +740,8 @@ struct GRDBObservableDataSourceTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) } diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift index 085eb128e2a..331bb6585ec 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -143,7 +143,8 @@ struct PersistedProductVariationTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try parentProduct.insert(db) @@ -271,7 +272,8 @@ struct PersistedProductVariationTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try parentProduct.insert(db) @@ -386,7 +388,8 @@ struct PersistedProductVariationTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try parentProduct.insert(db) @@ -506,7 +509,8 @@ struct PersistedProductVariationTests { parentID: 0, manageStock: false, stockQuantity: nil, - stockStatusKey: "instock" + stockStatusKey: "instock", + statusKey: "publish" ) try parentProduct.insert(db) } From d33b5e86b5c0e7dbe522595147b15a90cf14259e Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 11:27:36 +0000 Subject: [PATCH 14/24] Add incremental sync tests for two-request pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests verifying both regular and trashed products are fetched - Add test ensuring same modifiedAfter date is used for both requests - Add test verifying includeStatus parameter values - Add tests for combined persistence of regular and trashed products - Add tests for edge cases (empty trash, only trash updates) - Enhance MockPOSCatalogSyncRemote to support separate trashed product results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Mocks/MockPOSCatalogSyncRemote.swift | 49 +++++-- ...OSCatalogIncrementalSyncServiceTests.swift | 132 ++++++++++++++++++ 2 files changed, 172 insertions(+), 9 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index 364419e6ff0..e771c17181a 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -7,6 +7,7 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { private(set) var variationResults: [Int: Result, Error>] = [:] private(set) var incrementalProductResults: [Int: Result, Error>] = [:] private(set) var incrementalVariationResults: [Int: Result, Error>] = [:] + private(set) var trashedProductResults: [Int: Result, Error>] = [:] var catalogRequestResult: Result = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json")) var catalogDownloadResult: Result = .success(.init(products: [], variations: [])) @@ -15,9 +16,12 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { let loadProductVariationsCallCount = Counter() let loadIncrementalProductsCallCount = Counter() let loadIncrementalProductVariationsCallCount = Counter() + let loadTrashedProductsCallCount = Counter() private(set) var lastIncrementalProductsModifiedAfter: Date? private(set) var lastIncrementalVariationsModifiedAfter: Date? + private(set) var lastTrashedProductsModifiedAfter: Date? + private(set) var lastIncrementalProductsIncludeStatus: [String?] = [] private(set) var lastCatalogRequestForceGeneration: Bool? private(set) var lastCatalogDownloadAllowCellular: Bool? @@ -69,21 +73,48 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { } } + func setTrashedProductResult(pageNumber: Int, result: Result, Error>) { + trashedProductResults[pageNumber] = result + } + + func setTrashedProductResults(_ results: [PagedItems]) { + for (index, pagedItems) in results.enumerated() { + trashedProductResults[index + 1] = .success(pagedItems) + } + } + // MARK: - Protocol Methods - Incremental Sync func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int, includeStatus: String?) async throws -> PagedItems { - await loadIncrementalProductsCallCount.increment() - lastIncrementalProductsModifiedAfter = modifiedAfter - - if let result = incrementalProductResults[pageNumber] { - switch result { - case .success(let pagedItems): - return pagedItems - case .failure(let error): - throw error + lastIncrementalProductsIncludeStatus.append(includeStatus) + + // Route to appropriate results based on includeStatus + if includeStatus == "trash" { + await loadTrashedProductsCallCount.increment() + lastTrashedProductsModifiedAfter = modifiedAfter + + if let result = trashedProductResults[pageNumber] { + switch result { + case .success(let pagedItems): + return pagedItems + case .failure(let error): + throw error + } + } + } else { + await loadIncrementalProductsCallCount.increment() + lastIncrementalProductsModifiedAfter = modifiedAfter + + if let result = incrementalProductResults[pageNumber] { + switch result { + case .success(let pagedItems): + return pagedItems + case .failure(let error): + throw error + } } } return fallbackResult diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift index a4434a76460..4ac4a53726d 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift @@ -174,4 +174,136 @@ struct POSCatalogIncrementalSyncServiceTests { #expect(site1ModifiedAfter == lastFullSyncDate) #expect(site2ModifiedAfter == lastFullSyncDate) } + + // MARK: - Two-Request Pattern Tests (Regular + Trashed Products) + + @Test func startIncrementalSync_fetches_both_regular_and_trashed_products() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let regularProducts = [POSProduct.fake().copy(statusKey: "publish")] + let trashedProducts = [POSProduct.fake().copy(statusKey: "trash")] + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: regularProducts, hasMorePages: false, totalItems: 1))) + mockSyncRemote.setTrashedProductResult(pageNumber: 1, result: .success(PagedItems(items: trashedProducts, hasMorePages: false, totalItems: 1))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) + + // Then - Both regular and trashed products requests were made + #expect(await mockSyncRemote.loadIncrementalProductsCallCount.value >= 1) + #expect(await mockSyncRemote.loadTrashedProductsCallCount.value >= 1) + + // Verify persisted catalog contains both regular and trashed products + let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog) + #expect(persistedCatalog.products.count == 2) + } + + @Test func startIncrementalSync_uses_same_modifiedAfter_for_regular_and_trashed_requests() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setTrashedProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) + + // Then - Both requests use the same modifiedAfter date + let regularModifiedAfter = try #require(mockSyncRemote.lastIncrementalProductsModifiedAfter) + let trashedModifiedAfter = try #require(mockSyncRemote.lastTrashedProductsModifiedAfter) + #expect(regularModifiedAfter == trashedModifiedAfter) + #expect(regularModifiedAfter == lastFullSyncDate) + } + + @Test func startIncrementalSync_includes_correct_includeStatus_values_in_requests() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setTrashedProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) + + // Then - Verify both nil (regular) and "trash" statuses were requested + let includeStatuses = mockSyncRemote.lastIncrementalProductsIncludeStatus + #expect(includeStatuses.contains(nil)) + #expect(includeStatuses.contains("trash")) + } + + @Test func startIncrementalSync_combines_products_from_both_requests_into_single_persistence() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let regularProduct1 = POSProduct.fake().copy(statusKey: "publish") + let regularProduct2 = POSProduct.fake().copy(statusKey: "publish") + let trashedProduct = POSProduct.fake().copy(statusKey: "trash") + + mockSyncRemote.setIncrementalProductResult( + pageNumber: 1, + result: .success(PagedItems(items: [regularProduct1, regularProduct2], hasMorePages: false, totalItems: 2)) + ) + mockSyncRemote.setTrashedProductResult( + pageNumber: 1, + result: .success(PagedItems(items: [trashedProduct], hasMorePages: false, totalItems: 1)) + ) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) + + // Then - All products are combined in a single persistence call + #expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1) + let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog) + #expect(persistedCatalog.products.count == 3) + } + + @Test func startIncrementalSync_handles_empty_trashed_products_response() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let regularProducts = [POSProduct.fake(), POSProduct.fake()] + + mockSyncRemote.setIncrementalProductResult( + pageNumber: 1, + result: .success(PagedItems(items: regularProducts, hasMorePages: false, totalItems: 2)) + ) + mockSyncRemote.setTrashedProductResult( + pageNumber: 1, + result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + ) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) + + // Then - Only regular products are persisted + let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog) + #expect(persistedCatalog.products.count == 2) + } + + @Test func startIncrementalSync_handles_only_trashed_products_updated() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let trashedProducts = [POSProduct.fake().copy(statusKey: "trash")] + + mockSyncRemote.setIncrementalProductResult( + pageNumber: 1, + result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + ) + mockSyncRemote.setTrashedProductResult( + pageNumber: 1, + result: .success(PagedItems(items: trashedProducts, hasMorePages: false, totalItems: 1)) + ) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) + + // Then - Only trashed products are persisted + let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog) + #expect(persistedCatalog.products.count == 1) + } } + From 29c795926c29a5edfd73ca4afd99871da04808de Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 11:44:55 +0000 Subject: [PATCH 15/24] Fix thread safety issue in MockPOSCatalogSyncRemote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace non-thread-safe array append with actor-based IncludeStatusTracker to prevent EXC_BAD_ACCESS crash when concurrent requests modify the array. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Mocks/MockPOSCatalogSyncRemote.swift | 13 +++++++++++-- .../POS/POSCatalogIncrementalSyncServiceTests.swift | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index e771c17181a..82de03e62e3 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -21,7 +21,7 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { private(set) var lastIncrementalProductsModifiedAfter: Date? private(set) var lastIncrementalVariationsModifiedAfter: Date? private(set) var lastTrashedProductsModifiedAfter: Date? - private(set) var lastIncrementalProductsIncludeStatus: [String?] = [] + let includeStatusTracker = IncludeStatusTracker() private(set) var lastCatalogRequestForceGeneration: Bool? private(set) var lastCatalogDownloadAllowCellular: Bool? @@ -89,7 +89,7 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { siteID: Int64, pageNumber: Int, includeStatus: String?) async throws -> PagedItems { - lastIncrementalProductsIncludeStatus.append(includeStatus) + await includeStatusTracker.append(includeStatus) // Route to appropriate results based on includeStatus if includeStatus == "trash" { @@ -252,3 +252,12 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { } } } + +/// Thread-safe tracker for includeStatus values +actor IncludeStatusTracker { + private(set) var values: [String?] = [] + + func append(_ value: String?) { + values.append(value) + } +} diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift index 4ac4a53726d..6df57081d7c 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift @@ -229,7 +229,7 @@ struct POSCatalogIncrementalSyncServiceTests { try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: nil) // Then - Verify both nil (regular) and "trash" statuses were requested - let includeStatuses = mockSyncRemote.lastIncrementalProductsIncludeStatus + let includeStatuses = await mockSyncRemote.includeStatusTracker.values #expect(includeStatuses.contains(nil)) #expect(includeStatuses.contains("trash")) } From 15f8e8e05ba0cc557da1b01c20a78d8474616ca7 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 12:00:11 +0000 Subject: [PATCH 16/24] Add status field to JSON test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "status": "publish" to product fixtures that were missing the field, fixing decoding errors in POSCatalogSyncRemoteTests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Responses/pos-catalog-download-mixed.json | 6 ++++-- .../Tests/NetworkingTests/Responses/products-load-pos.json | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json index 472245e715b..ad09b55d89e 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json @@ -28,7 +28,8 @@ ], "parent_id": 0, "attributes": [], - "downloadable": false + "downloadable": false, + "status": "publish" }, { "id": 31, @@ -96,7 +97,8 @@ ] } ], - "downloadable": false + "downloadable": false, + "status": "publish" }, { "id": 32, diff --git a/Modules/Tests/NetworkingTests/Responses/products-load-pos.json b/Modules/Tests/NetworkingTests/Responses/products-load-pos.json index 7c08def1b52..c609bb62a22 100644 --- a/Modules/Tests/NetworkingTests/Responses/products-load-pos.json +++ b/Modules/Tests/NetworkingTests/Responses/products-load-pos.json @@ -43,6 +43,7 @@ "manage_stock": false, "stock_quantity": null, "stock_status": "instock", + "status": "publish", "global_unique_id": "" }, { @@ -102,6 +103,7 @@ "manage_stock": true, "stock_quantity": 10, "stock_status": "instock", + "status": "publish", "global_unique_id": "" } ] From 7720c5678266844240c4abe27f48b6a7114e1259 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 12:00:46 +0000 Subject: [PATCH 17/24] Fix JSON structure for trashed/draft product fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap single product objects in {"data": [...]} envelope to match standard WooCommerce REST API response format expected by tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Responses/pos-product-draft.json | 40 ++++++++++--------- .../Responses/pos-product-trashed.json | 40 ++++++++++--------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json b/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json index 4a7834d1980..567ac47429c 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-product-draft.json @@ -1,20 +1,24 @@ { - "id": 123, - "name": "Draft Product", - "type": "simple", - "sku": "DRAFT-SKU", - "global_unique_id": "123456789", - "price": "10.00", - "regular_price": "10.00", - "sale_price": null, - "on_sale": false, - "images": [], - "attributes": [], - "manage_stock": true, - "stock_quantity": 10, - "stock_status": "instock", - "status": "draft", - "downloadable": false, - "parent_id": 0, - "variations": [] + "data": [ + { + "id": 123, + "name": "Draft Product", + "type": "simple", + "sku": "DRAFT-SKU", + "global_unique_id": "123456789", + "price": "10.00", + "regular_price": "10.00", + "sale_price": null, + "on_sale": false, + "images": [], + "attributes": [], + "manage_stock": true, + "stock_quantity": 10, + "stock_status": "instock", + "status": "draft", + "downloadable": false, + "parent_id": 0, + "variations": [] + } + ] } diff --git a/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json b/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json index 2a39d68e0ff..8e7635f37a0 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-product-trashed.json @@ -1,20 +1,24 @@ { - "id": 123, - "name": "Trashed Product", - "type": "simple", - "sku": "TRASHED-SKU", - "global_unique_id": "123456789", - "price": "10.00", - "regular_price": "10.00", - "sale_price": null, - "on_sale": false, - "images": [], - "attributes": [], - "manage_stock": true, - "stock_quantity": 10, - "stock_status": "instock", - "status": "trash", - "downloadable": false, - "parent_id": 0, - "variations": [] + "data": [ + { + "id": 123, + "name": "Trashed Product", + "type": "simple", + "sku": "TRASHED-SKU", + "global_unique_id": "123456789", + "price": "10.00", + "regular_price": "10.00", + "sale_price": null, + "on_sale": false, + "images": [], + "attributes": [], + "manage_stock": true, + "stock_quantity": 10, + "stock_status": "instock", + "status": "trash", + "downloadable": false, + "parent_id": 0, + "variations": [] + } + ] } From c0dcae9816e0fa190b77e2d01f18991997c8e1e5 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 12:01:42 +0000 Subject: [PATCH 18/24] Add statusKey to TestProduct in GRDBManagerTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add statusKey field with default value "publish" to TestProduct struct to match updated PersistedProduct schema, fixing NOT NULL constraint errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift b/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift index 969283639f5..7a5391f20e1 100644 --- a/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift +++ b/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift @@ -685,10 +685,11 @@ struct TestProduct: Codable { let manageStock: Bool let stockQuantity: Double? let stockStatusKey: String + let statusKey: String init(siteID: Int64, id: Int64, name: String, productTypeKey: String, price: String, downloadable: Bool, parentID: Int64, - manageStock: Bool, stockStatusKey: String, + manageStock: Bool, stockStatusKey: String, statusKey: String = "publish", fullDescription: String? = nil, shortDescription: String? = nil, sku: String? = nil, globalUniqueID: String? = nil, stockQuantity: Double? = nil) { self.siteID = siteID @@ -700,6 +701,7 @@ struct TestProduct: Codable { self.parentID = parentID self.manageStock = manageStock self.stockStatusKey = stockStatusKey + self.statusKey = statusKey self.fullDescription = fullDescription self.shortDescription = shortDescription self.sku = sku From 574f15b9e40510127625b732ac516a5a97f14c66 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 12:33:13 +0000 Subject: [PATCH 19/24] Fix lint --- .../Tools/POS/POSCatalogIncrementalSyncServiceTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift index 6df57081d7c..38ca9aadbc6 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift @@ -306,4 +306,3 @@ struct POSCatalogIncrementalSyncServiceTests { #expect(persistedCatalog.products.count == 1) } } - From 6475ea224e4359effec043770459d20f05ef52e1 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 17 Nov 2025 13:03:52 +0000 Subject: [PATCH 20/24] Add status field to variation objects in catalog download fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog download parser decodes all items as POSProduct first before filtering by type, so variations also need the status field to avoid decoding errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Responses/pos-catalog-download-mixed.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json index ad09b55d89e..ccefb4aef1a 100644 --- a/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json +++ b/Modules/Tests/NetworkingTests/Responses/pos-catalog-download-mixed.json @@ -145,7 +145,8 @@ "option": "19" } ], - "downloadable": false + "downloadable": false, + "status": "publish" }, { "id": 33, @@ -192,6 +193,7 @@ "option": "8" } ], - "downloadable": false + "downloadable": false, + "status": "publish" }, ] From 0683d96f7de620ea5b4cef7bb8b1571ae9cd53bb Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 10:13:07 +0000 Subject: [PATCH 21/24] Use ProductStatus rawValue for status request Co-authored-by: Gabriel Maldonado --- .../Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index 2531efe0eff..576836e479a 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -90,7 +90,7 @@ private extension POSCatalogIncrementalSyncService { // Fetch trashed products separately to detect products moved to trash async let trashedProductsTask = batchedLoader.loadAll( makeRequest: { pageNumber in - try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber, includeStatus: "trash") + try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber, includeStatus: ProductStatus.trash.rawValue) } ) From da6db356e4249e141284c5ae31347c3d0214321c Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 16:45:39 +0000 Subject: [PATCH 22/24] =?UTF-8?q?Don=E2=80=99t=20filter=20out=20private=20?= =?UTF-8?q?products?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Storage/GRDB/Model/PersistedProduct.swift | 3 +-- .../Storage/PersistedProductTests.swift | 24 ++----------------- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index 29aa507407e..d2d38cedf3d 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -108,8 +108,7 @@ public extension PersistedProduct { let excludedStatuses = [ "trash", "draft", - "pending", - "private" + "pending" ] return PersistedProduct diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift index f3991c9aefe..119aaa0da19 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -612,8 +612,8 @@ struct PersistedProductTests { #expect(products.first?.statusKey == "publish") } - @Test("posProductsRequest filters out draft, pending, and private products") - func posProductsRequest_filters_out_draft_pending_private_products() throws { + @Test("posProductsRequest filters out draft, and pending products") + func posProductsRequest_filters_out_draft_pending_products() throws { // Given let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection @@ -681,26 +681,6 @@ struct PersistedProductTests { statusKey: "pending" ) try pendingProduct.insert(db) - - // Insert private product (should be filtered out) - let privateProduct = PersistedProduct( - id: 4, - siteID: 1, - name: "Private Product", - productTypeKey: "simple", - fullDescription: nil, - shortDescription: nil, - sku: nil, - globalUniqueID: nil, - price: "40.00", - downloadable: false, - parentID: 0, - manageStock: false, - stockQuantity: nil, - stockStatusKey: "instock", - statusKey: "private" - ) - try privateProduct.insert(db) } // When From 0a096fea34157109d88292c87c1962fc4991782b Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 17:19:04 +0000 Subject: [PATCH 23/24] Resolve warnings when not using the resulting catalog --- .../Tools/POS/POSCatalogIncrementalSyncService.swift | 7 +++++-- .../Mocks/MockPOSCatalogIncrementalSyncService.swift | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index 576836e479a..a0f960c26bc 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -14,7 +14,10 @@ public protocol POSCatalogIncrementalSyncServiceProtocol { /// - siteID: The site ID to sync catalog for. /// - lastFullSyncDate: The date of the last full sync to use if no incremental sync date exists. /// - Returns: The synced catalog containing updated products and variations - func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws -> POSCatalog + @discardableResult + func startIncrementalSync(for siteID: Int64, + lastFullSyncDate: Date, + lastIncrementalSyncDate: Date?) async throws -> POSCatalog } // TODO - remove the periphery ignore comment when the service is integrated with POS. @@ -53,7 +56,7 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe } // MARK: - Protocol Conformance - + @discardableResult public func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws -> POSCatalog { let modifiedAfter = latestSyncDate(fullSyncDate: lastFullSyncDate, incrementalSyncDate: lastIncrementalSyncDate) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift index 676fc59d12f..1e356ca1399 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift @@ -13,6 +13,7 @@ final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServi private var shouldBlockSync = false private var syncBlockedContinuations: [CheckedContinuation] = [] + @discardableResult func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws -> POSCatalog { startIncrementalSyncCallCount += 1 lastSyncSiteID = siteID From 9538e4057a596ea5a0c929cc6ee8d988dd3f8252 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 17:24:44 +0000 Subject: [PATCH 24/24] Fix line length --- .../Tools/POS/POSCatalogIncrementalSyncService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index a0f960c26bc..b874618c816 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -93,7 +93,10 @@ private extension POSCatalogIncrementalSyncService { // Fetch trashed products separately to detect products moved to trash async let trashedProductsTask = batchedLoader.loadAll( makeRequest: { pageNumber in - try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber, includeStatus: ProductStatus.trash.rawValue) + try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, + siteID: siteID, + pageNumber: pageNumber, + includeStatus: ProductStatus.trash.rawValue) } )