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.
516 lines
18 KiB
Rust
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
|
|
);
|
|
}
|