feat(marketplace): purchase service with atomic escrow, 30/70 fee splitting and refunds
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""Integration tests for Marketplace Purchase Service, Escrow, and Fee Splitting.
|
||||
|
||||
docs/planning/economy.md US-4 (task bc90d3f481b4).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services.purchase import purchase_service
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
def test_fee_split_calculation():
|
||||
# 30% platform fee
|
||||
fee, net = purchase_service.compute_fee_split(100)
|
||||
assert fee == 30
|
||||
assert net == 70
|
||||
|
||||
fee, net = purchase_service.compute_fee_split(250)
|
||||
assert fee == 75
|
||||
assert net == 175
|
||||
|
||||
|
||||
def test_marketplace_purchase_atomic_flow():
|
||||
client = TestClient(app)
|
||||
seller_id, seller_headers = _register(client, "purchase_seller_1")
|
||||
buyer_id, buyer_headers = _register(client, "purchase_buyer_1")
|
||||
|
||||
# 1. Seller creates catalog item and lists it for 200 Credits
|
||||
item_resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={
|
||||
"name": "Dragon Armor",
|
||||
"description": "Legendary chestplate",
|
||||
"item_type": "avatar_item",
|
||||
},
|
||||
)
|
||||
assert item_resp.status_code == 201, item_resp.text
|
||||
item_id = item_resp.json()["id"]
|
||||
|
||||
list_resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={
|
||||
"catalog_item_id": item_id,
|
||||
"price": 200,
|
||||
},
|
||||
)
|
||||
assert list_resp.status_code == 201, list_resp.text
|
||||
listing_id = list_resp.json()["id"]
|
||||
|
||||
# 2. Seller cannot purchase own listing
|
||||
self_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=seller_headers,
|
||||
)
|
||||
assert self_buy.status_code == 400
|
||||
assert "own listing" in self_buy.text
|
||||
|
||||
# 3. Buyer attempts purchase without enough credits -> 400
|
||||
poor_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=buyer_headers,
|
||||
)
|
||||
assert poor_buy.status_code == 400
|
||||
assert "Insufficient funds" in poor_buy.text
|
||||
|
||||
# 4. Buyer purchases 500 Credits via payments API
|
||||
sess_resp = client.post(
|
||||
"/api/payments/purchase",
|
||||
headers=buyer_headers,
|
||||
json={"credits": 500},
|
||||
)
|
||||
assert sess_resp.status_code == 201, sess_resp.text
|
||||
sess_id = sess_resp.json()["session_id"]
|
||||
|
||||
conf_resp = client.post(
|
||||
"/api/payments/confirm",
|
||||
headers=buyer_headers,
|
||||
json={"session_id": sess_id},
|
||||
)
|
||||
assert conf_resp.status_code == 200, conf_resp.text
|
||||
assert conf_resp.json()["new_balance"] == 500
|
||||
|
||||
# 5. Buyer purchases listing for 200 Credits
|
||||
buy_headers = dict(buyer_headers)
|
||||
buy_headers["Idempotency-Key"] = "buy-test-key-1"
|
||||
buy_resp = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=buy_headers,
|
||||
)
|
||||
assert buy_resp.status_code == 201, buy_resp.text
|
||||
purchase_data = buy_resp.json()
|
||||
assert purchase_data["price"] == 200
|
||||
assert purchase_data["platform_fee"] == 60
|
||||
assert purchase_data["seller_net"] == 140
|
||||
assert purchase_data["status"] == "pending_escrow"
|
||||
assert purchase_data["buyer_id"] == buyer_id
|
||||
assert purchase_data["seller_id"] == seller_id
|
||||
purchase_id = purchase_data["id"]
|
||||
|
||||
# 6. Check buyer wallet balance: 500 - 200 = 300
|
||||
w_resp = client.get("/api/wallet", headers=buyer_headers)
|
||||
assert w_resp.status_code == 200
|
||||
assert w_resp.json()["balance"] == 300
|
||||
|
||||
# 7. Check buyer has granted item in inventory
|
||||
inv_resp = client.get("/api/marketplace/items", headers=buyer_headers, params={"mine": "true"})
|
||||
assert inv_resp.status_code == 200
|
||||
my_items = inv_resp.json()
|
||||
assert any(i["name"] == "Dragon Armor" and i["owner_id"] == buyer_id for i in my_items)
|
||||
|
||||
# 8. Listing is now sold and cannot be purchased again
|
||||
re_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=buyer_headers,
|
||||
)
|
||||
assert re_buy.status_code == 400
|
||||
assert "not active" in re_buy.text
|
||||
|
||||
# 9. Seller's creator balance is pending escrow
|
||||
cb_resp = client.get("/api/marketplace/creator/balance", headers=seller_headers)
|
||||
assert cb_resp.status_code == 200, cb_resp.text
|
||||
cb = cb_resp.json()
|
||||
assert cb["pending"] == 140
|
||||
assert cb["available"] == 0
|
||||
|
||||
# 10. List purchases
|
||||
p_resp = client.get("/api/marketplace/purchases", headers=buyer_headers)
|
||||
assert p_resp.status_code == 200
|
||||
assert any(p["id"] == purchase_id for p in p_resp.json())
|
||||
|
||||
# 11. Escrow release
|
||||
rel_resp = client.post(
|
||||
f"/api/marketplace/purchases/{purchase_id}/release",
|
||||
headers=seller_headers,
|
||||
)
|
||||
assert rel_resp.status_code == 200, rel_resp.text
|
||||
assert rel_resp.json()["status"] == "completed"
|
||||
|
||||
# Check seller's creator balance now shows available = 140
|
||||
cb_resp2 = client.get("/api/marketplace/creator/balance", headers=seller_headers)
|
||||
assert cb_resp2.status_code == 200
|
||||
cb2 = cb_resp2.json()
|
||||
assert cb2["pending"] == 0
|
||||
assert cb2["available"] == 140
|
||||
assert cb2["lifetime_earned"] == 140
|
||||
|
||||
|
||||
def test_marketplace_refund():
|
||||
client = TestClient(app)
|
||||
seller_id, seller_headers = _register(client, "refund_seller_1")
|
||||
buyer_id, buyer_headers = _register(client, "refund_buyer_1")
|
||||
|
||||
# Item & Listing
|
||||
item_resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={"name": "Magic Ring", "item_type": "avatar_item"},
|
||||
)
|
||||
item_id = item_resp.json()["id"]
|
||||
|
||||
list_resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": item_id, "price": 100},
|
||||
)
|
||||
listing_id = list_resp.json()["id"]
|
||||
|
||||
# Fund buyer
|
||||
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
|
||||
assert s_resp.status_code == 201, s_resp.text
|
||||
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
|
||||
|
||||
# Purchase
|
||||
buy_resp = client.post(f"/api/marketplace/listings/{listing_id}/purchase", headers=buyer_headers)
|
||||
assert buy_resp.status_code == 201
|
||||
purchase_id = buy_resp.json()["id"]
|
||||
|
||||
# Buyer balance is 400
|
||||
w1 = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w1["balance"] == 400
|
||||
|
||||
# Seller pending is 70
|
||||
cb1 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
|
||||
assert cb1["pending"] == 70
|
||||
|
||||
# Refund
|
||||
ref_resp = client.post(
|
||||
f"/api/marketplace/purchases/{purchase_id}/refund",
|
||||
headers=seller_headers,
|
||||
json={"reason": "Buyer dissatisfaction"},
|
||||
)
|
||||
assert ref_resp.status_code == 200, ref_resp.text
|
||||
assert ref_resp.json()["status"] == "refunded"
|
||||
|
||||
# Buyer balance refunded back to 500
|
||||
w2 = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w2["balance"] == 500
|
||||
|
||||
# Seller pending reset to 0
|
||||
cb2 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
|
||||
assert cb2["pending"] == 0
|
||||
Reference in New Issue
Block a user