Files
vmall/apps/api/tests/aftersales.rs
T

536 lines
19 KiB
Rust

mod common;
use common::{
checkout, client, create_shop, login_admin, make_shop_owner, pay, register_customer,
setup_sellable, spawn_app, TestApp,
};
use serial_test::serial;
/// After-sale suite: every test builds its own shop/product/order fixtures and
/// only asserts on ids it created.
/// Buy one paid order line; returns (customer_token, order, order_item_id).
async fn paid_order_line(app: &TestApp, label: &str, price_minor: i64, qty: i32) -> (String, serde_json::Value, String) {
let admin = login_admin(app).await;
let (_owner, _shop, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 100).await;
let (customer, _) = register_customer(app, label).await;
common::add_to_cart(app, &customer, &sku_id, qty).await;
let orders = checkout(app, &customer).await;
let order = orders.into_iter().next().unwrap();
pay(app, &customer, order["id"].as_str().unwrap()).await;
let detail = client()
.get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap())))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
(customer, detail, item_id)
}
async fn apply(
app: &TestApp,
customer: &str,
item_id: &str,
kind: &str,
amount: i64,
) -> reqwest::Response {
client()
.post(app.url("/api/aftersales"))
.bearer_auth(customer)
.json(&serde_json::json!({
"order_item_id": item_id,
"kind": kind,
"reason": {"en": "not as described", "zh": "与描述不符"},
"amount_minor": amount,
"evidence": ["https://example.com/evidence.png"]
}))
.send()
.await
.unwrap()
}
async fn shop_token(app: &TestApp, shop_id: &str) -> String {
let admin = login_admin(app).await;
make_shop_owner(app, &admin, shop_id).await
}
async fn shop_action(app: &TestApp, token: &str, id: &str, action: &str) -> reqwest::Response {
client()
.post(app.url(&format!("/api/shop/aftersales/{id}/{action}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn balance(app: &TestApp, customer: &str) -> i64 {
client()
.get(app.url("/api/me/stats"))
.bearer_auth(customer)
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap()["balance_minor"]
.as_i64()
.unwrap()
}
#[tokio::test]
#[serial]
async fn refund_only_lifecycle_credits_balance_and_order_total() {
let app = spawn_app().await;
let (customer, order, item_id) = paid_order_line(&app, "as-full", 1000, 2).await;
let before = balance(&app, &customer).await;
let res = apply(&app, &customer, &item_id, "refund_only", 500).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let created: serde_json::Value = res.json().await.unwrap();
assert_eq!(created["status"], "pending");
assert_eq!(created["evidence"][0], "https://example.com/evidence.png");
assert_eq!(created["remaining_refundable_minor"], 2000);
let id = created["id"].as_str().unwrap().to_string();
// Shop approves and completes the refund.
let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await;
assert_eq!(shop_action(&app, &shop, &id, "approve").await.status(), 200);
let res = shop_action(&app, &shop, &id, "refund").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "refunded");
assert_eq!(balance(&app, &customer).await, before + 500);
let after: serde_json::Value = client()
.get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap())))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(after["refund_total_minor"], 500);
// Retrying the completion is idempotent: guarded transition rejects it.
assert_eq!(shop_action(&app, &shop, &id, "refund").await.status(), 409);
assert_eq!(balance(&app, &customer).await, before + 500);
// Remaining refundable balance reflects the completed refund.
let res = client()
.get(app.url(&format!("/api/aftersales/{id}")))
.bearer_auth(&customer)
.send()
.await
.unwrap();
let status = res.status();
let detail: serde_json::Value = res.json().await.unwrap();
assert_eq!(status, 200, "{detail}");
assert_eq!(detail["remaining_refundable_minor"], 1500);
}
#[tokio::test]
#[serial]
async fn amount_beyond_line_balance_is_rejected() {
let app = spawn_app().await;
let (customer, _order, item_id) = paid_order_line(&app, "as-amount", 1000, 2).await;
let res = apply(&app, &customer, &item_id, "refund_only", 2001).await;
assert_eq!(res.status(), 409, "over the line paid amount must be 409");
let res = apply(&app, &customer, &item_id, "refund_only", 0).await;
assert_eq!(res.status(), 400, "zero amount must be 400");
}
#[tokio::test]
#[serial]
async fn one_active_aftersale_per_item_and_history_does_not_block() {
let app = spawn_app().await;
let (customer, _order, item_id) = paid_order_line(&app, "as-unique", 1000, 1).await;
assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 201);
assert_eq!(
apply(&app, &customer, &item_id, "refund_only", 100).await.status(),
409,
"a second active application must conflict"
);
// Cancel frees the line for a new application.
let list: serde_json::Value = client()
.get(app.url("/api/aftersales"))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = list[0]["id"].as_str().unwrap();
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/cancel")))
.bearer_auth(&customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 201);
}
#[tokio::test]
#[serial]
async fn return_refund_flow_with_guarded_transitions() {
let app = spawn_app().await;
let (customer, order, item_id) = paid_order_line(&app, "as-return", 800, 1).await;
let res = apply(&app, &customer, &item_id, "return_refund", 800).await;
assert_eq!(res.status(), 201);
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await;
// Illegal: refund before approval and receipt.
assert_eq!(shop_action(&app, &shop, &id, "refund").await.status(), 409);
assert_eq!(shop_action(&app, &shop, &id, "confirm-receipt").await.status(), 409);
assert_eq!(shop_action(&app, &shop, &id, "approve").await.status(), 200);
// Buyer submits return tracking.
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/return-tracking")))
.bearer_auth(&customer)
.json(&serde_json::json!({ "carrier": "UPS", "tracking_no": "1Z999" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let row: serde_json::Value = res.json().await.unwrap();
assert_eq!(row["status"], "buyer_shipping");
assert_eq!(row["return_tracking_no"], "1Z999");
// Refund still blocked before merchant confirmation.
assert_eq!(shop_action(&app, &shop, &id, "refund").await.status(), 409);
let before = balance(&app, &customer).await;
assert_eq!(shop_action(&app, &shop, &id, "confirm-receipt").await.status(), 200);
let res = shop_action(&app, &shop, &id, "refund").await;
assert_eq!(res.status(), 200);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "refunded");
assert_eq!(balance(&app, &customer).await, before + 800);
}
#[tokio::test]
#[serial]
async fn reject_reopen_once_then_terminal() {
let app = spawn_app().await;
let (customer, order, item_id) = paid_order_line(&app, "as-reopen", 500, 1).await;
let res = apply(&app, &customer, &item_id, "refund_only", 500).await;
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await;
assert_eq!(shop_action(&app, &shop, &id, "reject").await.status(), 200);
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/reopen")))
.bearer_auth(&customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "pending");
// Second rejection then second reopen must fail.
assert_eq!(shop_action(&app, &shop, &id, "reject").await.status(), 200);
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/reopen")))
.bearer_auth(&customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "reopen is a one-time appeal");
}
#[tokio::test]
#[serial]
async fn ownership_and_role_boundaries() {
let app = spawn_app().await;
let (customer, order, item_id) = paid_order_line(&app, "as-owner", 500, 1).await;
let res = apply(&app, &customer, &item_id, "refund_only", 100).await;
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Another customer sees nothing.
let (other, _) = register_customer(&app, "as-owner-other").await;
let res = client()
.get(app.url(&format!("/api/aftersales/{id}")))
.bearer_auth(&other)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/cancel")))
.bearer_auth(&other)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
// A different shop cannot read or act on it.
let admin = login_admin(&app).await;
let other_shop_id = create_shop(&app, &admin, "as-owner-other-shop").await;
let other_shop = make_shop_owner(&app, &admin, &other_shop_id).await;
let res = client()
.get(app.url(&format!("/api/shop/aftersales/{id}")))
.bearer_auth(&other_shop)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
assert_eq!(shop_action(&app, &other_shop, &id, "approve").await.status(), 404);
// Customers cannot use shop endpoints; platform admin can list all.
let res = client()
.get(app.url("/api/shop/aftersales"))
.bearer_auth(&customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 403);
let res = client()
.get(app.url("/api/admin/aftersales"))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let all: serde_json::Value = res.json().await.unwrap();
assert!(all.as_array().unwrap().iter().any(|a| a["id"] == id));
let _ = order;
}
#[tokio::test]
#[serial]
async fn bilateral_messages_are_scoped() {
let app = spawn_app().await;
let (customer, order, item_id) = paid_order_line(&app, "as-msg", 500, 1).await;
let res = apply(&app, &customer, &item_id, "refund_only", 100).await;
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await;
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/messages")))
.bearer_auth(&customer)
.json(&serde_json::json!({ "content": {"en": "please hurry"} }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let res = client()
.post(app.url(&format!("/api/shop/aftersales/{id}/messages")))
.bearer_auth(&shop)
.json(&serde_json::json!({ "content": {"zh": "马上处理"} }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/aftersales/{id}")))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let messages = detail["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["author_role"], "buyer");
assert_eq!(messages[1]["author_role"], "merchant");
// Empty message rejected.
let res = client()
.post(app.url(&format!("/api/aftersales/{id}/messages")))
.bearer_auth(&customer)
.json(&serde_json::json!({ "content": {"en": " ", "zh": ""} }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
}
#[tokio::test]
#[serial]
async fn platform_arbitration_refunds_or_rejects() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (customer, _order, item_id) = paid_order_line(&app, "as-arb", 900, 1).await;
let res = apply(&app, &customer, &item_id, "refund_only", 400).await;
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Grant a refund straight from pending.
let before = balance(&app, &customer).await;
let res = client()
.post(app.url(&format!("/api/admin/aftersales/{id}/arbitrate")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "outcome": "refund" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "refunded");
assert_eq!(balance(&app, &customer).await, before + 400);
// Reject arbitration is terminal for a new application.
let res = apply(&app, &customer, &item_id, "refund_only", 100).await;
let status = res.status();
let body = res.text().await.unwrap();
assert_eq!(status, 201, "{body}");
let id2 = serde_json::from_str::<serde_json::Value>(&body).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let res = client()
.post(app.url(&format!("/api/admin/aftersales/{id2}/arbitrate")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "outcome": "reject" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let res = client()
.post(app.url(&format!("/api/admin/aftersales/{id2}/arbitrate")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "outcome": "refund" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "rejected aftersale cannot be refunded");
}
#[tokio::test]
#[serial]
async fn unpaid_or_expired_orders_are_ineligible() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "as-window", 500, 10).await;
let (customer, _) = register_customer(&app, "as-window").await;
common::add_to_cart(&app, &customer, &sku_id, 1).await;
let orders = checkout(&app, &customer).await;
let order_id = orders[0]["id"].as_str().unwrap().to_string();
// Unpaid order line is not eligible.
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/orders/{order_id}")))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 409);
// Paid but outside the window is not eligible either.
pay(&app, &customer, &order_id).await;
sqlx::query("UPDATE orders SET updated_at = now() - interval '30 days' WHERE id = $1::uuid")
.bind(&order_id)
.execute(&app.db)
.await
.unwrap();
assert_eq!(apply(&app, &customer, &item_id, "refund_only", 100).await.status(), 409);
}
#[tokio::test]
#[serial]
async fn refund_in_order_currency_creates_missing_account() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "as-fx", 1000, 10).await;
let (customer, user_id) = register_customer(&app, "as-fx").await;
common::add_to_cart(&app, &customer, &sku_id, 1).await;
// Checkout in JPY while the customer only holds the registration currency.
let res = client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(&customer)
.json(&serde_json::json!({
"shipping_address": {
"recipient": "FX Recipient",
"phone": "123456",
"country": "US",
"region": "CA",
"city": "San Jose",
"line1": "1 Test Way",
"postal_code": "95131"
},
"currency": "JPY"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let order = res.json::<Vec<serde_json::Value>>().await.unwrap().remove(0);
pay(&app, &customer, order["id"].as_str().unwrap()).await;
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap())))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
let amount = detail["total_minor"].as_i64().unwrap();
let res = apply(&app, &customer, &item_id, "refund_only", amount).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let shop = shop_token(&app, order["shop_id"].as_str().unwrap()).await;
assert_eq!(shop_action(&app, &shop, &id, "approve").await.status(), 200);
let res = shop_action(&app, &shop, &id, "refund").await;
assert_eq!(res.status(), 200, "refund must create the JPY account: {:?}", res.text().await);
// The refund landed on a lazily created JPY account with a ledger entry.
let balance: i64 = sqlx::query_scalar(
"SELECT balance_minor FROM customer_accounts
WHERE user_id = $1::uuid AND kind = 'available' AND currency = 'JPY'",
)
.bind(&user_id)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(balance, amount);
let entries: i64 = sqlx::query_scalar(
"SELECT count(*) FROM customer_account_entries e
JOIN customer_accounts c ON c.id = e.account_id
WHERE c.user_id = $1::uuid AND e.reason = 'aftersale_refund'",
)
.bind(&user_id)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(entries, 1);
}