Files
sembara/tests/Feature/Orders/OrderTest.php
T

269 lines
9.1 KiB
PHP

<?php
namespace Tests\Feature\Orders;
use App\Filament\Pages\Transactions\Orders as OrdersPage;
use App\Models\User;
use App\Services\OrderService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Validation\ValidationException;
use Livewire\Livewire;
use Tests\Support\InsForgeFake;
use Tests\TestCase;
class OrderTest extends TestCase
{
private const PRODUCT_ID = '00000000-0000-0000-0000-000000000101';
private const CUSTOMER_ID = 'cccccccc-0000-0000-0000-000000000001';
protected function setUp(): void
{
parent::setUp();
InsForgeFake::reset();
InsForgeFake::fakeRest();
InsForgeFake::seed('products', [
'id' => self::PRODUCT_ID,
'code' => 'BERAS-001',
'name' => 'Beras Premium',
'cost_price' => 13000,
'is_active' => true,
]);
InsForgeFake::seed('customers', [
'id' => self::CUSTOMER_ID,
'customer_code' => 'CUST-0001',
'name' => 'Andi',
]);
}
private function actor(string $role, string $idSuffix = '0001'): User
{
$user = new User;
$user->forceFill([
'id' => "eeee5555-0000-0000-0000-{$role}{$idSuffix}",
'name' => ucfirst($role),
'email' => "{$role}@beraspro.test",
'role' => $role,
'is_active' => true,
]);
return $user;
}
private function seedStock(int $quantity): void
{
InsForgeFake::seed('stocks', [
'id' => 'cccc3333-0000-0000-0000-000000000010',
'product_id' => self::PRODUCT_ID,
'quantity' => $quantity,
]);
}
private function seedOrder(array $overrides = []): array
{
$order = array_merge([
'id' => 'ffff6666-0000-0000-0000-000000000001',
'order_number' => 'ORD-20260825-0001',
'customer_id' => self::CUSTOMER_ID,
'created_by' => $this->actor('operator')->id,
'order_status' => OrderService::STATUS_PENDING,
'payment_method' => 'cash',
'payment_status' => 'unpaid',
'deleted_at' => null,
'order_date' => '2026-08-25',
'customers' => ['code' => 'CUST-0001', 'name' => 'Andi'],
'drivers' => null,
], $overrides);
if (! array_key_exists('order_items', $order)) {
$order['order_items'] = [
[
'quantity' => 5,
'unit_price' => 15000,
'subtotal' => 75000,
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium'],
],
];
}
InsForgeFake::seed('orders', $order);
InsForgeFake::seed('order_items', [
'id' => 'aaaa7777-0000-0000-0000-000000000001',
'order_id' => $order['id'],
'product_id' => self::PRODUCT_ID,
'quantity' => 5,
'unit_price' => 15000,
'subtotal' => 75000,
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium'],
]);
return $order;
}
public function test_create_generates_number_history_and_items(): void
{
$this->seedStock(100);
$result = app(OrderService::class)->createOrder([
'customer_id' => self::CUSTOMER_ID,
'order_date' => '2026-08-25',
], [
['product_id' => self::PRODUCT_ID, 'quantity' => 3, 'unit_price' => 15000],
], $this->actor('operator'));
$this->assertSame('ORD-'.now()->format('Ymd').'-0001', $result['order_number']);
$item = end(InsForgeFake::$store['order_items']);
$this->assertSame(45000, $item['subtotal']);
$this->assertSame(13000, $item['cost_price']);
$history = end(InsForgeFake::$store['order_status_histories']);
$this->assertSame(OrderService::STATUS_PENDING, $history['new_status']);
$log = end(InsForgeFake::$store['activity_logs']);
$this->assertSame('order_created', $log['action']);
}
public function test_create_rejects_when_stock_insufficient(): void
{
$this->seedStock(2);
$this->expectException(ValidationException::class);
app(OrderService::class)->createOrder([
'customer_id' => self::CUSTOMER_ID,
], [
['product_id' => self::PRODUCT_ID, 'quantity' => 10, 'unit_price' => 15000],
], $this->actor('operator'));
}
public function test_valid_transition_pending_to_processing(): void
{
$this->seedOrder();
app(OrderService::class)->transition($this->seedOrder()['id'], OrderService::STATUS_PROCESSING, null, $this->actor('admin'));
$history = end(InsForgeFake::$store['order_status_histories']);
$this->assertSame(OrderService::STATUS_PROCESSING, $history['new_status']);
}
public function test_invalid_jump_is_rejected(): void
{
$order = $this->seedOrder();
$this->expectException(ValidationException::class);
app(OrderService::class)->transition($order['id'], OrderService::STATUS_DELIVERING, null, $this->actor('admin'));
}
public function test_cancel_by_creator_allowed_but_other_operator_denied(): void
{
$creator = $this->actor('operator');
$other = new User;
$other->forceFill([
'id' => 'eeee5555-9999-9999-9999-operatorX',
'name' => 'Other',
'email' => 'o@x.test',
'role' => 'operator',
'is_active' => true,
]);
$order = $this->seedOrder(['created_by' => $creator->id]);
app(OrderService::class)->transition($order['id'], OrderService::STATUS_CANCELLED, 'batal', $creator);
$second = $this->seedOrder([
'id' => 'ffff6666-0000-0000-0000-000000000002',
'order_number' => 'ORD-20260825-0002',
'created_by' => $creator->id,
]);
$this->expectException(AuthorizationException::class);
app(OrderService::class)->transition($second['id'], OrderService::STATUS_CANCELLED, null, $other);
}
public function test_completed_calls_rpc_once_and_stock_moves_once(): void
{
$this->seedStock(10);
$order = $this->seedOrder(['order_status' => OrderService::STATUS_DELIVERING]);
app(OrderService::class)->transition($order['id'], OrderService::STATUS_COMPLETED, null, $this->actor('admin'));
$this->assertSame(1, count(InsForgeFake::$rpcCalls));
$this->assertSame('complete_order', InsForgeFake::$rpcCalls[0]['function']);
// Stok berkurang sekali + satu baris mutasi order_out.
$stock = array_values(array_filter(InsForgeFake::$store['stocks'], fn ($s) => $s['product_id'] === self::PRODUCT_ID))[0];
$this->assertSame(5, $stock['quantity']);
$outs = array_filter(InsForgeFake::$store['stock_movements'], fn ($m) => $m['type'] === 'order_out' && $m['reference_id'] === $order['id']);
$this->assertCount(1, $outs);
// Ulangi penyelesaian: transisi lokal ditolak (status sudah completed), rpc TIDAK dipanggil lagi.
try {
app(OrderService::class)->transition($order['id'], OrderService::STATUS_COMPLETED, null, $this->actor('admin'));
$this->fail('repeat completion should be rejected locally');
} catch (ValidationException) {
}
$this->assertSame(1, count(InsForgeFake::$rpcCalls));
$outsAfter = array_filter(InsForgeFake::$store['stock_movements'], fn ($m) => $m['type'] === 'order_out' && $m['reference_id'] === $order['id']);
$this->assertCount(1, $outsAfter);
}
public function test_soft_delete_admin_only(): void
{
$order = $this->seedOrder();
app(OrderService::class)->softDelete($order['id'], $this->actor('admin'));
$stored = InsForgeFake::$store['orders'][$order['id']];
$this->assertNotNull($stored['deleted_at']);
$this->expectException(AuthorizationException::class);
app(OrderService::class)->softDelete($order['id'], $this->actor('operator'));
}
public function test_orders_page_renders_for_operator(): void
{
$user = $this->actor('operator');
$this->actingAs($user);
$this->seedOrder();
Livewire::test(OrdersPage::class)
->assertSuccessful()
->assertSee('ORD-20260825-0001')
->assertSee('Andi');
}
public function test_delivery_note_streams_pdf_when_delivering(): void
{
$this->actingAs($this->actor('admin'));
$this->seedOrder(['order_status' => OrderService::STATUS_DELIVERING]);
$response = $this->get('/orders/ffff6666-0000-0000-0000-000000000001/delivery-note');
$response->assertOk();
$this->assertStringStartsWith('application/pdf', (string) $response->headers->get('Content-Type'));
}
public function test_delivery_note_forbidden_while_pending(): void
{
$this->actingAs($this->actor('admin'));
$this->seedOrder(['order_status' => OrderService::STATUS_PENDING]);
$this->get('/orders/ffff6666-0000-0000-0000-000000000001/delivery-note')->assertForbidden();
}
}