diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index 18778a43f71..6b3dad86fc0 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -1,6 +1,7 @@ import Foundation import GRDB +// periphery:ignore - TODO: remove ignore when populating database public struct PersistedProduct: Codable { public let id: Int64 public let siteID: Int64 @@ -48,6 +49,7 @@ public struct PersistedProduct: Codable { } } +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProduct: FetchableRecord, PersistableRecord { public static var databaseTableName: String { "product" } @@ -67,8 +69,12 @@ extension PersistedProduct: FetchableRecord, PersistableRecord { 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 diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift index 794fac2eb01..04bfdb458b7 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift @@ -1,6 +1,7 @@ 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 @@ -27,6 +28,7 @@ public struct PersistedProductAttribute: Codable { } } +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductAttribute: FetchableRecord, MutablePersistableRecord { public static var databaseTableName: String { "productAttribute" } @@ -46,6 +48,7 @@ extension PersistedProductAttribute: FetchableRecord, MutablePersistableRecord { } +// periphery:ignore - TODO: remove ignore when populating database private extension PersistedProductAttribute { enum CodingKeys: String, CodingKey { case id diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift index daea421e051..0e248403ee9 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift @@ -1,6 +1,7 @@ import Foundation import GRDB +// periphery:ignore - TODO: remove ignore when populating database public struct PersistedProductImage: Codable { public let id: Int64 public let productID: Int64 @@ -27,6 +28,7 @@ public struct PersistedProductImage: Codable { } } +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductImage: FetchableRecord, PersistableRecord { public static var databaseTableName: String { "productImage" } @@ -42,6 +44,7 @@ extension PersistedProductImage: FetchableRecord, PersistableRecord { } +// periphery:ignore - TODO: remove ignore when populating database private extension PersistedProductImage { enum CodingKeys: String, CodingKey { case id diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift index d17039fa375..495fed18e16 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift @@ -1,6 +1,7 @@ import Foundation import GRDB +// periphery:ignore - TODO: remove ignore when populating database public struct PersistedProductVariation: Codable { public let id: Int64 public let siteID: Int64 @@ -39,6 +40,7 @@ public struct PersistedProductVariation: Codable { } } +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductVariation: FetchableRecord, PersistableRecord { public static var databaseTableName: String { "productVariation" } @@ -55,9 +57,13 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord { 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 diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift index 8f38628efb6..d6dd60a32c7 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift @@ -1,6 +1,7 @@ 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 @@ -18,6 +19,8 @@ public struct PersistedProductVariationAttribute: Codable { } } +// 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" } @@ -34,6 +37,7 @@ extension PersistedProductVariationAttribute: FetchableRecord, MutablePersistabl } +// periphery:ignore - TODO: remove ignore when populating database private extension PersistedProductVariationAttribute { enum CodingKeys: String, CodingKey { case id diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift index 6b7b37f01fd..b20d5385b23 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift @@ -1,6 +1,7 @@ import Foundation import GRDB +// periphery:ignore - TODO: remove ignore when populating database public struct PersistedProductVariationImage: Codable { public let id: Int64 public let productVariationID: Int64 @@ -27,6 +28,7 @@ public struct PersistedProductVariationImage: Codable { } } +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductVariationImage: FetchableRecord, PersistableRecord { public static var databaseTableName: String { "productVariationImage" } @@ -42,6 +44,7 @@ extension PersistedProductVariationImage: FetchableRecord, PersistableRecord { } +// periphery:ignore - TODO: remove ignore when populating database private extension PersistedProductVariationImage { enum CodingKeys: String, CodingKey { case id diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift b/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift index a1edcbaf803..49c6201d655 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift @@ -1,22 +1,28 @@ 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 a8e0f4f923e..3c97dc6f6e9 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -350,12 +350,19 @@ 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 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/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") + } +}