store/tests/Feature/Admin/Manage/OrderTest.php

616 lines
22 KiB
PHP

<?php
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\Permission as PermissionEnum;
use App\Enums\PriceType;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\User;
use Database\Seeders\CashAccountSeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
User::factory()->create();
$this->seed(CashAccountSeeder::class);
Storage::fake('public');
});
// ─── Helper ───────────────────────────────────────────────
function createOrderUserWithPermission(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[PermissionEnum::DASHBOARD_VIEW->value],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
function createOrderWithItems(?User $user = null): Order
{
$user ??= User::factory()->create();
$order = Order::factory()->create([
'created_by_id' => $user->id,
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
]);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create([
'product_id' => $product->id,
'stock' => 100,
]);
OrderItem::factory()->create([
'order_id' => $order->id,
'product_variant_id' => $variant->id,
'quantity' => 5,
'unit_price' => 50000,
'subtotal' => 250000,
]);
return $order;
}
function setupOrderDraftItems(User $user): ProductVariant
{
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create([
'product_id' => $product->id,
'stock' => 100,
]);
// Create product price so CuttingResultPriceResolver can resolve it
ProductPrice::factory()->create([
'variant_id' => $variant->id,
'type' => PriceType::RETAIL,
'price' => 50000,
]);
// Create draft item via API
test()->actingAs($user)
->postJson(route('admin.manage.orders.draft_items.store'), [
'product_variant_id' => $variant->id,
'price_type' => PriceType::RETAIL->value,
'stock_quality' => 'good',
'quantity' => 5,
])
->assertOk();
return $variant;
}
// ─── Index ────────────────────────────────────────────────
describe('Order Index', function () {
test('authenticated user with permission can view order index', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$this->actingAs($user)
->get(route('admin.manage.orders.index'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.manage.orders.index'))
->assertRedirect(route('login'));
});
test('user without permission is forbidden', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.manage.orders.index'))
->assertForbidden();
});
test('index displays orders', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
createOrderWithItems($user);
createOrderWithItems($user);
$this->actingAs($user)
->get(route('admin.manage.orders.index'))
->assertOk();
});
test('index can search orders by order number', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$order = Order::factory()->create([
'created_by_id' => $user->id,
'order_number' => 'ORD-20260704-TEST',
]);
$this->actingAs($user)
->get(route('admin.manage.orders.index', ['search' => 'TEST']))
->assertOk();
});
});
// ─── Create ───────────────────────────────────────────────
describe('Order Create', function () {
test('authenticated user with permission can view create form', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE);
$this->actingAs($user)
->get(route('admin.manage.orders.create'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.manage.orders.create'))
->assertRedirect(route('login'));
});
test('user without create permission is forbidden', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$this->actingAs($user)
->get(route('admin.manage.orders.create'))
->assertForbidden();
});
});
// ─── Store ────────────────────────────────────────────────
describe('Order Store', function () {
test('authenticated user with permission can create an order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE);
$customer = Customer::factory()->create();
setupOrderDraftItems($user);
$this->actingAs($user)
->post(route('admin.manage.orders.store'), [
'customer_id' => $customer->id,
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
'discount' => 0,
])
->assertRedirect(route('admin.manage.orders.index'));
$order = Order::query()->latest()->first();
expect($order)->not->toBeNull();
expect($order->status)->toBe(OrderStatus::PENDING);
expect($order->items)->toHaveCount(1);
});
test('guest cannot create an order', function () {
$this->post(route('admin.manage.orders.store'), [
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
])->assertRedirect(route('login'));
});
test('user without create permission is forbidden', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$this->actingAs($user)
->post(route('admin.manage.orders.store'), [
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
])
->assertForbidden();
});
test('store fails without draft items', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE);
$this->actingAs($user)
->post(route('admin.manage.orders.store'), [
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
'discount' => 0,
])
->assertSessionHasErrors('items');
});
test('creating order decrements stock', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE);
$variant = setupOrderDraftItems($user);
$initialStock = $variant->stock;
$this->actingAs($user)
->post(route('admin.manage.orders.store'), [
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
'discount' => 0,
]);
expect($variant->fresh()->stock)->toBe($initialStock - 5);
});
test('cashier creates order automatically sets status to completed and unsets marketing and customer', function () {
$user = User::factory()->create();
$user->assignRole('cashier');
$user->givePermissionTo(PermissionEnum::ORDERS_CREATE->value);
$user->forgetCachedPermissions();
setupOrderDraftItems($user);
$marketing = User::factory()->create();
$customer = Customer::factory()->create();
$this->actingAs($user)
->post(route('admin.manage.orders.store'), [
'customer_id' => $customer->id,
'marketing_id' => $marketing->id,
'channel' => OrderChannel::STORE->value,
'price_type' => PriceType::RETAIL->value,
'payment_type' => PaymentType::CASH->value,
'status' => OrderStatus::PENDING->value,
'discount' => 0,
])
->assertRedirect(route('admin.manage.orders.index'));
$order = Order::query()->latest()->first();
expect($order)->not->toBeNull();
expect($order->status)->toBe(OrderStatus::COMPLETED);
expect($order->marketing_id)->toBeNull();
expect($order->customer_id)->toBeNull();
});
});
// ─── Show ─────────────────────────────────────────────────
describe('Order Show', function () {
test('authenticated user with permission can view order details', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$order = createOrderWithItems($user);
$this->actingAs($user)
->get(route('admin.manage.orders.show', $order))
->assertOk();
});
test('guest is redirected to login', function () {
$order = createOrderWithItems();
$this->get(route('admin.manage.orders.show', $order))
->assertRedirect(route('login'));
});
});
// ─── Edit ─────────────────────────────────────────────────
describe('Order Edit', function () {
test('authenticated user with permission can view edit form', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE);
$order = createOrderWithItems($user);
$this->actingAs($user)
->get(route('admin.manage.orders.edit', $order))
->assertOk();
});
test('guest is redirected to login', function () {
$order = createOrderWithItems();
$this->get(route('admin.manage.orders.edit', $order))
->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$order = createOrderWithItems($user);
$this->actingAs($user)
->get(route('admin.manage.orders.edit', $order))
->assertForbidden();
});
test('completed order cannot be edited', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE);
$order = Order::factory()->completed()->create(['created_by_id' => $user->id]);
$this->actingAs($user)
->get(route('admin.manage.orders.edit', $order))
->assertRedirect(route('admin.manage.orders.index'));
});
});
// ─── Destroy ──────────────────────────────────────────────
describe('Order Destroy', function () {
test('authenticated user with permission can delete an order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_DELETE);
$order = createOrderWithItems($user);
$this->actingAs($user)
->delete(route('admin.manage.orders.destroy', $order))
->assertRedirect(route('admin.manage.orders.index'));
$this->assertSoftDeleted('orders', ['id' => $order->id]);
});
test('guest cannot delete an order', function () {
$order = createOrderWithItems();
$this->delete(route('admin.manage.orders.destroy', $order))
->assertRedirect(route('login'));
$this->assertNotSoftDeleted('orders', ['id' => $order->id]);
});
test('user without delete permission is forbidden', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW);
$order = createOrderWithItems($user);
$this->actingAs($user)
->delete(route('admin.manage.orders.destroy', $order))
->assertForbidden();
$this->assertNotSoftDeleted('orders', ['id' => $order->id]);
});
test('deleting order increments stock back', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_DELETE);
$order = createOrderWithItems($user);
$item = $order->items->first();
$variant = $item->productVariant;
$stockBeforeDelete = $variant->fresh()->stock;
$this->actingAs($user)
->delete(route('admin.manage.orders.destroy', $order));
expect($variant->fresh()->stock)->toBe($stockBeforeDelete + $item->quantity);
});
});
// ─── Status Transition ────────────────────────────────────
describe('Order Status Transition', function () {
test('authenticated user can send an order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_SEND);
$order = Order::factory()->create([
'created_by_id' => $user->id,
'status' => OrderStatus::PENDING->value,
]);
$this->actingAs($user)
->post(route('admin.manage.orders.transition_status', $order), [
'status' => OrderStatus::PROCESSING->value,
])
->assertRedirect(route('admin.manage.orders.index'));
expect($order->fresh()->status)->toBe(OrderStatus::PROCESSING);
});
test('authenticated user can complete an order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_COMPLETE);
$order = Order::factory()->create([
'created_by_id' => $user->id,
'status' => OrderStatus::PROCESSING->value,
]);
$this->actingAs($user)
->post(route('admin.manage.orders.transition_status', $order), [
'status' => OrderStatus::COMPLETED->value,
]);
expect($order->fresh()->status)->toBe(OrderStatus::COMPLETED);
});
test('authenticated user can cancel an order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CANCEL);
$order = Order::factory()->create([
'created_by_id' => $user->id,
'status' => OrderStatus::PENDING->value,
]);
$this->actingAs($user)
->post(route('admin.manage.orders.transition_status', $order), [
'status' => OrderStatus::CANCELLED->value,
]);
expect($order->fresh()->status)->toBe(OrderStatus::CANCELLED);
});
test('guest cannot transition status', function () {
$order = Order::factory()->create(['status' => OrderStatus::PENDING->value]);
$this->post(route('admin.manage.orders.transition_status', $order), [
'status' => OrderStatus::PROCESSING->value,
])->assertRedirect(route('login'));
});
test('invalid status transition is rejected', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_COMPLETE);
$order = Order::factory()->create([
'created_by_id' => $user->id,
'status' => OrderStatus::PENDING->value,
]);
// PENDING cannot go directly to COMPLETED
$this->actingAs($user)
->post(route('admin.manage.orders.transition_status', $order), [
'status' => OrderStatus::COMPLETED->value,
])
->assertSessionHasErrors('status');
});
test('cancelling order increments stock back', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CANCEL);
$order = createOrderWithItems($user);
$item = $order->items->first();
$variant = $item->productVariant;
$stockBeforeCancel = $variant->fresh()->stock;
$this->actingAs($user)
->post(route('admin.manage.orders.transition_status', $order), [
'status' => OrderStatus::CANCELLED->value,
]);
expect($variant->fresh()->stock)->toBe($stockBeforeCancel + $item->quantity);
});
});
// ─── Order Model ──────────────────────────────────────────
describe('Order Model', function () {
test('order uses soft deletes', function () {
$order = Order::factory()->create();
$order->delete();
expect($order->trashed())->toBeTrue();
});
test('order has amounts cast to integer', function () {
$order = Order::factory()->create([
'subtotal' => 100000,
'discount' => 5000,
'total_amount' => 95000,
]);
expect($order->subtotal)->toBeInt();
expect($order->discount)->toBeInt();
expect($order->total_amount)->toBeInt();
});
test('order has formatted amount accessors', function () {
$order = Order::factory()->create([
'subtotal' => 100000,
'discount' => 5000,
'total_amount' => 95000,
]);
expect($order->subtotal_formatted)->toBe('Rp 100.000');
expect($order->discount_formatted)->toBe('Rp 5.000');
expect($order->total_amount_formatted)->toBe('Rp 95.000');
});
test('order has status cast to enum', function () {
$order = Order::factory()->create(['status' => OrderStatus::PENDING->value]);
expect($order->status)->toBe(OrderStatus::PENDING);
});
test('order has status label accessor', function () {
$order = Order::factory()->create(['status' => OrderStatus::PENDING->value]);
expect($order->status_label)->toBe('Menunggu');
});
test('order belongs to customer', function () {
$customer = Customer::factory()->create();
$order = Order::factory()->create(['customer_id' => $customer->id]);
expect($order->customer)->not->toBeNull();
expect($order->customer->id)->toBe($customer->id);
});
test('order belongs to created by user', function () {
$user = User::factory()->create();
$order = Order::factory()->create(['created_by_id' => $user->id]);
expect($order->createdBy)->not->toBeNull();
expect($order->createdBy->id)->toBe($user->id);
});
test('order can have items', function () {
$order = Order::factory()->create();
OrderItem::factory()->count(3)->create(['order_id' => $order->id]);
expect($order->fresh()->items)->toHaveCount(3);
});
test('order status is editable for pending and processing', function () {
$pending = Order::factory()->create(['status' => OrderStatus::PENDING->value]);
$processing = Order::factory()->create(['status' => OrderStatus::PROCESSING->value]);
expect($pending->status->isEditable())->toBeTrue();
expect($processing->status->isEditable())->toBeTrue();
});
});
// ─── OrderItem Model ──────────────────────────────────────
describe('OrderItem Model', function () {
test('order item has amounts cast to integer', function () {
$order = Order::factory()->create();
$item = OrderItem::factory()->create([
'order_id' => $order->id,
'quantity' => 10,
'unit_price' => 50000,
'subtotal' => 500000,
]);
expect($item->quantity)->toBeInt();
expect($item->unit_price)->toBeInt();
expect($item->subtotal)->toBeInt();
});
test('order item has formatted amount accessors', function () {
$order = Order::factory()->create();
$item = OrderItem::factory()->create([
'order_id' => $order->id,
'quantity' => 10,
'unit_price' => 50000,
'subtotal' => 500000,
]);
expect($item->quantity_formatted)->toBe('10 pcs');
expect($item->unit_price_formatted)->toBe('Rp 50.000');
expect($item->subtotal_formatted)->toBe('Rp 500.000');
});
test('order item belongs to order', function () {
$order = Order::factory()->create();
$item = OrderItem::factory()->create(['order_id' => $order->id]);
expect($item->order)->not->toBeNull();
expect($item->order->id)->toBe($order->id);
});
test('order item uses soft deletes', function () {
$order = Order::factory()->create();
$item = OrderItem::factory()->create(['order_id' => $order->id]);
$item->delete();
expect($item->trashed())->toBeTrue();
});
});