Lima bagian belakangan kita membuktikan setiap perilaku dengan cara yang sama: menulis demo, menjalankannya, lalu membaca outputnya dengan mata. Cara itu jujur tetapi rapuh — mata lelah, dan tidak ada yang mengingat kita untuk mengulang pembacaan itu setiap kali InventoryService berubah. Pada bagian ini kita serahkan pekerjaan itu kepada PHPUnit. Sembilan test akan membangun database :memory: yang segar untuk masing-masing dirinya, menjalankan alur masuk dan keluar stok, menolak stok negatif, memaksa kegagalan di tengah transaksi dengan trigger SQLite lalu membuktikan rollback mengembalikan keadaan utuh, dan menutup dengan pencarian wildcard dari Bagian 5. Sepanjang jalan kita juga menata bagaimana berkas test memuat class yang tersebar antar bagian — keputusan kecil dengan konsekuensi yang layak dipahami.

Dari Demo Manual ke Test Otomatis #

Kekuatan test otomatis muncul dari tiga sifat yang tidak dimiliki pembacaan manual. Pertama, pemeriksaan berjalan dalam hitungan milidetik — sembilan test kita selesai di bawah dua puluh milidetik, jadi tidak ada alasan menundanya. Kedua, pemeriksaan identik antar jalan: assertion yang sama, urutan yang sama, tanpa hari baik dan hari lelah. Ketiga, hasilnya adalah exit code — 0 saat lulus, 1 saat ada kegagalan — angka yang bisa dibaca pipeline CI untuk menahan commit yang merusak perilaku, topik yang kita bahas pada chapter Continuous Integration .

Setiap test mengikuti pola tiga babak arrange-act-assert: siapkan dunia (database segar beserta data awal), jalankan satu perilaku, lalu bandingkan keadaan dengan harapan. Babak pertama adalah pekerjaan terbesarnya, dan PHPUnit membuatnya murah lewat method setUp() — dipanggil otomatis sebelum setiap method test*, sehingga sembilan test berjalan di sembilan database terpisah. Tidak ada test yang mewarisi sisa kerusakan test lain, id movement selalu dilanjutkan dari 10, dan PRAGMA foreign_keys selalu dinyalakan karena pragma berlaku per koneksi — ingat pelajaran Bagian 1. schema.sql yang sama dipakai ulang tanpa modifikasi: fixture test dan struktur produksi tidak mungkin berbeda karena keduanya membaca satu berkas. Implementasi setUp() yang membangun dunia itu tampil utuh pada blok InventoryServiceTest di bawah. Strategi menguji lapisan service dengan database nyata yang ringan sudah dibahas pada chapter Database Testing ; SQLite :memory: membuatnya cepat hingga boleh dijalankan sebelum setiap commit.

tests/bootstrap.php — Memuat Class Antar Bagian #

Sebelum test pertama bisa berjalan, PHPUnit perlu tahu di mana class proyek hidup. Pada proyek nyata jawabannya satu baris: require 'vendor/autoload.php' setelah Composer memetakan namespace ke folder src/. Pada bundle kursus ini jawabannya berbeda — class sengaja tersebar di folder src/ milik masing-masing bagian agar setiap halaman bagian mandiri, sehingga berkas test harus menyeberang antar folder bagian yang bersaudara di bawah sistem-inventaris/. Berikut isi lengkap tests/bootstrap.php pada bundle bagian ini:

php
<?php

declare(strict_types=1);

/**
 * Bootstrap PHPUnit untuk suite sistem inventaris.
 *
 * Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
 * test pertama berjalan, lewat path relatif antar folder bagian.
 *
 * Pada proyek nyata, class hidup dalam satu folder src/ dengan
 * namespace dan autoloader Composer, sehingga berkas semacam ini cukup
 * memanggil require 'vendor/autoload.php'. Pada bundle kursus ini,
 * class justru tersebar di folder src/ milik masing-masing bagian agar
 * setiap halaman bagian mandiri - folder bagian bersaudara di bawah
 * sistem-inventaris/, maka dari tests/ kita naik dua tingkat ('/../..')
 * lalu masuk ke bagian tujuan. require_once menjaga berkas yang sama
 * tidak dimuat dua kali bila suite diperluas dengan test file lain.
 */

require_once __DIR__ . '/../../bagian-2-produk-dan-kategori/src/CategoryRepository.php';
require_once __DIR__ . '/../../bagian-2-produk-dan-kategori/src/ProductRepository.php';
require_once __DIR__ . '/../../bagian-3-stock-movements/src/StockMovementRepository.php';
require_once __DIR__ . '/../../bagian-4-transactions-dan-validation/src/InventoryService.php';
require_once __DIR__ . '/../../bagian-4-transactions-dan-validation/src/Validator.php';
require_once __DIR__ . '/../../bagian-5-laporan-dan-search/src/ReportService.php';

/**
 * Path absolut ke schema.sql Bagian 1 - satu-satunya sumber struktur
 * database. setUp() tiap test membaca dan mengeksekusi berkas ini pada
 * koneksi :memory: yang baru, sehingga fixture selalu identik.
 */
const SCHEMA_INVENTARIS = __DIR__ . '/../../bagian-1-desain-database/schema.sql';

Dua keputusan pada berkas tersebut perlu dijelaskan. Path __DIR__ . '/../../...' naik dua tingkat dari tests/ — satu tingkat ke folder bagian ini, satu tingkat lagi ke sistem-inventaris/ — lalu turun ke bagian tujuan; pola yang sama dengan require antar bagian pada demo-demo sebelumnya, hanya saja dari kedalaman folder yang berbeda. Konstanta SCHEMA_INVENTARIS memusatkan lokasi schema sehingga bila suatu saat struktur folder berubah, hanya bootstrap yang perlu disesuaikan, bukan setiap test.

Pemisahan bootstrap dari berkas test sendiri adalah keputusan struktur: memuat class adalah urusan wiring, sedangkan berkas test adalah urusan perilaku. PHPUnit menyediakan titik sambungnya lewat opsi --bootstrap, dan begitu suite membesar ke beberapa berkas test, seluruhnya tinggal menunjuk bootstrap yang sama.

Satu berkas test berisi class InventoryServiceTest dengan sembilan method test*. Berikut lima test pertamanya secara utuh — alur tulis, penolakan, dan bintang suite ini: rollback yang dipaksa gagal. Empat test sisanya menguji sisi baca dan berpola sama:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * Suite untuk invarian inti sistem inventaris: InventoryService sebagai
 * satu-satunya penulis kolom stok, transaction rollback saat kegagalan
 * di tengah jalan, dan - karena berbagi fixture yang sama - grup penutup
 * yang menguji pencarian serta laporan dari ReportService Bagian 5.
 *
 * Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
 * jalankan dengan:
 *   php phpunit.phar tests/InventoryServiceTest.php --bootstrap tests/bootstrap.php
 */
final class InventoryServiceTest extends TestCase
{
    private PDO $pdo;

    private ProductRepository $products;

    private StockMovementRepository $movements;

    private InventoryService $service;

    private ReportService $reports;

    protected function setUp(): void
    {
        // Database :memory: baru untuk setiap test: terisolasi, cepat,
        // dan schema.sql Bagian 1 selalu menghasilkan fixture identik
        // (4 kategori, 6 produk, 9 movement).
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec('PRAGMA foreign_keys = ON');
        $this->pdo->exec((string) file_get_contents(SCHEMA_INVENTARIS));

        $this->products = new ProductRepository($this->pdo);
        $this->movements = new StockMovementRepository($this->pdo);
        $this->service = new InventoryService($this->pdo, $this->movements);
        $this->reports = new ReportService($this->pdo);
    }

    public function testMasukkanStokMenaikkanStokDanMencatatMovement(): void
    {
        $this->service->masukkanStok(6, 40, 'Pembelian PO-2601-030', '2026-01-25 09:00:00');

        $this->assertSame(75, $this->stokSekarang(6));
        $this->assertSame(75, $this->movements->stokMenurutLedger(6));

        $riwayat = $this->movements->riwayatByProduct(6);
        $this->assertCount(2, $riwayat);
        $this->assertSame('masuk', $riwayat[1]['jenis']);
        $this->assertSame(40, (int) $riwayat[1]['jumlah']);
        $this->assertSame('Pembelian PO-2601-030', $riwayat[1]['keterangan']);
    }

    public function testKeluarkanStokMenurunkanStokDanMencatatMovement(): void
    {
        $this->service->keluarkanStok(6, 15, 'Penjualan INV-2601-201', '2026-01-25 15:30:00');

        $this->assertSame(20, $this->stokSekarang(6));
        $this->assertSame(20, $this->movements->stokMenurutLedger(6));

        $riwayat = $this->movements->riwayatByProduct(6);
        $this->assertCount(2, $riwayat);
        $this->assertSame('keluar', $riwayat[1]['jenis']);
        $this->assertSame(15, (int) $riwayat[1]['jumlah']);
    }

    public function testKeluarkanMelebihiStokDitolakTanpaJejak(): void
    {
        try {
            $this->service->keluarkanStok(6, 500, 'Penjualan INV-2601-999', '2026-01-25 16:00:00');
            $this->fail('keluarkanStok seharusnya melempar StokTidakCukupException.');
        } catch (StokTidakCukupException $e) {
            $this->assertSame(
                'Stok Buku Tulis 58 Halaman (BUKU-006) tidak cukup: tersisa 35, diminta 500.',
                $e->getMessage()
            );
        }

        // Penolakan guard terjadi SEBELUM penulisan: tidak ada movement
        // baru dan stok tidak berubah.
        $this->assertSame(35, $this->stokSekarang(6));
        $this->assertCount(1, $this->movements->riwayatByProduct(6));
    }

    public function testJumlahNolDitolakSebelumMenyentuhDatabase(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Jumlah harus bilangan bulat positif, diberikan: 0.');

        $this->service->masukkanStok(6, 0, 'Jumlah kosong', '2026-01-25 17:00:00');
    }

    public function testKegagalanDiTengahTransaksiDiRollbackUtuh(): void
    {
        // Paksa penulisan KEDUA (UPDATE stok) gagal lewat trigger yang
        // selalu ABORT. catat() lebih dulu berhasil menulis movement -
        // persis skenario "kegagalan setelah penulisan pertama" yang
        // dulu kita simulasikan manual pada Bagian 4.
        $this->pdo->exec(
            "CREATE TRIGGER blokir_ubah_stok
             BEFORE UPDATE ON products
             BEGIN
                 SELECT RAISE(ABORT, 'SIMULASI: perubahan stok diblokir');
             END"
        );

        try {
            $this->service->masukkanStok(6, 40, 'Pembelian yang gagal', '2026-01-25 18:00:00');
            $this->fail('masukkanStok seharusnya gagal ketika UPDATE stok diblokir trigger.');
        } catch (PDOException $e) {
            $this->assertStringContainsString('SIMULASI: perubahan stok diblokir', $e->getMessage());
        }

        // Movement yang sempat tertulis ikut ter-rollback: keadaan
        // database persis seperti sebelum panggilan yang gagal.
        $this->assertSame(35, $this->stokSekarang(6));
        $this->assertCount(1, $this->movements->riwayatByProduct(6));
        $this->assertSame(35, $this->movements->stokMenurutLedger(6));
    }

    // Empat test penutup menguji sisi baca yang berbagi fixture sama:
    // pencarian tanpa peduli kapital, escaping wildcard LIKE ('50%' dan
    // 'KOPI_001'), filter kategori, serta laporan stokMenipis dan
    // nilaiPersediaan yang mengikuti penjualan. Berkas lengkapnya ada
    // di tests/InventoryServiceTest.php pada bundle bagian ini.
}

Tiga hal pada kode tersebut menjadi inti suite ini:

  1. assertSame() dipilih sebagai assertion utama karena membandingkan nilai sekaligus tipe — id 7 integer tidak boleh lolos sebagai string '7', dan stok 35 dari SQLite (yang PDO kembalikan sebagai string) wajib lolos sebagai integer melalui cast di helper stokSekarang().
  2. Dua idiom exception dipakai sesuai kebutuhan. expectException() cocok saat satu-satunya hal yang diperiksa adalah “exception ini terlempar dengan pesan ini” — kode setelah pemanggilan memang tidak akan tereksekusi. Bila keadaan database setelah kegagalan juga perlu diperiksa, pola try/catch + $this->fail() memungkinkan assertion berlanjut setelah catch — itulah yang dipakai kedua test penolakan di atas.
  3. Test rollback memaksa kegagalan yang sungguhan. InventoryService adalah final class — tidak bisa diturunkan untuk menyisipkan kegagalan palsu. Trigger blokir_ubah_stok menyelesaikannya dari sisi database: RAISE(ABORT, ...) membuat UPDATE products selalu gagal, sehingga catat() sempat menulis movement, ubahStok() gagal, dan rollBack() harus membuktikan dirinya dengan menghapus movement yang tadi tertulis. Assertion terakhir memastikan keadaan kembali persis: stok 35, satu movement, ledger 35.

Penamaan test adalah bagian dari desain: testKeluarkanMelebihiStokDitolakTanpaJejak membaca seperti baris kontrak perilaku, dan ketika test itu gagal, namanya sendiri yang menunjuk apa yang rusak. Konvensi ini dibahas tuntas pada chapter Menulis PHPUnit Tests .

Rasa Test dalam PHP Polos #

Sebelum menjalankan PHPUnit, mari bongkar bahwa test hanyalah kode PHP biasa: bandingkan hasil dengan harapan, lalu berteriak bila tidak cocok. Harness mini berikut menjalankan inti dari suite — termasuk kegagalan yang dipaksa lewat trigger — memakai if dan echo saja:

php
<?php
require __DIR__ . '/../bagian-3-stock-movements/src/StockMovementRepository.php';
require __DIR__ . '/../bagian-4-transactions-dan-validation/src/InventoryService.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-database/schema.sql'));

$moves = new StockMovementRepository($pdo);
$service = new InventoryService($pdo, $moves);

function stok(PDO $pdo, int $id): int
{
    $stmt = $pdo->prepare('SELECT stok_sekarang FROM products WHERE id = ?');
    $stmt->execute([$id]);
    return (int) $stmt->fetchColumn();
}

function cek(bool $kondisi, string $nama): void
{
    echo $kondisi ? 'LULUS' : 'GAGAL';
    echo " - $nama\n";
}

echo "== Alur bahagia ==\n";
$service->masukkanStok(6, 40, 'Pembelian PO-2601-040', '2026-01-26 09:00:00');
cek(stok($pdo, 6) === 75 && $moves->stokMenurutLedger(6) === 75, 'masuk 40: stok dan ledger sama-sama 75');

$service->keluarkanStok(6, 15, 'Penjualan INV-2601-401', '2026-01-26 15:00:00');
cek(stok($pdo, 6) === 60 && $moves->stokMenurutLedger(6) === 60, 'keluar 15: stok dan ledger sama-sama 60');

echo "\n== Kegagalan dipaksa lewat trigger ==\n";
$pdo->exec(
    "CREATE TRIGGER blokir_ubah_stok BEFORE UPDATE ON products
     BEGIN
         SELECT RAISE(ABORT, 'SIMULASI: perubahan stok diblokir');
     END"
);

try {
    $service->masukkanStok(6, 10, 'Pembelian yang akan gagal', '2026-01-26 16:00:00');
    cek(false, 'masukkanStok gagal seperti diharapkan');
} catch (PDOException $e) {
    cek(str_contains($e->getMessage(), 'SIMULASI: perubahan stok diblokir'), 'UPDATE stok ditolak trigger');
}

cek(stok($pdo, 6) === 60 && $moves->stokMenurutLedger(6) === 60, 'setelah rollback: stok dan ledger tetap 60');
cek(count($moves->riwayatByProduct(6)) === 3, 'movement setengah jadi ikut terhapus (kembali 3 baris)');

Output yang Dihasilkan:

text
== Alur bahagia ==
LULUS - masuk 40: stok dan ledger sama-sama 75
LULUS - keluar 15: stok dan ledger sama-sama 60

== Kegagalan dipaksa lewat trigger ==
LULUS - UPDATE stok ditolak trigger
LULUS - setelah rollback: stok dan ledger tetap 60
LULUS - movement setengah jadi ikut terhapus (kembali 3 baris)

Fungsi cek() adalah assertion polos: kondisi di parameter pertama, nama perilaku di parameter kedua, dan hasilnya baris LULUS atau GAGAL yang terbaca tanpa alat bantu. Blok kedua adalah momen paling penting: masukkanStok() yang dipanggil setelah trigger dibuat gagal di tengah jalan, dan dua baris LULUS terakhir membuktikan keadaan database kembali utuh — stok tetap 60, ledger tetap 60, dan riwayat kembali tiga baris karena movement yang sempat tertulis ikut ter-rollback. Lima baris LULUS itu setara dengan lima test hijau; PHPUnit membungkus pola yang sama dengan laporan terstruktur dan exit code.

Menjalankan Suite dengan PHPUnit #

Saatnya versi sungguhannya. Dari folder bagian-6-testing, arahkan PHPUnit ke berkas test beserta bootstrap-nya:

bash
php phpunit.phar --no-progress tests/InventoryServiceTest.php --bootstrap tests/bootstrap.php

Kami menjalankan perintah tersebut pada lingkungan bagian ini (PHP 8.4.23, PHPUnit 12.5.39) dan berikut output lengkapnya:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

Time: 00:00.011, Memory: 28.05 MB

OK (9 tests, 34 assertions)

Baris OK (9 tests, 34 assertions) adalah ringkasan yang paling sering Anda baca: sembilan test dijalankan, tiga puluh empat assertion dievaluasi, semuanya lulus. Angka Time dan Memory bervariasi antar mesin dan antar jalan, jadi jangan dikhawatirkan; opsi --no-progress kita pakai agar output stabil tanpa animasi progress bar. Exit code perintah ini adalah 0 — angka inilah yang dibaca pipeline CI agar commit yang merusak perilaku langsung tertahan. Sebagai eksperimen kecil yang meyakinkan, coba ubah sementara guard catat() agar tidak melempar StokTidakCukupException, jalankan ulang, dan lihat OK berubah menjadi FAILURES dengan exit code 1 — suite ini benar-benar berjaga.

Apa yang Berubah #

Proyek mendapatkan penjaga regresi pertamanya:

  • Berkas tests/bootstrap.php baru yang memuat keenam class dari Bagian 2-5 lewat path relatif antar bagian dan memusatkan lokasi schema.sql pada konstanta SCHEMA_INVENTARIS — keputusan sibling-require yang didokumentasikan langsung di dalam berkasnya.
  • Berkas tests/InventoryServiceTest.php baru berisi sembilan test pada database :memory: masing-masing: dua alur tulis, penolakan stok negatif tanpa jejak, penolakan jumlah nol, rollback yang dipaksa gagal lewat trigger SQLite, tiga test pencarian (kapital, escaping wildcard, filter kategori), dan satu test laporan yang memperlihatkan stokMenipis() serta nilaiPersediaan() mengikuti penjualan.
  • Tidak ada satu baris pun dari class produksi yang berubah untuk kepentingan test — yang dibutuhkan hanyalah PDO yang disuntikkan dan schema yang idempoten, dua keputusan arsitektur dari Bagian 1 dan 2 yang kini terbayar.
  • Bukti terdokumentasi: OK (9 tests, 34 assertions) dengan exit code 0 pada PHP 8.4.23 dan PHPUnit 12.5.39.

Ringkasan #

Suite ini mengubah kepercayaan menjadi mekanisme: setiap janji yang kita ucapkan di Bagian 2 sampai 5 — stok hanya berubah lewat movement, transaksi atomik, pencarian aman terhadap wildcard — kini diperiksa ulang oleh mesin dalam hitungan milidetik setiap kali diperlukan. Sistem inventaris pun lapisan lengkap: schema berconstraint, repository, service transaksional, validator, laporan, dan test. Yang tersisa dari tujuh bagian adalah pertanyaan terakhir: bagaimana semua ini dipaketkan agar berjalan identik di komputer lain — pertanyaan yang dijawab Docker pada Bagian 7.

Referensi #

Berkas Sumber

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

tests/bootstrap.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Bootstrap PHPUnit untuk suite sistem inventaris.
 *
 * Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
 * test pertama berjalan, lewat path relatif antar folder bagian.
 *
 * Pada proyek nyata, class hidup dalam satu folder src/ dengan
 * namespace dan autoloader Composer, sehingga berkas semacam ini cukup
 * memanggil require 'vendor/autoload.php'. Pada bundle kursus ini,
 * class justru tersebar di folder src/ milik masing-masing bagian agar
 * setiap halaman bagian mandiri - folder bagian bersaudara di bawah
 * sistem-inventaris/, maka dari tests/ kita naik dua tingkat ('/../..')
 * lalu masuk ke bagian tujuan. require_once menjaga berkas yang sama
 * tidak dimuat dua kali bila suite diperluas dengan test file lain.
 */

require_once __DIR__ . '/../../bagian-2-produk-dan-kategori/src/CategoryRepository.php';
require_once __DIR__ . '/../../bagian-2-produk-dan-kategori/src/ProductRepository.php';
require_once __DIR__ . '/../../bagian-3-stock-movements/src/StockMovementRepository.php';
require_once __DIR__ . '/../../bagian-4-transactions-dan-validation/src/InventoryService.php';
require_once __DIR__ . '/../../bagian-4-transactions-dan-validation/src/Validator.php';
require_once __DIR__ . '/../../bagian-5-laporan-dan-search/src/ReportService.php';

/**
 * Path absolut ke schema.sql Bagian 1 - satu-satunya sumber struktur
 * database. setUp() tiap test membaca dan mengeksekusi berkas ini pada
 * koneksi :memory: yang baru, sehingga fixture selalu identik.
 */
const SCHEMA_INVENTARIS = __DIR__ . '/../../bagian-1-desain-database/schema.sql';

tests/InventoryServiceTest.php

Unduh berkas
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * Suite untuk invarian inti sistem inventaris: InventoryService sebagai
 * satu-satunya penulis kolom stok, transaction rollback saat kegagalan
 * di tengah jalan, dan - karena berbagi fixture yang sama - grup penutup
 * yang menguji pencarian serta laporan dari ReportService Bagian 5.
 *
 * Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
 * jalankan dengan:
 *   php phpunit.phar tests/InventoryServiceTest.php --bootstrap tests/bootstrap.php
 */
final class InventoryServiceTest extends TestCase
{
    private PDO $pdo;

    private ProductRepository $products;

    private StockMovementRepository $movements;

    private InventoryService $service;

    private ReportService $reports;

    protected function setUp(): void
    {
        // Database :memory: baru untuk setiap test: terisolasi, cepat,
        // dan schema.sql Bagian 1 selalu menghasilkan fixture identik
        // (4 kategori, 6 produk, 9 movement).
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec('PRAGMA foreign_keys = ON');
        $this->pdo->exec((string) file_get_contents(SCHEMA_INVENTARIS));

        $this->products = new ProductRepository($this->pdo);
        $this->movements = new StockMovementRepository($this->pdo);
        $this->service = new InventoryService($this->pdo, $this->movements);
        $this->reports = new ReportService($this->pdo);
    }

    public function testMasukkanStokMenaikkanStokDanMencatatMovement(): void
    {
        $this->service->masukkanStok(6, 40, 'Pembelian PO-2601-030', '2026-01-25 09:00:00');

        $this->assertSame(75, $this->stokSekarang(6));
        $this->assertSame(75, $this->movements->stokMenurutLedger(6));

        $riwayat = $this->movements->riwayatByProduct(6);
        $this->assertCount(2, $riwayat);
        $this->assertSame('masuk', $riwayat[1]['jenis']);
        $this->assertSame(40, (int) $riwayat[1]['jumlah']);
        $this->assertSame('Pembelian PO-2601-030', $riwayat[1]['keterangan']);
    }

    public function testKeluarkanStokMenurunkanStokDanMencatatMovement(): void
    {
        $this->service->keluarkanStok(6, 15, 'Penjualan INV-2601-201', '2026-01-25 15:30:00');

        $this->assertSame(20, $this->stokSekarang(6));
        $this->assertSame(20, $this->movements->stokMenurutLedger(6));

        $riwayat = $this->movements->riwayatByProduct(6);
        $this->assertCount(2, $riwayat);
        $this->assertSame('keluar', $riwayat[1]['jenis']);
        $this->assertSame(15, (int) $riwayat[1]['jumlah']);
    }

    public function testKeluarkanMelebihiStokDitolakTanpaJejak(): void
    {
        try {
            $this->service->keluarkanStok(6, 500, 'Penjualan INV-2601-999', '2026-01-25 16:00:00');
            $this->fail('keluarkanStok seharusnya melempar StokTidakCukupException.');
        } catch (StokTidakCukupException $e) {
            $this->assertSame(
                'Stok Buku Tulis 58 Halaman (BUKU-006) tidak cukup: tersisa 35, diminta 500.',
                $e->getMessage()
            );
        }

        // Penolakan guard terjadi SEBELUM penulisan: tidak ada movement
        // baru dan stok tidak berubah.
        $this->assertSame(35, $this->stokSekarang(6));
        $this->assertCount(1, $this->movements->riwayatByProduct(6));
    }

    public function testJumlahNolDitolakSebelumMenyentuhDatabase(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Jumlah harus bilangan bulat positif, diberikan: 0.');

        $this->service->masukkanStok(6, 0, 'Jumlah kosong', '2026-01-25 17:00:00');
    }

    public function testKegagalanDiTengahTransaksiDiRollbackUtuh(): void
    {
        // Paksa penulisan KEDUA (UPDATE stok) gagal lewat trigger yang
        // selalu ABORT. catat() lebih dulu berhasil menulis movement -
        // persis skenario "kegagalan setelah penulisan pertama" yang
        // dulu kita simulasikan manual pada Bagian 4.
        $this->pdo->exec(
            "CREATE TRIGGER blokir_ubah_stok
             BEFORE UPDATE ON products
             BEGIN
                 SELECT RAISE(ABORT, 'SIMULASI: perubahan stok diblokir');
             END"
        );

        try {
            $this->service->masukkanStok(6, 40, 'Pembelian yang gagal', '2026-01-25 18:00:00');
            $this->fail('masukkanStok seharusnya gagal ketika UPDATE stok diblokir trigger.');
        } catch (PDOException $e) {
            $this->assertStringContainsString('SIMULASI: perubahan stok diblokir', $e->getMessage());
        }

        // Movement yang sempat tertulis ikut ter-rollback: keadaan
        // database persis seperti sebelum panggilan yang gagal.
        $this->assertSame(35, $this->stokSekarang(6));
        $this->assertCount(1, $this->movements->riwayatByProduct(6));
        $this->assertSame(35, $this->movements->stokMenurutLedger(6));
    }

    public function testPencarianMenemukanNamaTanpaPeduliKapital(): void
    {
        $hasil = $this->reports->pencarian('kopi');

        $this->assertCount(1, $hasil);
        $this->assertSame('KOPI-001', $hasil[0]['sku']);
        $this->assertSame('Kopi Hitam Sachet 20g', $hasil[0]['nama']);
    }

    public function testPencarianMengescapeWildcardLike(): void
    {
        $this->products->simpan('PROMO-010', 'Promo Diskon 50% Kopi', 1, 3000);

        $hasilPersen = $this->reports->pencarian('50%');
        $this->assertCount(1, $hasilPersen);
        $this->assertSame('Promo Diskon 50% Kopi', $hasilPersen[0]['nama']);

        // Tanpa escaping, '_' akan cocok dengan karakter apa pun dan
        // pola KOPI_001 keliru menemukan KOPI-001.
        $this->assertCount(0, $this->reports->pencarian('KOPI_001'));
    }

    public function testPencarianBisaDibatasiKategori(): void
    {
        $hasil = $this->reports->pencarian('', 1);

        $this->assertCount(2, $hasil);
        $this->assertSame('KOPI-001', $hasil[0]['sku']);
        $this->assertSame('TEH-002', $hasil[1]['sku']);
        $this->assertSame('Minuman', $hasil[0]['kategori']);
    }

    public function testLaporanMengikutiPerubahanStok(): void
    {
        $nilaiSebelum = $this->reports->nilaiPersediaan();
        $this->assertSame(2410000, $nilaiSebelum);

        $this->service->keluarkanStok(5, 190, 'Penjualan borongan', '2026-01-25 19:00:00');

        $menipis = $this->reports->stokMenipis(50);
        $this->assertSame('PENS-005', $menipis[0]['sku']);
        $this->assertSame(10, (int) $menipis[0]['stok_sekarang']);
        $this->assertSame(2410000 - 190 * 3000, $this->reports->nilaiPersediaan());
    }

    private function stokSekarang(int $productId): int
    {
        $stmt = $this->pdo->prepare('SELECT stok_sekarang FROM products WHERE id = ?');
        $stmt->execute([$productId]);

        return (int) $stmt->fetchColumn();
    }
}