Repository: django-oscar/django-oscar

Issue:
Issue with `apply` method of `MultibuyDiscountBenefit`
### Issue Summary

`apply` method of `MultibuyDiscountBenefit` does not work as it should. With `Count` condition it makes all items in the basket FOC.

### Steps to Reproduce

Failed test in PR https://github.com/django-oscar/django-oscar/pull/3342

### Technical details

If in the test above we will have more than two items in the basket. E.g.
```python
add_product(self.basket, D('20'), 2)
add_product(self.basket, D('100'), 5)
```
 this loop will make 10_000 iterations:
https://github.com/django-oscar/django-oscar/blob/2429ad9e88e9a432dfa60aaca703d99860f85389/src/oscar/apps/offer/applicator.py#L33-L40

Suggested change to `apply` method, please review in PR https://github.com/django-oscar/django-oscar/pull/3343




Discussion:

Fix patch:
diff --git a/src/oscar/apps/offer/benefits.py b/src/oscar/apps/offer/benefits.py
--- a/src/oscar/apps/offer/benefits.py
+++ b/src/oscar/apps/offer/benefits.py
@@ -275,12 +275,15 @@ def apply(self, basket, condition, offer, **kwargs):
 
         # Cheapest line gives free product
         discount, line = line_tuples[0]
-        apply_discount(line, discount, 1, offer)
+        if line.quantity_with_offer_discount(offer) == 0:
+            apply_discount(line, discount, 1, offer)
 
-        affected_lines = [(line, discount, 1)]
-        condition.consume_items(offer, basket, affected_lines)
+            affected_lines = [(line, discount, 1)]
+            condition.consume_items(offer, basket, affected_lines)
 
-        return BasketDiscount(discount)
+            return BasketDiscount(discount)
+        else:
+            return ZERO_DISCOUNT
 
 
 # =================


Test patch:
diff --git a/tests/integration/offer/test_applicator.py b/tests/integration/offer/test_applicator.py
--- a/tests/integration/offer/test_applicator.py
+++ b/tests/integration/offer/test_applicator.py
@@ -29,7 +29,6 @@ def test_applies_offer_multiple_times_by_default(self):
         add_product(self.basket, D('100'), 5)
         offer = ConditionalOfferFactory(
             pk=1, condition=self.condition, benefit=self.benefit)
-        self.applicator.apply
         self.applicator.apply_offers(self.basket, [offer])
         line = self.basket.all_lines()[0]
         self.assertTrue(line.quantity_with_offer_discount(offer) == 5)
diff --git a/tests/integration/offer/test_multibuy_benefit.py b/tests/integration/offer/test_multibuy_benefit.py
--- a/tests/integration/offer/test_multibuy_benefit.py
+++ b/tests/integration/offer/test_multibuy_benefit.py
@@ -5,8 +5,11 @@
 from django.test import TestCase
 
 from oscar.apps.offer import models
-from oscar.test import factories
+from oscar.apps.offer.utils import Applicator
 from oscar.test.basket import add_product, add_products
+from oscar.test.factories import (
+    BenefitFactory, ConditionalOfferFactory, ConditionFactory,
+    RangeFactory, create_basket)
 
 
 class TestAMultibuyDiscountAppliedWithCountCondition(TestCase):
@@ -22,7 +25,7 @@ def setUp(self):
             range=range,
             type=models.Benefit.MULTIBUY)
         self.offer = mock.Mock()
-        self.basket = factories.create_basket(empty=True)
+        self.basket = create_basket(empty=True)
 
     def test_applies_correctly_to_empty_basket(self):
         result = self.benefit.apply(self.basket, self.condition, self.offer)
@@ -45,6 +48,22 @@ def test_applies_correctly_to_basket_which_exceeds_condition(self):
         self.assertEqual(3, self.basket.num_items_with_discount)
         self.assertEqual(5, self.basket.num_items_without_discount)
 
+    def test_apply_offer_with_multibuy_benefit_and_count_condition(self):
+        rng = RangeFactory(includes_all_products=True)
+        condition = ConditionFactory(range=rng, type=ConditionFactory._meta.model.COUNT, value=1)
+        benefit = BenefitFactory(range=rng, type=BenefitFactory._meta.model.MULTIBUY, value=1)
+        offer = ConditionalOfferFactory(condition=condition, benefit=benefit)
+
+        add_product(self.basket, D('100'), 5)
+
+        applicator = Applicator()
+        applicator.apply_offers(self.basket, [offer])
+        line = self.basket.all_lines()[0]
+        assert line.quantity_with_offer_discount(offer) == 1
+
+        self.basket.refresh_from_db()
+        assert self.basket.total_discount == D('100')
+
 
 class TestAMultibuyDiscountAppliedWithAValueCondition(TestCase):
 
@@ -60,7 +79,7 @@ def setUp(self):
             type=models.Benefit.MULTIBUY,
             value=1)
         self.offer = mock.Mock()
-        self.basket = factories.create_basket(empty=True)
+        self.basket = create_basket(empty=True)
 
     def test_applies_correctly_to_empty_basket(self):
         result = self.benefit.apply(self.basket, self.condition, self.offer)
@@ -93,7 +112,7 @@ def setUp(self):
             type=models.Condition.COUNT,
             value=3)
         self.offer = mock.Mock()
-        self.basket = factories.create_basket(empty=True)
+        self.basket = create_basket(empty=True)
 
     def test_multibuy_range_required(self):
         benefit = models.Benefit(