Sistem inventaris menyimpan data yang orang lain andalkan untuk keputusan pembelian, sehingga klaim “sudah berfungsi” tidak boleh mengandalkan klik manual yang hasilnya berlalu begitu saja. Pada bagian ini kita menutup proyek dengan feature test yang membuktikan tiga hal besar: CRUD produk menaati aturan stok, transaksi masuk-keluar menjaga konsistensi database, dan pencarian serta laporan menampilkan data yang tepat. Seluruh test berjalan lewat php artisan test dan mengeksekusi aplikasi dari route sampai database SQLite dalam memori.

Strategi Pengujian #

Empat file test dibagi berdasarkan area yang dijaganya:

File testYang dibuktikan
AuthTestlogin menolak kredensial salah dan meneruskan yang benar
ProductCrudTestgerbang login, CRUD produk, SKU unik, stok tak tersentuh form, pagination
StockMovementTestbarang masuk/keluar, penolakan stok negatif, atomicity, alur HTTP
SearchFilterTestpencarian nama/SKU, filter kategori, rentang stok, stok kritis, laporan

Seluruh test memakai trait RefreshDatabase sehingga setiap test mulai dari skema kosong yang dimigrasi ulang — tidak ada test yang bergantung pada sisa data test sebelumnya. Identitas pengguna dibuat dengan User::factory() lalu actingAs(), mensimulasikan sesi login tanpa harus melewati form. Assert paling sering dipakai adalah assertDatabaseHas() dan assertDatabaseCount(), karena pertanyaan terpenting inventaris selalu berakhir di satu tempat: apa isi database setelahnya.

Data uji dibuat langsung dengan Product::create() di dalam test, bukan lewat model factory. Pilihan ini disengaja: setiap test inventaris butuh kombinasi angka stok dan minimum yang spesifik — stok 5 melawan permintaan 8, stok 15 di dalam rentang 10–20 — dan menuliskannya secara eksplisit membuat skenario test terbaca sebagai cerita, bukan sebagai konfigurasi factory yang harus ditelusuri ke berkas lain. Nama test ditulis dalam Bahasa Indonesia dengan prefiks test_ sehingga output runner berbunyi seperti daftar perilaku sistem yang bisa dibaca petugas non-programmer sekalipun.

Menguji Aturan Stok #

Aturan “stok tidak bisa negatif” diuji dengan memanggil service secara langsung lalu memeriksa efeknya:

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

    app(StockService::class)->recordMovement($product, StockMovement::TYPE_IN, 5, movementDate: '2026-01-02');

    try {
        app(StockService::class)->recordMovement($product, StockMovement::TYPE_OUT, 8, movementDate: '2026-01-05');
        $this->fail('Barang keluar melebihi stok seharusnya melempar pengecualian.');
    } catch (InsufficientStockException $exception) {
        $this->assertSame(5, $exception->product->stock);
        $this->assertSame(8, $exception->requested);
    }

    // Stok tetap dan tidak ada baris riwayat baru yang tersimpan.
    $this->assertSame(5, $product->fresh()->stock);
    $this->assertDatabaseCount('stock_movements', 1);
}

Urutan skenarionya sengaja memperlihatkan sifat penolakan: stok lima diminta keluar delapan, pengecualian terlempar, dan dua assertion terakhir membuktikan stok masih lima dengan riwayat tetap satu baris — penolakan tidak meninggalkan jejak setengah jadi. Test atomicity pada file yang sama memakai listener StockMovement::creating yang melempar gangguan; kita sudah membedahnya di Bagian 4.

Menguji Lapisan HTTP #

Pengujian service tidak menggantikan pengujian form, karena form punya pengalaman pengguna sendiri. Test berikut mengirim request POST /movements dari browser tiruan:

php
$this->actingAs($user)
    ->from(route('movements.create'))
    ->post(route('movements.store'), [
        'product_id' => $product->id,
        'type' => 'out',
        'quantity' => 9,
        'note' => 'Peminjaman untuk pameran',
        'movement_date' => '2026-01-05',
    ])
    ->assertSessionHasErrors('quantity');

$this->assertSame(5, $product->fresh()->stock);
$this->assertDatabaseCount('stock_movements', 0);

Pola from() menetapkan halaman asal, assertSessionHasErrors('quantity') memastikan pesan penolakan tiba di kolom yang tepat, dan dua assertion bawah menegaskan database tidak berubah. Test pencarian memakai teknik yang berbeda: assertSee() untuk data yang harus tampil dan assertDontSee() untuk yang harus tersaring — misalnya mencari q=mouse harus menampilkan Mouse Wireless sekaligus menyembunyikan Kertas HVS, dan laporan stok kritis menampilkan Kertas HVS lengkap dengan kekurangan “7 rim” tanpa menyertakan produk yang stoknya aman.

Hasil Eksekusi Test #

Seluruh rangkaian dijalankan dengan satu perintah:

bash
php artisan test

Output yang Dihasilkan:

text
   PASS  Tests\Unit\ExampleTest
  ✓ that true is true

   PASS  Tests\Feature\AuthTest
  ✓ login berhasil dan gagal                                             0.38s

   PASS  Tests\Feature\ExampleTest
  ✓ halaman depan meneruskan ke daftar produk                            0.01s

   PASS  Tests\Feature\ProductCrudTest
  ✓ tamu diarahkan ke halaman login                                      0.01s
  ✓ pengguna melihat daftar produk                                       0.02s
  ✓ menyimpan produk baru dengan stok nol                                0.01s
  ✓ sku duplikat ditolak validasi                                        0.01s
  ✓ memperbarui produk tanpa menyentuh kolom stok                       0.01s
  ✓ daftar produk terpaginasi sepuluh per halaman                        0.02s
  ✓ menghapus produk beserta riwayat pergerakannya                       0.01s

   PASS  Tests\Feature\SearchFilterTest
  ✓ cari berdasarkan nama                                                0.01s
  ✓ cari berdasarkan sku                                                 0.01s
  ✓ cari tidak peka huruf besar kecil                                    0.01s
  ✓ filter berdasarkan kategori                                          0.01s
  ✓ filter rentang stok                                                  0.01s
  ✓ filter stok kritis hanya yang di bawah minimum                       0.01s
  ✓ filter gabungan pencarian dan kategori                               0.01s
  ✓ laporan stok kritis menampilkan produk yang perlu dipesan            0.01s

   PASS  Tests\Feature\StockMovementTest
  ✓ barang masuk menambah stok                                           0.02s
  ✓ barang keluar mengurangi stok                                        0.01s
  ✓ stok tidak bisa negatif                                              0.01s
  ✓ kegagalan di tengah transaksi dibatalkan seluruhnya                  0.01s
  ✓ form barang keluar melebihi stok ditolak                             0.02s
  ✓ form transaksi valid menyimpan dan mengalihkan                       0.01s
  ✓ riwayat transaksi menampilkan gerakan terbaru                        0.01s

  Tests:    25 passed (84 assertions)
  Duration: 0.74s

Dua puluh lima test dengan 84 assertion berlalu dalam waktu kurang dari satu detik. Dengan ini proyek inventaris tuntas: skema yang relasinya jelas, CRUD yang tunduk aturan, transaksi stok yang atomik, pencarian yang cepat, dan test yang menjaganya tetap demikian. Dari sini banyak arah pengembangan terbuka — ekspor laporan ke PDF, notifikasi untuk produk kritis lewat Laravel Notifications, atau pembatasan hak akses per peran dengan authorization — dan semua arah tersebut kini punya jaring pengaman berupa test yang tinggal dijalankan ulang.

Berkas Sumber

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

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 (%s) tidak cukup: tersedia %d %s, diminta %d %s.',
            $product->name,
            $product->sku,
            $product->stock,
            $product->unit,
            $requested,
            $product->unit,
        ));
    }
}

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('products.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('login');
    }
}

app/Http/Controllers/Controller.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

abstract class Controller
{
    //
}

app/Http/Controllers/ProductController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\ProductStoreRequest;
use App\Http\Requests\ProductUpdateRequest;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function index(Request $request): View
    {
        $products = Product::query()
            ->with('category')
            ->search($request->query('q'))
            ->ofCategory($request->query('category'))
            ->stockBetween($request->query('stock_min'), $request->query('stock_max'))
            ->when(
                $request->query('critical') === '1',
                fn ($query) => $query->critical(),
            )
            ->orderBy('name')
            ->paginate(10)
            ->withQueryString();

        return view('products.index', [
            'products' => $products,
            'categories' => Category::orderBy('name')->get(),
            'filters' => $request->only(['q', 'category', 'stock_min', 'stock_max', 'critical']),
        ]);
    }

    public function create(): View
    {
        return view('products.create', [
            'categories' => Category::orderBy('name')->get(),
        ]);
    }

    public function store(ProductStoreRequest $request): RedirectResponse
    {
        $product = Product::create([
            ...$request->validated(),
            // Stok awal selalu 0: angka hanya boleh berubah lewat transaksi.
            'stock' => 0,
        ]);

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

    public function show(Product $product): View
    {
        $movements = $product->movements()
            ->with('user')
            ->orderByDesc('id')
            ->limit(10)
            ->get();

        return view('products.show', [
            'product' => $product->load('category'),
            'movements' => $movements,
        ]);
    }

    public function edit(Product $product): View
    {
        return view('products.edit', [
            'product' => $product->load('category'),
            'categories' => Category::orderBy('name')->get(),
        ]);
    }

    public function update(ProductUpdateRequest $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} beserta riwayat pergerakannya berhasil dihapus.");
    }
}

app/Http/Controllers/ReportController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

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

class ReportController extends Controller
{
    /**
     * Laporan stok kritis: produk dengan stok <= stok minimum.
     */
    public function lowStock(): View
    {
        $products = Product::query()
            ->with('category')
            ->critical()
            ->orderBy('name')
            ->paginate(10);

        return view('reports.low-stock', [
            'products' => $products,
        ]);
    }
}

app/Http/Controllers/StockMovementController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Exceptions\InsufficientStockException;
use App\Http\Requests\StockMovementRequest;
use App\Models\Product;
use App\Models\StockMovement;
use App\Services\StockService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;

class StockMovementController extends Controller
{
    public function index(): View
    {
        $movements = StockMovement::query()
            ->with(['product.category', 'user'])
            ->orderByDesc('id')
            ->paginate(15);

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

    public function create(): View
    {
        return view('movements.create', [
            'products' => Product::orderBy('name')->get(),
            'preselected' => request('product'),
        ]);
    }

    public function store(
        StockMovementRequest $request,
        StockService $service,
    ): RedirectResponse {
        $data = $request->validated();

        $product = Product::findOrFail($data['product_id']);

        try {
            $service->recordMovement(
                $product,
                $data['type'],
                (int) $data['quantity'],
                $data['note'] ?? null,
                $request->user(),
                $data['movement_date'],
            );
        } catch (InsufficientStockException $exception) {
            return redirect()
                ->back()
                ->withInput()
                ->withErrors(['quantity' => $exception->getMessage()]);
        }

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

app/Http/Requests/ProductStoreRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductStoreRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<int, string>>
     */
    public function rules(): array
    {
        return [
            'sku' => ['required', 'string', 'max:32', 'unique:products,sku'],
            'name' => ['required', 'string', 'max:100'],
            'category_id' => ['required', 'integer', 'exists:categories,id'],
            'unit' => ['required', 'string', 'max:20'],
            'min_stock' => ['required', 'integer', 'min:0'],
            'price' => ['required', 'numeric', 'min:0'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'sku' => 'SKU',
            'category_id' => 'kategori',
            'unit' => 'satuan',
            'min_stock' => 'stok minimum',
            'price' => 'harga',
        ];
    }
}

app/Http/Requests/ProductUpdateRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

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

class ProductUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<int, string>>
     */
    public function rules(): array
    {
        return [
            'sku' => [
                'required', 'string', 'max:32',
                Rule::unique('products', 'sku')->ignore($this->route('product')),
            ],
            'name' => ['required', 'string', 'max:100'],
            'category_id' => ['required', 'integer', 'exists:categories,id'],
            'unit' => ['required', 'string', 'max:20'],
            'min_stock' => ['required', 'integer', 'min:0'],
            'price' => ['required', 'numeric', 'min:0'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'sku' => 'SKU',
            'category_id' => 'kategori',
            'unit' => 'satuan',
            'min_stock' => 'stok minimum',
            'price' => 'harga',
        ];
    }
}

app/Http/Requests/StockMovementRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StockMovementRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<int, string>>
     */
    public function rules(): array
    {
        return [
            'product_id' => ['required', 'integer', 'exists:products,id'],
            'type' => ['required', 'in:in,out'],
            'quantity' => ['required', 'integer', 'min:1'],
            'note' => ['nullable', 'string', 'max:255'],
            'movement_date' => ['required', 'date'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'product_id' => 'produk',
            'type' => 'jenis transaksi',
            'quantity' => 'jumlah',
            'movement_date' => 'tanggal transaksi',
        ];
    }
}

app/Models/Category.php

Unduh berkas
php
<?php

namespace App\Models;

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

class Category extends Model
{
    protected $fillable = ['name', 'description'];

    public function products(): HasMany
    {
        return $this->hasMany(Product::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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    protected $fillable = [
        'sku',
        'name',
        'category_id',
        'unit',
        'stock',
        'min_stock',
        'price',
    ];

    protected function casts(): array
    {
        return [
            'category_id' => 'integer',
            'stock' => 'integer',
            'min_stock' => 'integer',
            'price' => 'decimal:2',
        ];
    }

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

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

    /**
     * Cari produk berdasarkan nama atau SKU (partial match).
     */
    public function scopeSearch(Builder $query, ?string $keyword): Builder
    {
        return $query->when(filled($keyword), function (Builder $query) use ($keyword) {
            $keyword = trim($keyword);

            $query->where(function (Builder $query) use ($keyword) {
                $query->where('name', 'like', "%{$keyword}%")
                    ->orWhere('sku', 'like', "%{$keyword}%");
            });
        });
    }

    /**
     * Filter produk milik satu kategori.
     */
    public function scopeOfCategory(Builder $query, ?int $categoryId): Builder
    {
        return $query->when(filled($categoryId), function (Builder $query) use ($categoryId) {
            $query->where('category_id', $categoryId);
        });
    }

    /**
     * Filter produk pada rentang stok [min, max].
     */
    public function scopeStockBetween(Builder $query, ?int $min, ?int $max): Builder
    {
        return $query
            ->when(filled($min), fn (Builder $query) => $query->where('stock', '>=', $min))
            ->when(filled($max), fn (Builder $query) => $query->where('stock', '<=', $max));
    }

    /**
     * Produk dengan stok kritis: stok saat ini <= stok minimum.
     */
    public function scopeCritical(Builder $query): Builder
    {
        return $query->whereColumn('stock', '<=', 'min_stock');
    }
}

app/Models/StockMovement.php

Unduh berkas
php
<?php

namespace App\Models;

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

class StockMovement extends Model
{
    public const TYPE_IN = 'in';

    public const TYPE_OUT = 'out';

    protected $fillable = [
        'product_id',
        'type',
        'quantity',
        'note',
        'user_id',
        'movement_date',
    ];

    protected function casts(): array
    {
        return [
            'product_id' => 'integer',
            'quantity' => 'integer',
            'user_id' => 'integer',
            'movement_date' => 'date',
        ];
    }

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

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

app/Services/StockService.php

Unduh berkas
php
<?php

namespace App\Services;

use App\Exceptions\InsufficientStockException;
use App\Models\Product;
use App\Models\StockMovement;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class StockService
{
    /**
     * Catat satu pergerakan stok dan perbarui stok produk secara atomik.
     *
     * Seluruh operasi berjalan di dalam DB::transaction() sehingga baris
     * pada stock_movements dan angka products.stock selalu konsisten:
     * keduanya tersimpan bersama atau keduanya dibatalkan bersama.
     */
    public function recordMovement(
        Product $product,
        string $type,
        int $quantity,
        ?string $note = null,
        ?User $user = null,
        ?string $movementDate = null,
    ): StockMovement {
        return DB::transaction(function () use ($product, $type, $quantity, $note, $user, $movementDate): StockMovement {
            // Kunci baris produk agar dua proses tidak menghitung stok
            // dari angka yang sama secara bersamaan (race condition).
            $locked = Product::query()
                ->whereKey($product->id)
                ->lockForUpdate()
                ->firstOrFail();

            if ($type === StockMovement::TYPE_OUT && $locked->stock < $quantity) {
                throw new InsufficientStockException($locked, $quantity);
            }

            $movement = $locked->movements()->create([
                'type' => $type,
                'quantity' => $quantity,
                'note' => $note,
                'user_id' => $user?->id,
                'movement_date' => $movementDate ?? now()->toDateString(),
            ]);

            $locked->stock = $type === StockMovement::TYPE_IN
                ? $locked->stock + $quantity
                : $locked->stock - $quantity;
            $locked->save();

            return $movement;
        });
    }
}

database/migrations/2026_11_01_000010_create_categories_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('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name', 60)->unique();
            $table->string('description', 255)->nullable();
            $table->timestamps();
        });
    }

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

database/migrations/2026_11_01_000020_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('sku', 32)->unique();
            $table->string('name', 100);
            $table->foreignId('category_id')
                ->constrained()
                ->restrictOnDelete();
            $table->string('unit', 20)->default('pcs');
            $table->unsignedInteger('stock')->default(0);
            $table->unsignedInteger('min_stock')->default(0);
            $table->decimal('price', 12, 2)->default(0);
            $table->timestamps();
        });
    }

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

database/migrations/2026_11_01_000030_create_stock_movements_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('stock_movements', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->enum('type', ['in', 'out']);
            $table->unsignedInteger('quantity');
            $table->string('note', 255)->nullable();
            $table->foreignId('user_id')
                ->nullable()
                ->constrained()
                ->nullOnDelete();
            $table->date('movement_date');
            $table->timestamps();

            $table->index(['product_id', 'id']);
            $table->index(['type', 'movement_date']);
        });
    }

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

database/seeders/DatabaseSeeder.php

Unduh berkas
php
<?php

namespace Database\Seeders;

use App\Models\Category;
use App\Models\Product;
use App\Models\StockMovement;
use App\Models\User;
use App\Services\StockService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

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

        $elektronik = Category::create([
            'name' => 'Elektronik',
            'description' => 'Peralatan elektronik kantor',
        ]);

        $alatTulis = Category::create([
            'name' => 'Alat Tulis',
            'description' => 'Kebutuhan alat tulis kantor',
        ]);

        $service = app(StockService::class);

        $mouse = Product::create([
            'sku' => 'ELK-001',
            'name' => 'Mouse Wireless M1',
            'category_id' => $elektronik->id,
            'unit' => 'pcs',
            'stock' => 0,
            'min_stock' => 5,
            'price' => 85000,
        ]);

        $tinta = Product::create([
            'sku' => 'ELK-002',
            'name' => 'Tinta Printer Hitam',
            'category_id' => $elektronik->id,
            'unit' => 'botol',
            'stock' => 0,
            'min_stock' => 8,
            'price' => 120000,
        ]);

        $kertas = Product::create([
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 80gsm',
            'category_id' => $alatTulis->id,
            'unit' => 'rim',
            'stock' => 0,
            'min_stock' => 10,
            'price' => 55000,
        ]);

        $service->recordMovement($mouse, StockMovement::TYPE_IN, 20, 'Stok pembukaan', $budi, '2026-01-02');
        $service->recordMovement($mouse, StockMovement::TYPE_OUT, 3, 'Peminjaman ruang meeting', $budi, '2026-01-05');
        $service->recordMovement($tinta, StockMovement::TYPE_IN, 10, 'Stok pembukaan', $budi, '2026-01-02');
        $service->recordMovement($tinta, StockMovement::TYPE_OUT, 6, 'Penggantian tinta printer lantai 2', $budi, '2026-01-06');
        $service->recordMovement($kertas, StockMovement::TYPE_IN, 15, 'Stok pembukaan', $budi, '2026-01-02');
    }
}

routes/web.php

Unduh berkas
php
<?php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ReportController;
use App\Http\Controllers\StockMovementController;
use Illuminate\Support\Facades\Route;

Route::get('/', fn () => redirect()->route('products.index'))->name('home');

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');

Route::middleware('auth')->group(function () {
    Route::resource('products', ProductController::class);

    Route::get('movements', [StockMovementController::class, 'index'])
        ->name('movements.index');
    Route::get('movements/create', [StockMovementController::class, 'create'])
        ->name('movements.create');
    Route::post('movements', [StockMovementController::class, 'store'])
        ->name('movements.store');

    Route::get('reports/low-stock', [ReportController::class, 'lowStock'])
        ->name('reports.low-stock');
});

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_login_berhasil_dan_gagal(): void
    {
        User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
            'password' => bcrypt('password-rahasia'),
        ]);

        // Kredensial salah: kembali dengan error.
        $this->from(route('login'))
            ->post(route('login.attempt'), [
                'email' => '[email protected]',
                'password' => 'salah',
            ])
            ->assertSessionHasErrors('email');

        // Kredensial benar: masuk dan diarahkan ke daftar produk.
        $this->post(route('login.attempt'), [
            'email' => '[email protected]',
            'password' => 'password-rahasia',
        ])
        ->assertRedirect(route('products.index'));

        $this->assertAuthenticated();
    }
}

tests/Feature/ExampleTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * Halaman depan cukup meneruskan pengunjung ke daftar produk;
     * dari sana middleware auth yang meneruskan tamu ke halaman login.
     */
    public function test_halaman_depan_meneruskan_ke_daftar_produk(): void
    {
        $this->get('/')
            ->assertRedirect(route('products.index'));
    }
}

tests/Feature/ProductCrudTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

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

class ProductCrudTest extends TestCase
{
    use RefreshDatabase;

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

        $this->actingAs($user);

        return $user;
    }

    private function category(): Category
    {
        return Category::create(['name' => 'Alat Tulis']);
    }

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

    public function test_pengguna_melihat_daftar_produk(): void
    {
        $this->login();

        Product::create([
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 80gsm',
            'category_id' => $this->category()->id,
            'unit' => 'rim',
            'stock' => 15,
            'min_stock' => 10,
            'price' => 55000,
        ]);

        $this->get(route('products.index'))
            ->assertOk()
            ->assertSee('Kertas HVS A4 80gsm')
            ->assertSee('ATK-001');
    }

    public function test_menyimpan_produk_baru_dengan_stok_nol(): void
    {
        $this->login();
        $category = $this->category();

        $response = $this->post(route('products.store'), [
            'sku' => 'ATK-002',
            'name' => 'Pulpen Gel Hitam',
            'category_id' => $category->id,
            'unit' => 'pcs',
            'min_stock' => 20,
            'price' => 12000,
        ]);

        $response->assertRedirect(route('products.show', Product::where('sku', 'ATK-002')->firstOrFail()));

        // Stok awal selalu 0: hanya transaksi yang boleh mengubahnya.
        $this->assertDatabaseHas('products', [
            'sku' => 'ATK-002',
            'name' => 'Pulpen Gel Hitam',
            'stock' => 0,
        ]);
    }

    public function test_sku_duplikat_ditolak_validasi(): void
    {
        $this->login();
        $category = $this->category();

        Product::create([
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 80gsm',
            'category_id' => $category->id,
            'unit' => 'rim',
            'stock' => 0,
            'min_stock' => 10,
            'price' => 55000,
        ]);

        $this->from(route('products.create'))
            ->post(route('products.store'), [
                'sku' => 'ATK-001',
                'name' => 'Map Plastik Kancing',
                'category_id' => $category->id,
                'unit' => 'pcs',
                'min_stock' => 5,
                'price' => 9000,
            ])
            ->assertSessionHasErrors('sku');

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

    public function test_memperbarui_produk_tanpa_menyentuh_kolom_stok(): void
    {
        $this->login();
        $product = Product::create([
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 80gsm',
            'category_id' => $this->category()->id,
            'unit' => 'rim',
            'stock' => 15,
            'min_stock' => 10,
            'price' => 55000,
        ]);

        $this->put(route('products.update', $product), [
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 75gsm',
            'category_id' => $product->category_id,
            'unit' => 'rim',
            'min_stock' => 12,
            'price' => 52000,
        ])->assertRedirect(route('products.show', $product));

        $this->assertDatabaseHas('products', [
            'id' => $product->id,
            'name' => 'Kertas HVS A4 75gsm',
            'min_stock' => 12,
            'stock' => 15, // stok tidak ikut berubah saat mengubah data produk
        ]);
    }

    public function test_daftar_produk_terpaginasi_sepuluh_per_halaman(): void
    {
        $this->login();
        $category = $this->category();

        for ($i = 1; $i <= 12; $i++) {
            Product::create([
                'sku' => sprintf('ATK-%03d', $i),
                'name' => "Produk Contoh {$i}",
                'category_id' => $category->id,
                'unit' => 'pcs',
                'stock' => $i,
                'min_stock' => 1,
                'price' => 1000,
            ]);
        }

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

        // Halaman pertama memuat 10 dari 12 produk + tautan halaman kedua.
        $response->assertOk();
        $this->assertCount(10, $response->viewData('products')->items());
        $response->assertSee(route('products.index', ['page' => 2]));
        $response->assertSeeText('Produk Contoh 7');
        $response->assertDontSeeText('Produk Contoh 9');

        // Halaman kedua memuat sisa 2 produk.
        $second = $this->get(route('products.index', ['page' => 2]));
        $this->assertCount(2, $second->viewData('products')->items());
        $second->assertSeeText('Produk Contoh 8');
        $second->assertSeeText('Produk Contoh 9');
    }

    public function test_menghapus_produk_beserta_riwayat_pergerakannya(): void
    {
        $this->login();
        $product = Product::create([
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 80gsm',
            'category_id' => $this->category()->id,
            'unit' => 'rim',
            'stock' => 0,
            'min_stock' => 10,
            'price' => 55000,
        ]);

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

        $this->assertDatabaseMissing('products', ['id' => $product->id]);
    }
}

tests/Feature/SearchFilterTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

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

class SearchFilterTest extends TestCase
{
    use RefreshDatabase;

    private Category $elektronik;

    private Category $alatTulis;

    protected function setUp(): void
    {
        parent::setUp();

        $this->actingAs(User::factory()->create([
            'name' => 'Budi Santoso',
            'email' => '[email protected]',
        ]));

        $this->elektronik = Category::create(['name' => 'Elektronik']);
        $this->alatTulis = Category::create(['name' => 'Alat Tulis']);

        Product::create([
            'sku' => 'ELK-001',
            'name' => 'Mouse Wireless M1',
            'category_id' => $this->elektronik->id,
            'unit' => 'pcs',
            'stock' => 15,
            'min_stock' => 5,
            'price' => 85000,
        ]);

        Product::create([
            'sku' => 'ATK-001',
            'name' => 'Kertas HVS A4 80gsm',
            'category_id' => $this->alatTulis->id,
            'unit' => 'rim',
            'stock' => 3,
            'min_stock' => 10,
            'price' => 55000,
        ]);

        Product::create([
            'sku' => 'ELK-002',
            'name' => 'Tinta Printer Hitam',
            'category_id' => $this->elektronik->id,
            'unit' => 'botol',
            'stock' => 40,
            'min_stock' => 8,
            'price' => 120000,
        ]);
    }

    public function test_cari_berdasarkan_nama(): void
    {
        $this->get(route('products.index', ['q' => 'mouse']))
            ->assertOk()
            ->assertSee('Mouse Wireless M1')
            ->assertDontSee('Kertas HVS A4 80gsm')
            ->assertDontSee('Tinta Printer Hitam');
    }

    public function test_cari_berdasarkan_sku(): void
    {
        $this->get(route('products.index', ['q' => 'ATK']))
            ->assertOk()
            ->assertSee('Kertas HVS A4 80gsm')
            ->assertDontSee('Mouse Wireless M1');
    }

    public function test_cari_tidak_peka_huruf_besar_kecil(): void
    {
        $this->get(route('products.index', ['q' => 'TINTA']))
            ->assertOk()
            ->assertSee('Tinta Printer Hitam');
    }

    public function test_filter_berdasarkan_kategori(): void
    {
        $this->get(route('products.index', ['category' => $this->elektronik->id]))
            ->assertOk()
            ->assertSee('Mouse Wireless M1')
            ->assertSee('Tinta Printer Hitam')
            ->assertDontSee('Kertas HVS A4 80gsm');
    }

    public function test_filter_rentang_stok(): void
    {
        $this->get(route('products.index', ['stock_min' => 10, 'stock_max' => 30]))
            ->assertOk()
            ->assertSee('Mouse Wireless M1')   // stok 15 masuk rentang
            ->assertDontSee('Kertas HVS A4 80gsm') // stok 3 di bawah rentang
            ->assertDontSee('Tinta Printer Hitam'); // stok 40 di atas rentang
    }

    public function test_filter_stok_kritis_hanya_yang_di_bawah_minimum(): void
    {
        $this->get(route('products.index', ['critical' => 1]))
            ->assertOk()
            ->assertSee('Kertas HVS A4 80gsm')  // stok 3 <= min 10: kritis
            ->assertDontSee('Mouse Wireless M1') // stok 15 > min 5: aman
            ->assertDontSee('Tinta Printer Hitam');
    }

    public function test_filter_gabungan_pencarian_dan_kategori(): void
    {
        $this->get(route('products.index', ['q' => 'tinta', 'category' => $this->alatTulis->id]))
            ->assertOk()
            ->assertDontSee('Tinta Printer Hitam'); // cocok kata kunci, tetapi salah kategori
    }

    public function test_laporan_stok_kritis_menampilkan_produk_yang_perlu_dipesan(): void
    {
        $this->get(route('reports.low-stock'))
            ->assertOk()
            ->assertSee('Kertas HVS A4 80gsm')
            ->assertSee('7 rim') // kekurangan: 10 - 3
            ->assertDontSee('Mouse Wireless M1')
            ->assertDontSee('Tinta Printer Hitam');
    }
}

tests/Feature/StockMovementTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Exceptions\InsufficientStockException;
use App\Models\Category;
use App\Models\Product;
use App\Models\StockMovement;
use App\Models\User;
use App\Services\StockService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Tests\TestCase;

class StockMovementTest 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([
            'sku' => 'ELK-001',
            'name' => 'Mouse Wireless M1',
            'category_id' => Category::create(['name' => 'Elektronik'])->id,
            'unit' => 'pcs',
            'stock' => 0,
            'min_stock' => 5,
            'price' => 85000,
        ]);
    }

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

        $movement = app(StockService::class)->recordMovement(
            $product,
            StockMovement::TYPE_IN,
            20,
            'Stok pembukaan',
            $user,
            '2026-01-02',
        );

        $this->assertSame(20, $product->fresh()->stock);
        $this->assertSame(20, $movement->quantity);
        $this->assertDatabaseHas('stock_movements', [
            'product_id' => $product->id,
            'type' => 'in',
            'quantity' => 20,
            'user_id' => $user->id,
        ]);
    }

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

        app(StockService::class)->recordMovement($product, StockMovement::TYPE_IN, 20, movementDate: '2026-01-02');
        app(StockService::class)->recordMovement($product, StockMovement::TYPE_OUT, 7, 'Peminjaman ruang meeting', movementDate: '2026-01-05');

        $this->assertSame(13, $product->fresh()->stock);
        $this->assertDatabaseCount('stock_movements', 2);
    }

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

        app(StockService::class)->recordMovement($product, StockMovement::TYPE_IN, 5, movementDate: '2026-01-02');

        try {
            app(StockService::class)->recordMovement($product, StockMovement::TYPE_OUT, 8, movementDate: '2026-01-05');
            $this->fail('Barang keluar melebihi stok seharusnya melempar pengecualian.');
        } catch (InsufficientStockException $exception) {
            $this->assertSame(5, $exception->product->stock);
            $this->assertSame(8, $exception->requested);
        }

        // Stok tetap dan tidak ada baris riwayat baru yang tersimpan.
        $this->assertSame(5, $product->fresh()->stock);
        $this->assertDatabaseCount('stock_movements', 1);
    }

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

        // Simulasikan gangguan setelah pengecekan stok,
        // tepat saat baris stock_movements hendak dibuat.
        StockMovement::creating(function (): void {
            throw new RuntimeException('simulasi gangguan saat menyimpan riwayat');
        });

        try {
            app(StockService::class)->recordMovement(
                $product,
                StockMovement::TYPE_IN,
                10,
                movementDate: '2026-01-02',
            );
            $this->fail('Pengecualian simulasi seharusnya terlempar.');
        } catch (RuntimeException) {
            // dibiarkan: inilah skenario yang diuji
        } finally {
            StockMovement::flushEventListeners();
        }

        // Atomicity: tidak ada perubahan yang lolos setelah rollback.
        $this->assertSame(0, $product->fresh()->stock);
        $this->assertDatabaseCount('stock_movements', 0);
    }

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

        $product->forceFill(['stock' => 5])->save();

        $this->actingAs($user)
            ->from(route('movements.create'))
            ->post(route('movements.store'), [
                'product_id' => $product->id,
                'type' => 'out',
                'quantity' => 9,
                'note' => 'Peminjaman untuk pameran',
                'movement_date' => '2026-01-05',
            ])
            ->assertSessionHasErrors('quantity');

        $this->assertSame(5, $product->fresh()->stock);
        $this->assertDatabaseCount('stock_movements', 0);
    }

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

        $this->actingAs($user)
            ->post(route('movements.store'), [
                'product_id' => $product->id,
                'type' => 'in',
                'quantity' => 12,
                'note' => 'Pembelian batch pertama',
                'movement_date' => '2026-01-03',
            ])
            ->assertRedirect(route('products.show', $product));

        $this->assertSame(12, $product->fresh()->stock);
        $this->assertDatabaseHas('stock_movements', [
            'product_id' => $product->id,
            'type' => 'in',
            'quantity' => 12,
            'user_id' => $user->id,
        ]);
    }

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

        app(StockService::class)->recordMovement($product, StockMovement::TYPE_IN, 10, 'Stok pembukaan', $user, '2026-01-02');
        app(StockService::class)->recordMovement($product, StockMovement::TYPE_OUT, 4, 'Peminjaman ruang meeting', $user, '2026-01-05');

        $this->actingAs($user)
            ->get(route('movements.index'))
            ->assertOk()
            ->assertSee('Mouse Wireless M1')
            ->assertSee('Stok pembukaan')
            ->assertSee('Peminjaman ruang meeting');
    }
}