diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index 873d7361fcc..479305984dd 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -753,12 +753,11 @@ extension Networking.POSProduct { productID: .fake(), name: .fake(), productTypeKey: .fake(), + fullDescription: .fake(), + shortDescription: .fake(), sku: .fake(), globalUniqueID: .fake(), price: .fake(), - regularPrice: .fake(), - salePrice: .fake(), - onSale: .fake(), downloadable: .fake(), parentID: .fake(), images: .fake(), @@ -779,12 +778,10 @@ extension Networking.POSProductVariation { productVariationID: .fake(), attributes: .fake(), image: .fake(), + fullDescription: .fake(), sku: .fake(), globalUniqueID: .fake(), price: .fake(), - regularPrice: .fake(), - salePrice: .fake(), - onSale: .fake(), downloadable: .fake(), manageStock: .fake(), stockQuantity: .fake(), diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index b41103490e5..e236694ea40 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -1246,12 +1246,11 @@ extension Networking.POSProduct { productID: CopiableProp = .copy, name: CopiableProp = .copy, productTypeKey: CopiableProp = .copy, + fullDescription: NullableCopiableProp = .copy, + shortDescription: NullableCopiableProp = .copy, sku: NullableCopiableProp = .copy, globalUniqueID: NullableCopiableProp = .copy, price: CopiableProp = .copy, - regularPrice: NullableCopiableProp = .copy, - salePrice: NullableCopiableProp = .copy, - onSale: CopiableProp = .copy, downloadable: CopiableProp = .copy, parentID: CopiableProp = .copy, images: CopiableProp<[ProductImage]> = .copy, @@ -1264,12 +1263,11 @@ extension Networking.POSProduct { let productID = productID ?? self.productID let name = name ?? self.name let productTypeKey = productTypeKey ?? self.productTypeKey + let fullDescription = fullDescription ?? self.fullDescription + let shortDescription = shortDescription ?? self.shortDescription let sku = sku ?? self.sku let globalUniqueID = globalUniqueID ?? self.globalUniqueID let price = price ?? self.price - let regularPrice = regularPrice ?? self.regularPrice - let salePrice = salePrice ?? self.salePrice - let onSale = onSale ?? self.onSale let downloadable = downloadable ?? self.downloadable let parentID = parentID ?? self.parentID let images = images ?? self.images @@ -1283,12 +1281,11 @@ extension Networking.POSProduct { productID: productID, name: name, productTypeKey: productTypeKey, + fullDescription: fullDescription, + shortDescription: shortDescription, sku: sku, globalUniqueID: globalUniqueID, price: price, - regularPrice: regularPrice, - salePrice: salePrice, - onSale: onSale, downloadable: downloadable, parentID: parentID, images: images, @@ -1307,12 +1304,10 @@ extension Networking.POSProductVariation { productVariationID: CopiableProp = .copy, attributes: CopiableProp<[ProductVariationAttribute]> = .copy, image: NullableCopiableProp = .copy, + fullDescription: NullableCopiableProp = .copy, sku: NullableCopiableProp = .copy, globalUniqueID: NullableCopiableProp = .copy, price: CopiableProp = .copy, - regularPrice: NullableCopiableProp = .copy, - salePrice: NullableCopiableProp = .copy, - onSale: CopiableProp = .copy, downloadable: CopiableProp = .copy, manageStock: CopiableProp = .copy, stockQuantity: NullableCopiableProp = .copy, @@ -1323,12 +1318,10 @@ extension Networking.POSProductVariation { let productVariationID = productVariationID ?? self.productVariationID let attributes = attributes ?? self.attributes let image = image ?? self.image + let fullDescription = fullDescription ?? self.fullDescription let sku = sku ?? self.sku let globalUniqueID = globalUniqueID ?? self.globalUniqueID let price = price ?? self.price - let regularPrice = regularPrice ?? self.regularPrice - let salePrice = salePrice ?? self.salePrice - let onSale = onSale ?? self.onSale let downloadable = downloadable ?? self.downloadable let manageStock = manageStock ?? self.manageStock let stockQuantity = stockQuantity ?? self.stockQuantity @@ -1340,12 +1333,10 @@ extension Networking.POSProductVariation { productVariationID: productVariationID, attributes: attributes, image: image, + fullDescription: fullDescription, sku: sku, globalUniqueID: globalUniqueID, price: price, - regularPrice: regularPrice, - salePrice: salePrice, - onSale: onSale, downloadable: downloadable, manageStock: manageStock, stockQuantity: stockQuantity, diff --git a/Modules/Sources/Networking/Model/POSProduct.swift b/Modules/Sources/Networking/Model/POSProduct.swift index dfd504fe0cd..b9ad128874c 100644 --- a/Modules/Sources/Networking/Model/POSProduct.swift +++ b/Modules/Sources/Networking/Model/POSProduct.swift @@ -12,13 +12,13 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab public let productID: Int64 public let name: String public let productTypeKey: String + public let fullDescription: String? + // periphery:ignore - Will be used for search in future + public let shortDescription: String? public let sku: String? public let globalUniqueID: String? public let price: String - public let regularPrice: String? - public let salePrice: String? - public let onSale: Bool public let downloadable: Bool @@ -47,12 +47,11 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab productID: Int64, name: String, productTypeKey: String, + fullDescription: String?, + shortDescription: String?, sku: String?, globalUniqueID: String?, price: String, - regularPrice: String?, - salePrice: String?, - onSale: Bool, downloadable: Bool, parentID: Int64, images: [ProductImage], @@ -64,13 +63,12 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab self.productID = productID self.name = name self.productTypeKey = productTypeKey + self.fullDescription = fullDescription + self.shortDescription = shortDescription self.sku = sku self.globalUniqueID = globalUniqueID self.price = price - self.regularPrice = regularPrice - self.salePrice = salePrice - self.onSale = onSale self.downloadable = downloadable @@ -101,6 +99,8 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab let productID = try container.decode(Int64.self, forKey: .productID) let name = try container.decode(String.self, forKey: .name) let productTypeKey = try container.decode(String.self, forKey: .productTypeKey) + let fullDescription = try container.decodeIfPresent(String.self, forKey: .fullDescription) + let shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription) let sku = container.failsafeDecodeIfPresent( targetType: String.self, forKey: .sku, @@ -111,24 +111,6 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab targetType: String.self, forKey: .price, alternativeTypes: [decimalString]) ?? "" - let regularPrice = container.failsafeDecodeIfPresent( - targetType: String.self, - forKey: .regularPrice, - alternativeTypes: [decimalString]) - let onSale = container.failsafeDecodeIfPresent( - targetType: Bool.self, - forKey: .onSale, - alternativeTypes: [ .string(transform: { NSString(string: $0).boolValue })]) ?? false - - // Even though a plain install of WooCommerce Core provides string values, - // some plugins alter the field value from String to Int or Decimal. - let salePrice = container.failsafeDecodeIfPresent( - targetType: String.self, - forKey: .salePrice, - shouldDecodeTargetTypeFirst: false, - alternativeTypes: [ - .string(transform: { (onSale && $0.isEmpty) ? "0" : $0 }), - decimalString]) let downloadable = try container.decode(Bool.self, forKey: .downloadable) @@ -146,12 +128,11 @@ public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeab productID: productID, name: name, productTypeKey: productTypeKey, + fullDescription: fullDescription, + shortDescription: shortDescription, sku: sku, globalUniqueID: globalUniqueID, price: price, - regularPrice: regularPrice, - salePrice: salePrice, - onSale: onSale, downloadable: downloadable, parentID: parentID, images: images, @@ -179,12 +160,11 @@ private extension POSProduct { case productID = "id" case name case productTypeKey = "type" + case fullDescription = "description" + case shortDescription = "short_description" case sku case globalUniqueID = "global_unique_id" case price - case regularPrice = "regular_price" - case salePrice = "sale_price" - case onSale = "on_sale" case downloadable case parentID = "parent_id" case images diff --git a/Modules/Sources/Networking/Model/POSProductVariation.swift b/Modules/Sources/Networking/Model/POSProductVariation.swift index 9540379203f..3f601d88dae 100644 --- a/Modules/Sources/Networking/Model/POSProductVariation.swift +++ b/Modules/Sources/Networking/Model/POSProductVariation.swift @@ -17,14 +17,12 @@ public struct POSProductVariation: Codable, Equatable, GeneratedCopiable, Genera public let attributes: [ProductVariationAttribute] public let image: ProductImage? + // periphery:ignore - Will be used for search in future + public let fullDescription: String? public let sku: String? public let globalUniqueID: String? public let price: String - public let regularPrice: String? - public let salePrice: String? - public let onSale: Bool - public let downloadable: Bool public let manageStock: Bool @@ -40,12 +38,10 @@ public struct POSProductVariation: Codable, Equatable, GeneratedCopiable, Genera productVariationID: Int64, attributes: [ProductVariationAttribute], image: ProductImage?, + fullDescription: String?, sku: String?, globalUniqueID: String?, price: String, - regularPrice: String?, - salePrice: String?, - onSale: Bool, downloadable: Bool, manageStock: Bool, stockQuantity: Decimal?, @@ -55,12 +51,10 @@ public struct POSProductVariation: Codable, Equatable, GeneratedCopiable, Genera self.productVariationID = productVariationID self.attributes = attributes self.image = image + self.fullDescription = fullDescription self.sku = sku self.globalUniqueID = globalUniqueID self.price = price - self.regularPrice = regularPrice - self.salePrice = salePrice - self.onSale = onSale self.downloadable = downloadable self.manageStock = manageStock self.stockQuantity = stockQuantity @@ -81,6 +75,7 @@ public struct POSProductVariation: Codable, Equatable, GeneratedCopiable, Genera let productID = try container.decode(Int64.self, forKey: .productID) let attributes = try container.decode([ProductVariationAttribute].self, forKey: .attributes) let image = try container.decodeIfPresent(ProductImage.self, forKey: .image) + let fullDescription = try container.decodeIfPresent(String.self, forKey: .fullDescription) let sku = container.failsafeDecodeIfPresent( targetType: String.self, forKey: .sku, @@ -90,18 +85,6 @@ public struct POSProductVariation: Codable, Equatable, GeneratedCopiable, Genera targetType: String.self, forKey: .price, alternativeTypes: [decimalString]) ?? "" - let regularPrice = container.failsafeDecodeIfPresent( - targetType: String.self, - forKey: .regularPrice, - alternativeTypes: [decimalString]) - let salePrice = container.failsafeDecodeIfPresent( - targetType: String.self, - forKey: .salePrice, - alternativeTypes: [decimalString]) - let onSale = container.failsafeDecodeIfPresent( - targetType: Bool.self, - forKey: .onSale, - alternativeTypes: [.string(transform: { NSString(string: $0).boolValue })]) ?? false let downloadable = try container.decode(Bool.self, forKey: .downloadable) let manageStock = container.failsafeDecodeIfPresent( targetType: Bool.self, @@ -125,12 +108,10 @@ public struct POSProductVariation: Codable, Equatable, GeneratedCopiable, Genera productVariationID: productVariationID, attributes: attributes, image: image, + fullDescription: fullDescription, sku: sku, globalUniqueID: globalUniqueID, price: price, - regularPrice: regularPrice, - salePrice: salePrice, - onSale: onSale, downloadable: downloadable, manageStock: manageStock, stockQuantity: stockQuantity, @@ -158,9 +139,7 @@ private extension POSProductVariation { case attributes case image case price - case regularPrice = "regular_price" - case salePrice = "sale_price" - case onSale = "on_sale" + case fullDescription = "description" case sku case globalUniqueID = "global_unique_id" case downloadable diff --git a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift index 74a0624f4f6..fe2573fdb0b 100644 --- a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift +++ b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift @@ -43,6 +43,10 @@ struct V001InitialSchema { productTable.column("downloadable", .boolean).notNull() productTable.column("parentID", .integer).notNull() + + productTable.column("manageStock", .boolean).notNull() + productTable.column("stockQuantity", .double) + productTable.column("stockStatusKey", .text).notNull() } } @@ -87,6 +91,10 @@ struct V001InitialSchema { productVariationTable.column("downloadable", .boolean).notNull() productVariationTable.column("fullDescription", .text) + + productVariationTable.column("manageStock", .boolean).notNull() + productVariationTable.column("stockQuantity", .double) + productVariationTable.column("stockStatusKey", .text).notNull() } } diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift new file mode 100644 index 00000000000..6b3dad86fc0 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -0,0 +1,95 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedProduct: Codable { + public let id: Int64 + public let siteID: Int64 + public let name: String + public let productTypeKey: String + public let fullDescription: String? + public let shortDescription: String? + public let sku: String? + public let globalUniqueID: String? + public let price: String + public let downloadable: Bool + public let parentID: Int64 + public let manageStock: Bool + public let stockQuantity: Decimal? + public let stockStatusKey: String + + public init(id: Int64, + siteID: Int64, + name: String, + productTypeKey: String, + fullDescription: String?, + shortDescription: String?, + sku: String?, + globalUniqueID: String?, + price: String, + downloadable: Bool, + parentID: Int64, + manageStock: Bool, + stockQuantity: Decimal?, + stockStatusKey: String) { + self.id = id + self.siteID = siteID + self.name = name + self.productTypeKey = productTypeKey + self.fullDescription = fullDescription + self.shortDescription = shortDescription + self.sku = sku + self.globalUniqueID = globalUniqueID + self.price = price + self.downloadable = downloadable + self.parentID = parentID + self.manageStock = manageStock + self.stockQuantity = stockQuantity + self.stockStatusKey = stockStatusKey + } +} + +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProduct: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { "product" } + + public enum Columns { + static let id = Column(CodingKeys.id) + static let siteID = Column(CodingKeys.siteID) + static let name = Column(CodingKeys.name) + static let productTypeKey = Column(CodingKeys.productTypeKey) + static let fullDescription = Column(CodingKeys.fullDescription) + static let shortDescription = Column(CodingKeys.shortDescription) + static let sku = Column(CodingKeys.sku) + static let globalUniqueID = Column(CodingKeys.globalUniqueID) + static let price = Column(CodingKeys.price) + static let downloadable = Column(CodingKeys.downloadable) + static let parentID = Column(CodingKeys.parentID) + static let manageStock = Column(CodingKeys.manageStock) + static let stockQuantity = Column(CodingKeys.stockQuantity) + static let stockStatusKey = Column(CodingKeys.stockStatusKey) + } + + public static let images = hasMany(PersistedProductImage.self) + public static let attributes = hasMany(PersistedProductAttribute.self) +} + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedProduct { + enum CodingKeys: String, CodingKey { + case id + case siteID + case name + case productTypeKey + case fullDescription + case shortDescription + case sku + case globalUniqueID + case price + case downloadable + case parentID + case manageStock + case stockQuantity + case stockStatusKey + } +} diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift new file mode 100644 index 00000000000..04bfdb458b7 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift @@ -0,0 +1,62 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedProductAttribute: Codable { + public private(set) var id: Int64? + public let productID: Int64 + public let name: String + public let position: Int64 + public let visible: Bool + public let variation: Bool + public let options: [String] + + public init(id: Int64? = nil, + productID: Int64, + name: String, + position: Int64, + visible: Bool, + variation: Bool, + options: [String]) { + self.id = id + self.productID = productID + self.name = name + self.position = position + self.visible = visible + self.variation = variation + self.options = options + } +} + +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductAttribute: FetchableRecord, MutablePersistableRecord { + public static var databaseTableName: String { "productAttribute" } + + public enum Columns { + static let id = Column(CodingKeys.id) + static let productID = Column(CodingKeys.productID) + static let name = Column(CodingKeys.name) + static let position = Column(CodingKeys.position) + static let visible = Column(CodingKeys.visible) + static let variation = Column(CodingKeys.variation) + static let options = Column(CodingKeys.options) + } + + public mutating func didInsert(_ inserted: InsertionSuccess) { + id = inserted.rowID + } +} + + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedProductAttribute { + enum CodingKeys: String, CodingKey { + case id + case productID + case name + case position + case visible + case variation + case options + } +} diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift new file mode 100644 index 00000000000..0e248403ee9 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift @@ -0,0 +1,58 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedProductImage: Codable { + public let id: Int64 + public let productID: Int64 + public let dateCreated: Date + public let dateModified: Date? + public let src: String + public let name: String? + public let alt: String? + + public init(id: Int64, + productID: Int64, + dateCreated: Date, + dateModified: Date?, + src: String, + name: String?, + alt: String?) { + self.id = id + self.productID = productID + self.dateCreated = dateCreated + self.dateModified = dateModified + self.src = src + self.name = name + self.alt = alt + } +} + +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductImage: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { "productImage" } + + public enum Columns { + static let id = Column(CodingKeys.id) + static let productID = Column(CodingKeys.productID) + static let dateCreated = Column(CodingKeys.dateCreated) + static let dateModified = Column(CodingKeys.dateModified) + static let src = Column(CodingKeys.src) + static let name = Column(CodingKeys.name) + static let alt = Column(CodingKeys.alt) + } +} + + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedProductImage { + enum CodingKeys: String, CodingKey { + case id + case productID + case dateCreated + case dateModified + case src + case name + case alt + } +} diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift new file mode 100644 index 00000000000..495fed18e16 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift @@ -0,0 +1,81 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedProductVariation: Codable { + public let id: Int64 + public let siteID: Int64 + public let productID: Int64 + public let sku: String? + public let globalUniqueID: String? + public let price: String + public let downloadable: Bool + public let fullDescription: String? + public let manageStock: Bool + public let stockQuantity: Decimal? + public let stockStatusKey: String + + public init(id: Int64, + siteID: Int64, + productID: Int64, + sku: String?, + globalUniqueID: String?, + price: String, + downloadable: Bool, + fullDescription: String?, + manageStock: Bool, + stockQuantity: Decimal?, + stockStatusKey: String) { + self.id = id + self.siteID = siteID + self.productID = productID + self.sku = sku + self.globalUniqueID = globalUniqueID + self.price = price + self.downloadable = downloadable + self.fullDescription = fullDescription + self.manageStock = manageStock + self.stockQuantity = stockQuantity + self.stockStatusKey = stockStatusKey + } +} + +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductVariation: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { "productVariation" } + + public enum Columns { + static let id = Column(CodingKeys.id) + static let siteID = Column(CodingKeys.siteID) + static let productID = Column(CodingKeys.productID) + static let sku = Column(CodingKeys.sku) + static let globalUniqueID = Column(CodingKeys.globalUniqueID) + static let price = Column(CodingKeys.price) + static let downloadable = Column(CodingKeys.downloadable) + static let fullDescription = Column(CodingKeys.fullDescription) + static let manageStock = Column(CodingKeys.manageStock) + static let stockQuantity = Column(CodingKeys.stockQuantity) + static let stockStatusKey = Column(CodingKeys.stockStatusKey) + } + + public static let attributes = hasMany(PersistedProductVariationAttribute.self).forKey("attributes") + public static let image = hasOne(PersistedProductVariationImage.self).forKey("image") +} + + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedProductVariation { + enum CodingKeys: String, CodingKey { + case id + case siteID + case productID + case sku + case globalUniqueID + case price + case downloadable + case fullDescription + case manageStock + case stockQuantity + case stockStatusKey + } +} diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift new file mode 100644 index 00000000000..d6dd60a32c7 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift @@ -0,0 +1,48 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedProductVariationAttribute: Codable { + public private(set) var id: Int64? + public let productVariationID: Int64 + public let name: String + public let option: String + + public init(id: Int64? = nil, + productVariationID: Int64, + name: String, + option: String) { + self.id = id + self.productVariationID = productVariationID + self.name = name + self.option = option + } +} + +// periphery:ignore - TODO: remove ignore when populating database +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductVariationAttribute: FetchableRecord, MutablePersistableRecord { + public static var databaseTableName: String { "productVariationAttribute" } + + public enum Columns { + static let id = Column(CodingKeys.id) + static let productVariationID = Column(CodingKeys.productVariationID) + static let name = Column(CodingKeys.name) + static let option = Column(CodingKeys.option) + } + + public mutating func didInsert(_ inserted: InsertionSuccess) { + id = inserted.rowID + } +} + + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedProductVariationAttribute { + enum CodingKeys: String, CodingKey { + case id + case productVariationID + case name + case option + } +} diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift new file mode 100644 index 00000000000..b20d5385b23 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift @@ -0,0 +1,58 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedProductVariationImage: Codable { + public let id: Int64 + public let productVariationID: Int64 + public let dateCreated: Date + public let dateModified: Date? + public let src: String + public let name: String? + public let alt: String? + + public init(id: Int64, + productVariationID: Int64, + dateCreated: Date, + dateModified: Date?, + src: String, + name: String?, + alt: String?) { + self.id = id + self.productVariationID = productVariationID + self.dateCreated = dateCreated + self.dateModified = dateModified + self.src = src + self.name = name + self.alt = alt + } +} + +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductVariationImage: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { "productVariationImage" } + + public enum Columns { + static let id = Column(CodingKeys.id) + static let productVariationID = Column(CodingKeys.productVariationID) + static let dateCreated = Column(CodingKeys.dateCreated) + static let dateModified = Column(CodingKeys.dateModified) + static let src = Column(CodingKeys.src) + static let name = Column(CodingKeys.name) + static let alt = Column(CodingKeys.alt) + } +} + + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedProductVariationImage { + enum CodingKeys: String, CodingKey { + case id + case productVariationID + case dateCreated + case dateModified + case src + case name + case alt + } +} diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift b/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift new file mode 100644 index 00000000000..49c6201d655 --- /dev/null +++ b/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift @@ -0,0 +1,30 @@ +import Foundation +import GRDB + +// periphery:ignore - TODO: remove ignore when populating database +public struct PersistedSite: Codable { + // periphery:ignore - TODO: remove ignore when populating database + public let id: Int64 + + // periphery:ignore - TODO: remove ignore when populating database + public init(id: Int64) { + self.id = id + } +} + +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedSite: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { "site" } + + public enum Columns { + // periphery:ignore - TODO: remove ignore when populating database + static let id = Column(CodingKeys.id) + } +} + +// periphery:ignore - TODO: remove ignore when populating database +private extension PersistedSite { + enum CodingKeys: String, CodingKey { + case id + } +} diff --git a/Modules/Sources/Storage/Protocols/Object.swift b/Modules/Sources/Storage/Protocols/Object.swift index 43174c00af3..d87ce82d0d3 100644 --- a/Modules/Sources/Storage/Protocols/Object.swift +++ b/Modules/Sources/Storage/Protocols/Object.swift @@ -11,6 +11,7 @@ public protocol Object: AnyObject { /// Returns an instance of ObjectID: expected to identify the current instance, unequivocally. /// + // periphery:ignore - Used in tests, no changes but mysteriously stopped being ignored. var objectID: ObjectID { get } /// Returns the receiver's Entity Name. diff --git a/Modules/Sources/Yosemite/Model/Model.swift b/Modules/Sources/Yosemite/Model/Model.swift index 39277cd92cf..3c97dc6f6e9 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -349,6 +349,22 @@ public typealias StorageWooShippingSavedPredefinedPackage = Storage.WooShippingS public typealias StorageWooShippingShipment = Storage.WooShippingShipment public typealias StorageWooShippingOriginAddress = Storage.WooShippingOriginAddress +// MARK: - GRDB Persisted Models +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedSite = Storage.PersistedSite +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedProduct = Storage.PersistedProduct +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedProductAttribute = Storage.PersistedProductAttribute +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedProductImage = Storage.PersistedProductImage +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedProductVariation = Storage.PersistedProductVariation +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedProductVariationAttribute = Storage.PersistedProductVariationAttribute +// periphery:ignore - TODO: remove ignore when populating database +public typealias PersistedProductVariationImage = Storage.PersistedProductVariationImage + // MARK: - Internal ReadOnly Models typealias UploadableMedia = Networking.UploadableMedia diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift new file mode 100644 index 00000000000..bda7f7c17d2 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -0,0 +1,137 @@ +import Foundation +import Storage + +// MARK: - PersistedProduct Conversions +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProduct { + init(from posProduct: POSProduct) { + self.init( + id: posProduct.productID, + siteID: posProduct.siteID, + name: posProduct.name, + productTypeKey: posProduct.productTypeKey, + fullDescription: posProduct.fullDescription, + shortDescription: posProduct.shortDescription, + sku: posProduct.sku, + globalUniqueID: posProduct.globalUniqueID, + price: posProduct.price, + downloadable: posProduct.downloadable, + parentID: posProduct.parentID, + manageStock: posProduct.manageStock, + stockQuantity: posProduct.stockQuantity, + stockStatusKey: posProduct.stockStatusKey + ) + } + + func toPOSProduct(images: [ProductImage] = [], attributes: [ProductAttribute] = []) -> POSProduct { + return POSProduct( + siteID: siteID, + productID: id, + name: name, + productTypeKey: productTypeKey, + fullDescription: fullDescription, + shortDescription: shortDescription, + sku: sku, + globalUniqueID: globalUniqueID, + price: price, + downloadable: downloadable, + parentID: parentID, + images: images, + attributes: attributes, + manageStock: manageStock, + stockQuantity: stockQuantity, + stockStatusKey: stockStatusKey + ) + } + + func toPOSProduct(db: GRDBDatabaseConnection) throws -> POSProduct { + let (images, attributes) = try db.read { db in + let images = try request(for: PersistedProduct.images).fetchAll(db) + let attributes = try request(for: PersistedProduct.attributes).fetchAll(db) + return (images, attributes) + } + + return toPOSProduct( + images: images.map { $0.toProductImage() }, + attributes: attributes.map { $0.toProductAttribute(siteID: siteID) } + ) + } + +} + +// MARK: - POSProduct Storage Extensions +// periphery:ignore - TODO: remove ignore when populating database +extension POSProduct { + public func save(to db: GRDBDatabaseConnection) throws { + try db.write { db in + let product = PersistedProduct(from: self) + try product.insert(db) + + // Save related images + for image in self.images { + let persistedImage = PersistedProductImage(from: image, productID: self.productID) + try persistedImage.insert(db) + } + + // Save related attributes + for attribute in self.attributes { + var persistedAttribute = PersistedProductAttribute(from: attribute, productID: self.productID) + try persistedAttribute.insert(db) + } + } + } +} + +// MARK: - PersistedProductAttribute Conversions +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductAttribute { + init(from productAttribute: ProductAttribute, productID: Int64) { + self.init( + productID: productID, + name: productAttribute.name, + position: Int64(productAttribute.position), + visible: productAttribute.visible, + variation: productAttribute.variation, + options: productAttribute.options + ) + } + + func toProductAttribute(siteID: Int64) -> ProductAttribute { + return ProductAttribute( + siteID: siteID, + attributeID: id ?? 0, + name: name, + position: Int(position), + visible: visible, + variation: variation, + options: options + ) + } +} + +// MARK: - PersistedProductImage Conversions +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductImage { + init(from productImage: ProductImage, productID: Int64) { + self.init( + id: productImage.imageID, + productID: productID, + dateCreated: productImage.dateCreated, + dateModified: productImage.dateModified, + src: productImage.src, + name: productImage.name, + alt: productImage.alt + ) + } + + func toProductImage() -> ProductImage { + return ProductImage( + imageID: id, + dateCreated: dateCreated, + dateModified: dateModified, + src: src, + name: name, + alt: alt + ) + } +} diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift new file mode 100644 index 00000000000..8dfb451a348 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift @@ -0,0 +1,124 @@ +import Foundation +import Storage + +// MARK: - PersistedProductVariation Conversions +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductVariation { + init(from posProductVariation: POSProductVariation) { + self.init( + id: posProductVariation.productVariationID, + siteID: posProductVariation.siteID, + productID: posProductVariation.productID, + sku: posProductVariation.sku, + globalUniqueID: posProductVariation.globalUniqueID, + price: posProductVariation.price, + downloadable: posProductVariation.downloadable, + fullDescription: posProductVariation.fullDescription, + manageStock: posProductVariation.manageStock, + stockQuantity: posProductVariation.stockQuantity, + stockStatusKey: posProductVariation.stockStatusKey + ) + } + + func toPOSProductVariation(attributes: [ProductVariationAttribute] = [], image: ProductImage? = nil) -> POSProductVariation { + return POSProductVariation( + siteID: siteID, + productID: productID, + productVariationID: id, + attributes: attributes, + image: image, + fullDescription: fullDescription, + sku: sku, + globalUniqueID: globalUniqueID, + price: price, + downloadable: downloadable, + manageStock: manageStock, + stockQuantity: stockQuantity, + stockStatusKey: stockStatusKey + ) + } + + func toPOSProductVariation(db: GRDBDatabaseConnection) throws -> POSProductVariation { + let (image, attributes) = try db.read { db in + let image = try request(for: PersistedProductVariation.image).fetchOne(db) + let attributes = try request(for: PersistedProductVariation.attributes).fetchAll(db) + return (image, attributes) + } + + return toPOSProductVariation( + attributes: attributes.map { $0.toProductVariationAttribute() }, + image: image?.toProductImage() + ) + } + +} + +// MARK: - POSProductVariation Storage Extensions +// periphery:ignore - TODO: remove ignore when populating database +extension POSProductVariation { + public func save(to db: GRDBDatabaseConnection) throws { + try db.write { db in + let variation = PersistedProductVariation(from: self) + try variation.insert(db) + + // Save related image if present + if let image = self.image { + let persistedImage = PersistedProductVariationImage(from: image, productVariationID: self.productVariationID) + try persistedImage.insert(db) + } + + // Save related attributes + for attribute in self.attributes { + var persistedAttribute = PersistedProductVariationAttribute(from: attribute, productVariationID: self.productVariationID) + try persistedAttribute.insert(db) + } + } + } +} + +// MARK: - PersistedProductVariationAttribute Conversions +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductVariationAttribute { + init(from productVariationAttribute: ProductVariationAttribute, productVariationID: Int64) { + self.init( + productVariationID: productVariationID, + name: productVariationAttribute.name, + option: productVariationAttribute.option + ) + } + + func toProductVariationAttribute() -> ProductVariationAttribute { + return ProductVariationAttribute( + id: id ?? 0, + name: name, + option: option + ) + } +} + +// MARK: - PersistedProductVariationImage Conversions +// periphery:ignore - TODO: remove ignore when populating database +extension PersistedProductVariationImage { + public init(from productImage: ProductImage, productVariationID: Int64) { + self.init( + id: productImage.imageID, + productVariationID: productVariationID, + dateCreated: productImage.dateCreated, + dateModified: productImage.dateModified, + src: productImage.src, + name: productImage.name, + alt: productImage.alt + ) + } + + public func toProductImage() -> ProductImage { + return ProductImage( + imageID: id, + dateCreated: dateCreated, + dateModified: dateModified, + src: src, + name: name, + alt: alt + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/POSProductOrVariationResolver.swift b/Modules/Sources/Yosemite/PointOfSale/Items/POSProductOrVariationResolver.swift index 0fedc2c7a3c..b5fa502936c 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/POSProductOrVariationResolver.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/POSProductOrVariationResolver.swift @@ -83,12 +83,10 @@ private extension POSProduct { productVariationID: productID, attributes: try attributes.compactMap { try $0.toProductVariationAttribute() }, image: images.first, + fullDescription: fullDescription, sku: sku, globalUniqueID: globalUniqueID, price: price, - regularPrice: regularPrice, - salePrice: salePrice, - onSale: onSale, downloadable: downloadable, manageStock: manageStock, stockQuantity: stockQuantity, diff --git a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index 0dcad6b6b5b..7c34814be64 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -44,6 +44,8 @@ struct POSCatalogSyncRemoteTests { #expect(firstProduct.siteID == sampleSiteID) #expect(firstProduct.productID == 168) #expect(firstProduct.name == "Beanie") + #expect(firstProduct.fullDescription != nil) + #expect(firstProduct.shortDescription != nil) } @Test func loadProducts_relays_networking_error() async throws { @@ -397,4 +399,21 @@ struct POSCatalogSyncRemoteTests { // Then there are more pages #expect(pagedVariations.hasMorePages == true) } + + @Test func posProductVariation_provides_field_names_for_request() { + let fieldNames = POSProductVariation.requestFields + #expect(fieldNames.contains("id")) + #expect(fieldNames.contains("parent_id")) + #expect(fieldNames.contains("attributes")) + #expect(fieldNames.contains("image")) + #expect(fieldNames.contains("price")) + #expect(fieldNames.contains("description")) + #expect(fieldNames.contains("sku")) + #expect(fieldNames.contains("global_unique_id")) + #expect(fieldNames.contains("downloadable")) + #expect(fieldNames.contains("description")) + #expect(fieldNames.contains("manage_stock")) + #expect(fieldNames.contains("stock_quantity")) + #expect(fieldNames.contains("stock_status")) + } } diff --git a/Modules/Tests/NetworkingTests/Remote/POSProductsNetworkingTests.swift b/Modules/Tests/NetworkingTests/Remote/POSProductsNetworkingTests.swift index b1b24034335..091ce5d973b 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSProductsNetworkingTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSProductsNetworkingTests.swift @@ -183,12 +183,11 @@ struct POSProductsNetworkingTests { #expect(fieldNames.contains("id")) #expect(fieldNames.contains("name")) #expect(fieldNames.contains("type")) + #expect(fieldNames.contains("description")) + #expect(fieldNames.contains("short_description")) #expect(fieldNames.contains("sku")) #expect(fieldNames.contains("global_unique_id")) #expect(fieldNames.contains("price")) - #expect(fieldNames.contains("regular_price")) - #expect(fieldNames.contains("sale_price")) - #expect(fieldNames.contains("on_sale")) #expect(fieldNames.contains("downloadable")) #expect(fieldNames.contains("parent_id")) #expect(fieldNames.contains("images")) @@ -272,7 +271,7 @@ struct POSProductsNetworkingTests { // When network.simulateResponse(requestUrlSuffix: "products", filename: "pos-products") - let product = try await remote.loadPOSProductByGlobalUniqueIdentifier(for: sampleSiteID, globalUniqueID: globalUniqueID) + _ = try await remote.loadPOSProductByGlobalUniqueIdentifier(for: sampleSiteID, globalUniqueID: globalUniqueID) // Then let request = try #require(network.requestsForResponseData.first as? JetpackRequest) diff --git a/Modules/Tests/NetworkingTests/Remote/ProductVariationsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/ProductVariationsRemoteTests.swift index a32b8bff1f3..558f825e821 100644 --- a/Modules/Tests/NetworkingTests/Remote/ProductVariationsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/ProductVariationsRemoteTests.swift @@ -172,14 +172,12 @@ final class ProductVariationsRemoteTests: XCTestCase { } XCTAssertEqual(firstVariation.productVariationID, 1275) XCTAssertEqual(firstVariation.productID, 10275) + XCTAssertEqual(firstVariation.fullDescription, "

Nutty chocolate marble, 99% and organic.

\n") XCTAssertEqual(firstVariation.sku, "99%-nuts-marble") XCTAssertEqual(firstVariation.globalUniqueID, "12345") let expectedPrice = 12 XCTAssertEqual(firstVariation.price, "\(expectedPrice)") - XCTAssertEqual(firstVariation.regularPrice, "\(expectedPrice)") - XCTAssertEqual(firstVariation.salePrice, "8") - XCTAssertFalse(firstVariation.onSale) let expectedAttributes: [ProductVariationAttribute] = [ ProductVariationAttribute(id: 0, name: "Darkness", option: "99%"), diff --git a/Modules/Tests/NetworkingTests/Responses/products-load-pos.json b/Modules/Tests/NetworkingTests/Responses/products-load-pos.json index 53c339a319f..7c08def1b52 100644 --- a/Modules/Tests/NetworkingTests/Responses/products-load-pos.json +++ b/Modules/Tests/NetworkingTests/Responses/products-load-pos.json @@ -5,6 +5,8 @@ "name": "Beanie", "type": "simple", "sku": "Woo-beanie", + "description": "", + "short_description": "", "price": "18", "regular_price": "20", "sale_price": "18", @@ -48,6 +50,8 @@ "name": "Latte", "type": "variable", "sku": "200815216", + "description": "", + "short_description": "", "price": "7", "regular_price": "", "sale_price": "", diff --git a/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift b/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift index 626a195c958..3b4ad57ff81 100644 --- a/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift +++ b/Modules/Tests/StorageTests/GRDB/GRDBManagerTests.swift @@ -68,7 +68,9 @@ struct GRDBManagerTests { productTypeKey: "simple", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try record.insert(db) } @@ -92,7 +94,9 @@ struct GRDBManagerTests { productTypeKey: "variable", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) } @@ -104,7 +108,9 @@ struct GRDBManagerTests { id: 200, productID: 100, price: "12.00", - downloadable: false + downloadable: false, + manageStock: false, + stockStatusKey: "" ) try variation.insert(db) } @@ -130,7 +136,9 @@ struct GRDBManagerTests { productTypeKey: "variable", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) @@ -141,7 +149,9 @@ struct GRDBManagerTests { id: Int64(200 + i), productID: 100, price: "\(10 + i).00", - downloadable: false + downloadable: false, + manageStock: false, + stockStatusKey: "" ) try variation.insert(db) } @@ -171,7 +181,9 @@ struct GRDBManagerTests { productTypeKey: "simple", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) } @@ -210,7 +222,9 @@ struct GRDBManagerTests { productTypeKey: "variable", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) @@ -220,7 +234,9 @@ struct GRDBManagerTests { id: 200, productID: 100, price: "12.00", - downloadable: false + downloadable: false, + manageStock: false, + stockStatusKey: "" ) try variation.insert(db) } @@ -262,7 +278,9 @@ struct GRDBManagerTests { productTypeKey: "simple", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) @@ -271,7 +289,9 @@ struct GRDBManagerTests { id: 200, productID: 100, price: "12.00", - downloadable: false + downloadable: false, + manageStock: false, + stockStatusKey: "" ) try variation.insert(db) @@ -350,7 +370,9 @@ struct GRDBManagerTests { productTypeKey: "simple", price: "\(i * 10).00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) } @@ -364,7 +386,9 @@ struct GRDBManagerTests { productTypeKey: "variable", price: "\(i * 5).00", downloadable: true, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) } @@ -405,7 +429,9 @@ struct GRDBManagerTests { productTypeKey: "variable", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) @@ -415,7 +441,9 @@ struct GRDBManagerTests { id: Int64(200 + i), productID: 100, price: "\(10 + i).00", - downloadable: false + downloadable: false, + manageStock: false, + stockStatusKey: "" ) try variation.insert(db) } @@ -446,7 +474,9 @@ struct GRDBManagerTests { productTypeKey: "simple", price: "10.00", downloadable: false, - parentID: 0 + parentID: 0, + manageStock: false, + stockStatusKey: "" ) try product.insert(db) } @@ -463,7 +493,9 @@ struct GRDBManagerTests { id: 200, productID: 999, // Non-existent product price: "12.00", - downloadable: false + downloadable: false, + manageStock: false, + stockStatusKey: "" ) try variation.insert(db) } @@ -494,11 +526,15 @@ struct TestProduct: Codable { let price: String let downloadable: Bool let parentID: Int64 + let manageStock: Bool + let stockQuantity: Double? + let stockStatusKey: String init(siteID: Int64, id: Int64, name: String, productTypeKey: String, price: String, downloadable: Bool, parentID: Int64, + manageStock: Bool, stockStatusKey: String, fullDescription: String? = nil, shortDescription: String? = nil, - sku: String? = nil, globalUniqueID: String? = nil) { + sku: String? = nil, globalUniqueID: String? = nil, stockQuantity: Double? = nil) { self.siteID = siteID self.id = id self.name = name @@ -506,10 +542,13 @@ struct TestProduct: Codable { self.price = price self.downloadable = downloadable self.parentID = parentID + self.manageStock = manageStock + self.stockStatusKey = stockStatusKey self.fullDescription = fullDescription self.shortDescription = shortDescription self.sku = sku self.globalUniqueID = globalUniqueID + self.stockQuantity = stockQuantity } } @@ -526,18 +565,25 @@ struct TestProductVariation: Codable { let price: String let downloadable: Bool let fullDescription: String? + let manageStock: Bool + let stockQuantity: Double? + let stockStatusKey: String init(siteID: Int64, id: Int64, productID: Int64, - price: String, downloadable: Bool, - sku: String? = nil, globalUniqueID: String? = nil, fullDescription: String? = nil) { + price: String, downloadable: Bool, manageStock: Bool, stockStatusKey: String, + sku: String? = nil, globalUniqueID: String? = nil, fullDescription: String? = nil, + stockQuantity: Double? = nil) { self.siteID = siteID self.id = id self.productID = productID self.price = price self.downloadable = downloadable + self.manageStock = manageStock + self.stockStatusKey = stockStatusKey self.sku = sku self.globalUniqueID = globalUniqueID self.fullDescription = fullDescription + self.stockQuantity = stockQuantity } } diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift new file mode 100644 index 00000000000..1959e38f79c --- /dev/null +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -0,0 +1,406 @@ +import Foundation +import Testing +import GRDB +@testable import Yosemite +@testable import Storage + +struct PersistedProductTests { + + @Test("PersistedProduct init(from:) maps all POSProduct fields") + func product_init_from_posProduct_maps_all_fields() throws { + // Given + let siteID: Int64 = 1 + let productID: Int64 = 10 + let posProduct = POSProduct( + siteID: siteID, + productID: productID, + name: "Test Product", + productTypeKey: "simple", + fullDescription: "Full", + shortDescription: "Short", + sku: "SKU-123", + globalUniqueID: "GID-1", + price: "9.99", + downloadable: false, + parentID: 0, + images: [], + attributes: [], + manageStock: true, + stockQuantity: 5, + stockStatusKey: "instock" + ) + + // When + let persisted = PersistedProduct(from: posProduct) + + // Then + #expect(persisted.id == productID) + #expect(persisted.siteID == siteID) + #expect(persisted.name == posProduct.name) + #expect(persisted.productTypeKey == posProduct.productTypeKey) + #expect(persisted.fullDescription == posProduct.fullDescription) + #expect(persisted.shortDescription == posProduct.shortDescription) + #expect(persisted.sku == posProduct.sku) + #expect(persisted.globalUniqueID == posProduct.globalUniqueID) + #expect(persisted.price == posProduct.price) + #expect(persisted.downloadable == posProduct.downloadable) + #expect(persisted.parentID == posProduct.parentID) + #expect(persisted.manageStock == posProduct.manageStock) + #expect(persisted.stockQuantity == posProduct.stockQuantity) + #expect(persisted.stockStatusKey == posProduct.stockStatusKey) + } + + @Test("PersistedProduct toPOSProduct maps back with images and attributes") + func product_toPOSProduct_maps_back_including_images_and_attributes() throws { + // Given + let siteID: Int64 = 2 + let productID: Int64 = 20 + let persisted = PersistedProduct( + id: productID, + siteID: siteID, + name: "Prod", + productTypeKey: "variable", + fullDescription: "FullD", + shortDescription: "ShortD", + sku: nil, + globalUniqueID: nil, + price: "12.34", + downloadable: true, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "outofstock" + ) + + let productImages = [ + PersistedProductImage(id: 200, + productID: productID, + dateCreated: Date(timeIntervalSince1970: 10), + dateModified: nil, + src: "https://example.com/p1.png", + name: "p1", + alt: "a1"), + PersistedProductImage(id: 201, + productID: productID, + dateCreated: Date(timeIntervalSince1970: 11), + dateModified: Date(timeIntervalSince1970: 12), + src: "https://example.com/p2.png", + name: nil, + alt: nil) + ] + + let persistedAttributes = [ + PersistedProductAttribute(productID: productID, name: "Material", position: 0, visible: true, variation: false, options: ["Cotton"]), + PersistedProductAttribute(productID: productID, name: "Fit", position: 1, visible: true, variation: true, options: ["Slim", "Regular"]) + ] + + // When + let posProduct = persisted.toPOSProduct( + images: productImages.map { $0.toProductImage() }, + attributes: persistedAttributes.map { $0.toProductAttribute(siteID: siteID) } + ) + + // Then + #expect(posProduct.siteID == siteID) + #expect(posProduct.productID == productID) + #expect(posProduct.name == persisted.name) + #expect(posProduct.productTypeKey == persisted.productTypeKey) + #expect(posProduct.fullDescription == persisted.fullDescription) + #expect(posProduct.shortDescription == persisted.shortDescription) + #expect(posProduct.sku == persisted.sku) + #expect(posProduct.globalUniqueID == persisted.globalUniqueID) + #expect(posProduct.price == persisted.price) + #expect(posProduct.downloadable == persisted.downloadable) + #expect(posProduct.parentID == persisted.parentID) + #expect(posProduct.manageStock == persisted.manageStock) + #expect(posProduct.stockQuantity == persisted.stockQuantity) + #expect(posProduct.stockStatusKey == persisted.stockStatusKey) + #expect(posProduct.images.count == 2) + #expect(posProduct.attributes.count == 2) + #expect(posProduct.attributesForVariations.count == 1) + } + + @Test("A Product's images and attributes are fetched automatically") + func product_with_associations_fetches_related_records() throws { + // Given + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + // Insert test site first (required by foreign key) + let site = PersistedSite(id: 1) + try site.insert(db) + + // Insert test data + let product = PersistedProduct( + id: 100, + siteID: 1, + name: "Test Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "29.99", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try product.insert(db) + + let image1 = PersistedProductImage( + id: 200, + productID: 100, + dateCreated: Date(timeIntervalSince1970: 1000), + dateModified: nil, + src: "https://example.com/img1.png", + name: "Image 1", + alt: "Alt text 1" + ) + let image2 = PersistedProductImage( + id: 201, + productID: 100, + dateCreated: Date(timeIntervalSince1970: 2000), + dateModified: Date(timeIntervalSince1970: 2500), + src: "https://example.com/img2.png", + name: nil, + alt: "Alt text 2" + ) + try image1.insert(db) + try image2.insert(db) + + var attribute1 = PersistedProductAttribute( + productID: 100, + name: "Color", + position: 0, + visible: true, + variation: true, + options: ["Red", "Blue", "Green"] + ) + var attribute2 = PersistedProductAttribute( + productID: 100, + name: "Size", + position: 1, + visible: false, + variation: false, + options: ["S", "M", "L"] + ) + try attribute1.insert(db) + try attribute2.insert(db) + } + + // When the product is fetched and converted to a POSProduct + let fetchedProduct = try db.read { db in + try PersistedProduct.filter(PersistedProduct.Columns.id == 100).fetchOne(db) + } + + let product = try #require(fetchedProduct) + let posProduct = try product.toPOSProduct(db: db) + + // Then during conversion, images and attributes are populated via associations + // Verify product + #expect(posProduct.productID == 100) + + // Verify images were fetched + #expect(posProduct.images.count == 2) + let firstImage = posProduct.images.first { $0.imageID == 200 } + #expect(firstImage?.src == "https://example.com/img1.png") + #expect(firstImage?.name == "Image 1") + #expect(firstImage?.alt == "Alt text 1") + + let secondImage = posProduct.images.first { $0.imageID == 201 } + #expect(secondImage?.src == "https://example.com/img2.png") + #expect(secondImage?.name == nil) + #expect(secondImage?.alt == "Alt text 2") + + // Verify attributes were fetched + #expect(posProduct.attributes.count == 2) + let colorAttr = posProduct.attributes.first { $0.name == "Color" } + #expect(colorAttr?.options == ["Red", "Blue", "Green"]) + #expect(colorAttr?.visible == true) + #expect(colorAttr?.variation == true) + + let sizeAttr = posProduct.attributes.first { $0.name == "Size" } + #expect(sizeAttr?.options == ["S", "M", "L"]) + #expect(sizeAttr?.visible == false) + #expect(sizeAttr?.variation == false) + } + + @Test("Product without related records has empty arrays") + func product_without_related_records_creates_empty_arrays() throws { + // Given a product without any images or attributes + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + // Insert site first + let site = PersistedSite(id: 3) + try site.insert(db) + + let product = PersistedProduct( + id: 300, + siteID: 3, + name: "Lonely Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "5.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try product.insert(db) + } + + // When we retrieve and convert the product to a POSProduct + let fetchedProduct = try db.read { db in + try PersistedProduct.filter(PersistedProduct.Columns.id == 300).fetchOne(db) + } + + let product = try #require(fetchedProduct) + let posProduct = try product.toPOSProduct(db: db) + + // Then the POSProduct has empty arrays for images and attributes + #expect(posProduct.productID == 300) + #expect(posProduct.name == "Lonely Product") + #expect(posProduct.images.isEmpty) + #expect(posProduct.attributes.isEmpty) + } + + @Test("PersistedProductAttribute init(from:) and toProductAttribute round-trip") + func product_attribute_round_trip() throws { + // Given + let siteID: Int64 = 3 + let productID: Int64 = 30 + let attribute = ProductAttribute(siteID: siteID, + attributeID: 0, + name: "Flavor", + position: 2, + visible: true, + variation: true, + options: ["Vanilla", "Chocolate"]) + + // When + let persisted = PersistedProductAttribute(from: attribute, productID: productID) + let back = persisted.toProductAttribute(siteID: siteID) + + // Then + #expect(persisted.productID == productID) + #expect(persisted.name == attribute.name) + #expect(persisted.position == Int64(attribute.position)) + #expect(persisted.visible == attribute.visible) + #expect(persisted.variation == attribute.variation) + #expect(persisted.options == attribute.options) + + #expect(back.siteID == siteID) + #expect(back.name == attribute.name) + #expect(back.position == attribute.position) + #expect(back.visible == attribute.visible) + #expect(back.variation == attribute.variation) + #expect(back.options == attribute.options) + } + + @Test("PersistedProductImage init(from:) and toProductImage round-trip") + func product_image_round_trip() throws { + // Given + let productID: Int64 = 40 + let image = ProductImage(imageID: 400, + dateCreated: Date(timeIntervalSince1970: 100), + dateModified: Date(timeIntervalSince1970: 200), + src: "https://example.com/x.png", + name: "x", + alt: "y") + + // When + let persisted = PersistedProductImage(from: image, productID: productID) + let back = persisted.toProductImage() + + // Then + #expect(persisted.id == image.imageID) + #expect(persisted.productID == productID) + #expect(persisted.dateCreated == image.dateCreated) + #expect(persisted.dateModified == image.dateModified) + #expect(persisted.src == image.src) + #expect(persisted.name == image.name) + #expect(persisted.alt == image.alt) + + #expect(back.imageID == image.imageID) + #expect(back.dateCreated == image.dateCreated) + #expect(back.dateModified == image.dateModified) + #expect(back.src == image.src) + #expect(back.name == image.name) + #expect(back.alt == image.alt) + } + + @Test("POSProduct.save() persists complete product with relationships") + func save_persists_complete_pos_product() throws { + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + // Setup site + try db.write { db in + let site = PersistedSite(id: 1) + try site.insert(db) + } + + // Given a complete POSProduct + let posProduct = POSProduct( + siteID: 1, + productID: 123, + name: "Complete Product", + productTypeKey: "variable", + fullDescription: "Complete description", + shortDescription: "Short", + sku: "COMPLETE-SKU", + globalUniqueID: "GID-123", + price: "99.99", + downloadable: true, + parentID: 0, + images: [ + ProductImage(imageID: 1001, dateCreated: Date(), dateModified: nil, src: "https://example.com/1.png", name: "img1", alt: "alt1"), + ProductImage(imageID: 1002, dateCreated: Date(), dateModified: nil, src: "https://example.com/2.png", name: "img2", alt: nil) + ], + attributes: [ + ProductAttribute(siteID: 1, attributeID: 0, name: "Color", position: 0, visible: true, variation: true, options: ["Red", "Blue"]), + ProductAttribute(siteID: 1, attributeID: 0, name: "Size", position: 1, visible: true, variation: false, options: ["S", "M", "L"]) + ], + manageStock: true, + stockQuantity: 50, + stockStatusKey: "instock" + ) + + // When saving and loading back + try posProduct.save(to: db) + + let fetchedProduct = try db.read { db in + try PersistedProduct.filter(PersistedProduct.Columns.id == 123).fetchOne(db) + } + let product = try #require(fetchedProduct) + let loadedPOSProduct = try product.toPOSProduct(db: db) + + // Then all data should be preserved + #expect(loadedPOSProduct.productID == posProduct.productID) + #expect(loadedPOSProduct.name == posProduct.name) + #expect(loadedPOSProduct.fullDescription == posProduct.fullDescription) + #expect(loadedPOSProduct.shortDescription == posProduct.shortDescription) + #expect(loadedPOSProduct.sku == posProduct.sku) + #expect(loadedPOSProduct.images.count == 2) + #expect(loadedPOSProduct.attributes.count == 2) + + // Verify images preserved + let img1 = loadedPOSProduct.images.first { $0.imageID == 1001 } + #expect(img1?.name == "img1") + #expect(img1?.alt == "alt1") + + // Verify attributes preserved + let colorAttr = loadedPOSProduct.attributes.first { $0.name == "Color" } + #expect(colorAttr?.options == ["Red", "Blue"]) + #expect(colorAttr?.variation == true) + } +} diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift new file mode 100644 index 00000000000..7c6910b7da4 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -0,0 +1,439 @@ +import Foundation +import Testing +import GRDB +@testable import Yosemite +@testable import Storage + +struct PersistedProductVariationTests { + + @Test("PersistedProductVariation init(from:) maps all POSProductVariation fields") + func variation_init_from_posProductVariation_maps_all_fields() throws { + // Given + let siteID: Int64 = 5 + let productID: Int64 = 50 + let variationID: Int64 = 500 + let attrs = [ + ProductVariationAttribute(id: 0, name: "Color", option: "Green"), + ProductVariationAttribute(id: 0, name: "Size", option: "XL") + ] + let image = ProductImage(imageID: 501, + dateCreated: Date(timeIntervalSince1970: 1000), + dateModified: nil, + src: "https://example.com/v.png", + name: "v", + alt: nil) + let pos = POSProductVariation( + siteID: siteID, + productID: productID, + productVariationID: variationID, + attributes: attrs, + image: image, + fullDescription: "VFull", + sku: "VSKU", + globalUniqueID: "VGID", + price: "19.95", + downloadable: false, + manageStock: true, + stockQuantity: 2, + stockStatusKey: "instock" + ) + + // When + let persisted = PersistedProductVariation(from: pos) + + // Then + #expect(persisted.id == variationID) + #expect(persisted.siteID == siteID) + #expect(persisted.productID == productID) + #expect(persisted.sku == pos.sku) + #expect(persisted.globalUniqueID == pos.globalUniqueID) + #expect(persisted.price == pos.price) + #expect(persisted.downloadable == pos.downloadable) + #expect(persisted.fullDescription == pos.fullDescription) + #expect(persisted.manageStock == pos.manageStock) + #expect(persisted.stockQuantity == pos.stockQuantity) + #expect(persisted.stockStatusKey == pos.stockStatusKey) + } + + @Test("PersistedProductVariation toPOSProductVariation maps back with attributes and optional image") + func variation_toPOSProductVariation_maps_back_including_attributes_and_image() throws { + // Given + let siteID: Int64 = 6 + let productID: Int64 = 60 + let variationID: Int64 = 600 + let persisted = PersistedProductVariation( + id: variationID, + siteID: siteID, + productID: productID, + sku: "SKU", + globalUniqueID: "GID", + price: "11.00", + downloadable: true, + fullDescription: "Full", + manageStock: false, + stockQuantity: nil, + stockStatusKey: "outofstock" + ) + + let varAttrs = [ + PersistedProductVariationAttribute(productVariationID: variationID, name: "Material", option: "Wool"), + PersistedProductVariationAttribute(productVariationID: variationID, name: "Fit", option: "Slim") + ] + let varImage = PersistedProductVariationImage( + id: 601, + productVariationID: variationID, + dateCreated: Date(timeIntervalSince1970: 2000), + dateModified: Date(timeIntervalSince1970: 3000), + src: "https://example.com/vi.png", + name: "vi", + alt: "vai") + + // When + let pos = persisted.toPOSProductVariation( + attributes: varAttrs.map { $0.toProductVariationAttribute() }, + image: varImage.toProductImage() + ) + + // Then + #expect(pos.siteID == siteID) + #expect(pos.productID == productID) + #expect(pos.productVariationID == variationID) + #expect(pos.sku == persisted.sku) + #expect(pos.globalUniqueID == persisted.globalUniqueID) + #expect(pos.price == persisted.price) + #expect(pos.downloadable == persisted.downloadable) + #expect(pos.fullDescription == persisted.fullDescription) + #expect(pos.manageStock == persisted.manageStock) + #expect(pos.stockQuantity == persisted.stockQuantity) + #expect(pos.stockStatusKey == persisted.stockStatusKey) + #expect(pos.attributes.count == 2) + #expect(pos.image?.imageID == varImage.id) + } + + @Test("ProductVariation with associations fetches attributes and image automatically") + func product_variation_with_associations_fetches_related_records() throws { + // Given a persisted variation with associations + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + // Insert required site and product first + let site = PersistedSite(id: 2) + try site.insert(db) + + let parentProduct = PersistedProduct( + id: 50, + siteID: 2, + name: "Parent Product", + productTypeKey: "variable", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "0.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try parentProduct.insert(db) + + // Insert test data + let variation = PersistedProductVariation( + id: 500, + siteID: 2, + productID: 50, + sku: "VAR-SKU", + globalUniqueID: "GID-500", + price: "15.50", + downloadable: true, + fullDescription: "Variation description", + manageStock: false, + stockQuantity: nil, + stockStatusKey: "outofstock" + ) + try variation.insert(db) + + let variationImage = PersistedProductVariationImage( + id: 600, + productVariationID: 500, + dateCreated: Date(timeIntervalSince1970: 3000), + dateModified: Date(timeIntervalSince1970: 3500), + src: "https://example.com/var-img.png", + name: "Variation Image", + alt: "Variation alt text" + ) + try variationImage.insert(db) + + var attr1 = PersistedProductVariationAttribute( + productVariationID: 500, + name: "Color", + option: "Purple" + ) + var attr2 = PersistedProductVariationAttribute( + productVariationID: 500, + name: "Material", + option: "Cotton" + ) + try attr1.insert(db) + try attr2.insert(db) + } + + // When we fetch it, specifying inclusion of image and attributes + struct DetailedVariation: Decodable, FetchableRecord { + var variation: PersistedProductVariation + var image: PersistedProductVariationImage + var attributes: [PersistedProductVariationAttribute] + + enum CodingKeys: CodingKey { + case variation + case image + case attributes + } + } + + let fetchedVariation = try db.read { db in + try PersistedProductVariation + .including(optional: PersistedProductVariation.image) + .including(all: PersistedProductVariation.attributes) + .asRequest(of: DetailedVariation.self) + .fetchAll(db) + }.first + + // Verify variation fields + #expect(fetchedVariation?.variation.id == 500) + #expect(fetchedVariation?.variation.siteID == 2) + #expect(fetchedVariation?.variation.productID == 50) + #expect(fetchedVariation?.variation.sku == "VAR-SKU") + #expect(fetchedVariation?.variation.fullDescription == "Variation description") + #expect(fetchedVariation?.variation.price == "15.50") + #expect(fetchedVariation?.variation.downloadable == true) + #expect(fetchedVariation?.variation.manageStock == false) + #expect(fetchedVariation?.variation.stockQuantity == nil) + #expect(fetchedVariation?.variation.stockStatusKey == "outofstock") + + // Then the image and attributes are included in the fetched data + // Verify image was fetched + #expect(fetchedVariation?.image.id == 600) + #expect(fetchedVariation?.image.src == "https://example.com/var-img.png") + #expect(fetchedVariation?.image.name == "Variation Image") + #expect(fetchedVariation?.image.alt == "Variation alt text") + + // Verify attributes were fetched + #expect(fetchedVariation?.attributes.count == 2) + let colorAttr = fetchedVariation?.attributes.first { $0.name == "Color" } + #expect(colorAttr?.option == "Purple") + + let materialAttr = fetchedVariation?.attributes.first { $0.name == "Material" } + #expect(materialAttr?.option == "Cotton") + } + + @Test("ProductVariation without image returns nil image") + func product_variation_without_image_returns_nil() throws { + // Given a persisted variation without an image + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + try db.write { db in + // Insert required site and product first + let site = PersistedSite(id: 4) + try site.insert(db) + + let parentProduct = PersistedProduct( + id: 70, + siteID: 4, + name: "Parent Product", + productTypeKey: "variable", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "0.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try parentProduct.insert(db) + + // Insert variation with attributes but no image + let variation = PersistedProductVariation( + id: 700, + siteID: 4, + productID: 70, + sku: "VAR-NO-IMG", + globalUniqueID: "GID-700", + price: "12.00", + downloadable: false, + fullDescription: "No image variation", + manageStock: true, + stockQuantity: 3, + stockStatusKey: "instock" + ) + try variation.insert(db) + + // Add attributes only, no image + var attr = PersistedProductVariationAttribute( + productVariationID: 700, + name: "Style", + option: "Modern" + ) + try attr.insert(db) + } + + // When we fetch that variation + let fetchedVariation = try db.read { db in + try PersistedProductVariation.filter(PersistedProductVariation.Columns.id == 700).fetchOne(db) + } + + let variation = try #require(fetchedVariation) + let posVariation = try variation.toPOSProductVariation(db: db) + + // Then the image is nil + #expect(posVariation.productVariationID == 700) + #expect(posVariation.image == nil) + #expect(posVariation.attributes.count == 1) + #expect(posVariation.attributes.first?.name == "Style") + #expect(posVariation.attributes.first?.option == "Modern") + } + + @Test("PersistedProductVariationAttribute init(from:) and toProductVariationAttribute round-trip") + func variation_attribute_round_trip() throws { + // Given + let variationID: Int64 = 700 + let attr = ProductVariationAttribute(id: 0, name: "Style", option: "Modern") + + // When + let persisted = PersistedProductVariationAttribute(from: attr, productVariationID: variationID) + let back = persisted.toProductVariationAttribute() + + // Then + #expect(persisted.productVariationID == variationID) + #expect(persisted.name == attr.name) + #expect(persisted.option == attr.option) + + #expect(back.name == attr.name) + #expect(back.option == attr.option) + } + + @Test("PersistedProductVariationImage init(from:) and toProductImage round-trip") + func variation_image_round_trip() throws { + // Given + let variationID: Int64 = 800 + let image = ProductImage(imageID: 801, + dateCreated: Date(timeIntervalSince1970: 4000), + dateModified: nil, + src: "https://example.com/img.png", + name: nil, + alt: nil) + + // When + let persisted = PersistedProductVariationImage(from: image, productVariationID: variationID) + let back = persisted.toProductImage() + + // Then + #expect(persisted.id == image.imageID) + #expect(persisted.productVariationID == variationID) + #expect(persisted.dateCreated == image.dateCreated) + #expect(persisted.dateModified == image.dateModified) + #expect(persisted.src == image.src) + #expect(persisted.name == image.name) + #expect(persisted.alt == image.alt) + + #expect(back.imageID == image.imageID) + #expect(back.dateCreated == image.dateCreated) + #expect(back.dateModified == image.dateModified) + #expect(back.src == image.src) + #expect(back.name == image.name) + #expect(back.alt == image.alt) + } + + @Test("POSProductVariation.save() persists complete variation with relationships") + func save_persists_complete_pos_product_variation() throws { + let grdbManager = try GRDBManager() + let db = grdbManager.databaseConnection + + // Setup site and parent product + try db.write { db in + let site = PersistedSite(id: 1) + try site.insert(db) + + let parentProduct = PersistedProduct( + id: 200, + siteID: 1, + name: "Parent Product", + productTypeKey: "variable", + fullDescription: nil, + shortDescription: nil, + sku: "PARENT-SKU", + globalUniqueID: nil, + price: "0.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try parentProduct.insert(db) + } + + // Given a complete POSProductVariation + let posVariation = POSProductVariation( + siteID: 1, + productID: 200, + productVariationID: 456, + attributes: [ + ProductVariationAttribute(id: 0, name: "Color", option: "Red"), + ProductVariationAttribute(id: 0, name: "Size", option: "Large") + ], + image: ProductImage(imageID: 2001, + dateCreated: Date(), + dateModified: nil, + src: "https://example.com/var.png", + name: "var-img", + alt: "variation image"), + fullDescription: "Complete variation description", + sku: "VAR-COMPLETE-SKU", + globalUniqueID: "GID-456", + price: "149.99", + downloadable: false, + manageStock: true, + stockQuantity: 25, + stockStatusKey: "instock" + ) + + // When saving and loading back + try posVariation.save(to: db) + + let fetchedVariation = try db.read { db in + try PersistedProductVariation.filter(PersistedProductVariation.Columns.id == 456).fetchOne(db) + } + let variation = try #require(fetchedVariation) + let loadedPOSVariation = try variation.toPOSProductVariation(db: db) + + // Then all data should be preserved + #expect(loadedPOSVariation.productVariationID == posVariation.productVariationID) + #expect(loadedPOSVariation.productID == posVariation.productID) + #expect(loadedPOSVariation.siteID == posVariation.siteID) + #expect(loadedPOSVariation.fullDescription == posVariation.fullDescription) + #expect(loadedPOSVariation.sku == posVariation.sku) + #expect(loadedPOSVariation.price == posVariation.price) + #expect(loadedPOSVariation.stockQuantity == posVariation.stockQuantity) + #expect(loadedPOSVariation.attributes.count == 2) + #expect(loadedPOSVariation.image != nil) + + // Verify image preserved + #expect(loadedPOSVariation.image?.imageID == 2001) + #expect(loadedPOSVariation.image?.name == "var-img") + #expect(loadedPOSVariation.image?.alt == "variation image") + + // Verify attributes preserved + let colorAttr = loadedPOSVariation.attributes.first { $0.name == "Color" } + #expect(colorAttr?.option == "Red") + + let sizeAttr = loadedPOSVariation.attributes.first { $0.name == "Size" } + #expect(sizeAttr?.option == "Large") + } +}