diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index b2ee73d35db..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" } @@ -72,6 +74,7 @@ extension PersistedProduct: FetchableRecord, PersistableRecord { 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 f9427570aab..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" } @@ -56,11 +58,12 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord { static let stockStatusKey = Column(CodingKeys.stockStatusKey) } - public static let attributes = hasMany(PersistedProductVariationAttribute.self) - public static let image = hasOne(PersistedProductVariationImage.self) + 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 index b2917065e1d..bda7f7c17d2 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -2,6 +2,7 @@ import Foundation import Storage // MARK: - PersistedProduct Conversions +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProduct { init(from posProduct: POSProduct) { self.init( @@ -59,6 +60,7 @@ extension PersistedProduct { } // 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 @@ -81,6 +83,7 @@ extension POSProduct { } // MARK: - PersistedProductAttribute Conversions +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductAttribute { init(from productAttribute: ProductAttribute, productID: Int64) { self.init( @@ -107,6 +110,7 @@ extension PersistedProductAttribute { } // MARK: - PersistedProductImage Conversions +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductImage { init(from productImage: ProductImage, productID: Int64) { self.init( diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift index 429ec9f83fa..8dfb451a348 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift @@ -2,6 +2,7 @@ import Foundation import Storage // MARK: - PersistedProductVariation Conversions +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductVariation { init(from posProductVariation: POSProductVariation) { self.init( @@ -53,6 +54,7 @@ extension PersistedProductVariation { } // 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 @@ -75,6 +77,7 @@ extension POSProductVariation { } // MARK: - PersistedProductVariationAttribute Conversions +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductVariationAttribute { init(from productVariationAttribute: ProductVariationAttribute, productVariationID: Int64) { self.init( @@ -94,6 +97,7 @@ extension PersistedProductVariationAttribute { } // MARK: - PersistedProductVariationImage Conversions +// periphery:ignore - TODO: remove ignore when populating database extension PersistedProductVariationImage { public init(from productImage: ProductImage, productVariationID: Int64) { self.init( diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift deleted file mode 100644 index 7aa7bf0ea16..00000000000 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Foundation -import Testing -@testable import Yosemite - -struct PersistedProductConversionsTests { - - @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 images: [ProductImage] = [ - ProductImage(imageID: 100, - dateCreated: Date(timeIntervalSince1970: 1), - dateModified: nil, - src: "https://example.com/1.png", - name: "img1", - alt: "alt1"), - ProductImage(imageID: 101, - dateCreated: Date(timeIntervalSince1970: 2), - dateModified: Date(timeIntervalSince1970: 3), - src: "https://example.com/2.png", - name: "img2", - alt: nil) - ] - let attributes: [ProductAttribute] = [ - ProductAttribute(siteID: siteID, attributeID: 0, name: "Color", position: 0, visible: true, variation: true, options: []), - ProductAttribute(siteID: siteID, attributeID: 0, name: "Size", position: 1, visible: false, variation: false, options: []) - ] - let pos = 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: images, - attributes: attributes, - manageStock: true, - stockQuantity: 5, - stockStatusKey: "instock" - ) - - // When - let persisted = PersistedProduct(from: pos) - - // Then - #expect(persisted.id == productID) - #expect(persisted.siteID == siteID) - #expect(persisted.name == pos.name) - #expect(persisted.productTypeKey == pos.productTypeKey) - #expect(persisted.fullDescription == pos.fullDescription) - #expect(persisted.shortDescription == pos.shortDescription) - #expect(persisted.sku == pos.sku) - #expect(persisted.globalUniqueID == pos.globalUniqueID) - #expect(persisted.price == pos.price) - #expect(persisted.downloadable == pos.downloadable) - #expect(persisted.parentID == pos.parentID) - #expect(persisted.manageStock == pos.manageStock) - #expect(persisted.stockQuantity == pos.stockQuantity) - #expect(persisted.stockStatusKey == pos.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: []), - PersistedProductAttribute(productID: productID, name: "Fit", position: 1, visible: true, variation: true, options: []) - ] - - // When - let pos = persisted.toPOSProduct( - images: productImages.map { $0.toProductImage() }, - attributes: persistedAttributes.map { $0.toProductAttribute(siteID: siteID) } - ) - - // Then - #expect(pos.siteID == siteID) - #expect(pos.productID == productID) - #expect(pos.name == persisted.name) - #expect(pos.productTypeKey == persisted.productTypeKey) - #expect(pos.fullDescription == persisted.fullDescription) - #expect(pos.shortDescription == persisted.shortDescription) - #expect(pos.sku == persisted.sku) - #expect(pos.globalUniqueID == persisted.globalUniqueID) - #expect(pos.price == persisted.price) - #expect(pos.downloadable == persisted.downloadable) - #expect(pos.parentID == persisted.parentID) - #expect(pos.manageStock == persisted.manageStock) - #expect(pos.stockQuantity == persisted.stockQuantity) - #expect(pos.stockStatusKey == persisted.stockStatusKey) - #expect(pos.images.count == 2) - #expect(pos.attributes.count == 2) - #expect(pos.attributesForVariations.count == 1) - } - - @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) - } -} 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/PersistedProductVariationConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift deleted file mode 100644 index 735b4e6c4ab..00000000000 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -import Foundation -import Testing -@testable import Yosemite - -struct PersistedProductVariationConversionsTests { - - @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("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) - } -} 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") + } +}