flamingo.me/flamingo-commerce/v3@v3.11.0/sourcing/domain/service_test.go (about)

     1  package domain_test
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"testing"
     7  
     8  	"flamingo.me/flamingo/v3/framework/flamingo"
     9  
    10  	"flamingo.me/flamingo-commerce/v3/cart/domain/cart"
    11  	"flamingo.me/flamingo-commerce/v3/cart/domain/decorator"
    12  	productDomain "flamingo.me/flamingo-commerce/v3/product/domain"
    13  	"flamingo.me/flamingo-commerce/v3/sourcing/domain"
    14  
    15  	"github.com/stretchr/testify/assert"
    16  )
    17  
    18  type (
    19  	availableSourcesProviderMock struct {
    20  		Sources []domain.Source
    21  		Error   error
    22  	}
    23  	stockProviderMock struct {
    24  		Qty   int
    25  		Error error
    26  	}
    27  	stockBySourceAndProductProviderMock struct {
    28  		// [source.LocationCode][product.Identifier] = Qty
    29  		Qty   map[string]map[string]int
    30  		Error error
    31  	}
    32  )
    33  
    34  var (
    35  	_ domain.AvailableSourcesProvider = new(availableSourcesProviderMock)
    36  	_ domain.StockProvider            = new(stockProviderMock)
    37  	_ domain.AvailableSourcesProvider = new(stockBySourceAndProductProviderMock)
    38  )
    39  
    40  func (a availableSourcesProviderMock) GetPossibleSources(_ context.Context, _ productDomain.BasicProduct, _ *cart.DeliveryInfo) ([]domain.Source, error) {
    41  	return a.Sources, a.Error
    42  }
    43  
    44  func (s stockProviderMock) GetStock(_ context.Context, _ productDomain.BasicProduct, _ domain.Source, _ *cart.DeliveryInfo) (int, error) {
    45  	return s.Qty, s.Error
    46  }
    47  
    48  func (s stockBySourceAndProductProviderMock) GetStock(_ context.Context, product productDomain.BasicProduct, source domain.Source, _ *cart.DeliveryInfo) (int, error) {
    49  	return s.Qty[source.LocationCode][product.GetIdentifier()], s.Error
    50  }
    51  
    52  func (s stockBySourceAndProductProviderMock) GetPossibleSources(_ context.Context, _ productDomain.BasicProduct, _ *cart.DeliveryInfo) ([]domain.Source, error) {
    53  	panic("implement me")
    54  }
    55  
    56  func TestDefaultSourcingService_GetAvailableSources(t *testing.T) {
    57  	t.Run("error handling on unbound providers", func(t *testing.T) {
    58  		sourcingService := domain.DefaultSourcingService{}
    59  		sourcingService.Inject(flamingo.NullLogger{}, nil)
    60  		_, err := sourcingService.GetAvailableSources(context.Background(), nil, nil, nil)
    61  		assert.EqualError(t, err, "no Source Provider bound", "received error if available sources provider and stock provider are not configured")
    62  
    63  		sourcingService = newDefaultSourcingService(nil, nil)
    64  		_, err = sourcingService.GetAvailableSources(context.Background(), productDomain.SimpleProduct{}, nil, nil)
    65  		assert.EqualError(t, err, "no Stock Provider bound", "received error if stock provider is not set")
    66  	})
    67  
    68  	t.Run("error handing on error fetching available sources", func(t *testing.T) {
    69  		sourcingService := domain.DefaultSourcingService{}
    70  		sourcingService.Inject(flamingo.NullLogger{}, &struct {
    71  			AvailableSourcesProvider domain.AvailableSourcesProvider `inject:",optional"`
    72  			StockProvider            domain.StockProvider            `inject:",optional"`
    73  		}{
    74  			AvailableSourcesProvider: availableSourcesProviderMock{
    75  				Sources: nil,
    76  				Error:   errors.New("mocked available sources provider error"),
    77  			},
    78  			StockProvider: stockProviderMock{},
    79  		})
    80  
    81  		_, err := sourcingService.GetAvailableSources(context.Background(), productDomain.SimpleProduct{Identifier: "example"}, nil, nil)
    82  		assert.Contains(t, err.Error(), "mocked available sources provider error", "result contains the error message of the available sources provider")
    83  	})
    84  
    85  	t.Run("full qty with nil cart", func(t *testing.T) {
    86  		stubbedSources := []domain.Source{{LocationCode: "loc1"}}
    87  		stubbedStockQty := 10
    88  
    89  		stockProviderMock := stockProviderMock{Qty: stubbedStockQty}
    90  		sourcingService := newDefaultSourcingService(stockProviderMock, stubbedSources)
    91  
    92  		sources, err := sourcingService.GetAvailableSources(context.Background(), productDomain.SimpleProduct{Identifier: "simple_test"}, nil, nil)
    93  		assert.NoError(t, err)
    94  
    95  		expectedSources := domain.AvailableSourcesPerProduct{domain.ProductID("simple_test"): domain.AvailableSources{
    96  			stubbedSources[0]: stubbedStockQty,
    97  		}}
    98  
    99  		assert.Equal(t, expectedSources, sources)
   100  	})
   101  
   102  	t.Run("qty reduced with existing cart", func(t *testing.T) {
   103  		stubbedSources := []domain.Source{{LocationCode: "loc1"}}
   104  		stubbedStockQty := 10
   105  		stubbedQtyAlreadyInCart := 2
   106  		stubbedProduct := productDomain.SimpleProduct{Identifier: "productid"}
   107  
   108  		testCart := decorator.DecoratedCart{
   109  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   110  				{
   111  					DecoratedItems: []decorator.DecoratedCartItem{
   112  						{
   113  							Product: stubbedProduct,
   114  							Item:    cart.Item{Qty: stubbedQtyAlreadyInCart, ID: "item1"},
   115  						},
   116  					},
   117  				},
   118  			},
   119  		}
   120  
   121  		stockProviderMock := stockProviderMock{Qty: stubbedStockQty}
   122  		sourcingService := newDefaultSourcingService(stockProviderMock, stubbedSources)
   123  
   124  		sources, err := sourcingService.GetAvailableSources(context.Background(), stubbedProduct, nil, &testCart)
   125  		assert.NoError(t, err)
   126  
   127  		expectedSources := domain.AvailableSourcesPerProduct{domain.ProductID("productid"): domain.AvailableSources{
   128  			stubbedSources[0]: stubbedStockQty - stubbedQtyAlreadyInCart,
   129  		}}
   130  		assert.Equal(t, expectedSources, sources)
   131  	})
   132  
   133  	t.Run("all available qty is already in cart", func(t *testing.T) {
   134  		stubbedSources := []domain.Source{{LocationCode: "loc1"}, {LocationCode: "loc2"}}
   135  		stubbedStockQty := 5
   136  		stubbedQtyAlreadyInCart := 10
   137  		stubbedProduct := productDomain.SimpleProduct{
   138  			Identifier: "marketPlaceCode1",
   139  		}
   140  		testCart := decorator.DecoratedCart{
   141  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   142  				{
   143  					DecoratedItems: []decorator.DecoratedCartItem{
   144  						{
   145  							Product: stubbedProduct,
   146  							Item: cart.Item{
   147  								ID:  "itemID1",
   148  								Qty: stubbedQtyAlreadyInCart,
   149  							},
   150  						},
   151  					},
   152  				},
   153  			},
   154  		}
   155  		stockProviderMock := stockProviderMock{Qty: stubbedStockQty}
   156  		sourcingService := newDefaultSourcingService(stockProviderMock, stubbedSources)
   157  
   158  		availableSources, err := sourcingService.GetAvailableSources(context.Background(), stubbedProduct, nil, &testCart)
   159  
   160  		t.Log(availableSources)
   161  
   162  		assert.Error(t, err)
   163  		assert.ErrorIs(t, err, domain.ErrNoSourceAvailable)
   164  	})
   165  
   166  	t.Run("get sources for bundle product, cart is nil, sources shouldn't be allocated", func(t *testing.T) {
   167  		t.Parallel()
   168  
   169  		simpleInBundle1 := productDomain.SimpleProduct{Identifier: "product1"}
   170  		simpleInBundle2 := productDomain.SimpleProduct{Identifier: "product2"}
   171  
   172  		bundleProduct := productDomain.BundleProductWithActiveChoices{
   173  			BundleProduct: productDomain.BundleProduct{
   174  				Identifier: "bundle_product",
   175  			},
   176  			ActiveChoices: map[productDomain.Identifier]productDomain.ActiveChoice{
   177  				"identifier1": {
   178  					Qty:     1,
   179  					Product: simpleInBundle1,
   180  				},
   181  				"identifier2": {
   182  					Qty:     2,
   183  					Product: simpleInBundle2,
   184  				},
   185  			},
   186  		}
   187  
   188  		source1 := domain.Source{LocationCode: "Source1"}
   189  		source2 := domain.Source{LocationCode: "Source2"}
   190  		stubbedSources := []domain.Source{source1, source2}
   191  
   192  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   193  			Qty: map[string]map[string]int{
   194  				"Source1": {
   195  					"product1": 6,
   196  					"product2": 4,
   197  				},
   198  				"Source2": {
   199  					"product1": 4,
   200  					"product2": 1,
   201  				},
   202  			},
   203  		}
   204  
   205  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   206  
   207  		availableSources, err := sourcingService.GetAvailableSources(context.Background(), bundleProduct, nil, nil)
   208  		assert.NoError(t, err)
   209  
   210  		assert.Equal(t, 10, availableSources[domain.ProductID(simpleInBundle1.GetIdentifier())].QtySum())
   211  		assert.Equal(t, 5, availableSources[domain.ProductID(simpleInBundle2.GetIdentifier())].QtySum())
   212  	})
   213  
   214  	t.Run("get sources for simple product, cart is nil, sources shouldn't be allocated", func(t *testing.T) {
   215  		t.Parallel()
   216  
   217  		simpleProduct := productDomain.SimpleProduct{
   218  			Identifier: "product2",
   219  		}
   220  
   221  		source1 := domain.Source{LocationCode: "Source1"}
   222  		source2 := domain.Source{LocationCode: "Source2"}
   223  		stubbedSources := []domain.Source{source1, source2}
   224  
   225  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   226  			Qty: map[string]map[string]int{
   227  				"Source1": {
   228  					"product1": 6,
   229  					"product2": 4,
   230  				},
   231  				"Source2": {
   232  					"product1": 4,
   233  					"product2": 1,
   234  				},
   235  			},
   236  		}
   237  
   238  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   239  
   240  		availableSources, err := sourcingService.GetAvailableSources(context.Background(), simpleProduct, nil, nil)
   241  		assert.NoError(t, err)
   242  
   243  		assert.Equal(t, 5, availableSources[domain.ProductID(simpleProduct.GetIdentifier())].QtySum())
   244  	})
   245  
   246  	t.Run("get sources for simple product, cart is not nil, sources should be allocated", func(t *testing.T) {
   247  		t.Parallel()
   248  
   249  		simpleProduct := productDomain.SimpleProduct{
   250  			Identifier: "product2",
   251  		}
   252  
   253  		source1 := domain.Source{LocationCode: "Source1"}
   254  		source2 := domain.Source{LocationCode: "Source2"}
   255  		stubbedSources := []domain.Source{source1, source2}
   256  
   257  		testCart := decorator.DecoratedCart{
   258  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   259  				{
   260  					DecoratedItems: []decorator.DecoratedCartItem{
   261  						{
   262  							Product: simpleProduct,
   263  							Item:    cart.Item{Qty: 2, ID: "item2"},
   264  						},
   265  					},
   266  				},
   267  				{
   268  					DecoratedItems: []decorator.DecoratedCartItem{
   269  						{
   270  							Product: simpleProduct,
   271  							Item:    cart.Item{Qty: 1, ID: "item2"},
   272  						},
   273  					},
   274  				},
   275  			},
   276  		}
   277  
   278  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   279  			Qty: map[string]map[string]int{
   280  				"Source1": {
   281  					"product1": 5,
   282  					"product2": 4,
   283  				},
   284  				"Source2": {
   285  					"product1": 5,
   286  					"product2": 1,
   287  				},
   288  			},
   289  		}
   290  
   291  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   292  
   293  		availableSources, err := sourcingService.GetAvailableSources(context.Background(), simpleProduct, nil, &testCart)
   294  		assert.NoError(t, err)
   295  
   296  		assert.Equal(t, 3, availableSources[domain.ProductID(simpleProduct.GetIdentifier())].QtySum())
   297  	})
   298  
   299  	t.Run("get sources for bundle product, cart is not nil, sources should be allocated", func(t *testing.T) {
   300  		t.Parallel()
   301  
   302  		simpleInBundle1 := productDomain.SimpleProduct{Identifier: "gucciSlippers"}
   303  		simple2 := productDomain.SimpleProduct{Identifier: "gucciTShirt"}
   304  
   305  		bundleProduct := productDomain.BundleProductWithActiveChoices{
   306  			BundleProduct: productDomain.BundleProduct{
   307  				Identifier: "bundle_product",
   308  			},
   309  			ActiveChoices: map[productDomain.Identifier]productDomain.ActiveChoice{
   310  				"identifier1": {
   311  					Qty:     1,
   312  					Product: simpleInBundle1,
   313  				},
   314  				"identifier2": {
   315  					Qty:     1,
   316  					Product: simple2,
   317  				},
   318  			},
   319  		}
   320  
   321  		testCart := decorator.DecoratedCart{
   322  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   323  				{
   324  					DecoratedItems: []decorator.DecoratedCartItem{
   325  						{
   326  							Product: bundleProduct,
   327  							Item:    cart.Item{Qty: 2, ID: "item1"},
   328  						},
   329  						{
   330  							Product: simple2,
   331  							Item:    cart.Item{Qty: 1, ID: "item2"},
   332  						},
   333  					},
   334  				},
   335  				{
   336  					DecoratedItems: []decorator.DecoratedCartItem{
   337  						{
   338  							Product: simple2,
   339  							Item:    cart.Item{Qty: 1, ID: "item3"},
   340  						},
   341  					},
   342  				},
   343  			},
   344  		}
   345  
   346  		source1 := domain.Source{LocationCode: "Source1"}
   347  		source2 := domain.Source{LocationCode: "Source2"}
   348  		stubbedSources := []domain.Source{source1, source2}
   349  
   350  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   351  			Qty: map[string]map[string]int{
   352  				"Source1": {
   353  					"gucciSlippers": 5,
   354  					"gucciTShirt":   4,
   355  				},
   356  				"Source2": {
   357  					"gucciSlippers": 5,
   358  					"gucciTShirt":   1,
   359  				},
   360  			},
   361  		}
   362  
   363  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   364  
   365  		availableSources, err := sourcingService.GetAvailableSources(context.Background(), bundleProduct, nil, &testCart)
   366  		assert.NoError(t, err)
   367  
   368  		assert.Equal(t, 8, availableSources[domain.ProductID(simpleInBundle1.GetIdentifier())].QtySum())
   369  		assert.Equal(t, 1, availableSources[domain.ProductID(simple2.GetIdentifier())].QtySum())
   370  	})
   371  }
   372  
   373  func TestDefaultSourcingService_AllocateItems(t *testing.T) {
   374  	t.Parallel()
   375  	/**
   376  	Given:
   377  	Cart:
   378  		Delivery1:
   379  			product1 - 10
   380  			product2 - 5
   381  		Delivery2:
   382  			product1 - 5
   383  
   384  	existing Stock Source :
   385  		Source1:
   386  			product1: 8
   387  			product2: 3
   388  		Source2:
   389  			product1: 10
   390  		Source3:
   391  			product2: 10
   392  
   393  	=> Expected Result:
   394  
   395  		Cart:
   396  			Delivery1:
   397  				item1:product1 - 10
   398  						sourced: Source1 -> 8 & Source2 -> 2
   399  				item2:product2 - 5
   400  						sourced: Source1 -> 3 & Source3 -> 2
   401  			Delivery2:
   402  				item3: product1 - 5
   403  						sourced: Source2 -> 5
   404  
   405  	*/
   406  	t.Run("allocate easy", func(t *testing.T) {
   407  		t.Parallel()
   408  
   409  		stubbedProduct1 := productDomain.SimpleProduct{Identifier: "product1"}
   410  		stubbedProduct2 := productDomain.SimpleProduct{Identifier: "product2"}
   411  
   412  		testCart := decorator.DecoratedCart{
   413  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   414  				{
   415  					DecoratedItems: []decorator.DecoratedCartItem{
   416  						{
   417  							Product: stubbedProduct1,
   418  							Item:    cart.Item{Qty: 10, ID: "item1"},
   419  						},
   420  						{
   421  							Product: stubbedProduct2,
   422  							Item:    cart.Item{Qty: 5, ID: "item2"},
   423  						},
   424  					},
   425  				},
   426  				{
   427  					DecoratedItems: []decorator.DecoratedCartItem{
   428  						{
   429  							Product: stubbedProduct1,
   430  							Item:    cart.Item{Qty: 5, ID: "item3"},
   431  						},
   432  					},
   433  				},
   434  			},
   435  		}
   436  
   437  		source1 := domain.Source{LocationCode: "Source1"}
   438  		source2 := domain.Source{LocationCode: "Source2"}
   439  		source3 := domain.Source{LocationCode: "Source3"}
   440  		stubbedSources := []domain.Source{source1, source2, source3}
   441  
   442  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   443  			Qty: map[string]map[string]int{
   444  				"Source1": {
   445  					"product1": 8,
   446  					"product2": 3,
   447  				},
   448  				"Source2": {
   449  					"product1": 10,
   450  				},
   451  				"Source3": {
   452  					"product2": 10,
   453  				},
   454  			},
   455  		}
   456  
   457  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   458  
   459  		itemAllocation, err := sourcingService.AllocateItems(context.Background(), &testCart)
   460  		assert.NoError(t, err)
   461  		assert.NoError(t, itemAllocation[domain.ItemID("item1")].Error)
   462  		assert.Len(t, itemAllocation[domain.ItemID("item1")].AllocatedQtys[domain.ProductID(stubbedProduct1.GetIdentifier())], 2)
   463  
   464  		assert.Equal(t, 8, itemAllocation[domain.ItemID("item1")].AllocatedQtys[domain.ProductID(stubbedProduct1.GetIdentifier())][source1])
   465  		assert.Equal(t, 2, itemAllocation[domain.ItemID("item1")].AllocatedQtys[domain.ProductID(stubbedProduct1.GetIdentifier())][source2])
   466  		assert.Equal(t, 3, itemAllocation[domain.ItemID("item2")].AllocatedQtys[domain.ProductID(stubbedProduct2.GetIdentifier())][source1])
   467  		assert.Equal(t, 2, itemAllocation[domain.ItemID("item2")].AllocatedQtys[domain.ProductID(stubbedProduct2.GetIdentifier())][source3])
   468  		assert.Equal(t, 5, itemAllocation[domain.ItemID("item3")].AllocatedQtys[domain.ProductID(stubbedProduct1.GetIdentifier())][source2])
   469  	})
   470  
   471  	t.Run("allocate cart with bundle item", func(t *testing.T) {
   472  		t.Parallel()
   473  
   474  		bundleProduct := productDomain.BundleProductWithActiveChoices{
   475  			BundleProduct: productDomain.BundleProduct{
   476  				Identifier: "bundle_product",
   477  			},
   478  			ActiveChoices: map[productDomain.Identifier]productDomain.ActiveChoice{
   479  				"identifier1": {
   480  					Qty: 1,
   481  					Product: productDomain.SimpleProduct{
   482  						Identifier: "product4",
   483  					},
   484  				},
   485  				"identifier2": {
   486  					Qty: 2,
   487  					Product: productDomain.SimpleProduct{
   488  						Identifier: "product3",
   489  					},
   490  				},
   491  			},
   492  		}
   493  
   494  		simple2 := productDomain.SimpleProduct{Identifier: "product2"}
   495  		simple5 := productDomain.SimpleProduct{Identifier: "product5"}
   496  
   497  		testCart := decorator.DecoratedCart{
   498  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   499  				{
   500  					DecoratedItems: []decorator.DecoratedCartItem{
   501  						{
   502  							Product: bundleProduct,
   503  							Item:    cart.Item{Qty: 1, ID: "item1"},
   504  						},
   505  						{
   506  							Product: simple2,
   507  							Item:    cart.Item{Qty: 1, ID: "item2"},
   508  						},
   509  					},
   510  				},
   511  				{
   512  					DecoratedItems: []decorator.DecoratedCartItem{
   513  						{
   514  							Product: simple5,
   515  							Item:    cart.Item{Qty: 4, ID: "item3"},
   516  						},
   517  					},
   518  				},
   519  			},
   520  		}
   521  
   522  		source1 := domain.Source{LocationCode: "Source1"}
   523  		source2 := domain.Source{LocationCode: "Source2"}
   524  		source3 := domain.Source{LocationCode: "Source3"}
   525  		stubbedSources := []domain.Source{source1, source2, source3}
   526  
   527  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   528  			Qty: map[string]map[string]int{
   529  				"Source1": {
   530  					"product2": 3,
   531  					"product4": 27,
   532  					"product5": 3,
   533  				},
   534  				"Source2": {
   535  					"product1": 10,
   536  					"product4": 27,
   537  				},
   538  				"Source3": {
   539  					"product2": 10,
   540  					"product3": 4,
   541  				},
   542  			},
   543  		}
   544  
   545  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   546  
   547  		itemAllocation, err := sourcingService.AllocateItems(context.Background(), &testCart)
   548  		assert.NoError(t, err)
   549  		assert.NoError(t, itemAllocation[domain.ItemID("item1")].Error)
   550  		assert.NoError(t, itemAllocation[domain.ItemID("item2")].Error)
   551  
   552  		assert.ErrorIs(t, domain.ErrInsufficientSourceQty, itemAllocation[domain.ItemID("item3")].Error)
   553  
   554  		assert.Equal(t, 1,
   555  			itemAllocation[domain.ItemID("item1")].AllocatedQtys["product4"][source1])
   556  		assert.Equal(t, 2,
   557  			itemAllocation[domain.ItemID("item1")].AllocatedQtys["product3"][source3])
   558  		assert.Equal(t, 1,
   559  			itemAllocation[domain.ItemID("item2")].AllocatedQtys[domain.ProductID(simple2.GetIdentifier())][source1])
   560  	})
   561  
   562  	t.Run("if too many products are allocated to a bundle, they won't be available for the next item", func(t *testing.T) {
   563  		t.Parallel()
   564  
   565  		simpleInBundle1 := productDomain.SimpleProduct{Identifier: "product1"}
   566  		simple2 := productDomain.SimpleProduct{Identifier: "product2"}
   567  
   568  		bundleProduct := productDomain.BundleProductWithActiveChoices{
   569  			BundleProduct: productDomain.BundleProduct{
   570  				Identifier: "bundle_product",
   571  			},
   572  			ActiveChoices: map[productDomain.Identifier]productDomain.ActiveChoice{
   573  				"identifier1": {
   574  					Qty:     1,
   575  					Product: simpleInBundle1,
   576  				},
   577  				"identifier2": {
   578  					Qty:     2,
   579  					Product: simple2,
   580  				},
   581  			},
   582  		}
   583  
   584  		testCart := decorator.DecoratedCart{
   585  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   586  				{
   587  					DecoratedItems: []decorator.DecoratedCartItem{
   588  						{
   589  							Product: bundleProduct,
   590  							Item:    cart.Item{Qty: 2, ID: "item1"},
   591  						},
   592  						{
   593  							Product: simple2,
   594  							Item:    cart.Item{Qty: 1, ID: "item2"},
   595  						},
   596  					},
   597  				},
   598  			},
   599  		}
   600  
   601  		source1 := domain.Source{LocationCode: "Source1"}
   602  		source2 := domain.Source{LocationCode: "Source2"}
   603  		stubbedSources := []domain.Source{source1, source2}
   604  
   605  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   606  			Qty: map[string]map[string]int{
   607  				"Source1": {
   608  					"product1": 28,
   609  					"product2": 4,
   610  				},
   611  				"Source2": {
   612  					"product1": 28,
   613  					"product2": 0,
   614  				},
   615  			},
   616  		}
   617  
   618  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   619  
   620  		itemAllocation, err := sourcingService.AllocateItems(context.Background(), &testCart)
   621  		assert.NoError(t, err)
   622  		assert.NoError(t, itemAllocation[domain.ItemID("item1")].Error)
   623  
   624  		assert.ErrorIs(t, domain.ErrInsufficientSourceQty, itemAllocation[domain.ItemID("item2")].Error)
   625  
   626  		assert.Equal(t, 2,
   627  			itemAllocation[domain.ItemID("item1")].AllocatedQtys["product1"][source1])
   628  		assert.Equal(t, 4,
   629  			itemAllocation[domain.ItemID("item1")].AllocatedQtys["product2"][source1])
   630  	})
   631  
   632  	t.Run("if an item from a bundle is purchased separately and insufficient quantity remains, it won't be available for the bundle", func(t *testing.T) {
   633  		t.Parallel()
   634  
   635  		simpleInBundle1 := productDomain.SimpleProduct{Identifier: "product1"}
   636  		simple2 := productDomain.SimpleProduct{Identifier: "product2", Saleable: productDomain.Saleable{IsSaleable: true}}
   637  
   638  		bundleProduct := productDomain.BundleProductWithActiveChoices{
   639  			BundleProduct: productDomain.BundleProduct{
   640  				Identifier: "bundle_product",
   641  			},
   642  			ActiveChoices: map[productDomain.Identifier]productDomain.ActiveChoice{
   643  				"identifier1": {
   644  					Qty:     1,
   645  					Product: simpleInBundle1,
   646  				},
   647  				"identifier2": {
   648  					Qty:     3,
   649  					Product: simple2,
   650  				},
   651  			},
   652  		}
   653  
   654  		testCart := decorator.DecoratedCart{
   655  			DecoratedDeliveries: []decorator.DecoratedDelivery{
   656  				{
   657  					DecoratedItems: []decorator.DecoratedCartItem{
   658  						{
   659  							Product: simple2,
   660  							Item:    cart.Item{Qty: 3, ID: "item1"},
   661  						},
   662  						{
   663  							Product: bundleProduct,
   664  							Item:    cart.Item{Qty: 1, ID: "item2"},
   665  						},
   666  					},
   667  				},
   668  			},
   669  		}
   670  
   671  		source1 := domain.Source{LocationCode: "Source1"}
   672  		source2 := domain.Source{LocationCode: "Source2"}
   673  		stubbedSources := []domain.Source{source1, source2}
   674  
   675  		stockBySourceAndProductProviderMock := stockBySourceAndProductProviderMock{
   676  			Qty: map[string]map[string]int{
   677  				"Source1": {
   678  					"product1": 28,
   679  					"product2": 3,
   680  				},
   681  				"Source2": {
   682  					"product1": 28,
   683  					"product2": 0,
   684  				},
   685  			},
   686  		}
   687  
   688  		sourcingService := newDefaultSourcingService(stockBySourceAndProductProviderMock, stubbedSources)
   689  
   690  		itemAllocation, err := sourcingService.AllocateItems(context.Background(), &testCart)
   691  		assert.NoError(t, err)
   692  		assert.NoError(t, itemAllocation[domain.ItemID("item1")].Error)
   693  
   694  		assert.ErrorIs(t, domain.ErrInsufficientSourceQty, itemAllocation[domain.ItemID("item2")].Error)
   695  
   696  		assert.Equal(t, 3,
   697  			itemAllocation[domain.ItemID("item1")].AllocatedQtys[domain.ProductID(simple2.GetIdentifier())][source1])
   698  	})
   699  }
   700  
   701  func newDefaultSourcingService(stockProvider domain.StockProvider, expectedSources []domain.Source) domain.DefaultSourcingService {
   702  	sourcingService := domain.DefaultSourcingService{}
   703  	availableSourcesProviderMock := availableSourcesProviderMock{Sources: expectedSources}
   704  
   705  	sourcingService.Inject(flamingo.NullLogger{}, &struct {
   706  		AvailableSourcesProvider domain.AvailableSourcesProvider `inject:",optional"`
   707  		StockProvider            domain.StockProvider            `inject:",optional"`
   708  	}{
   709  		StockProvider:            stockProvider,
   710  		AvailableSourcesProvider: availableSourcesProviderMock,
   711  	})
   712  
   713  	return sourcingService
   714  }