536 lines
19 KiB
Rust
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);
|
|
}
|