Kita telah menempuh materi Laravel dari konsep routing, Eloquent ORM, validasi, sampai testing. Proyek ini menjadi tempat seluruh pengetahuan tersebut dipakai membangun satu aplikasi web utuh: sistem manajemen inventaris yang mencatat produk, mengelompokkannya per kategori, menjaga pergerakan stok barang masuk dan keluar, lalu melaporkan produk yang perlu segera dipesan ulang. Aplikasi inventaris dipilih karena masalah intinya menuntut disiplin data: satu angka stok yang salah dapat merusak seluruh kepercayaan terhadap sistem, sedangkan pergerakan barang terjadi berkali-kali setiap hari dari banyak orang.
Aplikasi yang akan kita bangun bekerja sebagai aplikasi web internal dengan login:
- mengelola data produk melalui CRUD lengkap dengan validasi dan pagination,
- mengelompokkan produk ke dalam kategori (Elektronik, Alat Tulis, dan seterusnya),
- mencatat transaksi barang masuk (
in) dan barang keluar (out) beserta petugas dan tanggalnya, - menjaga stok selalu konsisten dengan riwayat transaksi lewat transaksi database yang atomik,
- menolak barang keluar yang melebihi stok tersedia sehingga stok tidak pernah menjadi negatif,
- menyediakan pencarian nama/SKU, filter kategori, filter rentang stok, dan tanda stok kritis,
- menampilkan laporan stok kritis sebagai kandidat pemesanan ulang.
Tujuan Pembelajaran #
Setelah menyelesaikan proyek ini, kita mampu:
- menerjemahkan kebutuhan bisnis menjadi skema database: tabel, foreign key, unique constraint, dan index,
- memakai relasi Eloquent
belongsTodanhasManyuntuk menghubungkan kategori, produk, dan riwayat stok, - memusatkan aturan bisnis stok dalam satu service class agar controller tetap ramping,
- menjaga konsistensi dua tabel dengan
DB::transaction()danlockForUpdate(), - menolak input yang melanggar aturan bisnis lewat form request dan exception kustom,
- membangun query pencarian dan filter yang bisa dikombinasikan, lalu mengujinya dengan feature test.
Prasyarat #
Proyek ini tidak memperkenalkan materi baru, melainkan memakai materi yang sudah dibahas. Chapter yang paling sering dirujuk:
- Membuat Migration , Foreign Keys , dan Database Transactions — membangun skema tiga tabel beserta relasinya,
- Konsep Eloquent
dan One to Many
— model
Product,Category, danStockMovement, - Query Scopes dan Pagination — pencarian, filter, dan halaman daftar,
- Resource Controller dan Form Request Classes — CRUD produk dan validasi,
- Middleware untuk Autentikasi — gerbang login seluruh fitur inventaris,
- Database Testing dan Feature Tests — pengujian alur stok.
Struktur Data #
Tiga tabel baru menjadi fondasi sistem, dihubungkan dengan tabel users bawaan Laravel untuk mencatat petugas setiap transaksi. Diagram berikut memetakan seluruh entitas beserta kolom kuncinya:
Aturan bisnis terpenting berada pada hubungan products dan stock_movements: kolom products.stock harus selalu sama dengan hasil penjumlahan seluruh pergerakan (masuk dikurangi keluar). Kita akan menjaga aturan tersebut dengan satu-satunya jalur penulisan stok, yaitu method StockService::recordMovement() yang membungkus penulisan riwayat dan pembaruan stok di dalam satu transaksi database.
Tahapan Pengerjaan #
Proyek dikerjakan dalam enam bagian berurutan. Setiap bagian menghasilkan aplikasi yang dapat dijalankan penuh:
- Bagian 1: Perancangan Sistem — memetakan entitas, aturan bisnis stok, dan membangun migrasi tiga tabel,
- Bagian 2: Manajemen Produk — membangun CRUD produk dengan form request, pagination, dan Blade,
- Bagian 3: Kategori dan Stok — menghubungkan relasi antar model dan memantau pergerakan stok,
- Bagian 4: Transaksi Inventaris — membangun
StockServiceatomik sebagai jantung sistem, - Bagian 5: Pencarian dan Filter — menyediakan pencarian nama/SKU, filter kategori, rentang stok, dan laporan stok kritis,
- Bagian 6: Pengujian Sistem — membuktikan seluruh aturan bisnis lewat feature test.
Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas berkas bagian sebelumnya. Seluruh berkas sumber tersedia pada folder proyek ini: app/, database/, resources/, routes/, dan tests/.
Menjalankan Aplikasi #
Setelah mengikuti seluruh bagian (atau menyalin berkas sumber ke proyek Laravel baru dengan database SQLite), siapkan database beserta data contoh:
php artisan migrate --seedOutput yang Dihasilkan:
INFO Preparing database.
Creating migration table ....................................... DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ........................... DONE
0001_01_01_000001_create_cache_table ........................... DONE
0001_01_01_000002_create_jobs_table ............................ DONE
2026_10_13_153049_create_personal_access_tokens_table .......... DONE
2026_11_01_000010_create_categories_table ...................... DONE
2026_11_01_000020_create_products_table ........................ DONE
2026_11_01_000030_create_stock_movements_table ................. DONE
INFO Seeding database.Seeder membuat satu akun petugas percobaan: email [email protected] dengan password password. Jalankan php artisan serve, buka http://localhost:8000, lalu masuk dengan akun tersebut untuk mulai mencatat transaksi stok.
Petakan entitas produk, kategori, transaksi, dan aturan bisnis stok.
Buka → # Bagian 2: Manajemen ProdukBangun CRUD produk dengan validasi dan halaman daftar terpaginasi.
Buka → # Bagian 3: Kategori dan StokKelompokkan produk per kategori dan pantau pergerakan stok.
Buka → # Bagian 4: Transaksi InventarisCatat barang masuk dan keluar dengan transaksi database yang aman.
Buka → # Bagian 5: Pencarian dan FilterSediakan pencarian nama, filter kategori, dan rentang stok.
Buka → # Bagian 6: Pengujian SistemUji alur stok dan transaksi dengan feature test.
Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/Exceptions/InsufficientStockException.php
Unduh berkas<?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
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
namespace App\Http\Controllers;
abstract class Controller
{
//
}
app/Http/Controllers/ProductController.php
Unduh berkas<?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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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');
}
}