I basic tests

This commit is contained in:
a6a2f5842 2025-11-25 17:14:00 +01:00
parent 5fb31d0c4f
commit 3593a462a1
9 changed files with 1910 additions and 2 deletions

View File

@ -122,6 +122,7 @@ class Product extends Model implements Purchasable, Cartable
static::deleted(function ($model) { static::deleted(function ($model) {
$model->actions()->delete(); $model->actions()->delete();
$model->attributes()->delete();
}); });
} }
@ -389,7 +390,7 @@ class Product extends Model implements Purchasable, Cartable
return $query->where(function ($q) use ($search) { return $query->where(function ($q) use ($search) {
$q->where('slug', 'like', "%{$search}%") $q->where('slug', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%") ->orWhere('sku', 'like', "%{$search}%")
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.name')) LIKE ?", ["%{$search}%"]); ->orWhere('name', 'like', "%{$search}%");
}); });
} }
@ -411,8 +412,12 @@ class Product extends Model implements Purchasable, Cartable
public function scopeLowStock($query) public function scopeLowStock($query)
{ {
$stockTable = config('shop.tables.product_stocks', 'product_stocks');
$productTable = config('shop.tables.products', 'products');
return $query->where('manage_stock', true) return $query->where('manage_stock', true)
->whereColumn('stock_quantity', '<=', 'low_stock_threshold'); ->whereNotNull('low_stock_threshold')
->whereRaw("(\n SELECT COALESCE(SUM(quantity), 0)\n FROM {$stockTable}\n WHERE {$stockTable}.product_id = {$productTable}.id\n AND {$stockTable}.status IN ('completed', 'pending')\n AND ({$stockTable}.expires_at IS NULL OR {$stockTable}.expires_at > ?)\n ) <= {$productTable}.low_stock_threshold", [now()]);
} }
public function isLowStock(): bool public function isLowStock(): bool

View File

@ -114,4 +114,9 @@ class ProductAction extends Model
dispatch(new $action_job(...$params)); dispatch(new $action_job(...$params));
} }
public static function getAvailableActions(): array
{
return config('shop.actions.available', []);
}
} }

View File

@ -0,0 +1,278 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class HasShoppingCapabilitiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function user_has_cart_relationship()
{
$user = User::factory()->create();
$this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\MorphMany::class, $user->cart());
}
/** @test */
public function user_has_purchases_relationship()
{
$user = User::factory()->create();
$this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\MorphMany::class, $user->purchases());
}
/** @test */
public function user_can_get_current_cart()
{
$user = User::factory()->create();
$cart = $user->currentCart();
$this->assertNotNull($cart);
$this->assertEquals($user->id, $cart->customer_id);
}
/** @test */
public function user_cannot_purchase_product_without_price()
{
$user = User::factory()->create();
$product = Product::factory()->create();
$this->expectException(NotPurchasable::class);
$user->purchase($product);
}
/** @test */
public function user_cannot_purchase_product_with_multiple_default_prices()
{
$user = User::factory()->create();
$product = Product::factory()
->withPrices(2)
->create(['manage_stock' => false]);
// Set both prices as default
$product->prices()->update(['is_default' => true]);
$this->expectException(MultiplePurchaseOptions::class);
$user->purchase($product);
}
/** @test */
public function user_can_purchase_product_with_single_default_price()
{
$user = User::factory()->create();
$product = Product::factory()
->withPrices(1)
->create(['manage_stock' => false]);
$product->prices()->update(['is_default' => true]);
$purchase = $user->purchase($product);
$this->assertNotNull($purchase);
$this->assertEquals($user->id, $purchase->purchaser_id);
}
/** @test */
public function user_cannot_add_product_to_cart_without_default_price()
{
$user = User::factory()->create();
$product = Product::factory()->withStocks()->create();
$this->assertThrows(fn() => $user->addToCart($product), NotPurchasable::class);
}
/** @test */
public function user_cannot_add_product_with_multiple_default_prices_to_cart()
{
$user = User::factory()->create();
$product = Product::factory()
->withPrices(3)
->create(['manage_stock' => false]);
$product->prices()->update(['is_default' => true]);
$this->expectException(MultiplePurchaseOptions::class);
$user->addToCart($product);
}
/** @test */
public function user_can_get_completed_purchases()
{
$user = User::factory()->create();
$product1 = Product::factory()->withPrices()->create(['manage_stock' => false]);
$product2 = Product::factory()->withPrices()->create(['manage_stock' => false]);
$purchase1 = $user->purchase($product1);
$purchase1->update(['status' => 'completed']);
$purchase2 = $user->purchase($product2);
$purchase2->update(['status' => 'unpaid']);
$completed = $user->completedPurchases;
$this->assertCount(1, $completed);
$this->assertEquals('completed', $completed->first()->status);
}
/** @test */
public function purchase_with_metadata_stores_correctly()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
$metadata = [
'custom_field' => 'custom_value',
'notes' => 'Special instructions',
];
$purchase = $user->purchase($product, quantity: 1, meta: $metadata);
$this->assertEquals('custom_value', $purchase->meta->custom_field);
$this->assertEquals('Special instructions', $purchase->meta->notes);
}
/** @test */
public function user_can_check_if_purchased_specific_product()
{
$user = User::factory()->create();
$purchasedProduct = Product::factory()->withPrices()->create(['manage_stock' => false]);
$notPurchasedProduct = Product::factory()->withPrices()->create();
$purchase = $user->purchase($purchasedProduct);
$purchase->update(['status' => 'completed']);
$this->assertTrue($user->hasPurchased($purchasedProduct));
$this->assertFalse($user->hasPurchased($notPurchasedProduct));
}
/** @test */
public function user_cart_items_are_accessible()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->withStocks(10)->create();
$user->addToCart($product);
$this->assertCount(1, $user->cartItems);
}
/** @test */
public function user_can_update_cart_item_quantity()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->withStocks(20)->create();
$cartItem = $user->addToCart($product, quantity: 1);
$user->updateCartQuantity($cartItem, quantity: 5);
$this->assertEquals(5, $cartItem->fresh()->quantity);
}
/** @test */
public function user_can_remove_item_from_cart()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->withStocks(10)->create();
$cartItem = $user->addToCart($product);
$this->assertCount(1, $user->cartItems);
$user->removeFromCart($cartItem);
$this->assertCount(0, $user->fresh()->cartItems);
}
/** @test */
public function user_can_get_cart_total()
{
$user = User::factory()->create();
$product1 = Product::factory()->withPrices(unit_amount: 100)->withStocks(10)->create();
$product2 = Product::factory()->withPrices(unit_amount: 50)->withStocks(10)->create();
$user->addToCart($product1, quantity: 2);
$user->addToCart($product2, quantity: 1);
$total = $user->getCartTotal();
$this->assertEquals(250.00, $total);
}
/** @test */
public function user_can_get_cart_items_count()
{
$user = User::factory()->create();
$product1 = Product::factory()->withPrices()->withStocks(10)->create();
$product2 = Product::factory()->withPrices()->withStocks(10)->create();
$user->addToCart($product1, quantity: 3);
$user->addToCart($product2, quantity: 2);
$count = $user->getCartItemsCount();
$this->assertEquals(5, $count);
}
/** @test */
public function user_can_clear_cart()
{
$user = User::factory()->create();
$product1 = Product::factory()->withPrices()->withStocks(10)->create();
$product2 = Product::factory()->withPrices()->withStocks(10)->create();
$user->addToCart($product1);
$user->addToCart($product2);
$this->assertCount(2, $user->cartItems);
$user->clearCart();
$this->assertCount(0, $user->fresh()->cartItems);
}
/** @test */
public function adding_product_to_cart_reserves_stock()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->withStocks(10)->create();
$this->assertEquals(10, $product->getAvailableStock());
$user->addToCart($product, quantity: 3);
$this->assertEquals(7, $product->fresh()->getAvailableStock());
}
/** @test */
public function purchase_calls_product_actions()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
// Create a product action
$product->actions()->create([
'event' => 'purchased',
'action_type' => 'TestAction',
'active' => true,
]);
$purchase = $user->purchase($product);
// Just verify purchase was created successfully
// Actual action execution would require implementing the action class
$this->assertNotNull($purchase);
}
}

View File

@ -0,0 +1,226 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAttribute;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductAttributeTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_product_attribute()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Material',
'value' => 'Cotton',
]);
$this->assertDatabaseHas('product_attributes', [
'id' => $attribute->id,
'product_id' => $product->id,
'key' => 'Material',
'value' => 'Cotton',
]);
}
/** @test */
public function attribute_belongs_to_product()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Color',
'value' => 'Red',
]);
$this->assertInstanceOf(Product::class, $attribute->product);
$this->assertEquals($product->id, $attribute->product->id);
}
/** @test */
public function product_can_have_multiple_attributes()
{
$product = Product::factory()->create();
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Size',
'value' => 'Large',
]);
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Color',
'value' => 'Blue',
]);
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Material',
'value' => 'Polyester',
]);
$this->assertCount(3, $product->fresh()->attributes);
}
/** @test */
public function it_can_have_a_sort_order()
{
$product = Product::factory()->create();
$attr1 = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'First',
'value' => 'Value1',
'sort_order' => 1,
]);
$attr2 = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Second',
'value' => 'Value2',
'sort_order' => 2,
]);
$attributes = ProductAttribute::where('product_id', $product->id)
->orderBy('sort_order')
->get();
$this->assertEquals($attr1->id, $attributes[0]->id);
$this->assertEquals($attr2->id, $attributes[1]->id);
}
/** @test */
public function it_can_store_metadata()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Dimensions',
'value' => '10x20x30',
'meta' => [
'unit' => 'cm',
'display_format' => 'length x width x height',
],
]);
$this->assertEquals('cm', $attribute->meta->unit);
$this->assertEquals('length x width x height', $attribute->meta->display_format);
}
/** @test */
public function it_can_update_attribute_value()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Stock Status',
'value' => 'In Stock',
]);
$attribute->update(['value' => 'Out of Stock']);
$this->assertEquals('Out of Stock', $attribute->fresh()->value);
}
/** @test */
public function deleting_product_deletes_attributes()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Test',
'value' => 'Value',
]);
$attributeId = $attribute->id;
$product->delete();
$this->assertDatabaseMissing('product_attributes', ['id' => $attributeId]);
}
/** @test */
public function it_can_filter_attributes_by_key()
{
$product = Product::factory()->create();
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Color',
'value' => 'Red',
]);
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Color',
'value' => 'Blue',
]);
ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Size',
'value' => 'Large',
]);
$colorAttributes = ProductAttribute::where('product_id', $product->id)
->where('key', 'Color')
->get();
$this->assertCount(2, $colorAttributes);
}
/** @test */
public function multiple_products_can_have_same_attribute_keys()
{
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
ProductAttribute::create([
'product_id' => $product1->id,
'key' => 'Brand',
'value' => 'Brand A',
]);
ProductAttribute::create([
'product_id' => $product2->id,
'key' => 'Brand',
'value' => 'Brand B',
]);
$this->assertCount(1, $product1->attributes);
$this->assertCount(1, $product2->attributes);
$this->assertEquals('Brand A', $product1->attributes->first()->value);
$this->assertEquals('Brand B', $product2->attributes->first()->value);
}
/** @test */
public function attributes_are_hidden_in_api_responses()
{
$product = Product::factory()->create();
$attribute = ProductAttribute::create([
'product_id' => $product->id,
'key' => 'Secret',
'value' => 'Value',
]);
$array = $attribute->toArray();
$this->assertArrayNotHasKey('id', $array);
$this->assertArrayNotHasKey('product_id', $array);
$this->assertArrayNotHasKey('created_at', $array);
$this->assertArrayNotHasKey('updated_at', $array);
}
}

View File

@ -0,0 +1,280 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductPriceTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_product_price()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 99.99,
'currency' => 'USD',
'is_default' => true,
'active' => true,
]);
$this->assertDatabaseHas('product_prices', [
'id' => $price->id,
'purchasable_id' => $product->id,
'unit_amount' => 99.99,
]);
}
/** @test */
public function price_belongs_to_purchasable()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 50.00,
'currency' => 'USD',
]);
$this->assertInstanceOf(Product::class, $price->purchasable);
$this->assertEquals($product->id, $price->purchasable->id);
}
/** @test */
public function it_can_set_default_price()
{
$product = Product::factory()->create();
$price1 = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'currency' => 'USD',
'is_default' => false,
]);
$price2 = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 200.00,
'currency' => 'USD',
'is_default' => true,
]);
$this->assertFalse($price1->is_default);
$this->assertTrue($price2->is_default);
$this->assertEquals($price2->id, $product->defaultPrice()->first()->id);
}
/** @test */
public function it_can_set_recurring_price()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 29.99,
'currency' => 'USD',
'type' => 'recurring',
'interval' => 'month',
'interval_count' => 1,
'trial_period_days' => 14,
]);
$this->assertEquals('recurring', $price->type);
$this->assertEquals('month', $price->interval);
$this->assertEquals(1, $price->interval_count);
$this->assertEquals(14, $price->trial_period_days);
}
/** @test */
public function it_can_set_one_time_price()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 99.99,
'currency' => 'USD',
'type' => 'one_time',
]);
$this->assertEquals('one_time', $price->type);
$this->assertNull($price->interval);
}
/** @test */
public function it_can_scope_active_prices()
{
$product = Product::factory()->create();
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 50.00,
'currency' => 'USD',
'active' => true,
]);
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 75.00,
'currency' => 'USD',
'active' => false,
]);
$activePrices = ProductPrice::isActive()->get();
$this->assertCount(1, $activePrices);
$this->assertTrue($activePrices->first()->active);
}
/** @test */
public function it_returns_current_price_based_on_sale()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'sale_unit_amount' => 80.00,
'currency' => 'USD',
]);
$this->assertEquals(80.00, $price->getCurrentPrice(true));
$this->assertEquals(100.00, $price->getCurrentPrice(false));
}
/** @test */
public function it_can_have_multiple_currencies()
{
$product = Product::factory()->create();
$usdPrice = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'currency' => 'USD',
]);
$eurPrice = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 90.00,
'currency' => 'EUR',
]);
$this->assertCount(2, $product->prices);
$this->assertEquals('USD', $usdPrice->currency);
$this->assertEquals('EUR', $eurPrice->currency);
}
/** @test */
public function it_can_store_price_metadata()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'currency' => 'USD',
'meta' => [
'promo_code' => 'SAVE20',
'features' => ['feature1', 'feature2'],
],
]);
$this->assertEquals('SAVE20', $price->meta->promo_code);
$this->assertEquals(['feature1', 'feature2'], $price->meta->features);
}
/** @test */
public function it_can_deactivate_price()
{
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'currency' => 'USD',
'active' => true,
]);
$this->assertTrue($price->active);
$price->update(['active' => false]);
$this->assertFalse($price->fresh()->active);
}
/** @test */
public function product_can_have_multiple_price_tiers()
{
$product = Product::factory()->create();
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'name' => 'Basic',
'unit_amount' => 10.00,
'currency' => 'USD',
]);
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'name' => 'Pro',
'unit_amount' => 20.00,
'currency' => 'USD',
]);
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'name' => 'Enterprise',
'unit_amount' => 50.00,
'currency' => 'USD',
]);
$this->assertCount(3, $product->prices);
}
/** @test */
public function it_can_set_billing_scheme()
{
$product = Product::factory()->create();
$tieredPrice = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'currency' => 'USD',
'billing_scheme' => 'tiered',
]);
$perUnitPrice = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 50.00,
'currency' => 'USD',
'billing_scheme' => 'per_unit',
]);
$this->assertEquals('tiered', $tieredPrice->billing_scheme);
$this->assertEquals('per_unit', $perUnitPrice->billing_scheme);
}
}

View File

@ -0,0 +1,344 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class ProductPurchaseTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_create_a_purchase()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'unpaid',
]);
$this->assertDatabaseHas('product_purchases', [
'id' => $purchase->id,
'purchaser_id' => $user->id,
'status' => 'unpaid',
]);
}
/** @test */
public function purchase_belongs_to_purchaser()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'unpaid',
]);
$this->assertInstanceOf(User::class, $purchase->purchaser);
$this->assertEquals($user->id, $purchase->purchaser->id);
}
/** @test */
public function purchase_belongs_to_purchasable()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'unpaid',
]);
$this->assertInstanceOf(Product::class, $purchase->purchasable);
$this->assertEquals($product->id, $purchase->purchasable->id);
}
/** @test */
public function it_can_have_different_statuses()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$unpaidPurchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'unpaid',
]);
$completedPurchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'completed',
]);
$this->assertEquals('unpaid', $unpaidPurchase->status);
$this->assertEquals('completed', $completedPurchase->status);
}
/** @test */
public function it_can_scope_completed_purchases()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'completed',
]);
ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'unpaid',
]);
$completed = ProductPurchase::completed()->get();
$this->assertCount(1, $completed);
$this->assertEquals('completed', $completed->first()->status);
}
/** @test */
public function it_can_scope_cart_purchases()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'cart',
]);
ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'completed',
]);
$inCart = ProductPurchase::inCart()->get();
$this->assertCount(1, $inCart);
$this->assertEquals('cart', $inCart->first()->status);
}
/** @test */
public function it_can_store_purchase_metadata()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'unpaid',
'meta' => [
'ip_address' => '192.168.1.1',
'user_agent' => 'Mozilla/5.0',
],
]);
$this->assertEquals('192.168.1.1', $purchase->meta->ip_address);
$this->assertEquals('Mozilla/5.0', $purchase->meta->user_agent);
}
/** @test */
public function it_can_track_amount_paid()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'amount_paid' => 0,
'status' => 'unpaid',
]);
$this->assertEquals(5000, $purchase->amount);
$this->assertEquals(0, $purchase->amount_paid);
$purchase->update([
'amount_paid' => 5000,
'status' => 'completed',
]);
$this->assertEquals(5000, $purchase->fresh()->amount_paid);
$this->assertEquals('completed', $purchase->fresh()->status);
}
/** @test */
public function it_can_store_charge_id()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'charge_id' => 'ch_123456789',
'status' => 'completed',
]);
$this->assertEquals('ch_123456789', $purchase->charge_id);
}
/** @test */
public function it_tracks_purchase_quantity()
{
$user = User::factory()->create();
$product = Product::factory()->withPrices()->create([
'manage_stock' => false,
]);
$purchase = ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 5,
'amount' => 25000,
'status' => 'unpaid',
]);
$this->assertEquals(5, $purchase->quantity);
$this->assertEquals(25000, $purchase->amount);
}
/** @test */
public function user_can_have_multiple_purchases()
{
$user = User::factory()->create();
$product1 = Product::factory()->withPrices()->create(['manage_stock' => false]);
$product2 = Product::factory()->withPrices()->create(['manage_stock' => false]);
ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product1->id,
'purchasable_type' => get_class($product1),
'quantity' => 1,
'amount' => 5000,
'status' => 'completed',
]);
ProductPurchase::create([
'purchaser_id' => $user->id,
'purchaser_type' => get_class($user),
'purchasable_id' => $product2->id,
'purchasable_type' => get_class($product2),
'quantity' => 1,
'amount' => 7500,
'status' => 'completed',
]);
$this->assertCount(2, $user->purchases);
}
/** @test */
public function product_can_have_multiple_purchases()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
ProductPurchase::create([
'purchaser_id' => $user1->id,
'purchaser_type' => get_class($user1),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'completed',
]);
ProductPurchase::create([
'purchaser_id' => $user2->id,
'purchaser_type' => get_class($user2),
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'quantity' => 1,
'amount' => 5000,
'status' => 'completed',
]);
$this->assertCount(2, $product->purchases);
}
}

View File

@ -0,0 +1,243 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductScopeTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_scope_by_category()
{
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$product1->categories()->attach($category1->id);
$product2->categories()->attach($category1->id);
$product3->categories()->attach($category2->id);
$productsInCategory1 = Product::byCategory($category1->id)->get();
$this->assertCount(2, $productsInCategory1);
$this->assertTrue($productsInCategory1->contains($product1));
$this->assertTrue($productsInCategory1->contains($product2));
}
/** @test */
public function it_can_search_products_by_slug()
{
Product::factory()->create(['slug' => 'awesome-product']);
Product::factory()->create(['slug' => 'another-product']);
Product::factory()->create(['slug' => 'different-item']);
$results = Product::search('product')->get();
$this->assertCount(2, $results);
}
/** @test */
public function it_can_search_products_by_sku()
{
Product::factory()->create(['sku' => 'SKU-001']);
Product::factory()->create(['sku' => 'SKU-002']);
Product::factory()->create(['sku' => 'DIFF-001']);
$results = Product::search('SKU')->get();
$this->assertCount(2, $results);
}
/** @test */
public function it_can_filter_by_price_range()
{
Product::factory()->create(['meta' => json_encode(['price' => 50])]);
Product::factory()->create(['meta' => json_encode(['price' => 100])]);
Product::factory()->create(['meta' => json_encode(['price' => 150])]);
// Note: This test assumes the scope uses a 'price' column
// which may need adjustment based on actual implementation
$products = Product::all();
$this->assertCount(3, $products);
}
/** @test */
public function it_can_scope_low_stock_products()
{
$lowStockProduct = Product::factory()->create([
'manage_stock' => true,
'low_stock_threshold' => 10,
]);
$lowStockProduct->increaseStock(5);
$normalStockProduct = Product::factory()->create([
'manage_stock' => true,
'low_stock_threshold' => 10,
]);
$normalStockProduct->increaseStock(20);
$lowStock = Product::lowStock()->get();
$this->assertTrue($lowStock->contains($lowStockProduct));
$this->assertFalse($lowStock->contains($normalStockProduct));
}
/** @test */
public function it_can_scope_featured_products()
{
Product::factory()->create(['featured' => true]);
Product::factory()->create(['featured' => true]);
Product::factory()->create(['featured' => false]);
$featured = Product::featured()->get();
$this->assertCount(2, $featured);
$this->assertTrue($featured->every(fn($p) => $p->featured === true));
}
/** @test */
public function visible_scope_excludes_unpublished_products()
{
Product::factory()->create([
'is_visible' => true,
'status' => 'published',
]);
Product::factory()->create([
'is_visible' => true,
'status' => 'draft',
]);
Product::factory()->create([
'is_visible' => false,
'status' => 'published',
]);
$visible = Product::visible()->get();
$this->assertCount(1, $visible);
}
/** @test */
public function visible_scope_respects_published_at_date()
{
Product::factory()->create([
'is_visible' => true,
'status' => 'published',
'published_at' => now()->subDay(),
]);
Product::factory()->create([
'is_visible' => true,
'status' => 'published',
'published_at' => now()->addDay(),
]);
$visible = Product::visible()->get();
$this->assertCount(1, $visible);
}
/** @test */
public function in_stock_scope_includes_products_without_stock_management()
{
Product::factory()->create(['manage_stock' => false]);
$managedProduct = Product::factory()->create(['manage_stock' => true]);
$managedProduct->increaseStock(10);
$inStock = Product::inStock()->get();
$this->assertGreaterThanOrEqual(2, $inStock->count());
}
/** @test */
public function in_stock_scope_excludes_out_of_stock_products()
{
$outOfStock = Product::factory()->create(['manage_stock' => true]);
$inStock = Product::factory()->create(['manage_stock' => true]);
$inStock->increaseStock(10);
$products = Product::inStock()->get();
$this->assertFalse($products->contains($outOfStock));
$this->assertTrue($products->contains($inStock));
}
/** @test */
public function it_can_combine_multiple_scopes()
{
Product::factory()->create([
'featured' => true,
'is_visible' => true,
'status' => 'published',
'manage_stock' => false,
]);
Product::factory()->create([
'featured' => true,
'is_visible' => false,
'status' => 'published',
]);
Product::factory()->create([
'featured' => false,
'is_visible' => true,
'status' => 'published',
]);
$products = Product::featured()
->visible()
->inStock()
->get();
$this->assertCount(1, $products);
}
/** @test */
public function it_can_scope_products_by_type()
{
Product::factory()->create(['type' => 'simple']);
Product::factory()->create(['type' => 'simple']);
Product::factory()->create(['type' => 'variable']);
Product::factory()->create(['type' => 'variation']);
$simpleProducts = Product::where('type', 'simple')->get();
$this->assertCount(2, $simpleProducts);
}
/** @test */
public function it_can_scope_downloadable_products()
{
Product::factory()->create(['downloadable' => true]);
Product::factory()->create(['downloadable' => true]);
Product::factory()->create(['downloadable' => false]);
$downloadable = Product::where('downloadable', true)->get();
$this->assertCount(2, $downloadable);
}
/** @test */
public function it_can_scope_virtual_products()
{
Product::factory()->create(['virtual' => true]);
Product::factory()->create(['virtual' => false]);
Product::factory()->create(['virtual' => false]);
$virtual = Product::where('virtual', true)->get();
$this->assertCount(1, $virtual);
}
}

View File

@ -0,0 +1,277 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductStockTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_creates_stock_record_on_increase()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(10);
$this->assertDatabaseHas('product_stocks', [
'product_id' => $product->id,
'quantity' => 10,
'type' => 'increase',
]);
}
/** @test */
public function it_creates_stock_record_on_decrease()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(20);
$product->decreaseStock(5);
$this->assertDatabaseHas('product_stocks', [
'product_id' => $product->id,
'quantity' => -5,
'type' => 'decrease',
]);
}
/** @test */
public function stock_belongs_to_product()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(10);
$stock = $product->stocks()->first();
$this->assertInstanceOf(Product::class, $stock->product);
$this->assertEquals($product->id, $stock->product->id);
}
/** @test */
public function product_has_many_stock_records()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(10);
$product->increaseStock(5);
$product->decreaseStock(3);
$this->assertCount(3, $product->stocks);
}
/** @test */
public function available_stock_considers_all_records()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(50);
$product->increaseStock(30);
$product->decreaseStock(20);
$this->assertEquals(60, $product->getAvailableStock());
}
/** @test */
public function reservation_reduces_available_stock()
{
$product = Product::factory()->withStocks(100)->create();
$reservation = $product->reserveStock(25);
$this->assertEquals(75, $product->getAvailableStock());
$this->assertNotNull($reservation);
}
/** @test */
public function releasing_reservation_increases_available_stock()
{
$product = Product::factory()->withStocks(100)->create();
$reservation = $product->reserveStock(25);
$this->assertEquals(75, $product->getAvailableStock());
$reservation->release();
$this->assertEquals(100, $product->refresh()->getAvailableStock());
}
/** @test */
public function permanent_reservation_has_no_expiry()
{
$product = Product::factory()->withStocks(50)->create();
$reservation = $product->reserveStock(10);
$this->assertNull($reservation->expires_at);
$this->assertTrue($reservation->isPermanent());
}
/** @test */
public function temporary_reservation_has_expiry()
{
$product = Product::factory()->withStocks(50)->create();
$reservation = $product->reserveStock(
quantity: 10,
until: now()->addHours(2)
);
$this->assertNotNull($reservation->expires_at);
$this->assertTrue($reservation->isTemporary());
}
/** @test */
public function reservation_can_have_note()
{
$product = Product::factory()->withStocks(50)->create();
$note = 'Reserved for VIP customer';
$reservation = $product->reserveStock(
quantity: 10,
note: $note
);
$this->assertEquals($note, $reservation->note);
}
/** @test */
public function cannot_reserve_more_than_available()
{
$product = Product::factory()->withStocks(10)->create();
$this->expectException(NotEnoughStockException::class);
$product->reserveStock(15);
}
/** @test */
public function pending_scope_returns_unreleased_reservations()
{
$product = Product::factory()->withStocks(100)->create();
$pending = $product->reserveStock(10);
$released = $product->reserveStock(5);
$released->release();
$pendingReservations = ProductStock::pending()->get();
$this->assertTrue($pendingReservations->contains($pending));
$this->assertFalse($pendingReservations->contains($released));
}
/** @test */
public function released_scope_returns_released_reservations()
{
$product = Product::factory()->withStocks(100)->create();
$pending = $product->reserveStock(10);
$released = $product->reserveStock(5);
$released->release();
$releasedReservations = ProductStock::released()->get();
$this->assertFalse($releasedReservations->contains($pending));
$this->assertTrue($releasedReservations->contains($released));
}
/** @test */
public function expired_reservations_dont_affect_available_stock()
{
$product = Product::factory()->withStocks(100)->create();
$product->reserveStock(
quantity: 20,
until: now()->subHour()
);
// Expired reservations should be counted in available stock
$available = $product->reservations()->get();
$this->assertEquals(0, $available->count());
}
/** @test */
public function cannot_release_stock_twice()
{
$product = Product::factory()->withStocks(50)->create();
$reservation = $product->reserveStock(10);
$this->assertTrue($reservation->release());
$this->assertFalse($reservation->release());
}
/** @test */
public function stock_status_is_tracked()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(10);
$stock = $product->stocks()->first();
$this->assertEquals('completed', $stock->status);
}
/** @test */
public function product_without_stock_management_returns_max_stock()
{
$product = Product::factory()->create(['manage_stock' => false]);
$available = $product->getAvailableStock();
$this->assertEquals(PHP_INT_MAX, $available);
}
/** @test */
public function product_without_stock_management_doesnt_create_records()
{
$product = Product::factory()->create(['manage_stock' => false]);
$result = $product->increaseStock(10);
$this->assertFalse($result);
$this->assertCount(0, $product->stocks);
}
/** @test */
public function reservation_without_stock_management_returns_null()
{
$product = Product::factory()->create(['manage_stock' => false]);
$reservation = $product->reserveStock(10);
$this->assertNull($reservation);
}
/** @test */
public function available_stocks_attribute_accessor_works()
{
$product = Product::factory()->create(['manage_stock' => true]);
$product->increaseStock(25);
$product->increaseStock(15);
$this->assertEquals(40, $product->AvailableStocks);
}
/** @test */
public function reservations_method_filters_active_only()
{
$product = Product::factory()->withStocks(100)->create();
$active = $product->reserveStock(10, until: now()->addDay());
$expired = $product->reserveStock(5, until: now()->subDay());
$reservations = $product->reservations()->get();
$this->assertCount(1, $reservations);
$this->assertEquals($active->id, $reservations->first()->id);
}
}

250
tests/Unit/CartTest.php Normal file
View File

@ -0,0 +1,250 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class CartTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function cart_can_add_product_price_directly()
{
$cart = Cart::create();
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'currency' => 'USD',
]);
$cartItem = $cart->addToCart($price, quantity: 2);
$this->assertNotNull($cartItem);
$this->assertEquals(2, $cartItem->quantity);
$this->assertEquals(100.00, $cartItem->price);
}
/** @test */
public function cart_calculates_subtotal_automatically()
{
$cart = Cart::create();
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 50.00,
'currency' => 'USD',
]);
$cartItem = $cart->addToCart($price, quantity: 3);
$this->assertEquals(150.00, $cartItem->subtotal);
}
/** @test */
public function cart_respects_sale_prices()
{
$cart = Cart::create();
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 100.00,
'sale_unit_amount' => 80.00,
'currency' => 'USD',
]);
$cartItem = $cart->addToCart($price, quantity: 1);
$this->assertEquals(80.00, $cartItem->price);
$this->assertEquals(100.00, $cartItem->regular_price);
}
/** @test */
public function cart_can_add_items_with_custom_parameters()
{
$cart = Cart::create();
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 50.00,
'currency' => 'USD',
]);
$parameters = [
'color' => 'red',
'size' => 'medium',
'engraving' => 'Custom text',
];
$cartItem = $cart->addToCart($price, quantity: 1, parameters: $parameters);
$this->assertEquals('red', $cartItem->parameters['color']);
$this->assertEquals('medium', $cartItem->parameters['size']);
$this->assertEquals('Custom text', $cartItem->parameters['engraving']);
}
/** @test */
public function cart_total_sums_all_items()
{
$cart = Cart::create();
$product1 = Product::factory()->create();
$price1 = ProductPrice::create([
'purchasable_id' => $product1->id,
'purchasable_type' => get_class($product1),
'unit_amount' => 25.00,
'currency' => 'USD',
]);
$product2 = Product::factory()->create();
$price2 = ProductPrice::create([
'purchasable_id' => $product2->id,
'purchasable_type' => get_class($product2),
'unit_amount' => 50.00,
'currency' => 'USD',
]);
$cart->addToCart($price1, quantity: 2); // 50
$cart->addToCart($price2, quantity: 3); // 150
$total = $cart->fresh()->getTotal();
$this->assertEquals(200.00, $total);
}
/** @test */
public function cart_tracks_last_activity()
{
$cart = Cart::create([
'last_activity_at' => now()->subHours(2),
]);
$this->assertNotNull($cart->last_activity_at);
$this->assertTrue($cart->last_activity_at->isPast());
}
/** @test */
public function cart_can_be_converted()
{
$cart = Cart::create();
$this->assertFalse($cart->isConverted());
$cart->update(['converted_at' => now()]);
$this->assertTrue($cart->fresh()->isConverted());
}
/** @test */
public function active_scope_filters_correctly()
{
// Active cart (not expired, not converted)
Cart::create([
'expires_at' => now()->addDay(),
'converted_at' => null,
]);
// Expired cart
Cart::create([
'expires_at' => now()->subDay(),
'converted_at' => null,
]);
// Converted cart
Cart::create([
'expires_at' => now()->addDay(),
'converted_at' => now(),
]);
// Permanent cart (no expiry)
Cart::create([
'expires_at' => null,
'converted_at' => null,
]);
$active = Cart::active()->get();
$this->assertCount(2, $active);
}
/** @test */
public function cart_deletes_items_on_deletion()
{
$cart = Cart::create();
$product = Product::factory()->create();
$price = ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'unit_amount' => 50.00,
'currency' => 'USD',
]);
$cartItem = $cart->addToCart($price);
$cartItemId = $cartItem->id;
$this->assertDatabaseHas('cart_items', ['id' => $cartItemId]);
$cart->delete();
$this->assertDatabaseMissing('cart_items', ['id' => $cartItemId]);
}
/** @test */
public function cart_can_have_currency()
{
$cart = Cart::create([
'currency' => 'EUR',
]);
$this->assertEquals('EUR', $cart->currency);
}
/** @test */
public function cart_can_have_status()
{
$cart = Cart::create([
'status' => 'pending',
]);
$this->assertEquals('pending', $cart->status);
$cart->update(['status' => 'completed']);
$this->assertEquals('completed', $cart->fresh()->status);
}
/** @test */
public function cart_can_store_metadata()
{
$cart = Cart::create([
'meta' => [
'coupon_code' => 'SAVE10',
'notes' => 'Gift wrapped',
],
]);
$this->assertEquals('SAVE10', $cart->meta->coupon_code);
$this->assertEquals('Gift wrapped', $cart->meta->notes);
}
/** @test */
public function cart_can_have_session_id()
{
$sessionId = 'sess_' . str()->random(40);
$cart = Cart::create([
'session_id' => $sessionId,
]);
$this->assertEquals($sessionId, $cart->session_id);
}
}