A tests, I product action

This commit is contained in:
a6a2f5842 2025-11-29 20:09:19 +01:00
parent c6c159a4ff
commit a4fedcdb58
10 changed files with 376 additions and 149 deletions

View File

@ -60,13 +60,21 @@ $product = Product::create([
'slug' => 'amazing-t-shirt',
'sku' => 'TSH-001',
'type' => 'simple',
'price' => 29.99,
'regular_price' => 29.99,
'manage_stock' => true,
'stock_quantity' => 100,
'status' => 'published',
]);
$product->prices()->create([
'currency' => 'USD',
'unit_amount' => 1999, // $19.99
'sale_unit_amount' => 1499, // $14.99
'is_default' => true,
]);
$product->stocks()->create([
'quantity' => 100,
]);
// Add translated name
$product->setLocalized('name', 'Amazing T-Shirt', 'en');
$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en');

View File

@ -3,16 +3,17 @@
return [
// Table names (customizable for multi-tenancy)
'tables' => [
'products' => 'products',
'product_prices' => 'product_prices',
'product_categories' => 'product_categories',
'cart_items' => 'cart_items',
'carts' => 'carts',
'payment_methods' => 'payment_methods',
'payment_provider_identities' => 'payment_provider_identities',
'product_action_runs' => 'product_action_runs',
'product_attributes' => 'product_attributes',
'product_categories' => 'product_categories',
'product_prices' => 'product_prices',
'product_purchases' => 'product_purchases',
'product_stocks' => 'product_stocks',
'carts' => 'carts',
'cart_items' => 'cart_items',
'payment_provider_identities' => 'payment_provider_identities',
'payment_methods' => 'payment_methods',
'products' => 'products',
],
// Model classes (allow overriding in main instance)

View File

@ -98,7 +98,7 @@ return new class extends Migration
// Product category pivot table
if (!Schema::hasTable(config('shop.tables.product_category_product', 'product_category_product'))) {
Schema::create(config('shop.tables.product_category_product', 'product_category_product'), function (Blueprint $table) {
$table->uuid('product_id');
$table->uuid('product_id')->nullable();
$table->uuid('product_category_id');
$table->integer('sort_order')->default(0);
$table->timestamps();
@ -210,15 +210,20 @@ return new class extends Migration
Schema::create(config('shop.tables.product_actions', 'product_actions'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('product_id');
$table->string('action_type');
$table->string('event')->default('purchased'); // purchased, refunded, etc.
$table->json('events')->default('[]'); // e.g. ["purchased","paid","refunded","viewed"]
$table->string('class'); // e.g. \App\...
$table->string('method')->nullable(); // null means __invoke via constructor params
$table->boolean('defer')->default(true); // queued when true, sync otherwise
$table->json('parameters')->nullable();
$table->boolean('active')->default(true);
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['product_id', 'event', 'active']);
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
$table->index(['product_id', 'active']);
$table->foreign('product_id')
->references('id')
->on(config('shop.tables.products', 'products'))
->nullOnDelete();
});
}
@ -338,6 +343,18 @@ return new class extends Migration
->onDelete('cascade');
});
}
// ProductActionRuns table
if (!Schema::hasTable(config('shop.tables.product_action_runs', 'product_action_runs'))) {
Schema::create(config('shop.tables.product_action_runs', 'product_action_runs'), function (Blueprint $table) {
$table->id();
$table->morphs('action');
$table->unsignedBigInteger('product_purchase_id')->nullable();
$table->boolean('success')->default(false);
$table->timestamps();
$table->foreign('product_purchase_id')->references('id')->on(config('shop.tables.product_purchases', 'product_purchases'))->onDelete('set null');
});
}
}
/**
@ -350,6 +367,7 @@ return new class extends Migration
Schema::dropIfExists(config('shop.tables.cart_discounts', 'cart_discounts'));
Schema::dropIfExists(config('shop.tables.cart_items', 'cart_items'));
Schema::dropIfExists(config('shop.tables.carts', 'carts'));
Schema::dropIfExists(config('shop.tables.product_action_runs', 'product_action_runs'));
Schema::dropIfExists(config('shop.tables.product_purchases', 'product_purchases'));
Schema::dropIfExists(config('shop.tables.product_actions', 'product_actions'));
Schema::dropIfExists(config('shop.tables.product_category_product', 'product_category_product'));
@ -358,7 +376,7 @@ return new class extends Migration
Schema::dropIfExists(config('shop.tables.product_stocks', 'product_stocks'));
Schema::dropIfExists(config('shop.tables.product_attributes', 'product_attributes'));
Schema::dropIfExists(config('shop.tables.product_prices', 'product_prices'));
Schema::dropIfExists('product_category_product');
Schema::dropIfExists(config('shop.tables.product_category_product', 'product_category_product'));
Schema::dropIfExists(config('shop.tables.product_categories', 'product_categories'));
Schema::dropIfExists(config('shop.tables.products', 'products'));
}

View File

@ -316,22 +316,19 @@ class Product extends Model implements Purchasable, Cartable
]);
}
public function syncPricesDown()
{
if (config('shop.stripe.enabled') && config('shop.stripe.sync_prices')) {
StripeService::syncProductPricesDown($this);
}
return $this;
}
public static function getAvailableActions(): array
{
return ProductAction::getAvailableActions();
}
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []): void
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = [])
{
ProductAction::callForProduct($this, $event, $productPurchase, $additionalData);
return ProductAction::callForProduct(
$this,
$event,
$productPurchase,
$additionalData
);
}
public function relatedProducts(): BelongsToMany
@ -411,7 +408,7 @@ class Product extends Model implements Purchasable, Cartable
{
$stockTable = config('shop.tables.product_stocks', 'product_stocks');
$productTable = config('shop.tables.products', 'products');
return $query->where('manage_stock', true)
->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()]);
@ -529,7 +526,7 @@ class Product extends Model implements Purchasable, Cartable
->where('product_id', $this->id);
}
public function hasPrice() : bool
public function hasPrice(): bool
{
return $this->prices()->exists();
}

View File

@ -13,16 +13,20 @@ class ProductAction extends Model
protected $fillable = [
'product_id',
'event',
'action_type',
'events',
'class',
'method',
'defer',
'parameters',
'active',
'sort_order',
];
protected $casts = [
'events' => 'array',
'parameters' => 'array',
'active' => 'boolean',
'defer' => 'boolean',
'sort_order' => 'integer',
];
@ -37,6 +41,43 @@ class ProductAction extends Model
return $this->belongsTo(config('shop.models.product', Product::class));
}
public function runs()
{
return $this->morphMany(ProductActionRun::class, 'action');
}
protected static function booted(): void
{
//
}
// Backward compatibility accessor: expose first event as 'event'
public function getEventAttribute(): ?string
{
$events = $this->events ?? [];
return is_array($events) ? ($events[0] ?? null) : null;
}
// Backward compatibility mutators for legacy fields used in tests/factories
public function setEventAttribute($value): void
{
// Ensure events array reflects provided single event
$this->events = is_null($value) ? [] : [(string) $value];
}
public function setActionTypeAttribute($value): void
{
if (is_string($value)) {
// If a fully-qualified class is passed, use it; otherwise prefix with namespace config
if (str_starts_with($value, '\\') || str_contains($value, '\\')) {
$this->class = $value;
} else {
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$this->class = $namespace . '\\' . $value;
}
}
}
public static function callForProduct(
Product $product,
string $event,
@ -44,7 +85,7 @@ class ProductAction extends Model
array $additionalData = []
): void {
$actions = $product->actions()
->where('event', $event)
->whereJsonContains('events', $event)
->where('active', true)
->orderBy('sort_order')
->get();
@ -53,21 +94,12 @@ class ProductAction extends Model
return;
}
$available_actions = self::getAvailableActions();
foreach ($actions as $action) {
$success = false;
try {
if (!isset($available_actions[$action->action_type])) {
Log::warning('Product action not found', [
'product_id' => $product->id,
'event' => $event,
'action_type' => $action->action_type,
]);
continue;
}
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$action_job = $namespace . '\\' . $action->action_type;
$class = $action->class;
$method = $action->method;
$defer = (bool) $action->defer;
$params = [
'product' => $product,
@ -77,46 +109,140 @@ class ProductAction extends Model
...$additionalData,
];
dispatch(new $action_job(...$params));
} catch (\Exception $e) {
// Skip if class is not defined
if (empty($class) || !is_string($class)) {
Log::warning('Product action class missing', [
'product_id' => $product->id,
'event' => $event,
'action_id' => $action->id ?? null,
]);
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => false,
]);
continue;
}
// Defer via queue or call synchronously
if ($defer) {
// If a method is provided, dispatch a closure job calling the static method
if ($method) {
defer(
fn() =>
dispatch(function () use ($class, $method, $params, $action, $productPurchase) {
if (!class_exists($class)) {
Log::warning('Product action class not found for deferred static call', ['class' => $class, 'method' => $method]);
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => false,
]);
}
$class::$method(...$params);
})
);
continue;
} else {
// Assume class is a Job or invokable and can be dispatched directly
if (class_exists($class)) {
dispatch(new $class(...$params));
} else {
defer(
fn() =>
dispatch(function () use ($class, $action, $productPurchase) {
Log::warning('Product action class not found for deferred job', ['class' => $class]);
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => false,
]);
})
);
continue;
}
}
// For deferred jobs, we assume success since they were dispatched
$success = true;
} else {
if ($method) {
// Call static method directly
if (class_exists($class)) {
$class::$method(...$params);
$success = true;
} else {
Log::warning('Product action class not found for static call', ['class' => $class, 'method' => $method]);
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => false,
]);
continue;
}
} else {
// Instantiate and invoke if invokable
if (class_exists($class)) {
$instance = new $class(...$params);
if (is_callable($instance)) {
$instance();
$success = true;
}
} else {
Log::warning('Product action class not found for direct instantiation', ['class' => $class]);
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => false,
]);
continue;
}
}
}
// Log successful action run
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => $success,
]);
} catch (\Throwable $e) {
Log::error('Error calling product action', [
'product_id' => $product->id,
'event' => $event,
'action_type' => $action->action_type ?? 'unknown',
'class' => $action->class ?? 'unknown',
'method' => $action->method ?? null,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// Log failed action run
ProductActionRun::create([
'action_id' => $action->id,
'action_type' => ProductAction::class,
'product_purchase_id' => $productPurchase?->id,
'success' => false,
]);
report($e);
}
}
}
public function execute(
Product $product,
?ProductPurchase $productPurchase = null,
array $additionalData = []
): void {
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$action_job = $namespace . '\\' . $this->action_type;
if (!class_exists($action_job)) {
throw new \Exception("Action class {$action_job} not found");
}
$params = [
'product' => $product,
'productPurchase' => $productPurchase,
'event' => $this->event,
...($this->parameters ?? []),
...$additionalData,
];
dispatch(new $action_job(...$params));
}
public static function getAvailableActions(): array
{
return config('shop.actions.available', []);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ProductActionRun extends Model
{
protected $table = 'product_action_runs';
protected $fillable = [
'action_id',
'action_type',
'product_purchase_id',
'success',
];
protected $casts = [
'success' => 'boolean',
];
public function action(): MorphTo
{
return $this->morphTo();
}
public function productPurchase()
{
return $this->belongsTo(ProductPurchase::class, 'product_purchase_id');
}
}

View File

@ -78,9 +78,45 @@ class ProductPurchase extends Model
protected static function booted()
{
static::created(function ($productPurchase) {
if ($productPurchase->status === 'completed' && $product = $productPurchase->product) {
$product = ($productPurchase->purchasable instanceof Product)
? $productPurchase->purchasable
: null;
$product ??= ($productPurchase->purchasable instanceof ProductPrice)
? $productPurchase->purchasable?->product
: $product;
if ($productPurchase->status === 'completed' && $product) {
$product->callActions('purchased', $productPurchase);
}
});
// updated purchase from unpaid to paid
static::updated(function ($productPurchase) {
$product = ($productPurchase->purchasable instanceof Product)
? $productPurchase->purchasable
: null;
$product ??= ($productPurchase->purchasable instanceof ProductPrice)
? $productPurchase->purchasable?->product
: $product;
if ($productPurchase->status === 'completed' && $product) {
$product->callActions('purchased', $productPurchase);
}
});
}
public function actionRuns()
{
return $this->hasManyThrough(
ProductActionRun::class,
ProductAction::class,
'product_id', // Foreign key on ProductAction table...
'action_id', // Foreign key on ProductActionRun table...
'purchasable_id', // Local key on ProductPurchase table...
'id' // Local key on ProductAction table...
);
}
}

View File

@ -2,6 +2,7 @@
namespace Blax\Shop\Traits;
use Blax\Shop\Contracts\Purchasable;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPurchasable;

View File

@ -264,8 +264,8 @@ class HasShoppingCapabilitiesTest extends TestCase
// Create a product action
$product->actions()->create([
'event' => 'purchased',
'action_type' => 'TestAction',
'events' => ['purchased'],
'class' => 'TestAction',
'active' => true,
]);

View File

@ -18,19 +18,18 @@ class ProductActionTest extends TestCase
{
$product = Product::factory()->create();
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SendWelcomeEmail',
'active' => true,
'sort_order' => 10,
$action = $product->actions()->create([
'events' => ['purchased', 'refunded'],
'class' => 'App\\Actions\\SendWelcomeEmail',
]);
$this->assertDatabaseHas('product_actions', [
'id' => $action->id,
'product_id' => $product->id,
'event' => 'purchased',
]);
$this->assertContains('purchased', $action->events ?? []);
$this->assertContains('refunded', $action->events ?? []);
}
/** @test */
@ -40,16 +39,14 @@ class ProductActionTest extends TestCase
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SendWelcomeEmail',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\SendWelcomeEmail',
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\GrantAccess',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\GrantAccess',
]);
$this->assertCount(2, $product->fresh()->actions);
@ -62,9 +59,8 @@ class ProductActionTest extends TestCase
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'active' => true,
'event' => ['purchased'],
'class' => 'App\\Actions\\TestAction',
]);
$this->assertEquals($product->id, $action->product->id);
@ -77,11 +73,12 @@ class ProductActionTest extends TestCase
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\TestAction',
]);
$action->refresh();
$this->assertTrue($action->active);
$action->update(['active' => false]);
@ -96,14 +93,13 @@ class ProductActionTest extends TestCase
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SendEmail',
'events' => ['purchased'],
'class' => 'App\\Actions\\SendEmail',
'parameters' => [
'template' => 'welcome',
'delay' => 60,
'subject' => 'Welcome to our service',
],
'active' => true,
]);
$action = $action->fresh();
@ -120,18 +116,14 @@ class ProductActionTest extends TestCase
$action1 = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\FirstAction',
'sort_order' => 1,
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\FirstAction',
]);
$action2 = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SecondAction',
'sort_order' => 2,
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\SecondAction',
]);
$sorted = ProductAction::where('product_id', $product->id)
@ -149,20 +141,20 @@ class ProductActionTest extends TestCase
$purchasedAction = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\OnPurchase',
'events' => ['purchased'],
'class' => 'App\\Actions\\OnPurchase',
'active' => true,
]);
$refundedAction = ProductAction::create([
'product_id' => $product->id,
'event' => 'refunded',
'action_type' => 'App\\Actions\\OnRefund',
'events' => ['refunded'],
'class' => 'App\\Actions\\OnRefund',
'active' => true,
]);
$this->assertEquals('purchased', $purchasedAction->event);
$this->assertEquals('refunded', $refundedAction->event);
$this->assertContains('purchased', $purchasedAction->events);
$this->assertContains('refunded', $refundedAction->events);
}
/** @test */
@ -172,27 +164,24 @@ class ProductActionTest extends TestCase
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\OnPurchase',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\OnPurchase',
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\AnotherPurchase',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\OnPurchase',
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'refunded',
'action_type' => 'App\\Actions\\OnRefund',
'active' => true,
'events' => ['refunded'],
'class' => 'App\\Actions\\OnPurchase',
]);
$purchaseActions = ProductAction::where('product_id', $product->id)
->where('event', 'purchased')
->whereJsonContains('events', 'purchased')
->get();
$this->assertCount(2, $purchaseActions);
@ -205,15 +194,14 @@ class ProductActionTest extends TestCase
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\EnabledAction',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\EnabledAction',
]);
ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\DisabledAction',
'events' => ['purchased'],
'class' => 'App\\Actions\\DisabledAction',
'active' => false,
]);
@ -232,16 +220,14 @@ class ProductActionTest extends TestCase
ProductAction::create([
'product_id' => $product1->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\CommonAction',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\CommonAction',
]);
ProductAction::create([
'product_id' => $product2->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\CommonAction',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\CommonAction',
]);
$this->assertCount(1, $product1->actions);
@ -255,12 +241,11 @@ class ProductActionTest extends TestCase
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'events' => ['purchased'],
'class' => 'App\\Actions\\TestAction',
'parameters' => [
'key' => 'old_value'
],
'active' => true,
]);
$action->update([
@ -284,8 +269,8 @@ class ProductActionTest extends TestCase
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\TestAction',
'event' => ['purchased'],
'class' => 'App\\Actions\\TestAction',
'active' => true,
]);
@ -303,9 +288,8 @@ class ProductActionTest extends TestCase
$action = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\SimpleAction',
'active' => true,
'events' => ['purchased'],
'class' => 'App\\Actions\\SimpleAction',
]);
$this->assertNull($action->parameters);
@ -318,24 +302,24 @@ class ProductActionTest extends TestCase
$high = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\HighPriority',
'events' => ['purchased'],
'class' => 'App\\Actions\\HighPriority',
'sort_order' => 100,
'active' => true,
]);
$medium = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\MediumPriority',
'events' => ['purchased'],
'class' => 'App\\Actions\\MediumPriority',
'sort_order' => 50,
'active' => true,
]);
$low = ProductAction::create([
'product_id' => $product->id,
'event' => 'purchased',
'action_type' => 'App\\Actions\\LowPriority',
'events' => ['purchased'],
'class' => 'App\\Actions\\LowPriority',
'sort_order' => 10,
'active' => true,
]);
@ -348,4 +332,30 @@ class ProductActionTest extends TestCase
$this->assertEquals($medium->id, $ordered[1]->id);
$this->assertEquals($high->id, $ordered[2]->id);
}
/** @test */
public function it_can_be_triggered_on_purchase()
{
$user = User::factory()->create();
$product = Product::factory()
->withStocks()
->withPrices(1, 5000)
->create();
$product->actions()->create([
'events' => ['purchased'],
'class' => 'App\\Actions\\SendThankYouEmail',
'defer' => true,
]);
$product->actions()->create([
'events' => ['purchased'],
'class' => 'App\\Actions\\SendThankYouEmail',
'defer' => false,
]);
$purchase = $user->purchase($product, 1);
$this->assertEquals(1, $purchase->actionRuns()->count());
}
}