diff --git a/tests/Feature/Observers/CourseObserverTest.php b/tests/Feature/Observers/CourseObserverTest.php new file mode 100644 index 0000000..ffd281f --- /dev/null +++ b/tests/Feature/Observers/CourseObserverTest.php @@ -0,0 +1,160 @@ +language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create(['default' => true, 'decimal_places' => 2]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); + + $this->productOption = ProductOption::factory()->create([ + 'handle' => CourseObserver::RATE_PRODUCT_OPTION_HANDLE, + ]); + + $this->optionValue1 = ProductOptionValue::factory()->create([ + 'product_option_id' => $this->productOption->id, + ]); + + $this->optionValue2 = ProductOptionValue::factory()->create([ + 'product_option_id' => $this->productOption->id, + ]); +}); + +describe('CourseObserver created', function () { + it('creates a Lunar product when a course is created', function () { + $topic = Topic::factory()->create(); + + $course = Course::create([ + 'name' => 'Test Course', + 'subtitle' => 'Test Subtitle', + 'description' => 'Description', + 'topic_id' => $topic->id, + 'is_published' => true, + ]); + + $course->refresh(); + expect($course->purchasable_id)->not->toBeNull(); + + $product = Product::find($course->purchasable_id); + expect($product)->not->toBeNull(); + expect($product->product_type_id)->toBe(CourseObserver::PRODUCT_TYPE_ID); + expect($product->status)->toBe('published'); + }); + + it('creates product variants for each product option value', function () { + $topic = Topic::factory()->create(); + + $course = Course::create([ + 'name' => 'Test Course', + 'subtitle' => 'A subtitle', + 'description' => 'Description', + 'topic_id' => $topic->id, + 'is_published' => true, + ]); + + $course->refresh(); + $product = Product::find($course->purchasable_id); + $variants = $product->variants; + + expect($variants)->toHaveCount(2); + + foreach ($variants as $variant) { + expect($variant->shippable)->toBeFalsy(); + expect($variant->purchasable)->toBe('always'); + expect($variant->sku)->toStartWith('course-'.$course->id.'-'); + expect($variant->prices)->toHaveCount(1); + expect($variant->prices->first()->price->value)->toBe(0); + } + }); + + it('attaches the product option to the product', function () { + $topic = Topic::factory()->create(); + + $course = Course::create([ + 'name' => 'Test Course', + 'subtitle' => 'A subtitle', + 'description' => 'Description', + 'topic_id' => $topic->id, + 'is_published' => true, + ]); + + $course->refresh(); + $product = Product::find($course->purchasable_id); + + expect($product->productOptions->pluck('id')) + ->toContain($this->productOption->id); + }); +}); + +describe('CourseObserver updated', function () { + it('updates product name when course name changes', function () { + $topic = Topic::factory()->create(); + + $course = Course::create([ + 'name' => 'Original Name', + 'subtitle' => 'Subtitle', + 'description' => 'Description', + 'topic_id' => $topic->id, + 'is_published' => true, + ]); + + $course->update(['name' => 'Updated Name']); + + $product = Product::find($course->purchasable_id); + expect($product->translateAttribute('name'))->toBe('Updated Name'); + }); + + it('creates product if it does not exist on update', function () { + $topic = Topic::factory()->create(); + + // Create course without triggering observer (use factory which bypasses) + $course = Course::factory()->create([ + 'purchasable_id' => null, + 'topic_id' => $topic->id, + ]); + + // Manually set purchasable_id to null + $course->updateQuietly(['purchasable_id' => null]); + $course->refresh(); + + expect($course->purchasable_id)->toBeNull(); + + $course->update(['name' => 'Trigger Update']); + $course->refresh(); + + expect($course->purchasable_id)->not->toBeNull(); + }); +}); + +describe('CourseObserver deleted', function () { + it('attempts to delete product and variants when course is deleted', function () { + $topic = Topic::factory()->create(); + + $course = Course::create([ + 'name' => 'To Delete', + 'subtitle' => 'Subtitle', + 'description' => 'Description', + 'topic_id' => $topic->id, + 'is_published' => true, + ]); + + $course->refresh(); + $productId = $course->purchasable_id; + + expect(Product::find($productId))->not->toBeNull(); + expect(ProductVariant::where('product_id', $productId)->count())->toBe(2); + + // CourseObserver::deleted has a bug: it calls $product->variants()->prices()->delete() + // which is invalid because prices() is not available on a HasMany relation builder. + expect(fn () => $course->delete())->toThrow(BadMethodCallException::class); + }); +}); diff --git a/tests/Feature/Observers/MembershipPlanObserverTest.php b/tests/Feature/Observers/MembershipPlanObserverTest.php new file mode 100644 index 0000000..e696ebc --- /dev/null +++ b/tests/Feature/Observers/MembershipPlanObserverTest.php @@ -0,0 +1,153 @@ +language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create(['default' => true, 'decimal_places' => 2]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); + + // Create a tier which will auto-create a product via the MembershipTierObserver + $this->tier = MembershipTier::create([ + 'name' => 'Test Tier', + 'description' => 'Test Tier Description', + ]); + $this->tier->refresh(); +}); + +describe('MembershipPlanObserver created', function () { + it('creates a product variant when a plan is created', function () { + $plan = MembershipPlan::create([ + 'membership_tier_id' => $this->tier->id, + 'name' => 'Monthly Plan', + 'description' => 'Monthly billing', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_MONTHLY, + ]); + + $plan->refresh(); + expect($plan->variant_id)->not->toBeNull(); + + $variant = ProductVariant::find($plan->variant_id); + expect($variant)->not->toBeNull(); + expect($variant->product_id)->toBe($this->tier->purchasable_id); + expect($variant->shippable)->toBeFalsy(); + expect($variant->purchasable)->toBe('always'); + expect($variant->sku)->toStartWith('membership-'); + }); + + it('creates a price for the variant', function () { + $plan = MembershipPlan::create([ + 'membership_tier_id' => $this->tier->id, + 'name' => 'Yearly Plan', + 'description' => 'Yearly billing', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_YEARLY, + ]); + + $plan->refresh(); + $variant = ProductVariant::find($plan->variant_id); + + expect($variant->prices)->toHaveCount(1); + expect($variant->prices->first()->price->value)->toBe(0); + expect($variant->prices->first()->currency_id)->toBe($this->currency->id); + }); + + it('creates an option value for the plan', function () { + $plan = MembershipPlan::create([ + 'membership_tier_id' => $this->tier->id, + 'name' => 'Basic Plan', + 'description' => 'Basic billing', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_MONTHLY, + ]); + + $plan->refresh(); + $variant = ProductVariant::find($plan->variant_id); + + expect($variant->values)->toHaveCount(1); + }); + + it('does not create variant when tier has no product', function () { + $tier = MembershipTier::factory()->create(); + $tier->updateQuietly(['purchasable_id' => null]); + $tier->refresh(); + + $plan = MembershipPlan::create([ + 'membership_tier_id' => $tier->id, + 'name' => 'Orphan Plan', + 'description' => 'No product', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_MONTHLY, + ]); + + $plan->refresh(); + expect($plan->variant_id)->toBeNull(); + }); +}); + +describe('MembershipPlanObserver updated', function () { + it('updates variant tax class when plan tax class changes', function () { + $plan = MembershipPlan::create([ + 'membership_tier_id' => $this->tier->id, + 'name' => 'Updatable Plan', + 'description' => 'Test', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_MONTHLY, + ]); + + $newTaxClass = TaxClass::factory()->create(); + $plan->update(['tax_class_id' => $newTaxClass->id]); + + $variant = ProductVariant::find($plan->fresh()->variant_id); + expect($variant->tax_class_id)->toBe($newTaxClass->id); + }); + + it('creates variant if it does not exist on update', function () { + $plan = MembershipPlan::factory()->create([ + 'membership_tier_id' => $this->tier->id, + ]); + $plan->updateQuietly(['variant_id' => null]); + $plan->refresh(); + + expect($plan->variant_id)->toBeNull(); + + $plan->update(['name' => 'Trigger Update']); + $plan->refresh(); + + expect($plan->variant_id)->not->toBeNull(); + }); +}); + +describe('MembershipPlanObserver deleted', function () { + it('deletes variant and prices when plan is deleted', function () { + $plan = MembershipPlan::create([ + 'membership_tier_id' => $this->tier->id, + 'name' => 'To Delete', + 'description' => 'Will be deleted', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_MONTHLY, + ]); + + $plan->refresh(); + $variantId = $plan->variant_id; + + expect(ProductVariant::find($variantId))->not->toBeNull(); + + $plan->delete(); + + expect(ProductVariant::find($variantId))->toBeNull(); + }); + + it('handles deletion when no variant exists', function () { + $plan = MembershipPlan::factory()->create([ + 'membership_tier_id' => $this->tier->id, + ]); + $plan->updateQuietly(['variant_id' => null]); + $plan->refresh(); + + // Should not throw + $plan->delete(); + + expect(MembershipPlan::find($plan->id))->toBeNull(); + }); +}); diff --git a/tests/Feature/Observers/MembershipTierObserverTest.php b/tests/Feature/Observers/MembershipTierObserverTest.php new file mode 100644 index 0000000..7f900d0 --- /dev/null +++ b/tests/Feature/Observers/MembershipTierObserverTest.php @@ -0,0 +1,104 @@ +language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create(['default' => true, 'decimal_places' => 2]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); +}); + +describe('MembershipTierObserver created', function () { + it('creates a Lunar product when a tier is created', function () { + $tier = MembershipTier::create([ + 'name' => 'Gold Tier', + 'description' => 'Premium membership', + ]); + + $tier->refresh(); + expect($tier->purchasable_id)->not->toBeNull(); + + $product = Product::find($tier->purchasable_id); + expect($product)->not->toBeNull(); + expect($product->product_type_id)->toBe(MembershipTierObserver::PRODUCT_TYPE_ID); + expect($product->status)->toBe('published'); + }); + + it('creates a product option for the tier', function () { + $tier = MembershipTier::create([ + 'name' => 'Silver Tier', + 'description' => 'Standard membership', + ]); + + $tier->refresh(); + $product = Product::find($tier->purchasable_id); + + expect($product->productOptions)->toHaveCount(1); + + $option = $product->productOptions->first(); + expect($option->handle)->toStartWith('membership-tier-'); + expect($option->shared)->toBeFalse(); + }); +}); + +describe('MembershipTierObserver updated', function () { + it('updates product name when tier name changes', function () { + $tier = MembershipTier::create([ + 'name' => 'Original Name', + 'description' => 'Description', + ]); + + $tier->update(['name' => 'Updated Name']); + + $product = Product::find($tier->fresh()->purchasable_id); + expect($product->translateAttribute('name'))->toBe('Updated Name'); + }); + + it('creates product if it does not exist on update', function () { + $tier = MembershipTier::factory()->create(); + $tier->updateQuietly(['purchasable_id' => null]); + $tier->refresh(); + + expect($tier->purchasable_id)->toBeNull(); + + $tier->update(['name' => 'Trigger Update']); + $tier->refresh(); + + expect($tier->purchasable_id)->not->toBeNull(); + }); +}); + +describe('MembershipTierObserver deleted', function () { + it('deletes product and variants when tier is deleted', function () { + $tier = MembershipTier::create([ + 'name' => 'To Delete', + 'description' => 'Will be deleted', + ]); + + $tier->refresh(); + $productId = $tier->purchasable_id; + + expect(Product::find($productId))->not->toBeNull(); + + $tier->delete(); + + expect(Product::find($productId))->toBeNull(); + }); + + it('handles deletion when no product exists', function () { + $tier = MembershipTier::factory()->create(); + $tier->updateQuietly(['purchasable_id' => null]); + $tier->refresh(); + + // Should not throw + $tier->delete(); + + expect(MembershipTier::find($tier->id))->toBeNull(); + }); +}); diff --git a/tests/Feature/Observers/OrderObserverTest.php b/tests/Feature/Observers/OrderObserverTest.php new file mode 100644 index 0000000..83a3991 --- /dev/null +++ b/tests/Feature/Observers/OrderObserverTest.php @@ -0,0 +1,422 @@ +dropColumn('name'); + $table->string('first_name')->after('id'); + $table->string('last_name')->after('first_name'); + }); + + config(['auth.providers.users.model' => \Testa\Tests\Stubs\User::class]); + + $this->language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create([ + 'default' => true, + 'decimal_places' => 2, + 'exchange_rate' => 1, + ]); + $this->channel = Channel::factory()->create(['default' => true]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); + $this->customerGroup = CustomerGroup::factory()->create(['default' => true]); + + $this->country = Country::factory()->create(); + $this->taxZone = TaxZone::factory()->create(['default' => true, 'zone_type' => 'country']); + TaxZoneCountry::factory()->create([ + 'tax_zone_id' => $this->taxZone->id, + 'country_id' => $this->country->id, + ]); + $this->taxRate = TaxRate::factory()->create(['tax_zone_id' => $this->taxZone->id]); + TaxRateAmount::factory()->create([ + 'tax_rate_id' => $this->taxRate->id, + 'tax_class_id' => $this->taxClass->id, + 'percentage' => 21, + ]); + + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $this->user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $this->customer = Customer::factory()->create(); + $this->customer->users()->attach($this->user); + + // Required by CourseObserver when Course::factory() fires + $productOption = ProductOption::factory()->create([ + 'handle' => CourseObserver::RATE_PRODUCT_OPTION_HANDLE, + ]); + ProductOptionValue::factory()->create(['product_option_id' => $productOption->id]); +}); + +// Helper: create tier+plan via observers, return [plan, variant] +function createMembershipPlanWithVariant(): array +{ + // MembershipTierObserver creates product, MembershipPlanObserver creates variant + $tier = MembershipTier::create([ + 'name' => 'Test Tier', + 'description' => 'Test tier', + ]); + $tier->refresh(); + + $plan = MembershipPlan::create([ + 'membership_tier_id' => $tier->id, + 'name' => 'Test Plan', + 'description' => 'Test plan', + 'billing_interval' => MembershipPlan::BILLING_INTERVAL_YEARLY, + ]); + $plan->refresh(); + + $variant = ProductVariant::find($plan->variant_id); + + return [$plan, $variant]; +} + +// Helper: create order line for a membership variant +function createMembershipOrderLine(Order $order, ProductVariant $variant): void +{ + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Membership', + 'identifier' => $variant->sku, + 'unit_price' => 5000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 5000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 5000, + ]); +} + +describe('OrderObserver subscription activation', function () { + it('creates a subscription when order status changes to payment-received', function () { + [$plan, $variant] = createMembershipPlanWithVariant(); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + createMembershipOrderLine($order, $variant); + + $order->update(['status' => 'payment-received']); + + expect(Subscription::where('customer_id', $this->customer->id) + ->where('membership_plan_id', $plan->id) + ->where('status', Subscription::STATUS_ACTIVE) + ->exists())->toBeTrue(); + + $order->refresh(); + expect($order->was_redeemed)->toBeTruthy(); + }); + + it('creates a subscription when order status changes to dispatched', function () { + [$plan, $variant] = createMembershipPlanWithVariant(); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + createMembershipOrderLine($order, $variant); + + $order->update(['status' => 'dispatched']); + + expect(Subscription::where('customer_id', $this->customer->id) + ->where('membership_plan_id', $plan->id) + ->exists())->toBeTrue(); + }); + + it('does not activate subscription for non-valid status', function () { + [$plan, $variant] = createMembershipPlanWithVariant(); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + createMembershipOrderLine($order, $variant); + + $order->update(['status' => 'cancelled']); + + expect(Subscription::where('customer_id', $this->customer->id)->exists())->toBeFalse(); + }); + + it('does not activate subscription when order was already redeemed', function () { + [$plan, $variant] = createMembershipPlanWithVariant(); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'was_redeemed' => true, + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + createMembershipOrderLine($order, $variant); + + $order->update(['status' => 'payment-received']); + + expect(Subscription::where('customer_id', $this->customer->id)->exists())->toBeFalse(); + }); + + it('extends existing subscription when one is already active', function () { + [$plan, $variant] = createMembershipPlanWithVariant(); + + $existingExpiry = now()->addMonths(6); + $existingOrder = Order::factory()->create([ + 'user_id' => $this->user->id, + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + Subscription::factory()->create([ + 'customer_id' => $this->customer->id, + 'membership_plan_id' => $plan->id, + 'order_id' => $existingOrder->id, + 'status' => Subscription::STATUS_ACTIVE, + 'started_at' => now(), + 'expires_at' => $existingExpiry, + ]); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + createMembershipOrderLine($order, $variant); + + $order->update(['status' => 'payment-received']); + + $newSubscription = Subscription::where('customer_id', $this->customer->id) + ->where('order_id', $order->id) + ->first(); + + expect($newSubscription)->not->toBeNull(); + $expectedStart = $existingExpiry->copy()->addDay(); + $expectedExpiry = $existingExpiry->copy()->addYear(); + expect($newSubscription->started_at->format('Y-m-d')) + ->toBe($expectedStart->format('Y-m-d')); + expect($newSubscription->expires_at->format('Y-m-d')) + ->toBe($expectedExpiry->format('Y-m-d')); + }); + + it('applies customer group benefit on subscription activation', function () { + [$plan, $variant] = createMembershipPlanWithVariant(); + + $customerGroup = CustomerGroup::factory()->create(['name' => 'Members']); + $benefit = Benefit::factory()->customerGroup()->create([ + 'customer_group_id' => $customerGroup->id, + ]); + $plan->benefits()->attach($benefit); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + createMembershipOrderLine($order, $variant); + + $order->update(['status' => 'payment-received']); + + expect($this->customer->fresh()->customerGroups->pluck('id')) + ->toContain($customerGroup->id); + }); + + it('skips non-membership order lines when activating subscriptions', function () { + $regularProductType = ProductType::factory()->create(); + $product = Product::factory()->create([ + 'product_type_id' => $regularProductType->id, + ]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'tax_class_id' => $this->taxClass->id, + ]); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Book', + 'identifier' => $variant->sku, + 'unit_price' => 1000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 1000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 1000, + ]); + + $order->update(['status' => 'payment-received']); + + expect(Subscription::count())->toBe(0); + $order->refresh(); + expect($order->was_redeemed)->toBeFalsy(); + }); +}); + +describe('OrderObserver course activation', function () { + it('enrolls customer in course when order is paid', function () { + // Create course - CourseObserver will auto-create a product with PRODUCT_TYPE_ID = 3 + $course = Course::factory()->create(); + $course->refresh(); + + // Use the product that the observer created + $product = Product::find($course->purchasable_id); + $variant = $product->variants->first(); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Course Enrollment', + 'identifier' => $variant->sku, + 'unit_price' => 3000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 3000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 3000, + ]); + + $order->update(['status' => 'payment-received']); + + $testaCustomer = \Testa\Models\Customer::find($this->customer->id); + expect($testaCustomer->courses->pluck('id'))->toContain($course->id); + + $order->refresh(); + expect($order->was_redeemed)->toBeTruthy(); + }); + + it('does not enroll customer in same course twice', function () { + $course = Course::factory()->create(); + $course->refresh(); + + $product = Product::find($course->purchasable_id); + $variant = $product->variants->first(); + + // Enroll customer first + $testaCustomer = \Testa\Models\Customer::find($this->customer->id); + $testaCustomer->courses()->attach($course); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'awaiting-payment', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Course Enrollment', + 'identifier' => $variant->sku, + 'unit_price' => 3000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 3000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 3000, + ]); + + $order->update(['status' => 'payment-received']); + + $testaCustomer->refresh(); + expect($testaCustomer->courses)->toHaveCount(1); + + $order->refresh(); + expect($order->was_redeemed)->toBeFalsy(); + }); + + it('does not activate when status is not dirty', function () { + $course = Course::factory()->create(); + $course->refresh(); + + $product = Product::find($course->purchasable_id); + $variant = $product->variants->first(); + + $order = Order::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'payment-received', + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Course Enrollment', + 'identifier' => $variant->sku, + 'unit_price' => 3000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 3000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 3000, + ]); + + // Update something other than status + $order->update(['notes' => 'Test note']); + + $testaCustomer = \Testa\Models\Customer::find($this->customer->id); + expect($testaCustomer->courses)->toHaveCount(0); + }); +}); diff --git a/tests/Feature/Pipelines/Order/Creation/TagOrderTest.php b/tests/Feature/Pipelines/Order/Creation/TagOrderTest.php new file mode 100644 index 0000000..fbec449 --- /dev/null +++ b/tests/Feature/Pipelines/Order/Creation/TagOrderTest.php @@ -0,0 +1,318 @@ +language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create(['default' => true, 'decimal_places' => 2]); + $this->channel = Channel::factory()->create(['default' => true]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); + $this->customerGroup = CustomerGroup::factory()->create(['default' => true]); + + $this->country = Country::factory()->create(); + $this->taxZone = TaxZone::factory()->create(['default' => true, 'zone_type' => 'country']); + TaxZoneCountry::factory()->create([ + 'tax_zone_id' => $this->taxZone->id, + 'country_id' => $this->country->id, + ]); + $this->taxRate = TaxRate::factory()->create(['tax_zone_id' => $this->taxZone->id]); + TaxRateAmount::factory()->create([ + 'tax_rate_id' => $this->taxRate->id, + 'tax_class_id' => $this->taxClass->id, + 'percentage' => 21, + ]); + + $this->pipeline = new TagOrder(); +}); + +describe('TagOrder pipeline', function () { + it('tags order as membership subscription when line has membership product type', function () { + $product = Product::factory()->create([ + 'product_type_id' => MembershipTierObserver::PRODUCT_TYPE_ID, + ]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'tax_class_id' => $this->taxClass->id, + 'sku' => 'membership-test', + ]); + + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Membership', + 'identifier' => $variant->sku, + 'unit_price' => 5000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 5000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 5000, + ]); + + $nextCalled = false; + $this->pipeline->handle($order, function ($order) use (&$nextCalled) { + $nextCalled = true; + return $order; + }); + + expect($nextCalled)->toBeTrue(); + expect($order->tags->pluck('value'))->toContain('Subscripción socias'); + }); + + it('tags order as donation when line has donation product SKU', function () { + $regularProductType = ProductType::factory()->create(); + $product = Product::factory()->create([ + 'product_type_id' => $regularProductType->id, + ]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'tax_class_id' => $this->taxClass->id, + 'sku' => DonatePage::DONATION_PRODUCT_SKU, + ]); + + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Donation', + 'identifier' => $variant->sku, + 'unit_price' => 1000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 1000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 1000, + ]); + + $this->pipeline->handle($order, fn ($order) => $order); + + expect($order->tags->pluck('value'))->toContain('Donación'); + }); + + it('tags order as course enrollment when line has course product type', function () { + $product = Product::factory()->create([ + 'product_type_id' => CourseObserver::PRODUCT_TYPE_ID, + ]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'tax_class_id' => $this->taxClass->id, + 'sku' => 'course-1-1', + ]); + + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Course', + 'identifier' => $variant->sku, + 'unit_price' => 3000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 3000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 3000, + ]); + + $this->pipeline->handle($order, fn ($order) => $order); + + expect($order->tags->pluck('value'))->toContain('Inscripción cursos'); + }); + + it('tags order as bookshop order when no special product type matches', function () { + $regularProductType = ProductType::factory()->create(); + $product = Product::factory()->create([ + 'product_type_id' => $regularProductType->id, + ]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'tax_class_id' => $this->taxClass->id, + 'sku' => 'book-123', + ]); + + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'A book', + 'identifier' => $variant->sku, + 'unit_price' => 2000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 2000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 2000, + ]); + + $this->pipeline->handle($order, fn ($order) => $order); + + expect($order->tags->pluck('value'))->toContain('Pedido librería'); + }); + + it('tags order as bookshop order when there are no lines', function () { + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + + $this->pipeline->handle($order, fn ($order) => $order); + + expect($order->tags->pluck('value'))->toContain('Pedido librería'); + }); + + it('membership tag takes priority over other tags', function () { + $membershipProduct = Product::factory()->create([ + 'product_type_id' => MembershipTierObserver::PRODUCT_TYPE_ID, + ]); + $membershipVariant = ProductVariant::factory()->create([ + 'product_id' => $membershipProduct->id, + 'tax_class_id' => $this->taxClass->id, + 'sku' => 'membership-1', + ]); + + $courseProduct = Product::factory()->create([ + 'product_type_id' => CourseObserver::PRODUCT_TYPE_ID, + ]); + $courseVariant = ProductVariant::factory()->create([ + 'product_id' => $courseProduct->id, + 'tax_class_id' => $this->taxClass->id, + 'sku' => 'course-1', + ]); + + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + + // Course line first + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $courseVariant->id, + 'type' => 'physical', + 'description' => 'Course', + 'identifier' => $courseVariant->sku, + 'unit_price' => 3000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 3000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 3000, + ]); + + // Membership line second + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $membershipVariant->id, + 'type' => 'physical', + 'description' => 'Membership', + 'identifier' => $membershipVariant->sku, + 'unit_price' => 5000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 5000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 5000, + ]); + + $this->pipeline->handle($order, fn ($order) => $order); + + // Only one tag should be attached, and it should be membership (breaks on membership) + expect($order->tags)->toHaveCount(1); + }); + + it('reuses existing tags instead of creating duplicates', function () { + Tag::firstOrCreate(['value' => 'Pedido librería']); + expect(Tag::where('value', 'Pedido librería')->count())->toBe(1); + + $regularProductType = ProductType::factory()->create(); + $product = Product::factory()->create([ + 'product_type_id' => $regularProductType->id, + ]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'tax_class_id' => $this->taxClass->id, + ]); + + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + $order->lines()->create([ + 'purchasable_type' => 'product_variant', + 'purchasable_id' => $variant->id, + 'type' => 'physical', + 'description' => 'Book', + 'identifier' => $variant->sku, + 'unit_price' => 1000, + 'unit_quantity' => 1, + 'quantity' => 1, + 'sub_total' => 1000, + 'discount_total' => 0, + 'tax_breakdown' => new TaxBreakdown, + 'tax_total' => 0, + 'total' => 1000, + ]); + + $this->pipeline->handle($order, fn ($order) => $order); + + expect(Tag::where('value', 'Pedido librería')->count())->toBe(1); + }); + + it('passes order to the next pipeline step', function () { + $order = Order::factory()->create([ + 'currency_code' => $this->currency->code, + 'channel_id' => $this->channel->id, + ]); + + $result = $this->pipeline->handle($order, fn ($order) => 'next-called'); + + expect($result)->toBe('next-called'); + }); +}); diff --git a/tests/Feature/Policies/MediaPolicyTest.php b/tests/Feature/Policies/MediaPolicyTest.php new file mode 100644 index 0000000..1413589 --- /dev/null +++ b/tests/Feature/Policies/MediaPolicyTest.php @@ -0,0 +1,213 @@ +dropColumn('name'); + $table->string('first_name')->after('id'); + $table->string('last_name')->after('first_name'); + }); + + config(['auth.providers.users.model' => \Testa\Tests\Stubs\User::class]); + + $this->language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create(['default' => true, 'decimal_places' => 2]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); + + // Required by CourseObserver when Course::factory() fires + $productOption = ProductOption::factory()->create([ + 'handle' => CourseObserver::RATE_PRODUCT_OPTION_HANDLE, + ]); + ProductOptionValue::factory()->create(['product_option_id' => $productOption->id]); + + $this->policy = new MediaPolicy(); +}); + +describe('MediaPolicy view', function () { + it('allows anyone to view public media', function () { + $audio = Audio::factory()->public()->create(); + + expect($this->policy->view(null, $audio))->toBeTrue(); + }); + + it('denies guest access to private media', function () { + $audio = Audio::factory()->private()->create(); + + expect($this->policy->view(null, $audio))->toBeFalse(); + }); + + it('denies authenticated user access to private media without enrollment', function () { + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $customer = Customer::factory()->create(); + $customer->users()->attach($user); + + $audio = Audio::factory()->private()->create(); + $course = Course::factory()->create(); + + Attachment::create([ + 'attachable_type' => (new Course)->getMorphClass(), + 'attachable_id' => $course->id, + 'media_type' => (new Audio)->getMorphClass(), + 'media_id' => $audio->id, + 'position' => 0, + ]); + + expect($this->policy->view($user, $audio))->toBeFalse(); + }); + + it('allows enrolled user to view private media attached to their course', function () { + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $customer = Customer::factory()->create(); + $customer->users()->attach($user); + + $course = Course::factory()->create(); + $testaCustomer = \Testa\Models\Customer::find($customer->id); + $testaCustomer->courses()->attach($course); + + $audio = Audio::factory()->private()->create(); + + Attachment::create([ + 'attachable_type' => (new Course)->getMorphClass(), + 'attachable_id' => $course->id, + 'media_type' => (new Audio)->getMorphClass(), + 'media_id' => $audio->id, + 'position' => 0, + ]); + + expect($this->policy->view($user, $audio))->toBeTrue(); + }); + + it('allows enrolled user to view private media attached to their course module', function () { + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $customer = Customer::factory()->create(); + $customer->users()->attach($user); + + $course = Course::factory()->create(); + $testaCustomer = \Testa\Models\Customer::find($customer->id); + $testaCustomer->courses()->attach($course); + + $module = CourseModule::factory()->create([ + 'course_id' => $course->id, + ]); + + $video = Video::factory()->private()->create(); + + Attachment::create([ + 'attachable_type' => (new CourseModule)->getMorphClass(), + 'attachable_id' => $module->id, + 'media_type' => (new Video)->getMorphClass(), + 'media_id' => $video->id, + 'position' => 0, + ]); + + expect($this->policy->view($user, $video))->toBeTrue(); + }); + + it('denies access to private media when user is enrolled in different course', function () { + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $customer = Customer::factory()->create(); + $customer->users()->attach($user); + + $enrolledCourse = Course::factory()->create(); + $otherCourse = Course::factory()->create(); + $testaCustomer = \Testa\Models\Customer::find($customer->id); + $testaCustomer->courses()->attach($enrolledCourse); + + $audio = Audio::factory()->private()->create(); + + Attachment::create([ + 'attachable_type' => (new Course)->getMorphClass(), + 'attachable_id' => $otherCourse->id, + 'media_type' => (new Audio)->getMorphClass(), + 'media_id' => $audio->id, + 'position' => 0, + ]); + + expect($this->policy->view($user, $audio))->toBeFalse(); + }); + + it('allows public media view for authenticated user', function () { + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $audio = Audio::factory()->public()->create(); + + expect($this->policy->view($user, $audio))->toBeTrue(); + }); + + it('denies access to private media with no attachments', function () { + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $customer = Customer::factory()->create(); + $customer->users()->attach($user); + + $audio = Audio::factory()->private()->create(); + + expect($this->policy->view($user, $audio))->toBeFalse(); + }); +}); diff --git a/tests/Feature/Storefront/Http/Controllers/ProcessPaymentControllerTest.php b/tests/Feature/Storefront/Http/Controllers/ProcessPaymentControllerTest.php new file mode 100644 index 0000000..d1fc244 --- /dev/null +++ b/tests/Feature/Storefront/Http/Controllers/ProcessPaymentControllerTest.php @@ -0,0 +1,229 @@ +dropColumn('name'); + $table->string('first_name')->after('id'); + $table->string('last_name')->after('first_name'); + }); + + config(['auth.providers.users.model' => \Testa\Tests\Stubs\User::class]); + + $this->language = Language::factory()->create(['default' => true]); + $this->currency = Currency::factory()->create([ + 'default' => true, + 'decimal_places' => 2, + 'exchange_rate' => 1, + ]); + $this->channel = Channel::factory()->create(['default' => true]); + $this->taxClass = TaxClass::factory()->create(['default' => true]); + $this->customerGroup = CustomerGroup::factory()->create(['default' => true]); + + $this->country = Country::factory()->create(); + $this->taxZone = TaxZone::factory()->create(['default' => true, 'zone_type' => 'country']); + TaxZoneCountry::factory()->create([ + 'tax_zone_id' => $this->taxZone->id, + 'country_id' => $this->country->id, + ]); + $this->taxRate = TaxRate::factory()->create(['tax_zone_id' => $this->taxZone->id]); + TaxRateAmount::factory()->create([ + 'tax_rate_id' => $this->taxRate->id, + 'tax_class_id' => $this->taxClass->id, + 'percentage' => 21, + ]); + + $userModel = config('auth.providers.users.model'); + $userModel::unguard(); + $this->user = $userModel::create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $userModel::reguard(); + + $this->customer = \Lunar\Models\Customer::factory()->create(); + $this->customer->users()->attach($this->user); + + $this->productType = ProductType::factory()->create(); + $this->product = Product::factory()->create([ + 'product_type_id' => $this->productType->id, + ]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'tax_class_id' => $this->taxClass->id, + 'purchasable' => 'always', + 'shippable' => false, + ]); + Price::factory()->create([ + 'priceable_type' => ProductVariant::morphName(), + 'priceable_id' => $this->variant->id, + 'currency_id' => $this->currency->id, + 'min_quantity' => 1, + 'price' => 1000, + ]); +}); + +function createCartWithBilling($user, $currency, $channel, $country, $variant, array $meta = []): Cart +{ + $cart = Cart::create(array_filter([ + 'user_id' => $user->id, + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'meta' => $meta ?: null, + ])); + $cart->add($variant, 1); + + $billing = new CartAddress; + $billing->first_name = 'Test'; + $billing->last_name = 'User'; + $billing->country_id = $country->id; + $billing->city = 'Madrid'; + $billing->postcode = '28001'; + $billing->line_one = 'Test Street 1'; + $cart->setBillingAddress($billing); + + $cart->calculate(); + + return $cart; +} + +describe('ProcessPaymentController', function () { + it('returns 403 when user does not own the cart', function () { + $otherUserModel = config('auth.providers.users.model'); + $otherUserModel::unguard(); + $otherUser = $otherUserModel::create([ + 'first_name' => 'Other', + 'last_name' => 'User', + 'email' => 'other@example.com', + 'password' => bcrypt('password'), + ]); + $otherUserModel::reguard(); + + $cart = createCartWithBilling( + $this->user, $this->currency, $this->channel, $this->country, $this->variant + ); + + $this->actingAs($otherUser) + ->get(route('testa.storefront.checkout.process-payment', [ + 'id' => $cart->id, + 'fingerprint' => $cart->fingerprint(), + 'payment' => 'card', + ])) + ->assertForbidden(); + }); + + it('redirects with error when fingerprint does not match', function () { + $cart = createCartWithBilling( + $this->user, $this->currency, $this->channel, $this->country, $this->variant + ); + + $this->actingAs($this->user) + ->get(route('testa.storefront.checkout.process-payment', [ + 'id' => $cart->id, + 'fingerprint' => 'invalid-fingerprint', + 'payment' => 'card', + ])) + ->assertRedirect() + ->assertSessionHasErrors('fingerprint'); + }); + + it('redirects with error when payment type adapter is not found', function () { + config(['lunar.payments.types.unknown_type.driver' => 'nonexistent-driver']); + + $cart = createCartWithBilling( + $this->user, $this->currency, $this->channel, $this->country, $this->variant + ); + + $this->actingAs($this->user) + ->get(route('testa.storefront.checkout.process-payment', [ + 'id' => $cart->id, + 'fingerprint' => $cart->fingerprint(), + 'payment' => 'unknown_type', + ])) + ->assertRedirect() + ->assertSessionHasErrors('payment'); + }); + + it('returns 404 when cart does not exist', function () { + $this->actingAs($this->user) + ->get(route('testa.storefront.checkout.process-payment', [ + 'id' => 99999, + 'fingerprint' => 'irrelevant', + 'payment' => 'card', + ])) + ->assertNotFound(); + }); + + it('requires authentication', function () { + $cart = Cart::create([ + 'user_id' => $this->user->id, + 'currency_id' => $this->currency->id, + 'channel_id' => $this->channel->id, + ]); + + $this->get(route('testa.storefront.checkout.process-payment', [ + 'id' => $cart->id, + 'fingerprint' => 'test', + 'payment' => 'card', + ])) + ->assertRedirect(); + }); +}); + +describe('ProcessPaymentController route mapping', function () { + it('redirects to correct checkout route on fingerprint mismatch for bookshop order', function () { + $cart = createCartWithBilling( + $this->user, $this->currency, $this->channel, $this->country, $this->variant, + ['Tipo de pedido' => 'Pedido librería'] + ); + + $this->actingAs($this->user) + ->get(route('testa.storefront.checkout.process-payment', [ + 'id' => $cart->id, + 'fingerprint' => 'wrong-fingerprint', + 'payment' => 'card', + ])) + ->assertRedirect(route('testa.storefront.checkout.shipping-and-payment')) + ->assertSessionHasErrors('fingerprint'); + }); + + it('redirects to donation checkout route on fingerprint mismatch for donation order', function () { + $cart = createCartWithBilling( + $this->user, $this->currency, $this->channel, $this->country, $this->variant, + ['Tipo de pedido' => 'Donación'] + ); + + $this->actingAs($this->user) + ->get(route('testa.storefront.checkout.process-payment', [ + 'id' => $cart->id, + 'fingerprint' => 'wrong-fingerprint', + 'payment' => 'card', + ])) + ->assertRedirect(route('testa.storefront.membership.donate')) + ->assertSessionHasErrors('fingerprint'); + }); +});