109 lines
4.1 KiB
PHP
109 lines
4.1 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
namespace Blax\Shop\Tests\Feature\Pricing;
|
|||
|
|
|
|||
|
|
use Blax\Shop\Models\Product;
|
|||
|
|
use Blax\Shop\Models\ProductPrice;
|
|||
|
|
use Blax\Shop\Models\ProductPriceTier;
|
|||
|
|
use Blax\Shop\Tests\TestCase;
|
|||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||
|
|
use PHPUnit\Framework\Attributes\Test;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The walker in ProductPrice::calculateForUsage() relies on tiers() coming
|
|||
|
|
* back in ladder order: sort_order ascending, with the unbounded tier
|
|||
|
|
* (up_to = null) always pinned to the end so it acts as the catch-all.
|
|||
|
|
*/
|
|||
|
|
class ProductPriceTiersRelationTest extends TestCase
|
|||
|
|
{
|
|||
|
|
use RefreshDatabase;
|
|||
|
|
|
|||
|
|
private function makePrice(array $overrides = []): ProductPrice
|
|||
|
|
{
|
|||
|
|
$product = Product::factory()->create();
|
|||
|
|
|
|||
|
|
return ProductPrice::factory()->create(array_merge([
|
|||
|
|
'purchasable_id' => $product->id,
|
|||
|
|
'purchasable_type' => Product::class,
|
|||
|
|
], $overrides));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[Test]
|
|||
|
|
public function tiers_relation_orders_by_sort_order(): void
|
|||
|
|
{
|
|||
|
|
$price = $this->makePrice();
|
|||
|
|
|
|||
|
|
$b = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 30, 'sort_order' => 1]);
|
|||
|
|
$a = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 10, 'sort_order' => 0]);
|
|||
|
|
$c = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 60, 'sort_order' => 2]);
|
|||
|
|
|
|||
|
|
$ids = $price->tiers->pluck('id')->all();
|
|||
|
|
|
|||
|
|
$this->assertSame([$a->id, $b->id, $c->id], $ids);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[Test]
|
|||
|
|
public function unbounded_tier_sorts_after_bounded_tiers_regardless_of_insertion_order(): void
|
|||
|
|
{
|
|||
|
|
$price = $this->makePrice();
|
|||
|
|
|
|||
|
|
// Insert the unbounded tier first (sort_order=0, the same as the
|
|||
|
|
// first bounded tier) — the orderByRaw guard should still push it
|
|||
|
|
// to the end.
|
|||
|
|
$unbounded = ProductPriceTier::factory()->create([
|
|||
|
|
'price_id' => $price->id,
|
|||
|
|
'up_to' => null,
|
|||
|
|
'unit_amount' => 999,
|
|||
|
|
'sort_order' => 99,
|
|||
|
|
]);
|
|||
|
|
$first = ProductPriceTier::factory()->create([
|
|||
|
|
'price_id' => $price->id,
|
|||
|
|
'up_to' => 14,
|
|||
|
|
'unit_amount' => 0,
|
|||
|
|
'sort_order' => 0,
|
|||
|
|
]);
|
|||
|
|
$second = ProductPriceTier::factory()->create([
|
|||
|
|
'price_id' => $price->id,
|
|||
|
|
'up_to' => 60,
|
|||
|
|
'unit_amount' => 100,
|
|||
|
|
'sort_order' => 1,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$ids = $price->tiers()->pluck('id')->all();
|
|||
|
|
|
|||
|
|
$this->assertSame([$first->id, $second->id, $unbounded->id], $ids);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[Test]
|
|||
|
|
public function calculate_for_usage_walks_tiers_in_relation_order(): void
|
|||
|
|
{
|
|||
|
|
$price = $this->makePrice(['billing_scheme' => 'tiered']);
|
|||
|
|
|
|||
|
|
// Out-of-order inserts to prove that the relation ordering — not
|
|||
|
|
// insertion order — drives the math.
|
|||
|
|
ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => null, 'unit_amount' => 200, 'sort_order' => 2]);
|
|||
|
|
ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'sort_order' => 0]);
|
|||
|
|
ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 60, 'unit_amount' => 100, 'sort_order' => 1]);
|
|||
|
|
|
|||
|
|
// 75 days: 14 free + 46×100 + 15×200 = 7600
|
|||
|
|
$this->assertSame(7600, $price->fresh()->calculateForUsage(75));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[Test]
|
|||
|
|
public function tiers_table_declares_cascade_on_delete_for_price_id(): void
|
|||
|
|
{
|
|||
|
|
// FK enforcement on SQLite under RefreshDatabase is config-sensitive
|
|||
|
|
// (transactions + PRAGMA scoping make a runtime cascade hard to
|
|||
|
|
// observe reliably). The package's contract here is structural:
|
|||
|
|
// the price_id FK should be declared with ON DELETE CASCADE so a
|
|||
|
|
// production MySQL / Postgres deployment behaves correctly.
|
|||
|
|
$migration = file_get_contents(__DIR__.'/../../../database/migrations/2025_01_01_000002_create_product_price_tiers_table.php');
|
|||
|
|
|
|||
|
|
$this->assertMatchesRegularExpression(
|
|||
|
|
'/foreignUuid\(\'price_id\'\)[^;]*cascadeOnDelete\(\)/s',
|
|||
|
|
$migration,
|
|||
|
|
'price_id should be declared with cascadeOnDelete()'
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|