|
| 1 | +"""Admin shop API soft-delete 회귀 — export / 부분 환불 lookup / list filter 가 soft-deleted row 를 노출하지 않는다.""" |
| 2 | + |
| 3 | +from datetime import datetime, timezone |
| 4 | +from io import BytesIO |
| 5 | + |
| 6 | +import pandas |
| 7 | +import pytest |
| 8 | +from freezegun import freeze_time |
| 9 | +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND |
| 10 | +from shop.order.models import Order, OrderProductRelation |
| 11 | +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus |
| 12 | +from shop.product.models import Product |
| 13 | +from shop.test.helpers import OrdersAdminApi, valid_refund_totp |
| 14 | + |
| 15 | + |
| 16 | +@pytest.mark.django_db |
| 17 | +def test_admin_refund_product_404_for_soft_deleted_opr(api_client, order_factory, product): |
| 18 | + """soft-deleted paid OPR 은 admin refund_product 에서 lookup 되지 않아 404.""" |
| 19 | + completed = order_factory(status="completed") |
| 20 | + stale_opr = OrderProductRelation.objects.create( |
| 21 | + order=completed, product=product, price=product.price, status=OrderProductRelation.OrderProductStatus.paid |
| 22 | + ) |
| 23 | + stale_opr.delete() |
| 24 | + |
| 25 | + response = OrdersAdminApi(http_client=api_client).refund_product( |
| 26 | + completed.id, stale_opr.id, totp=valid_refund_totp() |
| 27 | + ) |
| 28 | + assert response.status_code == HTTP_404_NOT_FOUND |
| 29 | + |
| 30 | + |
| 31 | +@freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc)) |
| 32 | +@pytest.mark.django_db |
| 33 | +def test_admin_export_excludes_soft_deleted_opr_from_product_sheet(api_client, order_factory, product): |
| 34 | + """admin export 의 '주문상품' 시트에 soft-deleted OPR 가 들어가지 않는다.""" |
| 35 | + completed = order_factory(status="completed") |
| 36 | + active_opr = completed.products.get() |
| 37 | + stale_opr = OrderProductRelation.objects.create( |
| 38 | + order=completed, |
| 39 | + product=product, |
| 40 | + price=99999, |
| 41 | + status=OrderProductRelation.OrderProductStatus.paid, |
| 42 | + ) |
| 43 | + stale_opr.delete() |
| 44 | + |
| 45 | + response = OrdersAdminApi(http_client=api_client).export( |
| 46 | + {"product_ids": [str(product.id)], "include_refunded": False} |
| 47 | + ) |
| 48 | + assert response.status_code == HTTP_200_OK |
| 49 | + df_dict = pandas.read_excel( |
| 50 | + BytesIO(b"".join(response.streaming_content)), |
| 51 | + sheet_name="주문상품", |
| 52 | + index_col=0, |
| 53 | + na_filter=False, |
| 54 | + dtype={"고객 전화번호": str, "PortOne ID": str}, |
| 55 | + ) |
| 56 | + rows = df_dict.to_dict(orient="records") |
| 57 | + prices = {row["결제 금액"] for row in rows} |
| 58 | + assert active_opr.price in prices # 활성 OPR row 가 존재 |
| 59 | + assert 99999 not in prices # soft-deleted OPR(price=99999) 는 시트에 없음 |
| 60 | + assert str(completed.id) in {row["주문 번호"] for row in rows} |
| 61 | + |
| 62 | + |
| 63 | +@freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc)) |
| 64 | +@pytest.mark.django_db |
| 65 | +def test_admin_export_excludes_order_with_only_soft_deleted_matching_opr( |
| 66 | + api_client, customer_user, product, order_factory |
| 67 | +): |
| 68 | + """선택 product_id 가 soft-deleted OPR 에만 있는 주문은 export 양쪽 시트 모두에서 제외돼야 한다.""" |
| 69 | + target_product = product |
| 70 | + |
| 71 | + # 1) 활성 OPR 가 있는 정상 주문 — export 대상. |
| 72 | + normal = order_factory(status="completed") |
| 73 | + |
| 74 | + # 2) 같은 product 로 soft-deleted OPR 만 가진 주문 (다른 product 의 paid OPR 보유) — export 에서 빠져야. |
| 75 | + other_product = Product.objects.create( |
| 76 | + category=target_product.category, |
| 77 | + name="기타", |
| 78 | + price=500, |
| 79 | + visible_starts_at=target_product.visible_starts_at, |
| 80 | + visible_ends_at=target_product.visible_ends_at, |
| 81 | + orderable_starts_at=target_product.orderable_starts_at, |
| 82 | + orderable_ends_at=target_product.orderable_ends_at, |
| 83 | + refundable_ends_at=target_product.refundable_ends_at, |
| 84 | + ) |
| 85 | + leak_candidate = Order.objects.create(user=customer_user, name="leak") |
| 86 | + OrderProductRelation.objects.create( |
| 87 | + order=leak_candidate, |
| 88 | + product=other_product, |
| 89 | + price=500, |
| 90 | + status=OrderProductRelation.OrderProductStatus.paid, |
| 91 | + ) |
| 92 | + stale = OrderProductRelation.objects.create( |
| 93 | + order=leak_candidate, |
| 94 | + product=target_product, |
| 95 | + price=99999, |
| 96 | + status=OrderProductRelation.OrderProductStatus.paid, |
| 97 | + ) |
| 98 | + stale.delete() |
| 99 | + # leak_candidate 도 완료 PH 가 있어야 current_status filter 를 통과 — 그래야 누수 가능성이 노출됨. |
| 100 | + PaymentHistory.objects.create(order=leak_candidate, imp_id="leak", status=PaymentHistoryStatus.completed, price=500) |
| 101 | + |
| 102 | + response = OrdersAdminApi(http_client=api_client).export( |
| 103 | + {"product_ids": [str(target_product.id)], "include_refunded": True} |
| 104 | + ) |
| 105 | + assert response.status_code == HTTP_200_OK |
| 106 | + df_dict = pandas.read_excel( |
| 107 | + BytesIO(b"".join(response.streaming_content)), |
| 108 | + sheet_name=None, |
| 109 | + index_col=0, |
| 110 | + na_filter=False, |
| 111 | + dtype={"고객 전화번호": str, "PortOne ID": str}, |
| 112 | + ) |
| 113 | + order_ids = {row["주문 번호"] for row in df_dict["주문"].to_dict(orient="records")} |
| 114 | + assert str(normal.id) in order_ids |
| 115 | + assert str(leak_candidate.id) not in order_ids |
| 116 | + |
| 117 | + |
| 118 | +@pytest.mark.django_db |
| 119 | +def test_admin_list_filter_by_product_id_ignores_soft_deleted_opr(api_client, customer_user, product, order_factory): |
| 120 | + """`?product_id=` 필터가 soft-deleted OPR 만 가진 주문을 매칭하지 않는다.""" |
| 121 | + matching_order = order_factory(status="completed") |
| 122 | + |
| 123 | + other_product = Product.objects.create( |
| 124 | + category=product.category, |
| 125 | + name="기타2", |
| 126 | + price=500, |
| 127 | + visible_starts_at=product.visible_starts_at, |
| 128 | + visible_ends_at=product.visible_ends_at, |
| 129 | + orderable_starts_at=product.orderable_starts_at, |
| 130 | + orderable_ends_at=product.orderable_ends_at, |
| 131 | + refundable_ends_at=product.refundable_ends_at, |
| 132 | + ) |
| 133 | + leak_candidate = Order.objects.create(user=customer_user, name="leak") |
| 134 | + OrderProductRelation.objects.create( |
| 135 | + order=leak_candidate, |
| 136 | + product=other_product, |
| 137 | + price=500, |
| 138 | + status=OrderProductRelation.OrderProductStatus.paid, |
| 139 | + ) |
| 140 | + stale = OrderProductRelation.objects.create( |
| 141 | + order=leak_candidate, product=product, price=99999, status=OrderProductRelation.OrderProductStatus.paid |
| 142 | + ) |
| 143 | + stale.delete() |
| 144 | + PaymentHistory.objects.create(order=leak_candidate, imp_id="leak", status=PaymentHistoryStatus.completed, price=500) |
| 145 | + |
| 146 | + response = OrdersAdminApi(http_client=api_client).list({"product_id": str(product.id)}) |
| 147 | + assert response.status_code == HTTP_200_OK |
| 148 | + returned_ids = {row["id"] for row in response.json()["results"]} |
| 149 | + assert str(matching_order.id) in returned_ids |
| 150 | + assert str(leak_candidate.id) not in returned_ids |
0 commit comments