diff --git a/database/factories/DiscountRuleFactory.php b/database/factories/DiscountRuleFactory.php new file mode 100644 index 00000000..168f8dae --- /dev/null +++ b/database/factories/DiscountRuleFactory.php @@ -0,0 +1,43 @@ + + */ + protected $model = DiscountRule::class; + + /** + * Get the name of the model that is generated by the factory. + * + * @return class-string<\Illuminate\Database\Eloquent\Model|TModel> + */ + public function modelName(): string + { + return $this->model::getProxiedClass(); + } + + /** + * Define the model's default state. + */ + public function definition(): array + { + return [ + 'active' => true, + 'name' => fake()->words(3, true), + 'rules' => [], + 'stackable' => false, + 'type' => DiscountRuleType::CART, + ]; + } +} diff --git a/src/Models/DiscountRule.php b/src/Models/DiscountRule.php index 3684da20..738f383d 100644 --- a/src/Models/DiscountRule.php +++ b/src/Models/DiscountRule.php @@ -4,6 +4,8 @@ namespace Cone\Bazar\Models; +use Cone\Bazar\Database\Factories\DiscountRuleFactory; +use Cone\Bazar\Enums\DiscountRuleType; use Cone\Bazar\Enums\DiscountRuleValueType; use Cone\Bazar\Enums\DiscountType; use Cone\Bazar\Exceptions\DiscountException; @@ -12,6 +14,7 @@ use Cone\Bazar\Models\Discountable as DiscountablePivot; use Cone\Root\Models\User; use Cone\Root\Traits\InteractsWithProxy; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -21,6 +24,7 @@ class DiscountRule extends Model implements Contract { + use HasFactory; use InteractsWithProxy; /** @@ -79,6 +83,14 @@ public function getMorphClass(): string return static::getProxiedClass(); } + /** + * Create a new factory instance for the model. + */ + protected static function newFactory(): DiscountRuleFactory + { + return DiscountRuleFactory::new(); + } + /** * Get the discountable types. */ diff --git a/tests/Enums/DiscountRuleTypeTest.php b/tests/Enums/DiscountRuleTypeTest.php new file mode 100644 index 00000000..78449223 --- /dev/null +++ b/tests/Enums/DiscountRuleTypeTest.php @@ -0,0 +1,69 @@ +assertEquals('cart', DiscountRuleType::CART->value); + } + + public function test_discount_rule_type_has_buyable_case(): void + { + $this->assertEquals('buyable', DiscountRuleType::BUYABLE->value); + } + + public function test_discount_rule_type_has_shipping_case(): void + { + $this->assertEquals('shipping', DiscountRuleType::SHIPPING->value); + } + + public function test_cart_type_has_highest_priority(): void + { + $this->assertEquals(3, DiscountRuleType::CART->priority()); + } + + public function test_buyable_type_has_medium_priority(): void + { + $this->assertEquals(2, DiscountRuleType::BUYABLE->priority()); + } + + public function test_shipping_type_has_lowest_priority(): void + { + $this->assertEquals(1, DiscountRuleType::SHIPPING->priority()); + } + + public function test_cart_type_has_correct_label(): void + { + $this->assertEquals('Cart Total', DiscountRuleType::CART->label()); + } + + public function test_buyable_type_has_correct_label(): void + { + $this->assertEquals('Buyable Item', DiscountRuleType::BUYABLE->label()); + } + + public function test_shipping_type_has_correct_label(): void + { + $this->assertEquals('Shipping', DiscountRuleType::SHIPPING->label()); + } + + public function test_priorities_are_ordered_correctly(): void + { + $this->assertGreaterThan( + DiscountRuleType::BUYABLE->priority(), + DiscountRuleType::CART->priority() + ); + + $this->assertGreaterThan( + DiscountRuleType::SHIPPING->priority(), + DiscountRuleType::BUYABLE->priority() + ); + } +} diff --git a/tests/Enums/DiscountValueTypeTest.php b/tests/Enums/DiscountValueTypeTest.php new file mode 100644 index 00000000..8416d9c6 --- /dev/null +++ b/tests/Enums/DiscountValueTypeTest.php @@ -0,0 +1,30 @@ +assertEquals('fixed_amount', DiscountValueType::FIX->value); + } + + public function test_discount_value_type_has_percent_case(): void + { + $this->assertEquals('percent', DiscountValueType::PERCENT->value); + } + + public function test_all_cases_are_present(): void + { + $cases = DiscountValueType::cases(); + + $this->assertCount(2, $cases); + $this->assertContains(DiscountValueType::FIX, $cases); + $this->assertContains(DiscountValueType::PERCENT, $cases); + } +} diff --git a/tests/Models/DiscountRuleTest.php b/tests/Models/DiscountRuleTest.php new file mode 100644 index 00000000..243805b7 --- /dev/null +++ b/tests/Models/DiscountRuleTest.php @@ -0,0 +1,185 @@ +discountRule = DiscountRule::factory()->create(); + + $this->cart = Cart::factory()->create(); + + Product::factory(3)->create()->each(function ($product) { + $this->cart->items()->create([ + 'buyable_id' => $product->id, + 'buyable_type' => Product::class, + 'quantity' => mt_rand(1, 5), + 'price' => $product->price, + 'name' => $product->name, + ]); + }); + } + + public function test_discount_rule_has_default_attributes(): void + { + $this->assertTrue($this->discountRule->active); + $this->assertFalse($this->discountRule->stackable); + $this->assertEquals(DiscountRuleType::CART, $this->discountRule->type); + $this->assertIsArray($this->discountRule->rules); + } + + public function test_discount_rule_can_have_rules(): void + { + $this->discountRule->rules = ['min_amount' => 100]; + $this->discountRule->save(); + + $this->assertSame(['min_amount' => 100], $this->discountRule->rules); + } + + public function test_discount_rule_can_be_associated_with_users(): void + { + $users = User::factory()->count(2)->create(); + + $this->discountRule->users()->attach($users); + + $this->assertCount(2, $this->discountRule->users); + $this->assertTrue($this->discountRule->users->contains($users[0])); + $this->assertTrue($this->discountRule->users->contains($users[1])); + } + + public function test_discount_rule_can_calculate_discount(): void + { + $value = $this->discountRule->calculate($this->cart); + + $this->assertIsFloat($value); + $this->assertEquals(0.0, $value); + } + + public function test_discount_rule_can_be_applied_to_discountable(): void + { + $this->assertCount(0, $this->cart->discounts); + + $this->discountRule->apply($this->cart); + + $this->cart->refresh(); + + $this->assertCount(1, $this->cart->discounts); + $this->assertTrue($this->cart->discounts->contains($this->discountRule)); + } + + public function test_discount_rule_can_be_applied_to_item(): void + { + $item = $this->cart->items->first(); + + $this->assertCount(0, $item->discounts); + + $this->discountRule->apply($item); + + $item->refresh(); + + $this->assertCount(1, $item->discounts); + $this->assertTrue($item->discounts->contains($this->discountRule)); + } + + public function test_discount_rule_types_have_priorities(): void + { + $this->assertEquals(3, DiscountRuleType::CART->priority()); + $this->assertEquals(2, DiscountRuleType::BUYABLE->priority()); + $this->assertEquals(1, DiscountRuleType::SHIPPING->priority()); + } + + public function test_discount_rule_types_have_labels(): void + { + $this->assertEquals('Cart Total', DiscountRuleType::CART->label()); + $this->assertEquals('Buyable Item', DiscountRuleType::BUYABLE->label()); + $this->assertEquals('Shipping', DiscountRuleType::SHIPPING->label()); + } + + public function test_discount_rule_can_have_different_types(): void + { + $cartRule = DiscountRule::factory()->create(['type' => DiscountRuleType::CART]); + $buyableRule = DiscountRule::factory()->create(['type' => DiscountRuleType::BUYABLE]); + $shippingRule = DiscountRule::factory()->create(['type' => DiscountRuleType::SHIPPING]); + + $this->assertEquals(DiscountRuleType::CART, $cartRule->type); + $this->assertEquals(DiscountRuleType::BUYABLE, $buyableRule->type); + $this->assertEquals(DiscountRuleType::SHIPPING, $shippingRule->type); + } + + public function test_discount_rule_applies_to_shipping(): void + { + $shipping = $this->cart->shipping()->create([ + 'name' => 'Standard Shipping', + 'cost' => 10.0, + 'driver' => 'local-pickup', + ]); + + $this->assertCount(0, $shipping->discounts); + + $this->discountRule->apply($shipping); + + $shipping->refresh(); + + $this->assertCount(1, $shipping->discounts); + $this->assertTrue($shipping->discounts->contains($this->discountRule)); + } + + public function test_multiple_discount_rules_can_be_applied(): void + { + $rule1 = DiscountRule::factory()->create(['stackable' => true]); + $rule2 = DiscountRule::factory()->create(['stackable' => true]); + + $rule1->apply($this->cart); + $rule2->apply($this->cart); + + $this->cart->refresh(); + + $this->assertCount(2, $this->cart->discounts); + $this->assertTrue($this->cart->discounts->contains($rule1)); + $this->assertTrue($this->cart->discounts->contains($rule2)); + } + + public function test_discount_rule_can_be_inactive(): void + { + $inactiveRule = DiscountRule::factory()->create(['active' => false]); + + $this->assertFalse($inactiveRule->active); + } + + public function test_discount_rule_stackable_attribute(): void + { + $stackableRule = DiscountRule::factory()->create(['stackable' => true]); + $nonStackableRule = DiscountRule::factory()->create(['stackable' => false]); + + $this->assertTrue($stackableRule->stackable); + $this->assertFalse($nonStackableRule->stackable); + } + + public function test_discount_rule_relationship_uses_correct_table(): void + { + $this->discountRule->apply($this->cart); + + $this->assertDatabaseHas('bazar_discounts', [ + 'discount_rule_id' => $this->discountRule->id, + 'discountable_id' => $this->cart->id, + 'discountable_type' => get_class($this->cart), + ]); + } +} diff --git a/tests/Models/DiscountTest.php b/tests/Models/DiscountTest.php new file mode 100644 index 00000000..05ba6767 --- /dev/null +++ b/tests/Models/DiscountTest.php @@ -0,0 +1,133 @@ +discountRule = DiscountRule::factory()->create(); + + $this->cart = Cart::factory()->create(); + + Product::factory(3)->create()->each(function ($product) { + $this->cart->items()->create([ + 'buyable_id' => $product->id, + 'buyable_type' => Product::class, + 'quantity' => mt_rand(1, 5), + 'price' => $product->price, + 'name' => $product->name, + ]); + }); + } + + public function test_discount_has_default_value(): void + { + $this->discountRule->apply($this->cart); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertInstanceOf(Discount::class, $discount); + $this->assertEquals(0.0, $discount->value); + } + + public function test_discount_value_can_be_set(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 25.50]); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertEquals(25.50, $discount->value); + } + + public function test_discount_can_be_formatted(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.00]); + + $discount = $this->cart->discounts()->first()->discount; + + $formatted = $discount->format(); + + $this->assertIsString($formatted); + } + + public function test_discount_has_formatted_value_attribute(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 15.00]); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertIsString($discount->formatted_value); + } + + public function test_discount_value_is_cast_to_float(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => '20.50']); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertIsFloat($discount->value); + $this->assertEquals(20.50, $discount->value); + } + + public function test_discount_pivot_has_timestamps(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.00]); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertNotNull($discount->created_at); + $this->assertNotNull($discount->updated_at); + } + + public function test_discount_uses_correct_table(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 5.00]); + + $this->assertDatabaseHas('bazar_discounts', [ + 'discount_rule_id' => $this->discountRule->id, + 'discountable_id' => $this->cart->id, + 'value' => 5.00, + ]); + } + + public function test_multiple_discounts_on_same_model(): void + { + $rule1 = DiscountRule::factory()->create(); + $rule2 = DiscountRule::factory()->create(); + + $this->cart->discounts()->attach($rule1, ['value' => 10.00]); + $this->cart->discounts()->attach($rule2, ['value' => 5.00]); + + $discounts = $this->cart->discounts; + + $this->assertCount(2, $discounts); + $this->assertEquals(10.00, $discounts->where('id', $rule1->id)->first()->discount->value); + $this->assertEquals(5.00, $discounts->where('id', $rule2->id)->first()->discount->value); + } + + public function test_discount_can_be_updated(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.00]); + + $this->cart->discounts()->updateExistingPivot($this->discountRule->id, ['value' => 20.00]); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertEquals(20.00, $discount->value); + } +} diff --git a/tests/Traits/InteractsWithDiscountsTest.php b/tests/Traits/InteractsWithDiscountsTest.php new file mode 100644 index 00000000..e3ec4e16 --- /dev/null +++ b/tests/Traits/InteractsWithDiscountsTest.php @@ -0,0 +1,184 @@ +cart = Cart::factory()->create(); + + $product = Product::factory()->create(); + + $this->item = $this->cart->items()->create([ + 'buyable_id' => $product->id, + 'buyable_type' => Product::class, + 'quantity' => 2, + 'price' => $product->price, + 'name' => $product->name, + ]); + + $this->discountRule = DiscountRule::factory()->create(); + } + + public function test_discountable_has_discounts_relationship(): void + { + $this->assertTrue(method_exists($this->cart, 'discounts')); + $this->assertTrue(method_exists($this->item, 'discounts')); + } + + public function test_discounts_can_be_attached_to_cart(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.0]); + + $this->assertCount(1, $this->cart->discounts); + $this->assertTrue($this->cart->discounts->contains($this->discountRule)); + } + + public function test_discounts_can_be_attached_to_item(): void + { + $this->item->discounts()->attach($this->discountRule, ['value' => 5.0]); + + $this->assertCount(1, $this->item->discounts); + $this->assertTrue($this->item->discounts->contains($this->discountRule)); + } + + public function test_multiple_discounts_can_be_attached(): void + { + $rule1 = DiscountRule::factory()->create(); + $rule2 = DiscountRule::factory()->create(); + + $this->cart->discounts()->attach($rule1, ['value' => 10.0]); + $this->cart->discounts()->attach($rule2, ['value' => 5.0]); + + $this->assertCount(2, $this->cart->discounts); + } + + public function test_discounts_are_detached_on_model_deletion(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.0]); + + $this->assertCount(1, $this->cart->discounts); + + $cartId = $this->cart->id; + $this->cart->delete(); + + // Verify the discount relationship is removed + $cart = Cart::withTrashed()->find($cartId); + if ($cart) { + $this->assertCount(0, $cart->discounts); + } + } + + public function test_discount_pivot_has_value(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 25.0]); + + $discount = $this->cart->discounts()->first(); + + $this->assertEquals(25.0, $discount->discount->value); + } + + public function test_discount_relationship_uses_custom_pivot(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 15.0]); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertInstanceOf(\Cone\Bazar\Models\Discount::class, $discount); + } + + public function test_sync_without_detaching_preserves_existing_discounts(): void + { + $rule1 = DiscountRule::factory()->create(); + $rule2 = DiscountRule::factory()->create(); + + $this->cart->discounts()->attach($rule1, ['value' => 10.0]); + $this->cart->discounts()->syncWithoutDetaching([$rule2->id => ['value' => 5.0]]); + + $this->cart->refresh(); + + $this->assertCount(2, $this->cart->discounts); + $this->assertTrue($this->cart->discounts->contains($rule1)); + $this->assertTrue($this->cart->discounts->contains($rule2)); + } + + public function test_discount_value_persists_correctly(): void + { + $expectedValue = 12.34; + + $this->cart->discounts()->attach($this->discountRule, ['value' => $expectedValue]); + + $this->cart->refresh(); + + $actualValue = $this->cart->discounts()->first()->discount->value; + + $this->assertEquals($expectedValue, $actualValue); + } + + public function test_discounts_use_morph_to_many_relationship(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.0]); + + $discount = $this->cart->discounts()->first(); + + $this->assertNotNull($discount->pivot); + $this->assertEquals($this->cart->id, $discount->pivot->discountable_id); + $this->assertEquals(get_class($this->cart), $discount->pivot->discountable_type); + } + + public function test_discount_relationship_includes_timestamps(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.0]); + + $discount = $this->cart->discounts()->first()->discount; + + $this->assertObjectHasProperty('created_at', $discount); + $this->assertObjectHasProperty('updated_at', $discount); + } + + public function test_shipping_can_have_discounts(): void + { + $shipping = $this->cart->shipping()->create([ + 'name' => 'Express Shipping', + 'cost' => 15.0, + 'driver' => 'local-pickup', + ]); + + $shipping->discounts()->attach($this->discountRule, ['value' => 3.0]); + + $this->assertCount(1, $shipping->discounts); + $this->assertEquals(3.0, $shipping->discounts()->first()->discount->value); + } + + public function test_discount_detaches_when_rule_is_deleted(): void + { + $this->cart->discounts()->attach($this->discountRule, ['value' => 10.0]); + + $this->assertCount(1, $this->cart->discounts); + + $ruleId = $this->discountRule->id; + $this->discountRule->delete(); + + $this->cart->refresh(); + + // The relationship should still exist but point to a non-existent rule + $this->assertDatabaseMissing('bazar_discount_rules', ['id' => $ruleId]); + } +}