Files
vmall/apps/api/tests/merchant_applications.rs
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

516 lines
18 KiB
Rust

mod common;
use common::{
category_id_by_slug, client, login_admin, register_customer, spawn_app, TestApp,
};
use serial_test::serial;
use uuid::Uuid;
// Merchant onboarding suite: each test provisions its own applicant and only
// asserts on rows it created; the shared test database is never truncated.
fn personal(category_id: &str, email: &str) -> serde_json::Value {
serde_json::json!({
"entity_type": "personal",
"real_name": "Jane Applicant",
"category_ids": [category_id],
"contact": {
"name": "Jane Applicant",
"phone": "13800000000",
"email": email,
"address": "1 Applicant Way"
},
"qualification": {
"identity_document_url": "https://example.com/id-card.png",
"extra_materials": ["https://example.com/extra-1.pdf"]
}
})
}
fn enterprise(category_id: &str, email: &str) -> serde_json::Value {
serde_json::json!({
"entity_type": "enterprise",
"company_name": "Acme Trading Co",
"category_ids": [category_id],
"contact": { "name": "Ann Manager", "phone": "13900000000", "email": email },
"qualification": {
"business_license_url": "https://example.com/license.pdf",
"business_license_no": "91310000MA1FL0XXXX",
"extra_materials": ["https://example.com/tax.pdf"]
}
})
}
async fn submit(app: &TestApp, token: &str, body: &serde_json::Value) -> reqwest::Response {
client()
.post(app.url("/api/merchant/applications"))
.bearer_auth(token)
.json(body)
.send()
.await
.unwrap()
}
async fn mine(app: &TestApp, token: &str) -> Vec<serde_json::Value> {
let res = client()
.get(app.url("/api/merchant/applications"))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
res.json().await.unwrap()
}
async fn admin_reject(
app: &TestApp,
admin: &str,
id: &str,
reason: &str,
) -> reqwest::Response {
client()
.post(app.url(&format!("/api/admin/merchant/applications/{id}/reject")))
.bearer_auth(admin)
.json(&serde_json::json!({ "reason": reason }))
.send()
.await
.unwrap()
}
async fn admin_approve(app: &TestApp, admin: &str, id: &str) -> reqwest::Response {
client()
.post(app.url(&format!("/api/admin/merchant/applications/{id}/approve")))
.bearer_auth(admin)
.send()
.await
.unwrap()
}
async fn contact_email(label: &str) -> String {
format!("{label}-{}@biz.test", Uuid::new_v4())
}
#[tokio::test]
#[serial]
async fn submissions_are_accepted_for_both_kinds() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "electronics").await;
let (seller, _) = register_customer(&app, "mo-enterprise").await;
let mail = contact_email("enterprise").await;
let res = submit(&app, &seller, &enterprise(&cat, &mail)).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let created: serde_json::Value = res.json().await.unwrap();
assert_eq!(created["entity_type"], "enterprise");
assert_eq!(created["status"], "pending");
assert_eq!(created["company_name"], "Acme Trading Co");
assert_eq!(created["contact"]["email"], mail);
assert_eq!(
created["qualification"]["business_license_no"],
"91310000MA1FL0XXXX"
);
assert_eq!(created["categories"][0]["id"], cat);
assert_eq!(created["category_ids"][0], cat);
assert!(created["created_shop_id"].is_null());
assert!(created["rejection_reason"].is_null());
let (buyer, _) = register_customer(&app, "mo-personal").await;
let res = submit(&app, &buyer, &personal(&cat, &contact_email("personal").await)).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let personal_row: serde_json::Value = res.json().await.unwrap();
assert_eq!(personal_row["entity_type"], "personal");
assert_eq!(personal_row["real_name"], "Jane Applicant");
assert_eq!(
personal_row["qualification"]["identity_document_url"],
"https://example.com/id-card.png"
);
assert!(personal_row["company_name"].is_null());
// The admin queue filters by status and includes both fresh rows.
let listed: serde_json::Value = client()
.get(app.url("/api/admin/merchant/applications"))
.query(&[("status", "pending"), ("per_page", "100")])
.bearer_auth(&admin)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<&str> = listed["items"]
.as_array()
.unwrap()
.iter()
.map(|a| a["id"].as_str().unwrap())
.collect();
assert!(ids.contains(&created["id"].as_str().unwrap()));
assert!(ids.contains(&personal_row["id"].as_str().unwrap()));
}
#[tokio::test]
#[serial]
async fn submission_validation_rejects_bad_input() {
let app = spawn_app().await;
let cat = category_id_by_slug(&app, "electronics").await;
let (seller, _) = register_customer(&app, "mo-invalid").await;
// Anonymous submissions are refused.
let anon = client()
.post(app.url("/api/merchant/applications"))
.json(&personal(&cat, &contact_email("anon").await))
.send()
.await
.unwrap();
assert_eq!(anon.status(), 401);
// Personal without the identity document.
let mut body = personal(&cat, &contact_email("nodoc").await);
body["qualification"] = serde_json::json!({ "extra_materials": [] });
assert_eq!(submit(&app, &seller, &body).await.status(), 400);
// Malformed qualification URL.
let mut body = personal(&cat, &contact_email("badurl").await);
body["qualification"]["identity_document_url"] = serde_json::json!("not-a-url");
assert_eq!(submit(&app, &seller, &body).await.status(), 400);
// Enterprise missing its company name.
let mut body = enterprise(&cat, &contact_email("nocname").await);
body["company_name"] = serde_json::json!(" ");
assert_eq!(submit(&app, &seller, &body).await.status(), 400);
// No operating categories.
let mut body = enterprise(&cat, &contact_email("nocat").await);
body["category_ids"] = serde_json::json!([]);
assert_eq!(submit(&app, &seller, &body).await.status(), 400);
// Unknown category reference.
let mut body = enterprise(&cat, &contact_email("unknowncat").await);
body["category_ids"] = serde_json::json!([Uuid::new_v4()]);
assert_eq!(submit(&app, &seller, &body).await.status(), 400);
// Invalid contact email.
let mut body = enterprise(&cat, &contact_email("badmail").await);
body["contact"]["email"] = serde_json::json!("not-an-email");
assert_eq!(submit(&app, &seller, &body).await.status(), 400);
// None of the rejected attempts stored a row.
assert!(mine(&app, &seller).await.is_empty());
}
#[tokio::test]
#[serial]
async fn one_active_application_per_user_and_reapply_after_rejection() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "electronics").await;
let (seller, _) = register_customer(&app, "mo-dedupe").await;
let body = enterprise(&cat, &contact_email("dedupe").await);
let first: serde_json::Value = submit(&app, &seller, &body)
.await
.json()
.await
.unwrap();
let id = first["id"].as_str().unwrap().to_string();
// A second live application is a conflict.
let res = submit(&app, &seller, &body).await;
assert_eq!(res.status(), 409, "{:?}", res.text().await);
assert_eq!(mine(&app, &seller).await.len(), 1);
// Rejection records the reason, then re-applying is allowed.
let res = admin_reject(&app, &admin, &id, "qualification unreadable").await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let rejected: serde_json::Value = res.json().await.unwrap();
assert_eq!(rejected["status"], "rejected");
assert_eq!(rejected["rejection_reason"], "qualification unreadable");
assert!(rejected["reviewed_at"].is_string());
let res = submit(&app, &seller, &body).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let reapplied: serde_json::Value = res.json().await.unwrap();
assert_ne!(reapplied["id"], first["id"]);
assert_eq!(reapplied["status"], "pending");
assert_eq!(mine(&app, &seller).await.len(), 2);
}
#[tokio::test]
#[serial]
async fn reviews_are_guarded_and_rejection_needs_a_reason() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "fashion").await;
let (seller, _) = register_customer(&app, "mo-guard").await;
let created: serde_json::Value = submit(&app, &seller, &personal(&cat, &contact_email("guard").await))
.await
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap().to_string();
// Blank / whitespace reasons never transition.
assert_eq!(admin_reject(&app, &admin, &id, "").await.status(), 400);
assert_eq!(admin_reject(&app, &admin, &id, " ").await.status(), 400);
assert_eq!(
admin_reject(&app, &admin, &id, "material incomplete")
.await
.status(),
200
);
// Terminal states are immutable for both actions.
assert_eq!(admin_approve(&app, &admin, &id).await.status(), 409);
assert_eq!(
admin_reject(&app, &admin, &id, "again").await.status(),
409
);
}
#[tokio::test]
#[serial]
async fn approval_provisions_shop_and_owner_with_one_time_credentials() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "home-living").await;
let (seller, _) = register_customer(&app, "mo-approve").await;
let mail = contact_email("approve").await;
let created: serde_json::Value = submit(&app, &seller, &enterprise(&cat, &mail))
.await
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap().to_string();
let res = admin_approve(&app, &admin, &id).await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let approved: serde_json::Value = res.json().await.unwrap();
assert_eq!(approved["application"]["status"], "approved");
let shop_id = approved["application"]["created_shop_id"]
.as_str()
.unwrap()
.to_string();
assert!(!shop_id.is_empty());
let credentials = &approved["credentials"];
assert_eq!(credentials["email"], mail);
assert_eq!(credentials["shop_id"], shop_id);
let password = credentials["initial_password"].as_str().unwrap().to_string();
assert!(password.len() >= 8);
let slug = credentials["shop_slug"].as_str().unwrap().to_string();
assert!(slug.starts_with("acme-trading-co-"), "unexpected slug {slug}");
// The password is not retrievable from any later read.
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/admin/merchant/applications/{id}")))
.bearer_auth(&admin)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(detail.get("credentials").is_none());
assert!(detail["initial_password"].is_null());
assert_eq!(detail["status"], "approved");
// The generated owner can log in and manage the linked shop.
let res = client()
.post(app.url("/api/auth/login"))
.json(&serde_json::json!({ "email": mail, "password": password }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let session: serde_json::Value = res.json().await.unwrap();
assert_eq!(session["user"]["role"], "shop_owner");
assert_eq!(session["user"]["shop_id"], shop_id);
let owner_token = session["token"].as_str().unwrap().to_string();
let res = client()
.get(app.url("/api/shop/profile"))
.bearer_auth(&owner_token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["id"], shop_id);
// One review only.
assert_eq!(admin_approve(&app, &admin, &id).await.status(), 409);
}
#[tokio::test]
#[serial]
async fn concurrent_reviews_transition_exactly_once() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "electronics").await;
let (seller, _) = register_customer(&app, "mo-race").await;
let created: serde_json::Value = submit(&app, &seller, &enterprise(&cat, &contact_email("race").await))
.await
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap().to_string();
let (a, b) = tokio::join!(
admin_approve(&app, &admin, &id),
admin_approve(&app, &admin, &id)
);
let statuses = [a.status().as_u16(), b.status().as_u16()];
assert!(
statuses.contains(&200) && statuses.contains(&409),
"expected one success and one conflict, got {statuses:?}"
);
// Exactly one shop owner was provisioned for the created shop.
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/admin/merchant/applications/{id}")))
.bearer_auth(&admin)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let shop_id = detail["created_shop_id"].as_str().unwrap().to_string();
let owners: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE shop_id = $1::uuid AND role = 'shop_owner'",
)
.bind(&shop_id)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(owners, 1);
}
#[tokio::test]
#[serial]
async fn concurrent_submissions_create_one_application() {
let app = spawn_app().await;
let cat = category_id_by_slug(&app, "fashion").await;
let (seller, _) = register_customer(&app, "mo-race-submit").await;
let body = personal(&cat, &contact_email("race-submit").await);
let (a, b) = tokio::join!(submit(&app, &seller, &body), submit(&app, &seller, &body));
let statuses = [a.status().as_u16(), b.status().as_u16()];
assert!(
statuses.contains(&201) && statuses.contains(&409),
"expected one success and one conflict, got {statuses:?}"
);
assert_eq!(mine(&app, &seller).await.len(), 1);
}
#[tokio::test]
#[serial]
async fn approval_rolls_back_when_provisioning_fails() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "electronics").await;
let (seller, _) = register_customer(&app, "mo-rollback").await;
let mail = contact_email("rollback").await;
// A fixed entity name makes the derived slug predictable.
let mut body = enterprise(&cat, &mail);
body["company_name"] = serde_json::json!("Rollback Mart");
let created: serde_json::Value = submit(&app, &seller, &body)
.await
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap().to_string();
let expected_slug = format!("rollback-mart-{}", &id.replace('-', "")[..6]);
// Claim the slug so the approval's shop insert fails mid-transaction.
sqlx::query("INSERT INTO shops (name, slug) VALUES ($1, $2)")
.bind(serde_json::json!({"en": "Squatter", "zh": "占位"}))
.bind(&expected_slug)
.execute(&app.db)
.await
.unwrap();
let res = admin_approve(&app, &admin, &id).await;
assert_eq!(res.status(), 409, "{:?}", res.text().await);
// The application stayed pending and nothing else was provisioned.
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/admin/merchant/applications/{id}")))
.bearer_auth(&admin)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(detail["status"], "pending");
assert!(detail["created_shop_id"].is_null());
assert!(detail["reviewed_at"].is_null());
let shops: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM shops WHERE slug = $1")
.bind(&expected_slug)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(shops, 1, "only the squatter shop may exist");
let owners: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = $1")
.bind(&mail)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(owners, 0, "no owner account may have been provisioned");
}
#[tokio::test]
#[serial]
async fn applications_are_user_isolated() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let cat = category_id_by_slug(&app, "electronics").await;
let (alice, _) = register_customer(&app, "mo-alice").await;
let (bob, _) = register_customer(&app, "mo-bob").await;
let created: serde_json::Value = submit(&app, &alice, &personal(&cat, &contact_email("alice").await))
.await
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap().to_string();
// Bob sees no trace of Alice's application.
assert!(mine(&app, &bob).await.is_empty());
assert_eq!(mine(&app, &alice).await.len(), 1);
// Customer routes expose no cross-user read, and admin routes need the role.
assert_eq!(
client()
.get(app.url(&format!("/api/admin/merchant/applications/{id}")))
.bearer_auth(&bob)
.send()
.await
.unwrap()
.status(),
403
);
assert_eq!(
client()
.get(app.url("/api/admin/merchant/applications"))
.bearer_auth(&alice)
.send()
.await
.unwrap()
.status(),
403
);
assert_eq!(
client()
.get(app.url(&format!("/api/admin/merchant/applications/{id}")))
.bearer_auth(&admin)
.send()
.await
.unwrap()
.status(),
200
);
}