dress/tests/Feature/Admin/Manage/OrderTest.php
Yoga Pangestu 482ce9f3b8
Some checks failed
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled
feat: implement order cancellation logic to restore stock while preserving existing order items
2026-05-02 19:29:11 +07:00

455 lines
15 KiB
PHP

<?php
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Enums\PriceType;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Settings\GeneralSettings;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertSoftDeleted;
use function Pest\Laravel\deleteJson;
use function Pest\Laravel\get;
use function Pest\Laravel\patchJson;
use function Pest\Laravel\postJson;
/*
|--------------------------------------------------------------------------
| Order Module Tests
|--------------------------------------------------------------------------
*/
describe('Order Module - Authorization', function () {
it('redirects to login when accessing order index unauthenticated', function () {
get(route('order.index'))
->assertRedirect(route('login'));
});
it('returns 403 when user has no permission to view orders', function () {
actingAs(createUnauthorizedUser())
->get(route('order.index'))
->assertStatus(403);
});
});
describe('Order Module - Authorized Actions', function () {
beforeEach(function () {
$user = createAuthorizedUser([
'View:Order',
'Create:Order',
'Edit:Order',
'Delete:Order',
'DeleteAny:Order',
]);
actingAs($user);
$settings = app(GeneralSettings::class);
$settings->site_name = 'VN Grup';
$settings->site_phone = '08123456789';
$settings->site_address = 'Jl. Test No. 1';
$settings->site_description = 'Test Description';
$settings->save();
});
it('can access order index page', function () {
Order::factory()->create();
get(route('order.index'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/manage/order/index')
->has('orders', fn ($page) => $page
->has('data.0.user')
->has('data.0.cogs_formatted')
->has('data.0.payment_formatted')
->has('data.0.created_at_formatted')
->etc()
)
);
});
it('can access order create page', function () {
get(route('order.create'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/manage/order/create')
->has('products')
->has('cartItems')
->where('orderStatus', function ($status) {
return collect($status)->every(fn ($item) => $item['value'] !== OrderStatus::CANCELLED->value);
})
);
});
it('can access order show page', function () {
$order = Order::factory()
->has(OrderItem::factory()->count(2), 'items')
->create();
get(route('order.show', $order))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/manage/order/show')
->has('order', fn ($page) => $page
->where('id', $order->id)
->has('items', 2)
->has('items.0.product')
->has('user')
->etc()
)
->has('setting')
);
});
it('can access order edit page', function () {
$order = Order::factory()->create();
get(route('order.edit', $order))
->assertOk()
->assertInertia(fn ($page) => $page
->component('admin/manage/order/edit')
->has('order')
->has('products')
->has('orderStatus')
->has('orderChannels')
->has('paymentMethods')
->has('priceTypes')
);
});
it('can manage cart items', function () {
$product = Product::factory()->create(['stock' => 10]);
// 1. Add to cart
postJson(route('order.addToCart'), [
'product_id' => $product->id,
'qty' => 2,
'price' => 10000,
'price_type' => PriceType::RETAIL->value,
])->assertRedirect();
assertDatabaseHas('order_items', [
'user_id' => auth()->id(),
'order_id' => null,
'product_id' => $product->id,
'qty' => 2,
]);
$cartItem = OrderItem::where('product_id', $product->id)->whereNull('order_id')->first();
// 2. Update cart item
patchJson(route('order.updateCartItem', $cartItem), [
'product_id' => $product->id,
'qty' => 5,
'price' => 10000,
'price_type' => PriceType::RETAIL->value,
])->assertRedirect();
expect($cartItem->fresh()->qty)->toBe(5);
// 3. Remove from cart
deleteJson(route('order.removeFromCart', $cartItem))->assertRedirect();
assertSoftDeleted('order_items', ['id' => $cartItem->id]);
});
it('can store a new order', function () {
$product = Product::factory()->create(['stock' => 10]);
// Update existing PURCHASE price from factory to ensure COGS calculation is predictable
$product->prices()->where('price_type', PriceType::PURCHASE->value)->update([
'price' => 8000,
]);
$data = [
'customer_name' => 'John Doe',
'subtotal' => 20000,
'discount' => 2000,
'payment' => 18000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PENDING->value,
'order_channel' => OrderChannel::STORE->value,
'items' => [
[
'product_id' => $product->id,
'qty' => 2,
'price' => 10000,
'total' => 20000,
'price_type' => PriceType::RETAIL->value,
],
],
];
postJson(route('order.store'), $data)
->assertRedirect(route('order.index'))
->assertSessionHas('success');
assertDatabaseHas('orders', [
'customer_name' => 'John Doe',
'subtotal' => 20000,
'discount' => 2000,
'total' => 18000,
'payment' => 18000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PENDING->value,
'order_channel' => OrderChannel::STORE->value,
'cogs' => 16000, // 8000 * 2
]);
// Check stock decrement
expect($product->fresh()->stock)->toBe(8);
// Check order items
assertDatabaseHas('order_items', [
'product_id' => $product->id,
'qty' => 2,
'price' => 10000,
]);
});
it('validates order creation', function () {
postJson(route('order.store'), [])
->assertStatus(422)
->assertJsonValidationErrors(['customer_name', 'items']);
});
it('returns flash error when stock is insufficient on order store', function () {
$product = Product::factory()->create(['stock' => 1]);
$data = [
'customer_name' => 'John Doe',
'subtotal' => 20000,
'discount' => 0,
'payment' => 20000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PENDING->value,
'order_channel' => OrderChannel::STORE->value,
'items' => [
[
'product_id' => $product->id,
'qty' => 5, // lebih dari stok
'price' => 10000,
'total' => 50000,
'price_type' => PriceType::RETAIL->value,
],
],
];
postJson(route('order.store'), $data)
->assertRedirect()
->assertSessionHas('error');
expect($product->fresh()->stock)->toBe(1);
});
it('returns flash error when stock is insufficient on order update', function () {
$product = Product::factory()->create(['stock' => 2]);
$order = Order::factory()->create();
$newData = [
'customer_name' => 'Updated Customer',
'subtotal' => 30000,
'discount' => 0,
'payment' => 30000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PROCESSING->value,
'order_channel' => OrderChannel::STORE->value,
'items' => [
[
'product_id' => $product->id,
'qty' => 10, // lebih dari stok
'price' => 3000,
'total' => 30000,
'price_type' => PriceType::RETAIL->value,
],
],
];
patchJson(route('order.update', $order), $newData)
->assertRedirect()
->assertSessionHas('error');
expect($product->fresh()->stock)->toBe(2);
});
it('can update an order', function () {
$product1 = Product::factory()->create(['stock' => 10]);
$product2 = Product::factory()->create(['stock' => 10]);
$order = Order::create([
'customer_name' => 'Old Customer',
'cogs' => 0,
'subtotal' => 10000,
'discount' => 0,
'payment' => 10000,
'total' => 10000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PENDING->value,
'order_channel' => OrderChannel::STORE->value,
]);
$order->items()->create([
'user_id' => auth()->id(),
'product_id' => $product1->id,
'qty' => 1,
'price' => 10000,
'total' => 10000,
'price_type' => PriceType::RETAIL->value,
]);
// Initial stock check
$product1->decrement('stock', 1);
$newData = [
'customer_name' => 'Updated Customer',
'subtotal' => 15000,
'discount' => 0,
'payment' => 15000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PROCESSING->value,
'order_channel' => OrderChannel::STORE->value,
'items' => [
[
'product_id' => $product2->id,
'qty' => 3,
'price' => 5000,
'total' => 15000,
'price_type' => PriceType::RETAIL->value,
],
],
];
patchJson(route('order.update', $order), $newData)
->assertRedirect(route('order.index'))
->assertSessionHas('success');
// Check data updated
assertDatabaseHas('orders', [
'id' => $order->id,
'customer_name' => 'Updated Customer',
'order_status' => OrderStatus::PROCESSING->value,
'payment' => 15000,
'total' => 15000,
]);
// Check stock: product1 should be restored (10), product2 should be decremented (7)
expect($product1->fresh()->stock)->toBe(10);
expect($product2->fresh()->stock)->toBe(7);
});
it('restores stock and preserves items when order status is changed to gagal', function () {
$product = Product::factory()->create(['stock' => 10]);
$order = Order::factory()->create(['order_status' => OrderStatus::PENDING->value]);
$item = $order->items()->create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'qty' => 3,
'price' => 1000,
'total' => 3000,
'price_type' => PriceType::RETAIL->value,
]);
$product->decrement('stock', 3);
expect($product->fresh()->stock)->toBe(7);
$newData = [
'customer_name' => $order->customer_name,
'subtotal' => 3000,
'discount' => 0,
'payment' => 3000,
'payment_method' => $order->payment_method,
'order_status' => OrderStatus::CANCELLED->value, // Change to Gagal
'order_channel' => $order->order_channel,
'items' => [
// Even if we send items, they shouldn't be deleted/updated based on my early return
[
'product_id' => $product->id,
'qty' => 5, // different qty
'price' => 1000,
'total' => 5000,
'price_type' => PriceType::RETAIL->value,
],
],
];
patchJson(route('order.update', $order), $newData)
->assertRedirect();
// Stock should be restored (7 + 3 = 10)
expect($product->fresh()->stock)->toBe(10);
// Items should be preserved (original item with qty 3, NOT deleted/recreated with qty 5)
expect($order->items()->count())->toBe(1);
expect($order->items()->first()->qty)->toBe(3);
expect($order->items()->first()->id)->toBe($item->id); // Same ID means not deleted/recreated
});
it('can delete an order and restore stock', function () {
$product = Product::factory()->create(['stock' => 5]);
$order = Order::factory()->create();
$order->items()->create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'qty' => 2,
'price' => 1000,
'total' => 2000,
'price_type' => PriceType::RETAIL->value,
]);
deleteJson(route('order.destroy', $order))
->assertRedirect()
->assertSessionHas('success');
assertSoftDeleted('orders', ['id' => $order->id]);
// Stock should be restored (5 + 2 = 7)
expect($product->fresh()->stock)->toBe(7);
});
it('can delete orders in bulk', function () {
$orders = Order::factory()->count(3)->create();
$ids = $orders->pluck('id')->toArray();
deleteJson(route('order.bulkDestroy'), ['ids' => $ids])
->assertRedirect()
->assertSessionHas('success');
foreach ($ids as $id) {
assertSoftDeleted('orders', ['id' => $id]);
}
});
});
describe('Order Module - Unauthorized Actions', function () {
beforeEach(function () {
actingAs(createUnauthorizedUser());
});
it('cannot view an order without permission', function () {
$order = Order::factory()->create();
get(route('order.show', $order))
->assertStatus(403);
});
it('cannot store an order without permission', function () {
postJson(route('order.store'), ['customer_name' => 'Unauthorized'])
->assertStatus(403);
});
it('cannot update an order without permission', function () {
$order = Order::factory()->create();
patchJson(route('order.update', $order), ['customer_name' => 'Unauthorized'])
->assertStatus(403);
});
it('cannot delete an order without permission', function () {
$order = Order::factory()->create();
deleteJson(route('order.destroy', $order))
->assertStatus(403);
});
});