initial_commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user