laravel-shop/tests/Feature/Pricing/ProductPriceTiersRelationTe...

109 lines
4.1 KiB
PHP
Raw Permalink Normal View History

<?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()'
);
}
}