Skema sudah siap, jadi kita mulai dari sisi yang paling cepat terlihat hasilnya: pengelolaan produk. Ada dua wajah dari data products yang akan kita bangun sekaligus. Wajah pertama adalah area admin — CRUD lengkap di balik login untuk menambah, mengubah, dan menonaktifkan produk. Wajah kedua adalah katalog publik yang menampilkan hanya produk aktif dan boleh dilihat siapa pun, tamu sekalipun, karena menarik pengunjung ke toko tidak boleh meminta login lebih dulu.

Model Produk dengan Scope Aktif #

Model Product di app/Models/Product.php membawa satu query scope yang nanti dipakai dua tempat:

php
public function scopeActive(Builder $query): Builder
{
    return $query->where('is_active', true);
}

Casts pada model yang sama memastikan is_active selalu berupa boolean dan price/stock berupa integer. Setiap pembacaan daftar produk untuk katalog cukup memanggil Product::active() sehingga aturan “hanya produk aktif yang tampil” hidup di satu tempat — bila kelak ada syarat tambahan (misalnya stok di atas nol), kita cukup mengubah satu method ini.

Satu Form Request untuk Simpan dan Ubah #

Berbeda dengan proyek inventaris yang butuh dua form request terpisah karena aturan unique, aturan simpan dan ubah produk di sini identik. Kita jadikan satu class ProductRequest yang dipakai store() dan update() sekaligus:

php
class ProductRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        $this->merge(['is_active' => $this->boolean('is_active')]);
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'description' => ['nullable', 'string', 'max:1000'],
            'price' => ['required', 'integer', 'min:0'],
            'stock' => ['required', 'integer', 'min:0'],
            'is_active' => ['required', 'boolean'],
        ];
    }
}

Method prepareForValidation() hadir karena kejanggalan HTML checkbox: yang tidak dicentang tidak mengirim nilai apa pun, bukan mengirim false. Tanpa persiapan ini, menyimpan produk nonaktif akan ditolak rule required. Method $this->boolean('is_active') membaca input menjadi true atau false secara eksplisit, sehingga rule boolean selalu lolos dan hasilnya tersimpan rapi.

Route dan Controller #

Seluruh route dideklarasikan di routes/web.php: katalog publik di luar middleware, keranjang mengikuti session tamu, sedangkan checkout, pesanan, dan CRUD produk berada di dalam grup auth:

php
Route::get('/', [CatalogController::class, 'index'])->name('catalog.index');

Route::get('cart', [CartController::class, 'index'])->name('cart.index');
Route::post('cart', [CartController::class, 'store'])->name('cart.store');

Route::middleware('auth')->group(function () {
    // checkout, pesanan, dan status menyusul di bagian berikutnya
    Route::resource('products', ProductController::class);
});

Kita bisa memeriksa hasilnya dengan artisan; output berikut sudah disaring dari route bawaan framework:

bash
php artisan route:list --except-vendor

Output yang Dihasilkan:

text
  GET|HEAD        / .................. catalog.index › CatalogController@index
  GET|HEAD        cart ..................... cart.index › CartController@index
  POST            cart ..................... cart.store › CartController@store
  PUT             cart/{product} ......... cart.update › CartController@update
  DELETE          cart/{product} ....... cart.destroy › CartController@destroy
  GET|HEAD        checkout ....... checkout.review › CheckoutController@review
  POST            checkout ......... checkout.store › CheckoutController@store
  GET|HEAD        login ..................... login › AuthController@showLogin
  POST            login ................. login.attempt › AuthController@login
  POST            logout ...................... logout › AuthController@logout
  GET|HEAD        orders ................ orders.index › OrderController@index
  GET|HEAD        orders/{order} .......... orders.show › OrderController@show
  POST            orders/{order}/status orders.status › OrderController@updat…
  GET|HEAD        products .......... products.index › ProductController@index
  POST            products .......... products.store › ProductController@store
  GET|HEAD        products/create . products.create › ProductController@create
  GET|HEAD        products/{product} .. products.show › ProductController@show
  PUT|PATCH       products/{product} products.update › ProductController@upda…
  DELETE          products/{product} products.destroy › ProductController@des…
  GET|HEAD        products/{product}/edit products.edit › ProductController@e…

                                                           Showing [20] routes

CatalogController::index() sendiri ringkas: Product::active()->orderBy('name')->paginate(8) lalu meneruskan hasilnya ke view. Perbedaannya dengan ProductController::index() sengaja dipertahankan — katalog menyaring produk aktif dan mengurutkan berdasarkan nama untuk pengunjung, sedangkan halaman admin menampilkan semua produk termasuk yang nonaktif agar statusnya selalu terpantau. Kedua controller ini juga mencontohkan prinsip yang sama dari sisi berbeda: query yang benar untuk pengguna mana pun sebaiknya dibangun di satu tempat yang bisa diperiksa ulang, bukan tersebar sebagai potongan where di banyak view.

Satu konsekuensi rancangan perlu kita antisipasi sejak sekarang. Foreign key restrictOnDelete() pada order_items.product_id berarti produk yang pernah dibeli tidak bisa dihapus begitu ada pesanan yang memuatnya — database akan menolak dengan error. Perilaku ini memang diinginkan karena riwayat penjualan tidak boleh kehilangan identitas barangnya, tetapi tampilannya kurang ramah bila admin menekan tombol hapus pada produk seperti itu. Solusi jangka pendek kita sederhana: jadikan produk nonaktif alih-alih menghapusnya; kolom is_active memang dirancang untuk kebutuhan ini. Penanganan error yang lebih halus bisa berupa pesan khusus di halaman admin, dan itu akan menjadi latihan yang bagus setelah proyek selesai.

Tampilan dan Pagination #

Formulir produk dipisah sebagai partial resources/views/products/_form.blade.php yang dipakai bersama halaman tambah dan ubah, lengkap dengan old(), @error per kolom, dan @checked untuk checkbox. Daftar produk admin memanggil paginate(10); karena pagination bawaan Laravel memakai class Tailwind, kita timpa dengan resources/views/vendor/pagination/default.blade.php bertuliskan CSS polos yang serasi dengan layout resources/views/layouts/app.blade.php — sama seperti proyek inventaris. Halaman katalog memakai tabel yang sama dengan satu kolom tambahan: form kecil berisi input jumlah dan tombol “+ Keranjang” yang POST ke cart.store.

CRUD produk dan katalog selesai. Data sudah bisa dilihat, tetapi tombol “+ Keranjang” masih menuju route yang belum punya isi. Bagian berikutnya kita hidupkan keranjangnya — dengan session sebagai penyimpanan, tanpa satu pun tabel baru.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

app/Exceptions/EmptyCartException.php

Unduh berkas
php
<?php

namespace App\Exceptions;

use RuntimeException;

class EmptyCartException extends RuntimeException
{
    public function __construct()
    {
        parent::__construct('Keranjang belanja kosong.');
    }
}

app/Exceptions/InsufficientStockException.php

Unduh berkas
php
<?php

namespace App\Exceptions;

use App\Models\Product;
use RuntimeException;

class InsufficientStockException extends RuntimeException
{
    public function __construct(public readonly Product $product, public readonly int $requested)
    {
        parent::__construct(sprintf(
            'Stok %s tidak cukup: tersedia %d, diminta %d.',
            $product->name,
            $product->stock,
            $requested,
        ));
    }
}

app/Exceptions/InvalidStatusTransitionException.php

Unduh berkas
php
<?php

namespace App\Exceptions;

use RuntimeException;

class InvalidStatusTransitionException extends RuntimeException
{
    public function __construct(public readonly string $from, public readonly string $to)
    {
        parent::__construct(sprintf(
            'Perubahan status dari "%s" ke "%s" tidak diizinkan.',
            $from,
            $to,
        ));
    }
}

app/Exceptions/ProductUnavailableException.php

Unduh berkas
php
<?php

namespace App\Exceptions;

use RuntimeException;

class ProductUnavailableException extends RuntimeException
{
    public function __construct()
    {
        parent::__construct(
            'Ada produk di keranjang yang sudah tidak tersedia lagi. Hapus produk tersebut lalu coba kembali.',
        );
    }
}

app/Http/Controllers/AuthController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    public function showLogin(): View
    {
        return view('auth.login');
    }

    public function login(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended(route('catalog.index'));
        }

        return back()
            ->withErrors(['email' => 'Email atau password salah.'])
            ->onlyInput('email');
    }

    public function logout(Request $request): RedirectResponse
    {
        Auth::logout();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect()->route('catalog.index');
    }
}

app/Http/Controllers/CartController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\CartItemRequest;
use App\Http\Requests\CartQuantityRequest;
use App\Models\Product;
use App\Services\CartService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;

class CartController extends Controller
{
    public function __construct(private readonly CartService $cart)
    {
    }

    public function index(): View
    {
        return view('cart.index', [
            'items' => $this->cart->items(),
            'total' => $this->cart->total(),
            'count' => $this->cart->count(),
        ]);
    }

    /**
     * Tambah produk ke keranjang. Berlaku juga untuk tamu:
     * keranjang hidup di session, bukan di tabel database.
     */
    public function store(CartItemRequest $request): RedirectResponse
    {
        $this->cart->add((int) $request->integer('product_id'), (int) $request->integer('qty'));

        $product = Product::findOrFail($request->integer('product_id'));

        return back()
            ->with('status', "{$product->name} ({$request->integer('qty')} pcs) masuk keranjang.");
    }

    public function update(CartQuantityRequest $request, Product $product): RedirectResponse
    {
        $this->cart->setQuantity($product->id, (int) $request->integer('qty'));

        return redirect()
            ->route('cart.index')
            ->with('status', "Jumlah {$product->name} diperbarui.");
    }

    public function destroy(Product $product): RedirectResponse
    {
        $this->cart->remove($product->id);

        return redirect()
            ->route('cart.index')
            ->with('status', "{$product->name} dihapus dari keranjang.");
    }
}

app/Http/Controllers/CatalogController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Contracts\View\View;

class CatalogController extends Controller
{
    /**
     * Katalog publik: hanya produk aktif, tamu boleh melihat.
     */
    public function index(): View
    {
        $products = Product::query()
            ->active()
            ->orderBy('name')
            ->paginate(8)
            ->withQueryString();

        return view('catalog.index', ['products' => $products]);
    }
}

app/Http/Controllers/CheckoutController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Exceptions\EmptyCartException;
use App\Exceptions\InsufficientStockException;
use App\Exceptions\ProductUnavailableException;
use App\Services\CartService;
use App\Services\CheckoutService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function __construct(
        private readonly CartService $cart,
        private readonly CheckoutService $checkout,
    ) {
    }

    /**
     * Halaman tinjauan sebelum pesanan dibuat.
     */
    public function review(): View
    {
        return view('checkout.review', [
            'items' => $this->cart->items(),
            'total' => $this->cart->total(),
        ]);
    }

    /**
     * Jalankan checkout: keranjang berubah menjadi pesanan.
     */
    public function store(Request $request): RedirectResponse
    {
        try {
            $order = $this->checkout->checkout($request->user());
        } catch (InsufficientStockException $exception) {
            return back()->withErrors(['cart' => $exception->getMessage()]);
        } catch (EmptyCartException|ProductUnavailableException $exception) {
            return back()->withErrors(['cart' => $exception->getMessage()]);
        }

        return redirect()
            ->route('orders.show', $order)
            ->with('status', "Pesanan #{$order->id} berhasil dibuat.");
    }
}

app/Http/Controllers/Controller.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

abstract class Controller
{
    //
}

app/Http/Controllers/OrderController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Exceptions\InvalidStatusTransitionException;
use App\Http\Requests\OrderStatusRequest;
use App\Models\Order;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    /**
     * Daftar pesanan milik pengguna yang sedang login.
     */
    public function index(Request $request): View
    {
        $orders = $request->user()
            ->orders()
            ->latest('id')
            ->paginate(10);

        return view('orders.index', ['orders' => $orders]);
    }

    /**
     * Detail pesanan: item dengan harga snapshot + riwayat status.
     */
    public function show(Request $request, Order $order): View
    {
        abort_unless($order->user_id === $request->user()->id, 403);

        $order->load('items.product');

        return view('orders.show', [
            'order' => $order,
            'histories' => $order->histories()->with('user')->oldest('id')->get(),
        ]);
    }

    /**
     * Pindahkan status pesanan dan catat riwayatnya.
     */
    public function updateStatus(OrderStatusRequest $request, Order $order): RedirectResponse
    {
        abort_unless($order->user_id === $request->user()->id, 403);

        try {
            $order->transitionTo(
                $request->string('status')->toString(),
                $request->user(),
                $request->string('note')->toString() ?: null,
            );
        } catch (InvalidStatusTransitionException $exception) {
            return back()->withErrors(['status' => $exception->getMessage()]);
        }

        return redirect()
            ->route('orders.show', $order)
            ->with('status', 'Status pesanan menjadi ' . $order->status . '.');
    }
}

app/Http/Controllers/ProductController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\ProductRequest;
use App\Models\Product;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    /**
     * Daftar produk untuk admin: termasuk yang nonaktif.
     */
    public function index(Request $request): View
    {
        $products = Product::query()
            ->orderBy('name')
            ->paginate(10);

        return view('products.index', ['products' => $products]);
    }

    public function create(): View
    {
        return view('products.create', ['product' => new Product()]);
    }

    public function store(ProductRequest $request): RedirectResponse
    {
        $product = Product::create($request->validated());

        return redirect()
            ->route('products.show', $product)
            ->with('status', "Produk {$product->name} berhasil disimpan.");
    }

    public function show(Product $product): View
    {
        return view('products.show', ['product' => $product]);
    }

    public function edit(Product $product): View
    {
        return view('products.edit', ['product' => $product]);
    }

    public function update(ProductRequest $request, Product $product): RedirectResponse
    {
        $product->update($request->validated());

        return redirect()
            ->route('products.show', $product)
            ->with('status', "Produk {$product->name} berhasil diperbarui.");
    }

    public function destroy(Product $product): RedirectResponse
    {
        $name = $product->name;
        $product->delete();

        return redirect()
            ->route('products.index')
            ->with('status', "Produk {$name} berhasil dihapus.");
    }
}

app/Http/Requests/CartItemRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class CartItemRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'product_id' => [
                'required',
                'integer',
                // Hanya produk aktif yang boleh masuk keranjang.
                Rule::exists('products', 'id')->where('is_active', true),
            ],
            'qty' => ['required', 'integer', 'min:1', 'max:99'],
        ];
    }

    public function attributes(): array
    {
        return [
            'product_id' => 'produk',
            'qty' => 'jumlah',
        ];
    }
}

app/Http/Requests/CartQuantityRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CartQuantityRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            // Nol diizinkan: artinya hapus produk dari keranjang.
            'qty' => ['required', 'integer', 'min:0', 'max:99'],
        ];
    }

    public function attributes(): array
    {
        return [
            'qty' => 'jumlah',
        ];
    }
}

app/Http/Requests/OrderStatusRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class OrderStatusRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status' => ['required', 'string', Rule::in(Order::STATUSES)],
            'note' => ['nullable', 'string', 'max:255'],
        ];
    }

    public function attributes(): array
    {
        return [
            'status' => 'status',
            'note' => 'catatan',
        ];
    }
}

app/Http/Requests/ProductRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductRequest extends FormRequest
{
    /**
     * Checkbox yang tidak dicentang tidak mengirim nilai apa pun,
     * jadi lengkapi dulu dengan boolean() sebelum divalidasi.
     */
    protected function prepareForValidation(): void
    {
        $this->merge(['is_active' => $this->boolean('is_active')]);
    }

    /**
     * Aturan yang sama dipakai untuk menyimpan dan mengubah produk.
     */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'description' => ['nullable', 'string', 'max:1000'],
            'price' => ['required', 'integer', 'min:0'],
            'stock' => ['required', 'integer', 'min:0'],
            'is_active' => ['required', 'boolean'],
        ];
    }

    public function attributes(): array
    {
        return [
            'name' => 'nama produk',
            'description' => 'deskripsi',
            'price' => 'harga',
            'stock' => 'stok',
            'is_active' => 'status aktif',
        ];
    }
}

app/Models/Order.php

Unduh berkas
php
<?php

namespace App\Models;

use App\Exceptions\InvalidStatusTransitionException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;

class Order extends Model
{
    public const STATUS_PENDING = 'pending';
    public const STATUS_PAID = 'paid';
    public const STATUS_SHIPPED = 'shipped';
    public const STATUS_COMPLETED = 'completed';
    public const STATUS_CANCELLED = 'cancelled';

    public const STATUSES = [
        self::STATUS_PENDING,
        self::STATUS_PAID,
        self::STATUS_SHIPPED,
        self::STATUS_COMPLETED,
        self::STATUS_CANCELLED,
    ];

    /**
     * Peta transisi status yang sah. Kuncinya adalah status saat ini,
     * nilainya daftar status yang boleh dituju.
     */
    public const TRANSITIONS = [
        self::STATUS_PENDING => [self::STATUS_PAID, self::STATUS_CANCELLED],
        self::STATUS_PAID => [self::STATUS_SHIPPED, self::STATUS_CANCELLED],
        self::STATUS_SHIPPED => [self::STATUS_COMPLETED],
        self::STATUS_COMPLETED => [],
        self::STATUS_CANCELLED => [],
    ];

    protected $fillable = [
        'user_id',
        'total',
        'status',
    ];

    protected function casts(): array
    {
        return [
            'total' => 'integer',
            'status' => 'string',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    public function histories(): HasMany
    {
        return $this->hasMany(OrderStatusHistory::class);
    }

    /**
     * Daftar status yang boleh dituju dari status saat ini.
     *
     * @return list<string>
     */
    public function allowedTransitions(): array
    {
        return self::TRANSITIONS[$this->status] ?? [];
    }

    public function canTransitionTo(string $status): bool
    {
        return in_array($status, $this->allowedTransitions(), true);
    }

    /**
     * Pindahkan status pesanan dan catat riwayatnya secara atomik.
     */
    public function transitionTo(string $status, ?User $user = null, ?string $note = null): void
    {
        DB::transaction(function () use ($status, $user, $note): void {
            // Baca ulang dan kunci baris pesanan agar dua request tidak
            // memindahkan status yang sama secara bersamaan.
            $locked = Order::query()
                ->whereKey($this->id)
                ->lockForUpdate()
                ->firstOrFail();

            if (! $locked->canTransitionTo($status)) {
                throw new InvalidStatusTransitionException($locked->status, $status);
            }

            $from = $locked->status;
            $locked->status = $status;
            $locked->save();

            $locked->histories()->create([
                'from' => $from,
                'to' => $status,
                'user_id' => $user?->id,
                'note' => $note,
            ]);
        });

        $this->refresh();
    }
}

app/Models/OrderItem.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = [
        'order_id',
        'product_id',
        'qty',
        'price',
    ];

    protected function casts(): array
    {
        return [
            'qty' => 'integer',
            'price' => 'integer',
        ];
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * Subtotal satu baris: harga snapshot dikali jumlah.
     */
    public function getSubtotalAttribute(): int
    {
        return $this->price * $this->qty;
    }
}

app/Models/OrderStatusHistory.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderStatusHistory extends Model
{
    protected $fillable = [
        'order_id',
        'from',
        'to',
        'user_id',
        'note',
    ];

    protected function casts(): array
    {
        return [
            'from' => 'string',
            'to' => 'string',
        ];
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

app/Models/Product.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    protected $fillable = [
        'name',
        'description',
        'price',
        'stock',
        'is_active',
    ];

    protected function casts(): array
    {
        return [
            'price' => 'integer',
            'stock' => 'integer',
            'is_active' => 'boolean',
        ];
    }

    public function orderItems(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    /**
     * Produk aktif yang boleh tampil di katalog dan dimasukkan
     * ke keranjang.
     */
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }
}

app/Models/User.php

Unduh berkas
php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }
}

app/Services/CartService.php

Unduh berkas
php
<?php

namespace App\Services;

use App\Models\Product;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Collection;

/**
 * Keranjang belanja berbasis session.
 *
 * Session menyimpan pasangan product_id => qty, tanpa tabel khusus:
 * keranjang bersifat sementara, per pengguna, dan boleh hilang saat
 * session berakhir. Hanya pesanan hasil checkout yang permanen.
 */
class CartService
{
    private const KEY = 'cart';

    public function __construct(private readonly Session $session)
    {
    }

    /**
     * Isi keranjang: [product_id => qty].
     *
     * @return array<int, int>
     */
    public function get(): array
    {
        return $this->session->get(self::KEY, []);
    }

    public function isEmpty(): bool
    {
        return $this->get() === [];
    }

    /**
     * Tambah jumlah produk; produk yang sama digabung.
     */
    public function add(int $productId, int $qty): void
    {
        $cart = $this->get();
        $cart[$productId] = ($cart[$productId] ?? 0) + $qty;

        $this->session->put(self::KEY, $cart);
    }

    /**
     * Set jumlah produk; jumlah nol berarti hapus dari keranjang.
     */
    public function setQuantity(int $productId, int $qty): void
    {
        $cart = $this->get();

        if ($qty <= 0) {
            unset($cart[$productId]);
        } else {
            $cart[$productId] = $qty;
        }

        $this->session->put(self::KEY, $cart);
    }

    public function remove(int $productId): void
    {
        $cart = $this->get();
        unset($cart[$productId]);

        $this->session->put(self::KEY, $cart);
    }

    public function clear(): void
    {
        $this->session->forget(self::KEY);
    }

    /**
     * Baris keranjang yang siap ditampilkan, selalu urut product_id.
     *
     * @return Collection<int, array{product: Product, qty: int, subtotal: int}>
     */
    public function items(): Collection
    {
        $cart = $this->get();

        if ($cart === []) {
            return collect();
        }

        $products = Product::query()
            ->whereIn('id', array_keys($cart))
            ->orderBy('id')
            ->get()
            ->keyBy('id');

        return $products
            ->map(fn (Product $product): array => [
                'product' => $product,
                'qty' => $cart[$product->id],
                'subtotal' => $product->price * $cart[$product->id],
            ])
            ->values();
    }

    /**
     * Total harga seluruh keranjang berdasarkan harga saat ini.
     */
    public function total(): int
    {
        return $this->items()->sum('subtotal');
    }

    /**
     * Total jumlah barang (bukan jumlah jenis produk).
     */
    public function count(): int
    {
        return array_sum($this->get());
    }
}

app/Services/CheckoutService.php

Unduh berkas
php
<?php

namespace App\Services;

use App\Exceptions\EmptyCartException;
use App\Exceptions\InsufficientStockException;
use App\Exceptions\ProductUnavailableException;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;

/**
 * Mengubah keranjang session menjadi pesanan permanen.
 *
 * Seluruh pekerjaan berat berjalan di dalam DB::transaction():
 * kunci baris produk, periksa ulang stok, kurangi stok, buat
 * pesanan beserta itemnya (harga snapshot), lalu tulis riwayat
 * status pertama. Keranjang hanya dibersihkan setelah commit.
 */
class CheckoutService
{
    public function __construct(private readonly CartService $cart)
    {
    }

    public function checkout(User $user): Order
    {
        if ($this->cart->isEmpty()) {
            throw new EmptyCartException();
        }

        $cart = $this->cart->get();

        $order = DB::transaction(function () use ($user, $cart): Order {
            // Kunci semua baris produk sekaligus dengan urutan id yang
            // tetap, agar dua checkout bersamaan tidak saling mengunci
            // dalam urutan berbeda (mencegah deadlock).
            $products = Product::query()
                ->whereIn('id', array_keys($cart))
                ->orderBy('id')
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            // Periksa ulang ketersediaan dan stok dari baris yang dikunci.
            foreach ($cart as $productId => $qty) {
                $product = $products->get($productId);

                if ($product === null || $product->is_active === false) {
                    throw new ProductUnavailableException();
                }

                if ($product->stock < $qty) {
                    throw new InsufficientStockException($product, $qty);
                }
            }

            // Hitung total dan siapkan item dengan harga snapshot:
            // harga yang dibaca saat checkout, bukan saat masuk keranjang.
            $total = 0;
            $items = [];

            foreach ($cart as $productId => $qty) {
                $product = $products->get($productId);

                $total += $product->price * $qty;
                $items[] = [
                    'product_id' => $product->id,
                    'qty' => $qty,
                    'price' => $product->price,
                ];
            }

            $order = $user->orders()->create([
                'total' => $total,
                'status' => Order::STATUS_PENDING,
            ]);

            foreach ($items as $item) {
                $order->items()->create($item);
            }

            foreach ($products as $product) {
                $product->stock -= $cart[$product->id];
                $product->save();
            }

            $order->histories()->create([
                'from' => null,
                'to' => Order::STATUS_PENDING,
                'user_id' => $user->id,
                'note' => 'Pesanan dibuat dari keranjang.',
            ]);

            return $order;
        });

        // Keranjang hanya dibersihkan setelah transaksi berhasil di-commit.
        $this->cart->clear();

        return $order;
    }
}

database/migrations/2026_10_13_234020_create_products_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name', 100);
            $table->text('description')->nullable();
            $table->unsignedInteger('price');
            $table->unsignedInteger('stock')->default(0);
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

database/migrations/2026_10_13_234021_create_orders_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->unsignedBigInteger('total')->default(0);
            $table->enum('status', ['pending', 'paid', 'shipped', 'completed', 'cancelled'])
                ->default('pending');
            $table->timestamps();
            $table->index(['user_id', 'id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

database/migrations/2026_10_13_234022_create_order_items_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->foreignId('product_id')
                ->constrained()
                ->restrictOnDelete();
            $table->unsignedInteger('qty');
            $table->unsignedInteger('price');
            $table->timestamps();

            // Satu pesanan paling banyak satu baris per produk,
            // karena keranjang disimpan sebagai pasangan produk => jumlah.
            $table->unique(['order_id', 'product_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
    }
};

database/migrations/2026_10_13_234023_create_order_status_histories_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('order_status_histories', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')
                ->constrained()
                ->cascadeOnDelete();
            // NULL berarti pesanan baru dibuat (transisi pertama menuju pending).
            $table->enum('from', ['pending', 'paid', 'shipped', 'completed', 'cancelled'])
                ->nullable();
            $table->enum('to', ['pending', 'paid', 'shipped', 'completed', 'cancelled']);
            $table->foreignId('user_id')
                ->nullable()
                ->constrained()
                ->nullOnDelete();
            $table->string('note')->nullable();
            $table->timestamps();
            $table->index(['order_id', 'id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_status_histories');
    }
};

database/seeders/DatabaseSeeder.php

Unduh berkas
php
<?php

namespace Database\Seeders;

use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        User::create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
            'password' => Hash::make('password'),
        ]);

        User::create([
            'name' => 'Siti Rahayu',
            'email' => '[email protected]',
            'password' => Hash::make('password'),
        ]);

        $products = [
            [
                'name' => 'Kopi Arabika Gayo 250g',
                'description' => 'Biji kopi single origin dari Gayo, roasting medium.',
                'price' => 75000,
                'stock' => 25,
                'is_active' => true,
            ],
            [
                'name' => 'Teh Hijau Melati 100g',
                'description' => 'Teh hijau wangi melati dalam kemasan tin.',
                'price' => 35000,
                'stock' => 40,
                'is_active' => true,
            ],
            [
                'name' => 'Gula Aren Cair 500ml',
                'description' => 'Pemanis alami dari aren, cocok untuk kopi dan teh.',
                'price' => 28000,
                'stock' => 15,
                'is_active' => true,
            ],
            [
                'name' => 'Madu Hutan 250ml',
                'description' => 'Madu multiflora tanpa campuran gula.',
                'price' => 95000,
                'stock' => 10,
                'is_active' => true,
            ],
            [
                'name' => 'Beras Merah 1kg',
                'description' => null,
                'price' => 22000,
                'stock' => 30,
                'is_active' => true,
            ],
            [
                'name' => 'Keripik Singkong Balado',
                'description' => 'Sedang tidak dijual: menunggu kemasan baru.',
                'price' => 18000,
                'stock' => 50,
                'is_active' => false,
            ],
        ];

        foreach ($products as $product) {
            Product::create($product);
        }
    }
}

routes/web.php

Unduh berkas
php
<?php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\CatalogController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\OrderController;
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;

Route::get('/', [CatalogController::class, 'index'])->name('catalog.index');

Route::middleware('guest')->group(function () {
    Route::get('login', [AuthController::class, 'showLogin'])->name('login');
    Route::post('login', [AuthController::class, 'login'])->name('login.attempt');
});

Route::post('logout', [AuthController::class, 'logout'])
    ->middleware('auth')
    ->name('logout');

// Keranjang hidup di session, jadi tamu pun boleh menggunakannya.
Route::get('cart', [CartController::class, 'index'])->name('cart.index');
Route::post('cart', [CartController::class, 'store'])->name('cart.store');
Route::put('cart/{product}', [CartController::class, 'update'])->name('cart.update');
Route::delete('cart/{product}', [CartController::class, 'destroy'])->name('cart.destroy');

Route::middleware('auth')->group(function () {
    Route::get('checkout', [CheckoutController::class, 'review'])->name('checkout.review');
    Route::post('checkout', [CheckoutController::class, 'store'])->name('checkout.store');

    Route::get('orders', [OrderController::class, 'index'])->name('orders.index');
    Route::get('orders/{order}', [OrderController::class, 'show'])->name('orders.show');
    Route::post('orders/{order}/status', [OrderController::class, 'updateStatus'])->name('orders.status');

    Route::resource('products', ProductController::class);
});

tests/Feature/AuthTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_tamu_diarahkan_ke_halaman_login(): void
    {
        $this->get(route('products.index'))
            ->assertRedirect(route('login'));
    }

    public function test_login_berhasil_dan_gagal(): void
    {
        User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
            'password' => 'password',
        ]);

        $this->from(route('login'))
            ->post(route('login.attempt'), [
                'email' => '[email protected]',
                'password' => 'salah',
            ])
            ->assertSessionHasErrors('email');

        $this->from(route('login'))
            ->post(route('login.attempt'), [
                'email' => '[email protected]',
                'password' => 'password',
            ])
            ->assertRedirect(route('catalog.index'));

        $this->assertAuthenticated();
    }

    public function test_logout_mengakhiri_sesi(): void
    {
        $user = User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);

        $this->actingAs($user)
            ->post(route('logout'))
            ->assertRedirect(route('catalog.index'));

        $this->assertGuest();
    }
}

tests/Feature/CartTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Product;
use App\Models\User;
use App\Services\CartService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CartTest extends TestCase
{
    use RefreshDatabase;

    private function user(): User
    {
        return User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);
    }

    private function product(array $overrides = []): Product
    {
        return Product::create([
            'name' => 'Kopi Arabika Gayo 250g',
            'description' => 'Biji kopi single origin dari Gayo, roasting medium.',
            'price' => 75000,
            'stock' => 25,
            'is_active' => true,
            ...$overrides,
        ]);
    }

    public function test_tamu_bisa_menambah_produk_ke_keranjang(): void
    {
        $product = $this->product();

        $this->from(route('catalog.index'))
            ->post(route('cart.store'), [
                'product_id' => $product->id,
                'qty' => 2,
            ])
            ->assertRedirect(route('catalog.index'));

        $this->assertSame([$product->id => 2], app(CartService::class)->get());
    }

    public function test_menambah_produk_sama_menggabungkan_jumlah(): void
    {
        $product = $this->product();

        $cart = app(CartService::class);
        $cart->add($product->id, 2);
        $cart->add($product->id, 3);

        $this->assertSame([$product->id => 5], $cart->get());
        $this->assertSame(5, $cart->count());
    }

    public function test_jumlah_produk_bisa_diperbarui(): void
    {
        $user = $this->user();
        $product = $this->product();

        app(CartService::class)->add($product->id, 2);

        $this->actingAs($user)
            ->from(route('cart.index'))
            ->put(route('cart.update', $product), ['qty' => 4])
            ->assertRedirect(route('cart.index'));

        $this->assertSame([$product->id => 4], app(CartService::class)->get());
    }

    public function test_update_jumlah_nol_menghapus_produk(): void
    {
        $user = $this->user();
        $product = $this->product();

        app(CartService::class)->add($product->id, 2);

        $this->actingAs($user)
            ->put(route('cart.update', $product), ['qty' => 0]);

        $this->assertSame([], app(CartService::class)->get());
    }

    public function test_produk_bisa_dihapus_dari_keranjang(): void
    {
        $user = $this->user();
        $product = $this->product();

        app(CartService::class)->add($product->id, 2);

        $this->actingAs($user)
            ->delete(route('cart.destroy', $product))
            ->assertRedirect(route('cart.index'));

        $this->assertSame([], app(CartService::class)->get());
    }

    public function test_produk_nonaktif_ditolak_masuk_keranjang(): void
    {
        $product = $this->product(['is_active' => false]);

        $this->from(route('catalog.index'))
            ->post(route('cart.store'), [
                'product_id' => $product->id,
                'qty' => 1,
            ])
            ->assertSessionHasErrors('product_id');

        $this->assertSame([], app(CartService::class)->get());
    }

    public function test_validasi_form_keranjang_ditolak(): void
    {
        $product = $this->product();

        $this->from(route('catalog.index'))
            ->post(route('cart.store'), [
                'product_id' => $product->id,
                'qty' => 0,
            ])
            ->assertSessionHasErrors('qty');

        $this->from(route('catalog.index'))
            ->post(route('cart.store'), [
                'product_id' => 99999,
                'qty' => 1,
            ])
            ->assertSessionHasErrors('product_id');

        $this->assertSame([], app(CartService::class)->get());
    }

    public function test_halaman_keranjang_menampilkan_isi_dan_total(): void
    {
        $user = $this->user();
        $kopi = $this->product();
        $teh = $this->product([
            'name' => 'Teh Hijau Melati 100g',
            'price' => 35000,
        ]);

        app(CartService::class)->add($kopi->id, 2); // 150000
        app(CartService::class)->add($teh->id, 1);  //  35000

        $this->actingAs($user)
            ->get(route('cart.index'))
            ->assertOk()
            ->assertSee('Kopi Arabika Gayo 250g')
            ->assertSee('Teh Hijau Melati 100g')
            ->assertSee('Rp185.000')
            ->assertSee('3 barang');
    }
}

tests/Feature/CheckoutTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Exceptions\InsufficientStockException;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\OrderStatusHistory;
use App\Models\Product;
use App\Models\User;
use App\Services\CartService;
use App\Services\CheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;

class CheckoutTest extends TestCase
{
    use RefreshDatabase;

    private function user(): User
    {
        return User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);
    }

    private function product(array $overrides = []): Product
    {
        return Product::create([
            'name' => 'Kopi Arabika Gayo 250g',
            'description' => 'Biji kopi single origin dari Gayo, roasting medium.',
            'price' => 75000,
            'stock' => 25,
            'is_active' => true,
            ...$overrides,
        ]);
    }

    public function test_checkout_membuat_pesanan_dengan_item_dan_snapshot(): void
    {
        $user = $this->user();
        $kopi = $this->product();
        $teh = $this->product([
            'name' => 'Teh Hijau Melati 100g',
            'price' => 35000,
            'stock' => 40,
        ]);

        $cart = app(CartService::class);
        $cart->add($kopi->id, 2); // 150000
        $cart->add($teh->id, 1);  //  35000

        $order = app(CheckoutService::class)->checkout($user);

        $this->assertSame(185000, $order->total);
        $this->assertSame(Order::STATUS_PENDING, $order->status);
        $this->assertCount(2, $order->items);

        // Stok berkurang tepat sesuai jumlah yang dipesan.
        $this->assertSame(23, $kopi->fresh()->stock);
        $this->assertSame(39, $teh->fresh()->stock);

        // Riwayat pertama tercatat: dari NULL menuju pending.
        $this->assertDatabaseHas('order_status_histories', [
            'order_id' => $order->id,
            'from' => null,
            'to' => 'pending',
            'user_id' => $user->id,
        ]);

        // Keranjang dibersihkan setelah commit.
        $this->assertSame([], $cart->get());
    }

    public function test_harga_snapshot_dibaca_saat_checkout_bukan_saat_masuk_keranjang(): void
    {
        $user = $this->user();
        $kopi = $this->product();

        app(CartService::class)->add($kopi->id, 2);

        // Admin menaikkan harga setelah produk masuk keranjang.
        $kopi->forceFill(['price' => 80000])->save();

        $order = app(CheckoutService::class)->checkout($user);

        $item = $order->items->first();
        $this->assertSame(80000, $item->price);
        $this->assertSame(160000, $order->total);
    }

    public function test_checkout_via_http_mengalihkan_ke_detail_pesanan(): void
    {
        $user = $this->user();
        $kopi = $this->product();

        app(CartService::class)->add($kopi->id, 2);

        $this->actingAs($user)
            ->from(route('checkout.review'))
            ->post(route('checkout.store'))
            ->assertRedirect(route('orders.show', 1));

        $this->assertDatabaseHas('orders', [
            'user_id' => $user->id,
            'total' => 150000,
            'status' => 'pending',
        ]);
        $this->assertDatabaseHas('order_items', [
            'product_id' => $kopi->id,
            'qty' => 2,
            'price' => 75000,
        ]);
        $this->assertSame(23, $kopi->fresh()->stock);
    }

    public function test_checkout_keranjang_kosong_ditolak(): void
    {
        $user = $this->user();

        $this->actingAs($user)
            ->from(route('checkout.review'))
            ->post(route('checkout.store'))
            ->assertSessionHasErrors('cart');

        $this->assertDatabaseCount('orders', 0);
    }

    public function test_stok_tidak_cukup_ditolak_dan_pesanan_tidak_terbentuk(): void
    {
        $user = $this->user();
        $kopi = $this->product(['stock' => 3]);

        app(CartService::class)->add($kopi->id, 5);

        $this->actingAs($user)
            ->from(route('checkout.review'))
            ->post(route('checkout.store'))
            ->assertSessionHasErrors('cart');

        // Tidak ada pesanan, stok utuh, keranjang tetap tersimpan.
        $this->assertDatabaseCount('orders', 0);
        $this->assertSame(3, $kopi->fresh()->stock);
        $this->assertSame([$kopi->id => 5], app(CartService::class)->get());
    }

    public function test_produk_dinonaktifkan_setelah_masuk_keranjang_ditolak(): void
    {
        $user = $this->user();
        $kopi = $this->product();

        app(CartService::class)->add($kopi->id, 1);

        $kopi->forceFill(['is_active' => false])->save();

        $this->actingAs($user)
            ->from(route('checkout.review'))
            ->post(route('checkout.store'))
            ->assertSessionHasErrors('cart');

        $this->assertDatabaseCount('orders', 0);
        $this->assertSame(25, $kopi->fresh()->stock);
    }

    public function test_kegagalan_di_tengah_transaksi_dibatalkan_seluruhnya(): void
    {
        $user = $this->user();
        $kopi = $this->product();

        app(CartService::class)->add($kopi->id, 4);

        // Simulasikan gangguan tepat saat baris order_items di-insert.
        OrderItem::creating(function (): void {
            throw new RuntimeException('simulasi gangguan saat menyimpan item');
        });

        try {
            app(CheckoutService::class)->checkout($user);
            $this->fail('Pengecualian simulasi seharusnya terlempar.');
        } catch (RuntimeException) {
            // dibiarkan: inilah skenario yang diuji
        } finally {
            OrderItem::flushEventListeners();
        }

        // Atomicity: tidak ada pesanan, stok utuh, keranjang masih ada.
        $this->assertDatabaseCount('orders', 0);
        $this->assertDatabaseCount('order_items', 0);
        $this->assertDatabaseCount('order_status_histories', 0);
        $this->assertSame(25, $kopi->fresh()->stock);
        $this->assertSame([$kopi->id => 4], app(CartService::class)->get());
    }

    public function test_tamu_diarahkan_ke_halaman_login_sebelum_checkout(): void
    {
        $this->get(route('checkout.review'))
            ->assertRedirect(route('login'));
    }
}

tests/Feature/ExampleTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Halaman depan adalah katalog publik: seluruh produk aktif
     * tampil tanpa perlu login.
     */
    public function test_halaman_depan_menampilkan_katalog(): void
    {
        $this->get('/')
            ->assertOk()
            ->assertSee('Belum ada produk aktif.');
    }
}

tests/Feature/OrderStatusTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Exceptions\InvalidStatusTransitionException;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use App\Services\CartService;
use App\Services\CheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderStatusTest extends TestCase
{
    use RefreshDatabase;

    private function user(): User
    {
        return User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);
    }

    private function product(): Product
    {
        return Product::create([
            'name' => 'Kopi Arabika Gayo 250g',
            'description' => 'Biji kopi single origin dari Gayo, roasting medium.',
            'price' => 75000,
            'stock' => 25,
            'is_active' => true,
        ]);
    }

    private function order(?User $user = null): Order
    {
        $user ??= $this->user();

        app(CartService::class)->add($this->product()->id, 2);

        return app(CheckoutService::class)->checkout($user);
    }

    public function test_pesanan_baru_berstatus_pending_dengan_riwayat_awal(): void
    {
        $order = $this->order();

        $this->assertSame('pending', $order->status);
        $this->assertSame(1, $order->histories()->count());

        $history = $order->histories()->first();
        $this->assertNull($history->from);
        $this->assertSame('pending', $history->to);
    }

    public function test_transaksi_status_sah_tercatat_berurutan(): void
    {
        $user = $this->user();
        $order = $this->order($user);

        $order->transitionTo(Order::STATUS_PAID, $user, 'Pembayaran diterima via transfer.');
        $order->transitionTo(Order::STATUS_SHIPPED, $user, 'Dikirim dengan kurir reguler.');
        $order->transitionTo(Order::STATUS_COMPLETED, $user);

        $this->assertSame('completed', $order->status);

        $this->assertDatabaseHas('order_status_histories', [
            'order_id' => $order->id,
            'from' => 'pending',
            'to' => 'paid',
            'user_id' => $user->id,
            'note' => 'Pembayaran diterima via transfer.',
        ]);
        $this->assertDatabaseHas('order_status_histories', [
            'order_id' => $order->id,
            'from' => 'paid',
            'to' => 'shipped',
        ]);

        // Riwayat bertambah: 1 awal + 3 transisi.
        $this->assertSame(4, $order->histories()->count());
    }

    public function test_transaksi_tidak_sah_ditolak(): void
    {
        $order = $this->order();

        // Pesanan belum dibayar tidak boleh langsung dikirim.
        try {
            $order->transitionTo(Order::STATUS_SHIPPED);
            $this->fail('Transisi pending ke seharusnya melempar pengecualian.');
        } catch (InvalidStatusTransitionException $exception) {
            $this->assertSame('pending', $exception->from);
            $this->assertSame('shipped', $exception->to);
        }

        $this->assertSame('pending', $order->fresh()->status);
        $this->assertSame(1, $order->histories()->count());
    }

    public function test_status_final_tidak_bisa_berubah(): void
    {
        $order = $this->order();
        $order->transitionTo(Order::STATUS_CANCELLED);

        $this->assertSame([], $order->fresh()->allowedTransitions());

        $this->expectException(InvalidStatusTransitionException::class);
        $order->transitionTo(Order::STATUS_PAID);
    }

    public function test_form_transaksi_tidak_sah_ditolak(): void
    {
        $user = $this->user();
        $order = $this->order($user);

        $this->actingAs($user)
            ->from(route('orders.show', $order))
            ->post(route('orders.status', $order), [
                'status' => 'shipped',
                'note' => 'Isi tidak sah dari pengguna nakal.',
            ])
            ->assertSessionHasErrors('status');

        $this->assertSame('pending', $order->fresh()->status);
        $this->assertSame(1, $order->histories()->count());
    }

    public function test_form_transaksi_sah_menyimpan_dan_mengalihkan(): void
    {
        $user = $this->user();
        $order = $this->order($user);

        $this->actingAs($user)
            ->from(route('orders.show', $order))
            ->post(route('orders.status', $order), [
                'status' => 'paid',
                'note' => 'Pembayaran diterima via transfer.',
            ])
            ->assertRedirect(route('orders.show', $order));

        $this->assertSame('paid', $order->fresh()->status);
        $this->assertSame(2, $order->histories()->count());
    }

    public function test_status_tidak_dikenal_ditolak_validasi(): void
    {
        $user = $this->user();
        $order = $this->order($user);

        $this->actingAs($user)
            ->post(route('orders.status', $order), [
                'status' => 'dikirim',
            ])
            ->assertSessionHasErrors('status');

        $this->assertSame('pending', $order->fresh()->status);
    }

    public function test_pesanan_orang_lain_tidak_bisa_dilihat(): void
    {
        $owner = $this->user();
        $order = $this->order($owner);

        $intruder = User::factory()->create([
            'name' => 'Siti Rahayu',
            'email' => '[email protected]',
        ]);

        $this->actingAs($intruder)
            ->get(route('orders.show', $order))
            ->assertForbidden();
    }

    public function test_halaman_detail_menampilkan_item_dan_riwayat(): void
    {
        $user = $this->user();
        $order = $this->order($user);
        $order->transitionTo(Order::STATUS_PAID, $user, 'Pembayaran diterima via transfer.');

        $this->actingAs($user)
            ->get(route('orders.show', $order))
            ->assertOk()
            ->assertSee('Kopi Arabika Gayo 250g')
            ->assertSee('Rp75.000')
            ->assertSee('Pembayaran diterima via transfer.')
            ->assertSee('pending')
            ->assertSee('paid');
    }
}

tests/Feature/ProductTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductTest extends TestCase
{
    use RefreshDatabase;

    private function user(): User
    {
        return User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]);
    }

    private function product(array $overrides = []): Product
    {
        return Product::create([
            'name' => 'Kopi Arabika Gayo 250g',
            'description' => 'Biji kopi single origin dari Gayo, roasting medium.',
            'price' => 75000,
            'stock' => 25,
            'is_active' => true,
            ...$overrides,
        ]);
    }

    public function test_katalog_hanya_menampilkan_produk_aktif(): void
    {
        $this->product();
        $this->product([
            'name' => 'Keripik Singkong Balado',
            'is_active' => false,
        ]);

        $this->get(route('catalog.index'))
            ->assertOk()
            ->assertSee('Kopi Arabika Gayo 250g')
            ->assertDontSee('Keripik Singkong Balado');
    }

    public function test_admin_menyimpan_produk_baru(): void
    {
        $user = $this->user();

        $this->actingAs($user)
            ->from(route('products.create'))
            ->post(route('products.store'), [
                'name' => 'Madu Hutan 250ml',
                'description' => 'Madu multiflora tanpa campuran gula.',
                'price' => 95000,
                'stock' => 10,
                'is_active' => '1',
            ])
            ->assertRedirect(route('products.show', 1));

        $this->assertDatabaseHas('products', [
            'name' => 'Madu Hutan 250ml',
            'price' => 95000,
            'stock' => 10,
            'is_active' => true,
        ]);
    }

    public function test_checkbox_tidak_dicentang_menyimpan_produk_nonaktif(): void
    {
        $user = $this->user();

        $this->actingAs($user)
            ->post(route('products.store'), [
                'name' => 'Keripik Singkong Balado',
                'description' => null,
                'price' => 18000,
                'stock' => 50,
            ]);

        $this->assertDatabaseHas('products', [
            'name' => 'Keripik Singkong Balado',
            'is_active' => false,
        ]);
    }

    public function test_validasi_harga_dan_stok_ditolak(): void
    {
        $user = $this->user();

        $this->actingAs($user)
            ->from(route('products.create'))
            ->post(route('products.store'), [
                'name' => 'Gula Aren Cair 500ml',
                'description' => null,
                'price' => -1000,
                'stock' => -5,
            ])
            ->assertSessionHasErrors(['price', 'stock']);

        $this->assertDatabaseCount('products', 0);
    }

    public function test_admin_memperbarui_dan_menghapus_produk(): void
    {
        $user = $this->user();
        $product = $this->product();

        $this->actingAs($user)
            ->from(route('products.edit', $product))
            ->put(route('products.update', $product), [
                'name' => 'Kopi Arabika Toraja 250g',
                'description' => 'Biji kopi single origin dari Toraja.',
                'price' => 78000,
                'stock' => 20,
                'is_active' => '1',
            ])
            ->assertRedirect(route('products.show', $product));

        $this->assertSame('Kopi Arabika Toraja 250g', $product->fresh()->name);

        $this->actingAs($user)
            ->delete(route('products.destroy', $product))
            ->assertRedirect(route('products.index'));

        $this->assertDatabaseCount('products', 0);
    }

    public function test_daftar_produk_admin_terpaginasi_sepuluh_per_halaman(): void
    {
        $user = $this->user();

        foreach (range(1, 11) as $i) {
            $this->product(['name' => "Produk Contoh {$i}"]);
        }

        $response = $this->actingAs($user)->get(route('products.index'));

        $response->assertOk();
        $this->assertCount(10, $response->viewData('products')->items());
    }
}