@@ -315,15 +315,8 @@ struct GRDBObservableDataSourceTests {
315315 // Then: Should show 5 variations for parent 2 (first page, excluding downloadable)
316316 #expect( sut. variationItems. count == 5 )
317317
318- // Then: hasMoreVariations should be false because we loaded all 5 variations
319- #expect( sut. hasMoreVariations == false )
320-
321- // When: Load more for parent 2
322- let hasMoreBefore = sut. hasMoreVariations
323- sut. loadMoreVariations ( )
324-
325- // Then: Should still not have more (no second page to load)
326- #expect( hasMoreBefore == false )
318+ // Then: hasMoreVariations should be false because we loaded all 5 variations (exactly one page)
319+ #expect( sut. hasMoreVariations == false , " Parent 2 has exactly 5 variations, should fit in one page " )
327320
328321 // Then: Verify downloadable variations were excluded from the items
329322 // Total variations in DB: 3 + 5 + 2 + 3 = 13, but only 5 non-downloadable for parent 2 should be loaded
@@ -379,6 +372,197 @@ struct GRDBObservableDataSourceTests {
379372 #expect( sut. hasMoreVariations == false , " All 8 variations loaded, no more pages " )
380373 }
381374
375+ @Test ( " Products load with associated images and attributes " )
376+ func test_products_load_with_images_and_attributes( ) async throws {
377+ // Given: Products with images and attributes
378+ // Note: Products are ordered alphabetically by name, so "Hoodie" comes before "T-Shirt"
379+ let hoodie = createPersistedProduct ( id: 1 , name: " Hoodie " , type: " variable " )
380+ let tshirt = createPersistedProduct ( id: 2 , name: " T-Shirt " , type: " simple " )
381+ try await insertProducts ( [ hoodie, tshirt] )
382+
383+ // Create images for products
384+ try await insertImage ( id: 100 , src: " https://example.com/hoodie.jpg " , name: " Hoodie Main " )
385+ try await insertImage ( id: 200 , src: " https://example.com/tshirt-front.jpg " , name: " T-Shirt Front " )
386+ try await insertImage ( id: 201 , src: " https://example.com/tshirt-back.jpg " , name: " T-Shirt Back " )
387+
388+ // Link images to products
389+ try await linkImageToProduct ( imageID: 100 , productID: 1 )
390+ try await linkImageToProduct ( imageID: 200 , productID: 2 )
391+ try await linkImageToProduct ( imageID: 201 , productID: 2 )
392+
393+ // Create attributes for products
394+ try await insertProductAttribute (
395+ productID: 1 ,
396+ remoteAttributeID: 1 ,
397+ name: " Material " ,
398+ position: 0 ,
399+ variation: true ,
400+ options: [ " Cotton " , " Polyester " ]
401+ )
402+ try await insertProductAttribute (
403+ productID: 2 ,
404+ remoteAttributeID: 2 ,
405+ name: " Color " ,
406+ position: 0 ,
407+ options: [ " Red " , " Blue " , " Green " ]
408+ )
409+ try await insertProductAttribute (
410+ productID: 2 ,
411+ remoteAttributeID: 3 ,
412+ name: " Size " ,
413+ position: 1 ,
414+ options: [ " S " , " M " , " L " , " XL " ]
415+ )
416+
417+ // When: Load products
418+ await waitForProductLoad ( expectedCount: 2 ) {
419+ sut. loadProducts ( )
420+ }
421+
422+ // Then: Products loaded with correct count
423+ #expect( sut. productItems. count == 2 )
424+
425+ // Then: Verify first product (Hoodie - alphabetically first) loads with image
426+ guard case . variableParentProduct( let hoodieItem) = sut. productItems [ 0 ] else {
427+ Issue . record ( " First item should be variable product (Hoodie) " )
428+ return
429+ }
430+
431+ #expect( hoodieItem. name == " Hoodie " )
432+ #expect( hoodieItem. productImageSource == " https://example.com/hoodie.jpg " ,
433+ " Should load image via .including() " )
434+ #expect( hoodieItem. allAttributes. count == 1 , " Should load attributes via .including() " )
435+ #expect( hoodieItem. allAttributes. first? . name == " Material " )
436+ #expect( hoodieItem. allAttributes. first? . options == [ " Cotton " , " Polyester " ] )
437+
438+ // Then: Verify second product (T-Shirt - alphabetically second) loads with image and attributes
439+ guard case . simpleProduct( let tshirtItem) = sut. productItems [ 1 ] else {
440+ Issue . record ( " Second item should be simple product (T-Shirt) " )
441+ return
442+ }
443+
444+ #expect( tshirtItem. name == " T-Shirt " )
445+ #expect( tshirtItem. productImageSource == " https://example.com/tshirt-front.jpg " ,
446+ " Should load first image via .including() " )
447+ }
448+
449+ @Test ( " Variations load with associated images " )
450+ func test_variations_load_with_images( ) async throws {
451+ // Given: Variable product with variations that have images
452+ let parent = createPersistedProduct ( id: 100 , name: " Variable Hoodie " , type: " variable " )
453+ try await insertProducts ( [ parent] )
454+
455+ // Create variations
456+ try await grdbManager. databaseConnection. write { db in
457+ // Variation 1: Red, Small
458+ let variation1 = PersistedProductVariation (
459+ id: 1001 ,
460+ siteID: siteID,
461+ productID: 100 ,
462+ sku: " VAR-RED-S " ,
463+ globalUniqueID: nil ,
464+ price: " 29.99 " ,
465+ downloadable: false ,
466+ fullDescription: nil ,
467+ manageStock: false ,
468+ stockQuantity: nil ,
469+ stockStatusKey: " instock "
470+ )
471+ try variation1. insert ( db)
472+
473+ // Variation 2: Blue, Large
474+ let variation2 = PersistedProductVariation (
475+ id: 1002 ,
476+ siteID: siteID,
477+ productID: 100 ,
478+ sku: " VAR-BLUE-L " ,
479+ globalUniqueID: nil ,
480+ price: " 34.99 " ,
481+ downloadable: false ,
482+ fullDescription: nil ,
483+ manageStock: false ,
484+ stockQuantity: nil ,
485+ stockStatusKey: " instock "
486+ )
487+ try variation2. insert ( db)
488+ }
489+
490+ // Create and link images for variations
491+ try await insertImage ( id: 1001 , src: " https://example.com/red-small.jpg " , name: " Red Small " )
492+ try await insertImage ( id: 1002 , src: " https://example.com/blue-large.jpg " , name: " Blue Large " )
493+ try await linkImageToVariation ( imageID: 1001 , variationID: 1001 )
494+ try await linkImageToVariation ( imageID: 1002 , variationID: 1002 )
495+
496+ // Create parent product attributes (for variation name generation)
497+ // Note: variation: true is required for attributes to be included in allAttributes
498+ try await insertProductAttribute (
499+ productID: 100 ,
500+ remoteAttributeID: 1 ,
501+ name: " Color " ,
502+ position: 0 ,
503+ variation: true ,
504+ options: [ " Red " , " Blue " ]
505+ )
506+ try await insertProductAttribute (
507+ productID: 100 ,
508+ remoteAttributeID: 2 ,
509+ name: " Size " ,
510+ position: 1 ,
511+ variation: true ,
512+ options: [ " Small " , " Large " ]
513+ )
514+
515+ // Create attributes for variations
516+ try await insertVariationAttribute ( variationID: 1001 , remoteAttributeID: 1 , name: " Color " , option: " Red " )
517+ try await insertVariationAttribute ( variationID: 1001 , remoteAttributeID: 2 , name: " Size " , option: " Small " )
518+ try await insertVariationAttribute ( variationID: 1002 , remoteAttributeID: 1 , name: " Color " , option: " Blue " )
519+ try await insertVariationAttribute ( variationID: 1002 , remoteAttributeID: 2 , name: " Size " , option: " Large " )
520+
521+ // Load the parent product first to get its attributes
522+ await waitForProductLoad ( expectedCount: 1 ) {
523+ sut. loadProducts ( )
524+ }
525+
526+ guard case . variableParentProduct( let loadedParent) = sut. productItems. first else {
527+ Issue . record ( " Expected variable parent product " )
528+ return
529+ }
530+
531+ let posParent = loadedParent
532+
533+ // When: Load variations
534+ await waitForVariationLoad {
535+ sut. loadVariations ( for: posParent)
536+ }
537+
538+ // Then: Variations loaded with correct count
539+ #expect( sut. variationItems. count == 2 )
540+
541+ // Then: Verify first variation has image and attributes
542+ guard case . variation( let variation1) = sut. variationItems [ 0 ] else {
543+ Issue . record ( " First item should be variation " )
544+ return
545+ }
546+
547+ #expect( variation1. price == " 29.99 " )
548+ #expect( variation1. productImageSource == " https://example.com/red-small.jpg " ,
549+ " Should load variation-specific image via .including() " )
550+ #expect( variation1. name. contains ( " Red " ) , " Variation name should include 'Red' from attributes loaded via .including() " )
551+ #expect( variation1. name. contains ( " Small " ) , " Variation name should include 'Small' from attributes loaded via .including() " )
552+
553+ // Then: Verify second variation has image and attributes
554+ guard case . variation( let variation2) = sut. variationItems [ 1 ] else {
555+ Issue . record ( " Second item should be variation " )
556+ return
557+ }
558+
559+ #expect( variation2. price == " 34.99 " )
560+ #expect( variation2. productImageSource == " https://example.com/blue-large.jpg " ,
561+ " Should load variation-specific image via .including() " )
562+ #expect( variation2. name. contains ( " Blue " ) , " Variation name should include 'Blue' from attributes loaded via .including() " )
563+ #expect( variation2. name. contains ( " Large " ) , " Variation name should include 'Large' from attributes loaded via .including() " )
564+ }
565+
382566 // MARK: - Helper Methods
383567
384568 private func waitForProductLoad( expectedCount: Int ? = nil , action: ( ) -> Void ) async {
@@ -529,4 +713,88 @@ struct GRDBObservableDataSourceTests {
529713 stockStatusKey: " instock "
530714 )
531715 }
716+
717+ // MARK: - Image Helpers
718+
719+ private func insertImage( id: Int64 , src: String , name: String ? ) async throws {
720+ try await grdbManager. databaseConnection. write { db in
721+ let image = PersistedImage (
722+ siteID: siteID,
723+ id: id,
724+ dateCreated: Date ( ) ,
725+ dateModified: nil ,
726+ src: src,
727+ name: name,
728+ alt: nil
729+ )
730+ try image. insert ( db)
731+ }
732+ }
733+
734+ private func linkImageToProduct( imageID: Int64 , productID: Int64 ) async throws {
735+ try await grdbManager. databaseConnection. write { db in
736+ let link = PersistedProductImage (
737+ siteID: siteID,
738+ productID: productID,
739+ imageID: imageID
740+ )
741+ try link. insert ( db)
742+ }
743+ }
744+
745+ private func linkImageToVariation( imageID: Int64 , variationID: Int64 ) async throws {
746+ try await grdbManager. databaseConnection. write { db in
747+ let link = PersistedProductVariationImage (
748+ siteID: siteID,
749+ productVariationID: variationID,
750+ imageID: imageID
751+ )
752+ try link. insert ( db)
753+ }
754+ }
755+
756+ // MARK: - Attribute Helpers
757+
758+ private func insertProductAttribute(
759+ productID: Int64 ,
760+ remoteAttributeID: Int64 ,
761+ name: String ,
762+ position: Int64 ,
763+ variation: Bool = false ,
764+ options: [ String ]
765+ ) async throws {
766+ try await grdbManager. databaseConnection. write { db in
767+ var attribute = PersistedProductAttribute (
768+ id: nil ,
769+ siteID: siteID,
770+ productID: productID,
771+ remoteAttributeID: remoteAttributeID,
772+ name: name,
773+ position: position,
774+ visible: true ,
775+ variation: variation,
776+ options: options
777+ )
778+ try attribute. insert ( db)
779+ }
780+ }
781+
782+ private func insertVariationAttribute(
783+ variationID: Int64 ,
784+ remoteAttributeID: Int64 ,
785+ name: String ,
786+ option: String
787+ ) async throws {
788+ try await grdbManager. databaseConnection. write { db in
789+ var attribute = PersistedProductVariationAttribute (
790+ id: nil ,
791+ siteID: siteID,
792+ productVariationID: variationID,
793+ remoteAttributeID: remoteAttributeID,
794+ name: name,
795+ option: option
796+ )
797+ try attribute. insert ( db)
798+ }
799+ }
532800}
0 commit comments