diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php index f5197d1200..40f65a7e62 100644 --- a/packages/core/src/Models/Cart.php +++ b/packages/core/src/Models/Cart.php @@ -214,7 +214,7 @@ protected static function newFactory() public function lines(): HasMany { - return $this->hasMany(CartLine::modelClass(), 'cart_id', 'id'); + return $this->hasMany(CartLine::modelClass(), 'cart_id', 'id')->orderBy('id'); } public function currency(): BelongsTo diff --git a/packages/core/src/Models/Order.php b/packages/core/src/Models/Order.php index 62a73bf532..ebaac2fd61 100644 --- a/packages/core/src/Models/Order.php +++ b/packages/core/src/Models/Order.php @@ -98,7 +98,7 @@ public function cart(): BelongsTo public function lines(): HasMany { - return $this->hasMany(OrderLine::modelClass()); + return $this->hasMany(OrderLine::modelClass())->orderBy('id'); } public function physicalLines(): HasMany diff --git a/packages/core/src/Models/ProductVariant.php b/packages/core/src/Models/ProductVariant.php index 41adceb682..5e4ef7d43a 100644 --- a/packages/core/src/Models/ProductVariant.php +++ b/packages/core/src/Models/ProductVariant.php @@ -106,7 +106,9 @@ public function values(): BelongsToMany "{$prefix}product_option_value_product_variant", 'variant_id', 'value_id' - )->withTimestamps(); + )->withTimestamps() + ->orderBy('position') + ->orderByPivot('id'); } public function getPrices(): Collection diff --git a/tests/core/Unit/Models/CartTest.php b/tests/core/Unit/Models/CartTest.php index 18bab4d905..3c79ecd073 100644 --- a/tests/core/Unit/Models/CartTest.php +++ b/tests/core/Unit/Models/CartTest.php @@ -1330,3 +1330,33 @@ ->and($foundCart->id)->not->toBe($mergedCart->id) ->and($foundCart->merged_id)->toBeNull(); }); + +test('orders cart lines by id', function () { + $cart = Cart::factory()->create(); + + // Cart::lines() must carry an explicit ordering contract. Without an + // ORDER BY, row order is undefined by the SQL standard: MySQL/InnoDB + // returns clustered primary-key order by coincidence, but PostgreSQL + // returns heap order, which changes after an UPDATE. Lunar relies on a + // stable line sequence (e.g. GenerateFingerprint reduces $cart->lines in + // iteration order), so it must be deterministic across engines. + expect($cart->lines()->toBase()->orders) + ->toBe([['column' => 'id', 'direction' => 'asc']]); +}); + +test('can retrieve cart lines in ascending id order', function () { + $currency = Currency::factory()->create(); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + ]); + + $lines = CartLine::factory() + ->count(5) + ->create(['cart_id' => $cart->id]); + + $expectedOrder = $lines->pluck('id')->sort()->values()->all(); + + expect($cart->load('lines')->lines->pluck('id')->all()) + ->toBe($expectedOrder); +}); diff --git a/tests/core/Unit/Models/OrderTest.php b/tests/core/Unit/Models/OrderTest.php index ead351e031..2196347d79 100644 --- a/tests/core/Unit/Models/OrderTest.php +++ b/tests/core/Unit/Models/OrderTest.php @@ -300,3 +300,26 @@ 'id' => $order->id, ]); }); + +test('orders order lines by id', function () { + $order = Order::factory()->create(); + + // Order::lines() must carry an explicit ordering contract so line order is + // deterministic across database engines. PostgreSQL does not return rows in + // insertion order without an ORDER BY, which shifts invoice/history display. + expect($order->lines()->toBase()->orders) + ->toBe([['column' => 'id', 'direction' => 'asc']]); +}); + +test('can retrieve order lines in ascending id order', function () { + $order = Order::factory()->create(); + + $lines = OrderLine::factory() + ->count(5) + ->create(['order_id' => $order->id]); + + $expectedOrder = $lines->pluck('id')->sort()->values()->all(); + + expect($order->load('lines')->lines->pluck('id')->all()) + ->toBe($expectedOrder); +}); diff --git a/tests/core/Unit/Models/ProductVariantTest.php b/tests/core/Unit/Models/ProductVariantTest.php index 373a68c14a..4e227c9203 100644 --- a/tests/core/Unit/Models/ProductVariantTest.php +++ b/tests/core/Unit/Models/ProductVariantTest.php @@ -8,6 +8,8 @@ use Lunar\Models\CustomerGroup; use Lunar\Models\Price; use Lunar\Models\Product; +use Lunar\Models\ProductOption; +use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductVariant; use Lunar\Models\TaxClass; use Lunar\Models\TaxRate; @@ -277,3 +279,22 @@ expect($variant->fresh()->isPurchasable())->toBeFalse(); }); + +test('returns variant option values ordered by position', function () { + $variant = ProductVariant::factory()->create(); + $option = ProductOption::factory()->create(); + + // Attached deliberately out of position order. Without an explicit ORDER BY + // the pivot rows would come back in attachment order ([3, 1, 2]); the + // relationship must order by the option value's position so getOption() + // (snapshotted onto order lines) is deterministic across database engines. + $values = collect([3, 1, 2])->map(fn ($position) => ProductOptionValue::factory()->create([ + 'product_option_id' => $option->id, + 'position' => $position, + ])); + + $variant->values()->attach($values->pluck('id')->all()); + + expect($variant->load('values')->values->pluck('position')->all()) + ->toBe([1, 2, 3]); +});