From f4cf1553d7f2b6df467e7b0db62ae28843c18ddd Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 18:34:26 +0100 Subject: [PATCH 01/18] Add mapping for persisted models Fix description use when making persistedproduct --- .../PersistedProduct+Conversions.swift | 97 +++++++++++++++++++ ...ersistedProductVariation+Conversions.swift | 84 ++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift create mode 100644 Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift 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..a26ba67330e --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -0,0 +1,97 @@ +import Foundation +import Storage + +// MARK: - PersistedProduct Conversions +extension PersistedProduct { + public 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 + ) + } + + public 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 + ) + } +} + +// MARK: - PersistedProductAttribute Conversions +extension PersistedProductAttribute { + public 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 + ) + } + + public func toProductAttribute(siteID: Int64) -> ProductAttribute { + return ProductAttribute( + siteID: siteID, + attributeID: 0, + name: name, + position: Int(position), + visible: visible, + variation: variation, + options: options + ) + } +} + +// MARK: - PersistedProductImage Conversions +extension PersistedProductImage { + public 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 + ) + } + + public 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..f6054ad409e --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift @@ -0,0 +1,84 @@ +import Foundation +import Storage + +// MARK: - PersistedProductVariation Conversions +extension PersistedProductVariation { + public 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 + ) + } + + public 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 + ) + } +} + +// MARK: - PersistedProductVariationAttribute Conversions +extension PersistedProductVariationAttribute { + public init(from productVariationAttribute: ProductVariationAttribute, productVariationID: Int64) { + self.init( + productVariationID: productVariationID, + name: productVariationAttribute.name, + option: productVariationAttribute.option + ) + } + + public func toProductVariationAttribute() -> ProductVariationAttribute { + return ProductVariationAttribute( + id: id ?? 0, + name: name, + option: option + ) + } +} + +// MARK: - PersistedProductVariationImage Conversions +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 + ) + } +} From b37d5749c7ca37681c0d6bc7fde2155e067d877a Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 17:29:37 +0100 Subject: [PATCH 02/18] Add attribute and image associations --- .../Storage/GRDB/Model/PersistedProduct.swift | 3 +++ .../GRDB/Model/PersistedProductVariation.swift | 3 +++ .../Storage/PersistedProduct+Conversions.swift | 13 +++++++++++++ .../PersistedProductVariation+Conversions.swift | 13 +++++++++++++ 4 files changed, 32 insertions(+) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index 18778a43f71..b0b9f09d925 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -67,6 +67,9 @@ 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) } private extension PersistedProduct { diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift index d17039fa375..70aa41e62fb 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift @@ -55,6 +55,9 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord { static let stockQuantity = Column(CodingKeys.stockQuantity) static let stockStatusKey = Column(CodingKeys.stockStatusKey) } + + public static let attributes = hasMany(PersistedProductVariationAttribute.self) + public static let image = hasOne(PersistedProductVariationImage.self) } diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift index a26ba67330e..6b86cf0c700 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -42,6 +42,19 @@ extension PersistedProduct { stockStatusKey: stockStatusKey ) } + + public func toPOSProduct(db: GRDBDatabaseConnection) throws -> POSProduct { + let images = try db.read { db in + return try request(for: PersistedProduct.images).fetchAll(db) + } + let attributes = try db.read { db in + return try request(for: PersistedProduct.attributes).fetchAll(db) + } + return toPOSProduct( + images: images.map { $0.toProductImage() }, + attributes: attributes.map { $0.toProductAttribute(siteID: siteID) } + ) + } } // MARK: - PersistedProductAttribute Conversions diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift index f6054ad409e..c2cadb3abf5 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift @@ -36,6 +36,19 @@ extension PersistedProductVariation { stockStatusKey: stockStatusKey ) } + + public func toPOSProductVariation(db: GRDBDatabaseConnection) throws -> POSProductVariation { + let image = try db.read { db in + return try request(for: PersistedProductVariation.image).fetchOne(db) + } + let attributes = try db.read { db in + return try request(for: PersistedProductVariation.attributes).fetchAll(db) + } + return toPOSProductVariation( + attributes: attributes.map { $0.toProductVariationAttribute() }, + image: image?.toProductImage() + ) + } } // MARK: - PersistedProductVariationAttribute Conversions From ff038819bcfd60f791f9867b20a79d3a59f104fe Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 16:30:59 +0100 Subject: [PATCH 03/18] Test persisted to networking model conversion --- .../PersistedProductConversionsTests.swift | 203 ++++++++++++++++++ ...stedProductVariationConversionsTests.swift | 161 ++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift create mode 100644 Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift new file mode 100644 index 00000000000..a3c34930be1 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import Yosemite + +struct PersistedProductConversionsTests { + + @Test("PersistedProduct init(from:) maps all POSProduct fields") + func test_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: ["Red", "Blue"]), + ProductAttribute(siteID: siteID, attributeID: 0, name: "Size", position: 1, visible: false, variation: false, options: ["S", "M"]) + ] + 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 == nil) + #expect(persisted.shortDescription == nil) + #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 test_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 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 test_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 test_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/PersistedProductVariationConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift new file mode 100644 index 00000000000..b30f39583d4 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import Yosemite + +struct PersistedProductVariationConversionsTests { + + @Test("PersistedProductVariation init(from:) maps all POSProductVariation fields") + func test_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 test_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 test_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 test_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) + } +} From f4162659d04ea351b97b3b99f2e58d8395fcd0c1 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 18:53:57 +0100 Subject: [PATCH 04/18] Save product/variation entities with relationships --- .../Storage/GRDB/Model/PersistedProduct.swift | 2 +- .../Model/PersistedProductVariation.swift | 2 +- .../PersistedProduct+Conversions.swift | 43 ++++++++++++++----- ...ersistedProductVariation+Conversions.swift | 33 +++++++++++--- 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index b0b9f09d925..b2ee73d35db 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -67,7 +67,7 @@ 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) } diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift index 70aa41e62fb..f9427570aab 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift @@ -55,7 +55,7 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord { static let stockQuantity = Column(CodingKeys.stockQuantity) static let stockStatusKey = Column(CodingKeys.stockStatusKey) } - + public static let attributes = hasMany(PersistedProductVariationAttribute.self) public static let image = hasOne(PersistedProductVariationImage.self) } diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift index 6b86cf0c700..362cff5602d 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -3,7 +3,7 @@ import Storage // MARK: - PersistedProduct Conversions extension PersistedProduct { - public init(from posProduct: POSProduct) { + init(from posProduct: POSProduct) { self.init( id: posProduct.productID, siteID: posProduct.siteID, @@ -21,8 +21,8 @@ extension PersistedProduct { stockStatusKey: posProduct.stockStatusKey ) } - - public func toPOSProduct(images: [ProductImage] = [], attributes: [ProductAttribute] = []) -> POSProduct { + + func toPOSProduct(images: [ProductImage] = [], attributes: [ProductAttribute] = []) -> POSProduct { return POSProduct( siteID: siteID, productID: id, @@ -43,7 +43,7 @@ extension PersistedProduct { ) } - public func toPOSProduct(db: GRDBDatabaseConnection) throws -> POSProduct { + func toPOSProduct(db: GRDBDatabaseConnection) throws -> POSProduct { let images = try db.read { db in return try request(for: PersistedProduct.images).fetchAll(db) } @@ -55,11 +55,34 @@ extension PersistedProduct { attributes: attributes.map { $0.toProductAttribute(siteID: siteID) } ) } + +} + +// MARK: - POSProduct Storage Extensions +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 extension PersistedProductAttribute { - public init(from productAttribute: ProductAttribute, productID: Int64) { + init(from productAttribute: ProductAttribute, productID: Int64) { self.init( productID: productID, name: productAttribute.name, @@ -69,8 +92,8 @@ extension PersistedProductAttribute { options: productAttribute.options ) } - - public func toProductAttribute(siteID: Int64) -> ProductAttribute { + + func toProductAttribute(siteID: Int64) -> ProductAttribute { return ProductAttribute( siteID: siteID, attributeID: 0, @@ -85,7 +108,7 @@ extension PersistedProductAttribute { // MARK: - PersistedProductImage Conversions extension PersistedProductImage { - public init(from productImage: ProductImage, productID: Int64) { + init(from productImage: ProductImage, productID: Int64) { self.init( id: productImage.imageID, productID: productID, @@ -96,8 +119,8 @@ extension PersistedProductImage { alt: productImage.alt ) } - - public func toProductImage() -> ProductImage { + + func toProductImage() -> ProductImage { return ProductImage( imageID: id, dateCreated: dateCreated, diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift index c2cadb3abf5..066f76683cd 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift @@ -3,7 +3,7 @@ import Storage // MARK: - PersistedProductVariation Conversions extension PersistedProductVariation { - public init(from posProductVariation: POSProductVariation) { + init(from posProductVariation: POSProductVariation) { self.init( id: posProductVariation.productVariationID, siteID: posProductVariation.siteID, @@ -19,7 +19,7 @@ extension PersistedProductVariation { ) } - public func toPOSProductVariation(attributes: [ProductVariationAttribute] = [], image: ProductImage? = nil) -> POSProductVariation { + func toPOSProductVariation(attributes: [ProductVariationAttribute] = [], image: ProductImage? = nil) -> POSProductVariation { return POSProductVariation( siteID: siteID, productID: productID, @@ -37,7 +37,7 @@ extension PersistedProductVariation { ) } - public func toPOSProductVariation(db: GRDBDatabaseConnection) throws -> POSProductVariation { + func toPOSProductVariation(db: GRDBDatabaseConnection) throws -> POSProductVariation { let image = try db.read { db in return try request(for: PersistedProductVariation.image).fetchOne(db) } @@ -49,11 +49,34 @@ extension PersistedProductVariation { image: image?.toProductImage() ) } + +} + +// MARK: - POSProductVariation Storage Extensions +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 extension PersistedProductVariationAttribute { - public init(from productVariationAttribute: ProductVariationAttribute, productVariationID: Int64) { + init(from productVariationAttribute: ProductVariationAttribute, productVariationID: Int64) { self.init( productVariationID: productVariationID, name: productVariationAttribute.name, @@ -61,7 +84,7 @@ extension PersistedProductVariationAttribute { ) } - public func toProductVariationAttribute() -> ProductVariationAttribute { + func toProductVariationAttribute() -> ProductVariationAttribute { return ProductVariationAttribute( id: id ?? 0, name: name, From 627787d6442ef1fdf174019ba589dd4648c52e65 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 19:06:27 +0100 Subject: [PATCH 05/18] Fix whitespace --- .../Storage/PersistedProduct+Conversions.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift index 362cff5602d..063db6a3cbf 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -21,7 +21,7 @@ extension PersistedProduct { stockStatusKey: posProduct.stockStatusKey ) } - + func toPOSProduct(images: [ProductImage] = [], attributes: [ProductAttribute] = []) -> POSProduct { return POSProduct( siteID: siteID, @@ -42,7 +42,7 @@ extension PersistedProduct { stockStatusKey: stockStatusKey ) } - + func toPOSProduct(db: GRDBDatabaseConnection) throws -> POSProduct { let images = try db.read { db in return try request(for: PersistedProduct.images).fetchAll(db) @@ -55,7 +55,7 @@ extension PersistedProduct { attributes: attributes.map { $0.toProductAttribute(siteID: siteID) } ) } - + } // MARK: - POSProduct Storage Extensions @@ -64,13 +64,13 @@ extension POSProduct { 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) @@ -92,7 +92,7 @@ extension PersistedProductAttribute { options: productAttribute.options ) } - + func toProductAttribute(siteID: Int64) -> ProductAttribute { return ProductAttribute( siteID: siteID, @@ -119,7 +119,7 @@ extension PersistedProductImage { alt: productImage.alt ) } - + func toProductImage() -> ProductImage { return ProductImage( imageID: id, From e831e89d5ced924fc8e17ba5b92390149ac5d7e6 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 17:59:58 +0100 Subject: [PATCH 06/18] Test saving POS models and regroup mapping tests --- .../PersistedProductConversionsTests.swift | 203 ---------- .../Storage/PersistedProductTests.swift | 364 ++++++++++++++++++ ...stedProductVariationConversionsTests.swift | 161 -------- .../PersistedProductVariationTests.swift | 335 ++++++++++++++++ 4 files changed, 699 insertions(+), 364 deletions(-) delete mode 100644 Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift create mode 100644 Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift delete mode 100644 Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift create mode 100644 Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift deleted file mode 100644 index a3c34930be1..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 test_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: ["Red", "Blue"]), - ProductAttribute(siteID: siteID, attributeID: 0, name: "Size", position: 1, visible: false, variation: false, options: ["S", "M"]) - ] - 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 == nil) - #expect(persisted.shortDescription == nil) - #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 test_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 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 test_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 test_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..54f008fd1a3 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -0,0 +1,364 @@ +import Foundation +import Testing +import GRDB +@testable import Yosemite +@testable import Storage + +struct PersistedProductTests { + + @Test("PersistedProduct init(from:) maps all POSProduct fields") + func test_product_init_from_posProduct_maps_all_fields() throws { + // Given + let siteID: Int64 = 1 + let productID: Int64 = 10 + let images: [Yosemite.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: [Yosemite.ProductAttribute] = [ + ProductAttribute(siteID: siteID, attributeID: 0, name: "Color", position: 0, visible: true, variation: true, options: ["Red", "Blue"]), + ProductAttribute(siteID: siteID, attributeID: 0, name: "Size", position: 1, visible: false, variation: false, options: ["S", "M"]) + ] + 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: images, + attributes: 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 test_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 test_product_with_associations_fetches_related_records() throws { + 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: "Full description", + shortDescription: "Short description", + sku: "TEST-SKU", + globalUniqueID: "GID-100", + price: "29.99", + downloadable: false, + parentID: 0, + manageStock: true, + stockQuantity: 10, + 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) + } + + // Test automatic fetching via associations + 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) + + // Verify product fields + #expect(posProduct.productID == 100) + #expect(posProduct.siteID == 1) + #expect(posProduct.name == "Test Product") + #expect(posProduct.fullDescription == "Full description") + #expect(posProduct.shortDescription == "Short description") + #expect(posProduct.sku == "TEST-SKU") + #expect(posProduct.price == "29.99") + #expect(posProduct.stockQuantity == 10) + + // 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 test_product_without_related_records_creates_empty_arrays() throws { + 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) + + // Insert product without any images or attributes + 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) + } + + // Test that product with no related records returns empty arrays + 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) + + #expect(posProduct.productID == 300) + #expect(posProduct.name == "Lonely Product") + #expect(posProduct.images.isEmpty) + #expect(posProduct.attributes.isEmpty) + #expect(posProduct.fullDescription == nil) + #expect(posProduct.shortDescription == nil) + } + + @Test("PersistedProductAttribute init(from:) and toProductAttribute round-trip") + func test_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 test_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/PersistedProductVariationConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift deleted file mode 100644 index b30f39583d4..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 test_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 test_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 test_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 test_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..0869b3e3580 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -0,0 +1,335 @@ +import Foundation +import Testing +import GRDB +@testable import Yosemite +@testable import Storage + +struct PersistedProductVariationTests { + + @Test("PersistedProductVariation init(from:) maps all POSProductVariation fields") + func test_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 test_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 test_product_variation_with_associations_fetches_related_records() throws { + 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) + } + + // Test automatic fetching via associations + let fetchedVariation = try db.read { db in + try PersistedProductVariation.filter(PersistedProductVariation.Columns.id == 500).fetchOne(db) + } + + let variation = try #require(fetchedVariation) + let posVariation = try variation.toPOSProductVariation(db: db) + + // Verify variation fields + #expect(posVariation.productVariationID == 500) + #expect(posVariation.siteID == 2) + #expect(posVariation.productID == 50) + #expect(posVariation.sku == "VAR-SKU") + #expect(posVariation.fullDescription == "Variation description") + #expect(posVariation.price == "15.50") + #expect(posVariation.downloadable == true) + #expect(posVariation.manageStock == false) + #expect(posVariation.stockQuantity == nil) + #expect(posVariation.stockStatusKey == "outofstock") + + // Verify image was fetched + #expect(posVariation.image?.imageID == 600) + #expect(posVariation.image?.src == "https://example.com/var-img.png") + #expect(posVariation.image?.name == "Variation Image") + #expect(posVariation.image?.alt == "Variation alt text") + + // Verify attributes were fetched + #expect(posVariation.attributes.count == 2) + let colorAttr = posVariation.attributes.first { $0.name == "Color" } + #expect(colorAttr?.option == "Purple") + + let materialAttr = posVariation.attributes.first { $0.name == "Material" } + #expect(materialAttr?.option == "Cotton") + } + + @Test("ProductVariation without image returns nil image") + func test_product_variation_without_image_returns_nil() throws { + 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) + } + + // Test that variation with no image returns nil for image + 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) + + #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 test_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 test_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) + } +} From e9fc1ce2902b6aec70c85513d5cf6b93b573eb80 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 18:54:23 +0100 Subject: [PATCH 07/18] Tests for saving POS models to GRDB --- .../Storage/PersistedProductTests.swift | 88 ++++++++++++++----- .../PersistedProductVariationTests.swift | 87 ++++++++++++++++++ 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift index 54f008fd1a3..add9037e63a 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -11,24 +11,6 @@ struct PersistedProductTests { // Given let siteID: Int64 = 1 let productID: Int64 = 10 - let images: [Yosemite.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: [Yosemite.ProductAttribute] = [ - ProductAttribute(siteID: siteID, attributeID: 0, name: "Color", position: 0, visible: true, variation: true, options: ["Red", "Blue"]), - ProductAttribute(siteID: siteID, attributeID: 0, name: "Size", position: 1, visible: false, variation: false, options: ["S", "M"]) - ] let posProduct = POSProduct( siteID: siteID, productID: productID, @@ -41,8 +23,8 @@ struct PersistedProductTests { price: "9.99", downloadable: false, parentID: 0, - images: images, - attributes: attributes, + images: [], + attributes: [], manageStock: true, stockQuantity: 5, stockStatusKey: "instock" @@ -361,4 +343,70 @@ struct PersistedProductTests { #expect(back.name == image.name) #expect(back.alt == image.alt) } + + @Test("POSProduct.save() persists complete product with relationships") + func test_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 index 0869b3e3580..d2cf0b16b5e 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -332,4 +332,91 @@ struct PersistedProductVariationTests { #expect(back.name == image.name) #expect(back.alt == image.alt) } + + @Test("POSProductVariation.save() persists complete variation with relationships") + func test_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") + } } From 9f46de120467253206ed3a6033ee8c15275e202c Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 29 Aug 2025 19:22:25 +0100 Subject: [PATCH 08/18] Fix test expectation --- .../Storage/PersistedProductConversionsTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift index a3c34930be1..7365e7c57c3 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift @@ -54,8 +54,8 @@ struct PersistedProductConversionsTests { #expect(persisted.siteID == siteID) #expect(persisted.name == pos.name) #expect(persisted.productTypeKey == pos.productTypeKey) - #expect(persisted.fullDescription == nil) - #expect(persisted.shortDescription == nil) + #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) From f822a0903b164572cf03058b3af4d53ddf9cd417 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 10:14:57 +0100 Subject: [PATCH 09/18] Match attribute handling for product and variation --- .../Yosemite/Model/Storage/PersistedProduct+Conversions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift index a26ba67330e..958d10f75ee 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -60,7 +60,7 @@ extension PersistedProductAttribute { public func toProductAttribute(siteID: Int64) -> ProductAttribute { return ProductAttribute( siteID: siteID, - attributeID: 0, + attributeID: id ?? 0, name: name, position: Int(position), visible: visible, From 218d704809b26ed997b19173fb40948b6f7db8ec Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 10:20:07 +0100 Subject: [PATCH 10/18] Test updates from code revier --- .../PersistedProductConversionsTests.swift | 16 ++++++++-------- ...rsistedProductVariationConversionsTests.swift | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift index 7365e7c57c3..7aa7bf0ea16 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift @@ -5,7 +5,7 @@ import Testing struct PersistedProductConversionsTests { @Test("PersistedProduct init(from:) maps all POSProduct fields") - func test_product_init_from_posProduct_maps_all_fields() throws { + func product_init_from_posProduct_maps_all_fields() throws { // Given let siteID: Int64 = 1 let productID: Int64 = 10 @@ -24,8 +24,8 @@ struct PersistedProductConversionsTests { alt: nil) ] let attributes: [ProductAttribute] = [ - ProductAttribute(siteID: siteID, attributeID: 0, name: "Color", position: 0, visible: true, variation: true, options: ["Red", "Blue"]), - ProductAttribute(siteID: siteID, attributeID: 0, name: "Size", position: 1, visible: false, variation: false, options: ["S", "M"]) + 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, @@ -67,7 +67,7 @@ struct PersistedProductConversionsTests { } @Test("PersistedProduct toPOSProduct maps back with images and attributes") - func test_product_toPOSProduct_maps_back_including_images_and_attributes() throws { + func product_toPOSProduct_maps_back_including_images_and_attributes() throws { // Given let siteID: Int64 = 2 let productID: Int64 = 20 @@ -106,8 +106,8 @@ struct PersistedProductConversionsTests { ] 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"]) + 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 @@ -137,7 +137,7 @@ struct PersistedProductConversionsTests { } @Test("PersistedProductAttribute init(from:) and toProductAttribute round-trip") - func test_product_attribute_round_trip() throws { + func product_attribute_round_trip() throws { // Given let siteID: Int64 = 3 let productID: Int64 = 30 @@ -170,7 +170,7 @@ struct PersistedProductConversionsTests { } @Test("PersistedProductImage init(from:) and toProductImage round-trip") - func test_product_image_round_trip() throws { + func product_image_round_trip() throws { // Given let productID: Int64 = 40 let image = ProductImage(imageID: 400, diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift index b30f39583d4..735b4e6c4ab 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationConversionsTests.swift @@ -5,7 +5,7 @@ import Testing struct PersistedProductVariationConversionsTests { @Test("PersistedProductVariation init(from:) maps all POSProductVariation fields") - func test_variation_init_from_posProductVariation_maps_all_fields() throws { + func variation_init_from_posProductVariation_maps_all_fields() throws { // Given let siteID: Int64 = 5 let productID: Int64 = 50 @@ -54,7 +54,7 @@ struct PersistedProductVariationConversionsTests { } @Test("PersistedProductVariation toPOSProductVariation maps back with attributes and optional image") - func test_variation_toPOSProductVariation_maps_back_including_attributes_and_image() throws { + func variation_toPOSProductVariation_maps_back_including_attributes_and_image() throws { // Given let siteID: Int64 = 6 let productID: Int64 = 60 @@ -109,7 +109,7 @@ struct PersistedProductVariationConversionsTests { } @Test("PersistedProductVariationAttribute init(from:) and toProductVariationAttribute round-trip") - func test_variation_attribute_round_trip() throws { + func variation_attribute_round_trip() throws { // Given let variationID: Int64 = 700 let attr = ProductVariationAttribute(id: 0, name: "Style", option: "Modern") @@ -128,7 +128,7 @@ struct PersistedProductVariationConversionsTests { } @Test("PersistedProductVariationImage init(from:) and toProductImage round-trip") - func test_variation_image_round_trip() throws { + func variation_image_round_trip() throws { // Given let variationID: Int64 = 800 let image = ProductImage(imageID: 801, From cdc2e9dff6cfa18566b8d793f3291f8d329a329f Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 10:55:46 +0100 Subject: [PATCH 11/18] Test fix --- .../Storage/PersistedProductConversionsTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift index a3c34930be1..aae343b5bca 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductConversionsTests.swift @@ -54,8 +54,8 @@ struct PersistedProductConversionsTests { #expect(persisted.siteID == siteID) #expect(persisted.name == pos.name) #expect(persisted.productTypeKey == pos.productTypeKey) - #expect(persisted.fullDescription == nil) - #expect(persisted.shortDescription == nil) + #expect(persisted.fullDescription == "Full") + #expect(persisted.shortDescription == "Short") #expect(persisted.sku == pos.sku) #expect(persisted.globalUniqueID == pos.globalUniqueID) #expect(persisted.price == pos.price) From e3d7b85aafb804a42dc24177f6c76e6d3932ee0b Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 10:57:40 +0100 Subject: [PATCH 12/18] Use a single transaction for reading relationships --- .../Model/Storage/PersistedProduct+Conversions.swift | 10 +++++----- .../PersistedProductVariation+Conversions.swift | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift index 063db6a3cbf..5b7830c3436 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift @@ -44,12 +44,12 @@ extension PersistedProduct { } func toPOSProduct(db: GRDBDatabaseConnection) throws -> POSProduct { - let images = try db.read { db in - return try request(for: PersistedProduct.images).fetchAll(db) - } - let attributes = try db.read { db in - return try request(for: PersistedProduct.attributes).fetchAll(db) + 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) } diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift index 066f76683cd..429ec9f83fa 100644 --- a/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift @@ -38,12 +38,12 @@ extension PersistedProductVariation { } func toPOSProductVariation(db: GRDBDatabaseConnection) throws -> POSProductVariation { - let image = try db.read { db in - return try request(for: PersistedProductVariation.image).fetchOne(db) - } - let attributes = try db.read { db in - return try request(for: PersistedProductVariation.attributes).fetchAll(db) + 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() From 5c8d9427b3ebf566859839a87220eb9d7339adda Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 11:05:10 +0100 Subject: [PATCH 13/18] Remove test prefixes --- .../Storage/PersistedProductTests.swift | 14 +++++++------- .../Storage/PersistedProductVariationTests.swift | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift index add9037e63a..110c11b141e 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -7,7 +7,7 @@ import GRDB struct PersistedProductTests { @Test("PersistedProduct init(from:) maps all POSProduct fields") - func test_product_init_from_posProduct_maps_all_fields() throws { + func product_init_from_posProduct_maps_all_fields() throws { // Given let siteID: Int64 = 1 let productID: Int64 = 10 @@ -51,7 +51,7 @@ struct PersistedProductTests { } @Test("PersistedProduct toPOSProduct maps back with images and attributes") - func test_product_toPOSProduct_maps_back_including_images_and_attributes() throws { + func product_toPOSProduct_maps_back_including_images_and_attributes() throws { // Given let siteID: Int64 = 2 let productID: Int64 = 20 @@ -121,7 +121,7 @@ struct PersistedProductTests { } @Test("A Product's images and attributes are fetched automatically") - func test_product_with_associations_fetches_related_records() throws { + func product_with_associations_fetches_related_records() throws { let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection @@ -234,7 +234,7 @@ struct PersistedProductTests { } @Test("Product without related records has empty arrays") - func test_product_without_related_records_creates_empty_arrays() throws { + func product_without_related_records_creates_empty_arrays() throws { let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection @@ -280,7 +280,7 @@ struct PersistedProductTests { } @Test("PersistedProductAttribute init(from:) and toProductAttribute round-trip") - func test_product_attribute_round_trip() throws { + func product_attribute_round_trip() throws { // Given let siteID: Int64 = 3 let productID: Int64 = 30 @@ -313,7 +313,7 @@ struct PersistedProductTests { } @Test("PersistedProductImage init(from:) and toProductImage round-trip") - func test_product_image_round_trip() throws { + func product_image_round_trip() throws { // Given let productID: Int64 = 40 let image = ProductImage(imageID: 400, @@ -345,7 +345,7 @@ struct PersistedProductTests { } @Test("POSProduct.save() persists complete product with relationships") - func test_save_persists_complete_pos_product() throws { + func save_persists_complete_pos_product() throws { let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift index d2cf0b16b5e..e2ebe21b24a 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -7,7 +7,7 @@ import GRDB struct PersistedProductVariationTests { @Test("PersistedProductVariation init(from:) maps all POSProductVariation fields") - func test_variation_init_from_posProductVariation_maps_all_fields() throws { + func variation_init_from_posProductVariation_maps_all_fields() throws { // Given let siteID: Int64 = 5 let productID: Int64 = 50 @@ -56,7 +56,7 @@ struct PersistedProductVariationTests { } @Test("PersistedProductVariation toPOSProductVariation maps back with attributes and optional image") - func test_variation_toPOSProductVariation_maps_back_including_attributes_and_image() throws { + func variation_toPOSProductVariation_maps_back_including_attributes_and_image() throws { // Given let siteID: Int64 = 6 let productID: Int64 = 60 @@ -111,7 +111,7 @@ struct PersistedProductVariationTests { } @Test("ProductVariation with associations fetches attributes and image automatically") - func test_product_variation_with_associations_fetches_related_records() throws { + func product_variation_with_associations_fetches_related_records() throws { let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection @@ -215,7 +215,7 @@ struct PersistedProductVariationTests { } @Test("ProductVariation without image returns nil image") - func test_product_variation_without_image_returns_nil() throws { + func product_variation_without_image_returns_nil() throws { let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection @@ -283,7 +283,7 @@ struct PersistedProductVariationTests { } @Test("PersistedProductVariationAttribute init(from:) and toProductVariationAttribute round-trip") - func test_variation_attribute_round_trip() throws { + func variation_attribute_round_trip() throws { // Given let variationID: Int64 = 700 let attr = ProductVariationAttribute(id: 0, name: "Style", option: "Modern") @@ -302,7 +302,7 @@ struct PersistedProductVariationTests { } @Test("PersistedProductVariationImage init(from:) and toProductImage round-trip") - func test_variation_image_round_trip() throws { + func variation_image_round_trip() throws { // Given let variationID: Int64 = 800 let image = ProductImage(imageID: 801, @@ -334,7 +334,7 @@ struct PersistedProductVariationTests { } @Test("POSProductVariation.save() persists complete variation with relationships") - func test_save_persists_complete_pos_product_variation() throws { + func save_persists_complete_pos_product_variation() throws { let grdbManager = try GRDBManager() let db = grdbManager.databaseConnection From a9048a2fc1304cbbda35c7fdd6734ff9e0fa2f07 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 12:00:38 +0100 Subject: [PATCH 14/18] Demonstrate joined records in variation tests --- .../Model/PersistedProductVariation.swift | 4 +- .../PersistedProductVariationTests.swift | 55 ++++++++++++------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift index f9427570aab..ecf4b0caf7d 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift @@ -56,8 +56,8 @@ 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") } diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift index e2ebe21b24a..fcde1974992 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -180,37 +180,50 @@ struct PersistedProductVariationTests { } // Test automatic fetching via associations - let fetchedVariation = try db.read { db in - try PersistedProductVariation.filter(PersistedProductVariation.Columns.id == 500).fetchOne(db) + struct DetailedVariation: Decodable, FetchableRecord { + var variation: PersistedProductVariation + var image: PersistedProductVariationImage + var attributes: [PersistedProductVariationAttribute] + + enum CodingKeys: CodingKey { + case variation + case image + case attributes + } } - let variation = try #require(fetchedVariation) - let posVariation = try variation.toPOSProductVariation(db: db) + 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(posVariation.productVariationID == 500) - #expect(posVariation.siteID == 2) - #expect(posVariation.productID == 50) - #expect(posVariation.sku == "VAR-SKU") - #expect(posVariation.fullDescription == "Variation description") - #expect(posVariation.price == "15.50") - #expect(posVariation.downloadable == true) - #expect(posVariation.manageStock == false) - #expect(posVariation.stockQuantity == nil) - #expect(posVariation.stockStatusKey == "outofstock") + #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") // Verify image was fetched - #expect(posVariation.image?.imageID == 600) - #expect(posVariation.image?.src == "https://example.com/var-img.png") - #expect(posVariation.image?.name == "Variation Image") - #expect(posVariation.image?.alt == "Variation alt text") + #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(posVariation.attributes.count == 2) - let colorAttr = posVariation.attributes.first { $0.name == "Color" } + #expect(fetchedVariation?.attributes.count == 2) + let colorAttr = fetchedVariation?.attributes.first { $0.name == "Color" } #expect(colorAttr?.option == "Purple") - let materialAttr = posVariation.attributes.first { $0.name == "Material" } + let materialAttr = fetchedVariation?.attributes.first { $0.name == "Material" } #expect(materialAttr?.option == "Cotton") } From 991e40579fd0fe6ea9f944569bee7f518fc3c133 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 12:05:22 +0100 Subject: [PATCH 15/18] Given/When/Then for all tests --- .../Storage/PersistedProductTests.swift | 32 ++++++++----------- .../PersistedProductVariationTests.swift | 8 +++-- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift index 110c11b141e..1959e38f79c 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductTests.swift @@ -122,6 +122,7 @@ struct PersistedProductTests { @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 @@ -136,15 +137,15 @@ struct PersistedProductTests { siteID: 1, name: "Test Product", productTypeKey: "simple", - fullDescription: "Full description", - shortDescription: "Short description", - sku: "TEST-SKU", - globalUniqueID: "GID-100", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, price: "29.99", downloadable: false, parentID: 0, - manageStock: true, - stockQuantity: 10, + manageStock: false, + stockQuantity: nil, stockStatusKey: "instock" ) try product.insert(db) @@ -190,7 +191,7 @@ struct PersistedProductTests { try attribute2.insert(db) } - // Test automatic fetching via associations + // 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) } @@ -198,15 +199,9 @@ struct PersistedProductTests { let product = try #require(fetchedProduct) let posProduct = try product.toPOSProduct(db: db) - // Verify product fields + // Then during conversion, images and attributes are populated via associations + // Verify product #expect(posProduct.productID == 100) - #expect(posProduct.siteID == 1) - #expect(posProduct.name == "Test Product") - #expect(posProduct.fullDescription == "Full description") - #expect(posProduct.shortDescription == "Short description") - #expect(posProduct.sku == "TEST-SKU") - #expect(posProduct.price == "29.99") - #expect(posProduct.stockQuantity == 10) // Verify images were fetched #expect(posProduct.images.count == 2) @@ -235,6 +230,7 @@ struct PersistedProductTests { @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 @@ -243,7 +239,6 @@ struct PersistedProductTests { let site = PersistedSite(id: 3) try site.insert(db) - // Insert product without any images or attributes let product = PersistedProduct( id: 300, siteID: 3, @@ -263,7 +258,7 @@ struct PersistedProductTests { try product.insert(db) } - // Test that product with no related records returns empty arrays + // 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) } @@ -271,12 +266,11 @@ struct PersistedProductTests { 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) - #expect(posProduct.fullDescription == nil) - #expect(posProduct.shortDescription == nil) } @Test("PersistedProductAttribute init(from:) and toProductAttribute round-trip") diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift index fcde1974992..a947ba1791c 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -112,6 +112,7 @@ struct PersistedProductVariationTests { @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 @@ -179,7 +180,7 @@ struct PersistedProductVariationTests { try attr2.insert(db) } - // Test automatic fetching via associations + // When we fetch it, specifying inclusion of image and attributes struct DetailedVariation: Decodable, FetchableRecord { var variation: PersistedProductVariation var image: PersistedProductVariationImage @@ -212,6 +213,7 @@ struct PersistedProductVariationTests { #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") @@ -229,6 +231,7 @@ struct PersistedProductVariationTests { @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 @@ -280,7 +283,7 @@ struct PersistedProductVariationTests { try attr.insert(db) } - // Test that variation with no image returns nil for image + // When we fetch that variation let fetchedVariation = try db.read { db in try PersistedProductVariation.filter(PersistedProductVariation.Columns.id == 700).fetchOne(db) } @@ -288,6 +291,7 @@ struct PersistedProductVariationTests { 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) From c523e48f94f27a09e7ee0938d03dd151e6ceac27 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 12:19:07 +0100 Subject: [PATCH 16/18] Fix lint --- .../YosemiteTests/Storage/PersistedProductVariationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift index a947ba1791c..7c6910b7da4 100644 --- a/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift +++ b/Modules/Tests/YosemiteTests/Storage/PersistedProductVariationTests.swift @@ -186,7 +186,7 @@ struct PersistedProductVariationTests { var image: PersistedProductVariationImage var attributes: [PersistedProductVariationAttribute] - enum CodingKeys: CodingKey { + enum CodingKeys: CodingKey { case variation case image case attributes From 2477ef44966bb1000fb434613530597bfd33bf92 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 13:12:30 +0100 Subject: [PATCH 17/18] Ignore periphery --- Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift | 3 +++ .../Storage/GRDB/Model/PersistedProductAttribute.swift | 3 +++ .../Sources/Storage/GRDB/Model/PersistedProductImage.swift | 3 +++ .../Storage/GRDB/Model/PersistedProductVariation.swift | 3 +++ .../GRDB/Model/PersistedProductVariationAttribute.swift | 4 ++++ .../GRDB/Model/PersistedProductVariationImage.swift | 3 +++ Modules/Sources/Storage/GRDB/Model/PersistedSite.swift | 6 ++++++ Modules/Sources/Yosemite/Model/Model.swift | 7 +++++++ .../Model/Storage/PersistedProduct+Conversions.swift | 4 ++++ .../Storage/PersistedProductVariation+Conversions.swift | 4 ++++ 10 files changed, 40 insertions(+) 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 ecf4b0caf7d..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" } @@ -61,6 +63,7 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord { } +// 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/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( From 604a3df1a6410c778a8176bf89d7fb3821c5620c Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 1 Sep 2025 13:35:09 +0100 Subject: [PATCH 18/18] More periphery --- Modules/Sources/Storage/Protocols/Object.swift | 1 + 1 file changed, 1 insertion(+) 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.