commit b06be6e0a749e147835f896ef2424184c1c7899c Author: MelvynFaith Date: Mon Nov 2 14:58:43 2026 +0700 initial_commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..472cdf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Test dan coverage (dibuat ulang oleh perintah pytest) +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ + +# Editor dan sistem operasi +.vscode/ +.idea/ +.DS_Store +Thumbs.db +~$*.docx diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc678f5 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Week 11: Pengujian White-Box Marketplace UMKM Lokal + +Tugas Software Testing Week 11, Kelompok D. Berisi potongan sederhana dari logika Order dan Catalog pada proyek [Marketplace UMKM Lokal](https://github.com/MelvynFaith/umkm-marketplace), beserta test PyTest dan analisis coverage. + +Isi tugas: + +1. Control flow graph, V(G), dan jalur independen untuk `validate_checkout` +2. Test PyTest yang menutup semua jalur +3. State machine dan tabel transisi untuk class `Order` +4. Test class: transisi valid, transisi tidak valid, dan polimorfisme +5. Laporan `--cov-branch` beserta analisisnya + +Penjelasan lengkap ada di [`docs/laporan.md`](docs/laporan.md) (versi Word: `docs/laporan.docx`). + +## Struktur + +```text +umkm/ + checkout.py validate_checkout (modul prosedural, V(G) = 7) + order.py class Order (state machine, 6 state) + catalog.py Product, FoodProduct, ClothingProduct (pewarisan dan polimorfisme) +tests/ + test_checkout_paths.py satu test per jalur independen (P1-P7) + test_order_state.py seluruh matriks transisi 6 x 6 dan guard pelaku + test_product_polymorphism.py pewarisan dan polimorfisme Product +docs/ + laporan.md, laporan.docx laporan + *.png diagram dan screenshot bukti +``` + +## Menjalankan + +Membutuhkan Python 3.10 atau lebih baru (diuji pada Python 3.14). + +```bash +pip install -r requirements.txt +python -m pytest +``` + +Dengan laporan branch coverage: + +```bash +python -m pytest --cov=umkm --cov-branch --cov-report=term-missing --cov-report=html:htmlcov +``` + +Laporan HTML dibuat di `htmlcov/index.html`. + +## Hasil + +67 test lulus. Statement coverage dan branch coverage 100% (28 dari 28 branch). Analisisnya ada di bagian 5 laporan. diff --git a/docs/cfg-validate-checkout.png b/docs/cfg-validate-checkout.png new file mode 100644 index 0000000..7b8410f Binary files /dev/null and b/docs/cfg-validate-checkout.png differ diff --git a/docs/coverage-html-checkout.png b/docs/coverage-html-checkout.png new file mode 100644 index 0000000..e30c72b Binary files /dev/null and b/docs/coverage-html-checkout.png differ diff --git a/docs/coverage-html-index.png b/docs/coverage-html-index.png new file mode 100644 index 0000000..9a81394 Binary files /dev/null and b/docs/coverage-html-index.png differ diff --git a/docs/coverage-terminal.png b/docs/coverage-terminal.png new file mode 100644 index 0000000..0a5eec7 Binary files /dev/null and b/docs/coverage-terminal.png differ diff --git a/docs/laporan.docx b/docs/laporan.docx new file mode 100644 index 0000000..e18ad75 Binary files /dev/null and b/docs/laporan.docx differ diff --git a/docs/laporan.pdf b/docs/laporan.pdf new file mode 100644 index 0000000..bc04b35 Binary files /dev/null and b/docs/laporan.pdf differ diff --git a/docs/pytest-verbose-1.png b/docs/pytest-verbose-1.png new file mode 100644 index 0000000..abd186a Binary files /dev/null and b/docs/pytest-verbose-1.png differ diff --git a/docs/pytest-verbose-2.png b/docs/pytest-verbose-2.png new file mode 100644 index 0000000..9b17ff1 Binary files /dev/null and b/docs/pytest-verbose-2.png differ diff --git a/docs/state-machine-order.png b/docs/state-machine-order.png new file mode 100644 index 0000000..f92a523 Binary files /dev/null and b/docs/state-machine-order.png differ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb34cb8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pytest==9.1.1 +pytest-cov==7.1.0 diff --git a/tests/test_checkout_paths.py b/tests/test_checkout_paths.py new file mode 100644 index 0000000..41ba21e --- /dev/null +++ b/tests/test_checkout_paths.py @@ -0,0 +1,74 @@ +"""Pengujian jalur independen validate_checkout (V(G) = 7). + +Satu test per jalur basis. Nomor P1..P7 sama dengan tabel di docs/laporan.md. +Kebutuhan: FR-ORD-02, FR-ORD-03, FR-ORD-08. +""" + +import pytest + +from umkm.catalog import ClothingProduct +from umkm.checkout import ( + CartItem, + CheckoutSummary, + InsufficientStockError, + InvalidQuantityError, + MultiShopError, + ProductNotFoundError, + validate_checkout, +) + + +@pytest.fixture +def products(): + return { + "p1": ClothingProduct("p1", "shop-A", "Kaos", price=50_000, on_hand=10, size="M"), + "p2": ClothingProduct("p2", "shop-A", "Kemeja", price=120_000, on_hand=5, size="L"), + "p3": ClothingProduct("p3", "shop-B", "Jaket", price=200_000, on_hand=5, size="XL"), + "p4": ClothingProduct("p4", "shop-A", "Topi", price=30_000, on_hand=3, size="S", reserved=3), + } + + +def test_p1_keranjang_kosong_total_nol(products): + """P1: loop tidak dimasuki.""" + assert validate_checkout([], products) == CheckoutSummary(None, 0) + + +def test_p2_produk_tidak_ada(products): + """P2: product is None -> ProductNotFoundError.""" + with pytest.raises(ProductNotFoundError): + validate_checkout([CartItem("tidak-ada", 1)], products) + + +@pytest.mark.parametrize("qty", [0, -1]) +def test_p3_kuantitas_tidak_valid(products, qty): + """P3: quantity <= 0 -> InvalidQuantityError (batas 0 dan negatif).""" + with pytest.raises(InvalidQuantityError): + validate_checkout([CartItem("p1", qty)], products) + + +def test_p4_stok_tidak_cukup_pada_item_pertama(products): + """P4: available < quantity pada iterasi pertama.""" + with pytest.raises(InsufficientStockError): + validate_checkout([CartItem("p4", 1)], products) + + +def test_p5_satu_item_valid(products): + """P5: jalur baseline, satu item lolos semua cek.""" + assert validate_checkout([CartItem("p1", 2)], products) == CheckoutSummary("shop-A", 100_000) + + +def test_p6_dua_item_satu_toko(products): + """P6: iterasi kedua, toko sama -> shop_id tidak berubah, total dijumlahkan.""" + result = validate_checkout([CartItem("p1", 2), CartItem("p2", 1)], products) + assert result == CheckoutSummary("shop-A", 2 * 50_000 + 120_000) + + +def test_p7_dua_item_beda_toko(products): + """P7: iterasi kedua, toko berbeda -> MultiShopError (FR-ORD-08).""" + with pytest.raises(MultiShopError): + validate_checkout([CartItem("p1", 1), CartItem("p3", 1)], products) + + +def test_batas_stok_tepat_sama_lolos(products): + """Batas available == quantity harus lolos (cabang stok false).""" + assert validate_checkout([CartItem("p2", 5)], products).total_amount == 600_000 diff --git a/tests/test_order_state.py b/tests/test_order_state.py new file mode 100644 index 0000000..759947b --- /dev/null +++ b/tests/test_order_state.py @@ -0,0 +1,123 @@ +"""Pengujian mesin status Order: transisi valid, tidak valid, dan guard pelaku. + +Kebutuhan: FR-ORD-04, FR-ORD-06, FR-ORD-09, FR-ORD-10. +""" + +from datetime import datetime, timedelta + +import pytest + +from umkm.order import ( + TRANSITIONS, + InvalidTransitionError, + Order, + OrderStatus as S, + PermissionDeniedError, +) + +BUYER, SELLER = "buyer-1", "seller-1" +T0 = datetime(2026, 11, 1, 10, 0) + + +def new_order(): + return Order("o-1", BUYER, SELLER) + + +def order_in(status): + """Bawa order ke status yang diminta lewat transisi sungguhan.""" + order = new_order() + if status in (S.PAID, S.SHIPPED, S.COMPLETED): + order.pay() + if status in (S.SHIPPED, S.COMPLETED): + order.ship(SELLER, T0) + if status is S.COMPLETED: + order.confirm_received(BUYER) + if status is S.CANCELLED: + order.cancel(BUYER) + if status is S.EXPIRED: + order.expire() + assert order.status is status + return order + + +# Semua event yang dapat dipicu; auto_complete memakai waktu tepat jatuh tempo. +EVENTS = { + "pay": lambda o: o.pay(), + "cancel": lambda o: o.cancel(BUYER), + "expire": lambda o: o.expire(), + "ship": lambda o: o.ship(SELLER, T0), + "complete": lambda o: o.confirm_received(BUYER), + "auto_complete": lambda o: o.auto_complete(T0 + timedelta(days=3)), +} +VALID = dict(TRANSITIONS) +VALID[(S.SHIPPED, "auto_complete")] = S.COMPLETED +INVALID = [(s, e) for s in S for e in EVENTS if (s, e) not in VALID] + + +def test_order_baru_berstatus_pending_payment(): + assert new_order().status is S.PENDING_PAYMENT + + +@pytest.mark.parametrize(("src", "event"), list(VALID)) +def test_transisi_valid(src, event): + order = order_in(src) + EVENTS[event](order) + assert order.status is VALID[(src, event)] + + +@pytest.mark.parametrize(("src", "event"), INVALID) +def test_transisi_tidak_valid_ditolak_dan_status_tetap(src, event): + order = order_in(src) + with pytest.raises(InvalidTransitionError): + EVENTS[event](order) + assert order.status is src + + +def test_jumlah_kombinasi_lengkap(): + """6 status x 6 event = 36 kombinasi: 6 valid + 30 tidak valid.""" + assert len(VALID) == 6 + assert len(INVALID) == 30 + + +@pytest.mark.parametrize("final", [S.COMPLETED, S.CANCELLED, S.EXPIRED]) +def test_status_akhir_tidak_punya_transisi_keluar(final): + assert not [key for key in VALID if key[0] is final] + + +def test_seller_tidak_boleh_menyelesaikan_order(): + """FR-ORD-10: hanya pembeli yang mengonfirmasi.""" + order = order_in(S.SHIPPED) + with pytest.raises(PermissionDeniedError): + order.confirm_received(SELLER) + assert order.status is S.SHIPPED + + +def test_hanya_seller_pemilik_toko_boleh_mengirim(): + order = order_in(S.PAID) + with pytest.raises(PermissionDeniedError): + order.ship("seller-lain", T0) + assert order.status is S.PAID + + +def test_hanya_pembeli_pemilik_order_boleh_membatalkan(): + order = new_order() + with pytest.raises(PermissionDeniedError): + order.cancel("buyer-lain") + assert order.status is S.PENDING_PAYMENT + + +def test_auto_complete_ditolak_sebelum_3_hari(): + order = order_in(S.SHIPPED) + with pytest.raises(InvalidTransitionError): + order.auto_complete(T0 + timedelta(days=3) - timedelta(seconds=1)) + assert order.status is S.SHIPPED + + +def test_auto_complete_tepat_3_hari_berhasil(): + order = order_in(S.SHIPPED) + order.auto_complete(T0 + timedelta(days=3)) + assert order.status is S.COMPLETED + + +def test_ship_mencatat_waktu_pengiriman(): + assert order_in(S.SHIPPED).shipped_at == T0 diff --git a/tests/test_product_polymorphism.py b/tests/test_product_polymorphism.py new file mode 100644 index 0000000..c09950c --- /dev/null +++ b/tests/test_product_polymorphism.py @@ -0,0 +1,68 @@ +"""Pewarisan dan polimorfisme hierarki Product (AR-01, FR-CAT-03).""" + +from datetime import date + +import pytest + +from umkm.catalog import ClothingProduct, FoodProduct, Product + +TODAY = date(2026, 11, 2) + + +def make_products(): + return [ + FoodProduct("f1", "shop-A", "Keripik", 15_000, 10, expiry_date=date(2026, 12, 1)), + ClothingProduct("c1", "shop-A", "Kaos", 50_000, 10, size="M"), + ] + + +def test_subclass_adalah_product(): + assert all(isinstance(p, Product) for p in make_products()) + + +def test_product_abstrak_tidak_bisa_dibuat(): + with pytest.raises(TypeError): + Product("x", "s", "x", 1, 1) + + +def test_attributes_polimorfik_lewat_referensi_basis(): + """Pemanggilan yang sama pada tipe basis memberi hasil berbeda per subclass.""" + hasil = {p.category: p.attributes() for p in make_products()} + assert hasil == {"food": {"expiryDate": "2026-12-01"}, "clothing": {"size": "M"}} + + +def test_available_diwarisi_semua_subclass(): + for p in make_products(): + p.reserved = 4 + assert p.available == 6 + + +def test_food_kedaluwarsa_tidak_bisa_dipesan_override(): + food = FoodProduct("f1", "s", "Keripik", 1, 10, expiry_date=date(2026, 11, 1)) + assert food.is_orderable(TODAY) is False + assert food.is_orderable(date(2026, 11, 1)) is True # hari kedaluwarsa masih boleh + + +def test_food_stok_habis_tidak_bisa_dipesan_lewat_super(): + food = FoodProduct("f1", "s", "Keripik", 1, 5, expiry_date=date(2027, 1, 1), reserved=5) + assert food.is_orderable(TODAY) is False + + +def test_clothing_memakai_aturan_dasar_tanpa_override(): + assert "is_orderable" not in ClothingProduct.__dict__ + assert ClothingProduct("c", "s", "Kaos", 1, 1, size="S").is_orderable(TODAY) is True + assert ClothingProduct("c", "s", "Kaos", 1, 1, size="S", reserved=1).is_orderable(TODAY) is False + + +def test_clothing_size_tidak_valid_ditolak(): + with pytest.raises(ValueError): + ClothingProduct("c", "s", "Kaos", 1, 1, size="XXL") + + +@pytest.mark.parametrize( + ("price", "on_hand", "reserved"), + [(-1, 1, 0), (1, 1, 2), (1, 1, -1)], +) +def test_validasi_konstruktor_basis(price, on_hand, reserved): + with pytest.raises(ValueError): + ClothingProduct("c", "s", "Kaos", price, on_hand, size="M", reserved=reserved) diff --git a/umkm/__init__.py b/umkm/__init__.py new file mode 100644 index 0000000..bb4929f --- /dev/null +++ b/umkm/__init__.py @@ -0,0 +1 @@ +"""Potongan sederhana Marketplace UMKM Lokal (Kelompok D) untuk tugas Week 11.""" diff --git a/umkm/catalog.py b/umkm/catalog.py new file mode 100644 index 0000000..66c3166 --- /dev/null +++ b/umkm/catalog.py @@ -0,0 +1,65 @@ +"""Model produk Catalog. Atribut khusus per kategori mengikuti AR-01 / FR-CAT-03.""" + +from abc import ABC, abstractmethod +from datetime import date + +CLOTHING_SIZES = ("S", "M", "L", "XL") + + +class Product(ABC): + """Kelas dasar produk. Subclass menentukan atribut khusus kategorinya.""" + + category = "generic" + + def __init__(self, product_id, shop_id, name, price, on_hand, reserved=0): + if price < 0: + raise ValueError("price tidak boleh negatif") + if not 0 <= reserved <= on_hand: + raise ValueError("reserved harus di antara 0 dan on_hand") + self.product_id = product_id + self.shop_id = shop_id + self.name = name + self.price = price + self.on_hand = on_hand + self.reserved = reserved + + @property + def available(self): + """Stok yang dapat dipesan: onHand - reserved (glosarium SRS).""" + return self.on_hand - self.reserved + + def is_orderable(self, today): + """Aturan dasar: produk dapat dipesan jika masih ada stok tersedia.""" + return self.available > 0 + + @abstractmethod + def attributes(self): + """Atribut khusus kategori, dikembalikan sebagai dict.""" + + +class FoodProduct(Product): + category = "food" + + def __init__(self, product_id, shop_id, name, price, on_hand, expiry_date, reserved=0): + super().__init__(product_id, shop_id, name, price, on_hand, reserved) + self.expiry_date = expiry_date + + def is_orderable(self, today): + """Makanan tidak boleh dipesan setelah tanggal kedaluwarsa.""" + return super().is_orderable(today) and today <= self.expiry_date + + def attributes(self): + return {"expiryDate": self.expiry_date.isoformat()} + + +class ClothingProduct(Product): + category = "clothing" + + def __init__(self, product_id, shop_id, name, price, on_hand, size, reserved=0): + if size not in CLOTHING_SIZES: + raise ValueError(f"size harus salah satu dari {CLOTHING_SIZES}") + super().__init__(product_id, shop_id, name, price, on_hand, reserved) + self.size = size + + def attributes(self): + return {"size": self.size} diff --git a/umkm/checkout.py b/umkm/checkout.py new file mode 100644 index 0000000..7d4e628 --- /dev/null +++ b/umkm/checkout.py @@ -0,0 +1,60 @@ +"""Modul prosedural validasi checkout (FR-ORD-02, FR-ORD-03, FR-ORD-08). + +Harga diambil dari katalog, bukan dari input klien. Satu order hanya boleh +berisi produk dari satu toko. Keranjang kosong mengembalikan total 0; penolakan +keranjang kosong menjadi tanggung jawab pemanggil. +""" + +from dataclasses import dataclass + + +class CheckoutError(Exception): + """Kelas dasar semua kegagalan validasi checkout.""" + + +class ProductNotFoundError(CheckoutError): + pass + + +class InvalidQuantityError(CheckoutError): + pass + + +class MultiShopError(CheckoutError): + pass + + +class InsufficientStockError(CheckoutError): + pass + + +@dataclass(frozen=True) +class CartItem: + product_id: str + quantity: int + + +@dataclass(frozen=True) +class CheckoutSummary: + shop_id: str | None + total_amount: int + + +def validate_checkout(items, products): + """Validasi keranjang terhadap katalog lalu hitung total dalam rupiah.""" + shop_id = None + total = 0 + for item in items: + product = products.get(item.product_id) + if product is None: + raise ProductNotFoundError(item.product_id) + if item.quantity <= 0: + raise InvalidQuantityError(item.product_id) + if shop_id is None: + shop_id = product.shop_id + elif product.shop_id != shop_id: + raise MultiShopError(item.product_id) + if product.available < item.quantity: + raise InsufficientStockError(item.product_id) + total += product.price * item.quantity + return CheckoutSummary(shop_id, total) diff --git a/umkm/order.py b/umkm/order.py new file mode 100644 index 0000000..ed67410 --- /dev/null +++ b/umkm/order.py @@ -0,0 +1,83 @@ +"""Mesin status order (FR-ORD-04, FR-ORD-09, FR-ORD-10, UC-07, UC-08).""" + +from datetime import timedelta +from enum import Enum + +AUTO_COMPLETE_AFTER = timedelta(days=3) + + +class OrderStatus(Enum): + PENDING_PAYMENT = "PENDING_PAYMENT" + PAID = "PAID" + SHIPPED = "SHIPPED" + COMPLETED = "COMPLETED" + CANCELLED = "CANCELLED" + EXPIRED = "EXPIRED" + + +class InvalidTransitionError(Exception): + """Event tidak diizinkan pada status order saat ini.""" + + +class PermissionDeniedError(Exception): + """Pelaku tidak berhak memicu event ini.""" + + +S = OrderStatus + +# Tabel transisi: (status asal, event) -> status tujuan. Pasangan lain ditolak. +TRANSITIONS = { + (S.PENDING_PAYMENT, "pay"): S.PAID, + (S.PENDING_PAYMENT, "cancel"): S.CANCELLED, + (S.PENDING_PAYMENT, "expire"): S.EXPIRED, + (S.PAID, "ship"): S.SHIPPED, + (S.SHIPPED, "complete"): S.COMPLETED, +} + + +class Order: + def __init__(self, order_id, buyer_id, seller_id): + self.order_id = order_id + self.buyer_id = buyer_id + self.seller_id = seller_id + self.status = S.PENDING_PAYMENT + self.shipped_at = None + + def _fire(self, event): + target = TRANSITIONS.get((self.status, event)) + if target is None: + raise InvalidTransitionError(f"{event} tidak valid pada status {self.status.value}") + self.status = target + + def pay(self): + """Event payment.succeeded dari Payment Service.""" + self._fire("pay") + + def cancel(self, actor_id): + """Pembeli membatalkan order yang belum dibayar.""" + if actor_id != self.buyer_id: + raise PermissionDeniedError("hanya pembeli yang dapat membatalkan") + self._fire("cancel") + + def expire(self): + """Batas waktu pembayaran habis (dipicu sistem).""" + self._fire("expire") + + def ship(self, actor_id, now): + """Seller pemilik toko menandai order terkirim.""" + if actor_id != self.seller_id: + raise PermissionDeniedError("hanya seller pemilik toko yang dapat mengirim") + self._fire("ship") + self.shipped_at = now + + def confirm_received(self, actor_id): + """Pembeli mengonfirmasi pesanan diterima. Seller tidak boleh.""" + if actor_id != self.buyer_id: + raise PermissionDeniedError("hanya pembeli yang dapat mengonfirmasi") + self._fire("complete") + + def auto_complete(self, now): + """Sistem menyelesaikan order 3 hari setelah SHIPPED tanpa konfirmasi.""" + if self.status is S.SHIPPED and now - self.shipped_at < AUTO_COMPLETE_AFTER: + raise InvalidTransitionError("belum 3 hari sejak SHIPPED") + self._fire("complete")