Files
vmall/apps/api/tests/membership.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

575 lines
19 KiB
Rust

mod common;
use common::{
add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app,
TestApp,
};
use serial_test::serial;
use uuid::Uuid;
// Membership suite: levels have globally unique growth thresholds in a database
// that is never truncated, so every test derives its thresholds from a per-run
// random salt. Base currency is USD (exponent 2): an order of `threshold * 100`
// minor units contributes exactly `threshold` whole growth units.
/// Random base well above any realistic fixture, unique per test.
fn salt() -> i64 {
100_000_000 + (Uuid::new_v4().as_u128() % 1_000_000) as i64
}
/// Minor-unit price that accrues exactly `growth_units` growth value in USD.
fn price_for(growth_units: i64) -> i64 {
growth_units * 100
}
/// member_levels is written only by these tests, so clearing it (and the
/// derived `users.level`) before each test keeps threshold-order assertions
/// deterministic in the shared, never-truncated test database.
async fn reset_levels(app: &TestApp) {
sqlx::query("UPDATE users SET level = NULL")
.execute(&app.db)
.await
.unwrap();
sqlx::query("DELETE FROM member_levels")
.execute(&app.db)
.await
.unwrap();
}
struct OrderFixture {
customer: String,
owner: String,
shop_id: String,
order_id: String,
item_id: String,
qty: i32,
/// Set when the fixture shipped the order, so a retry can target it.
ship_id: Option<String>,
}
async fn level(
app: &TestApp,
admin: &str,
growth_threshold: i64,
en: &str,
zh: &str,
) -> serde_json::Value {
let res = client()
.post(app.url("/api/admin/member-levels"))
.bearer_auth(admin)
.json(&serde_json::json!({
"name": { "en": en, "zh": zh },
"icon": "star",
"growth_threshold": growth_threshold,
"benefits": { "en": format!("{en} benefits"), "zh": format!("{zh}权益") }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
res.json().await.unwrap()
}
/// A paid order, optionally shipped + buyer-confirmed (completed).
async fn order_fixture(
app: &TestApp,
label: &str,
price_minor: i64,
qty: i32,
complete: bool,
) -> OrderFixture {
let admin = login_admin(app).await;
let (owner, shop_id, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 50).await;
let (customer, _) = register_customer(app, label).await;
add_to_cart(app, &customer, &sku_id, qty).await;
let orders = checkout(app, &customer).await;
let order_id = orders[0]["id"].as_str().unwrap().to_string();
pay(app, &customer, &order_id).await;
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/shop/orders/{order_id}")))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
let mut ship_id: Option<String> = None;
if complete {
let res = client()
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
.bearer_auth(&owner)
.json(&serde_json::json!({
"carrier": "SF", "tracking_no": "T1",
"items": [{ "order_item_id": item_id, "qty": qty }]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
client()
.post(app.url(&format!("/api/shop/shipments/{id}/ship")))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.status(),
200
);
let res = client()
.post(app.url(&format!("/api/shipments/{id}/confirm-delivered")))
.bearer_auth(&customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
ship_id = Some(id);
}
OrderFixture {
customer,
owner,
shop_id,
order_id,
item_id,
qty,
ship_id,
}
}
async fn membership(app: &TestApp, token: &str) -> serde_json::Value {
let res = client()
.get(app.url("/api/membership"))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
res.json().await.unwrap()
}
async fn growth_logs(app: &TestApp, token: &str, page: i64, per_page: i64) -> serde_json::Value {
client()
.get(app.url("/api/membership/growth-logs"))
.query(&[
("page", page.to_string()),
("per_page", per_page.to_string()),
])
.bearer_auth(token)
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn level_catalog_crud_and_constraints() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
let base = salt();
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
let silver = level(&app, &admin, base + 200, "Silver", "白银").await;
assert_eq!(bronze["growth_threshold"], base);
// Thresholds order the list.
let listed: Vec<serde_json::Value> = client()
.get(app.url("/api/admin/member-levels"))
.bearer_auth(&admin)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let thresholds: Vec<i64> = listed
.iter()
.filter(|l| l["id"] == bronze["id"] || l["id"] == silver["id"])
.map(|l| l["growth_threshold"].as_i64().unwrap())
.collect();
assert_eq!(thresholds, vec![base, base + 200]);
// Duplicate threshold is a conflict, on create and on edit.
let res = client()
.post(app.url("/api/admin/member-levels"))
.bearer_auth(&admin)
.json(&serde_json::json!({
"name": { "en": "Copy", "zh": "副本" }, "icon": "star",
"growth_threshold": base, "benefits": { "en": "b", "zh": "b" }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "{:?}", res.text().await);
let res = client()
.put(app.url(&format!(
"/api/admin/member-levels/{}",
silver["id"].as_str().unwrap()
)))
.bearer_auth(&admin)
.json(&serde_json::json!({
"name": { "en": "Silver", "zh": "白银" }, "icon": "star",
"growth_threshold": base, "benefits": { "en": "b", "zh": "b" }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "{:?}", res.text().await);
// Bilingual content is mandatory.
let res = client()
.post(app.url("/api/admin/member-levels"))
.bearer_auth(&admin)
.json(&serde_json::json!({
"name": { "en": "Only English" }, "icon": "star",
"growth_threshold": base + 900, "benefits": { "en": "b", "zh": "b" }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400, "{:?}", res.text().await);
// Editing to a unique threshold persists.
let res = client()
.put(app.url(&format!(
"/api/admin/member-levels/{}",
silver["id"].as_str().unwrap()
)))
.bearer_auth(&admin)
.json(&serde_json::json!({
"name": { "en": "Silver", "zh": "白银" }, "icon": "medal",
"growth_threshold": base + 250, "benefits": { "en": "b", "zh": "b" }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
assert_eq!(
res.json::<serde_json::Value>().await.unwrap()["growth_threshold"],
base + 250
);
// An unused level deletes; a customer-facing one is protected (next test).
let res = client()
.delete(app.url(&format!(
"/api/admin/member-levels/{}",
silver["id"].as_str().unwrap()
)))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 204, "{:?}", res.text().await);
// Customers cannot manage levels.
let (customer, _) = register_customer(&app, "mb-forbidden").await;
assert_eq!(
client()
.get(app.url("/api/admin/member-levels"))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.status(),
403
);
}
#[tokio::test]
#[serial]
async fn completion_accrues_growth_and_upgrades_at_the_threshold() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
let base = salt();
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
let f = order_fixture(&app, "mb-exact", price_for(base), 1, true).await;
let status = membership(&app, &f.customer).await;
assert_eq!(status["growth_total"], base);
assert_eq!(status["level"]["id"], bronze["id"]);
assert_eq!(status["level"]["growth_threshold"], base);
assert!(status["next_level"].is_null());
let logs = growth_logs(&app, &f.customer, 1, 20).await;
assert_eq!(logs["total"], 1);
let entry = &logs["items"][0];
assert_eq!(entry["delta"], base);
assert_eq!(entry["growth_total"], base);
assert_eq!(entry["reason"], "order_complete");
assert_eq!(entry["reference_type"], "order");
assert_eq!(entry["reference_id"], f.order_id);
// A retried completion cannot accrue twice: the guarded delivery transition
// rejects the repeat before the accrual hook can run.
let ship_id = f.ship_id.as_deref().expect("fixture shipped the order");
let res = client()
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
.bearer_auth(&f.customer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "{:?}", res.text().await);
assert_eq!(membership(&app, &f.customer).await["growth_total"], base);
assert_eq!(growth_logs(&app, &f.customer, 1, 20).await["total"], 1);
let _ = (f.item_id, f.qty, f.shop_id, f.owner);
}
#[tokio::test]
#[serial]
async fn jumping_two_thresholds_lands_on_the_highest_level() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
let base = salt();
let _bronze = level(&app, &admin, base, "Bronze", "青铜").await;
let silver = level(&app, &admin, base + 200, "Silver", "白银").await;
// Growth clears both thresholds in one accrual.
let f = order_fixture(&app, "mb-jump", price_for(base + 250), 1, true).await;
let status = membership(&app, &f.customer).await;
assert_eq!(status["growth_total"], base + 250);
assert_eq!(status["level"]["id"], silver["id"]);
assert_eq!(status["level"]["growth_threshold"], base + 200);
}
#[tokio::test]
#[serial]
async fn below_every_threshold_holds_no_level_until_a_later_accrual_qualifies() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
let base = salt();
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
// A sub-unit order truncates to zero growth, which is below every defined
// threshold regardless of other fixtures in the shared database.
let first = order_fixture(&app, "mb-below", 1, 1, true).await;
let status = membership(&app, &first.customer).await;
assert_eq!(status["growth_total"], 0);
assert!(status["level"].is_null());
assert_eq!(status["next_level"]["id"], bronze["id"]);
assert_eq!(status["next_level"]["remaining"], base);
// The zero-value accrual is still one immutable ledger entry.
let logs = growth_logs(&app, &first.customer, 1, 20).await;
assert_eq!(logs["total"], 1);
assert_eq!(logs["items"][0]["delta"], 0);
// A later qualifying accrual assigns the level.
let (owner, _shop, _product, sku_id) =
setup_sellable(&app, &admin, "mb-later", price_for(base), 50).await;
add_to_cart(&app, &first.customer, &sku_id, 1).await;
let orders = checkout(&app, &first.customer).await;
let order_id = orders[0]["id"].as_str().unwrap().to_string();
pay(&app, &first.customer, &order_id).await;
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/shop/orders/{order_id}")))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
let res = client()
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
.bearer_auth(&owner)
.json(&serde_json::json!({
"carrier": "SF", "tracking_no": "T2",
"items": [{ "order_item_id": item_id, "qty": 1 }]
}))
.send()
.await
.unwrap();
let ship_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
client()
.post(app.url(&format!("/api/shop/shipments/{ship_id}/ship")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
client()
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
.bearer_auth(&first.customer)
.send()
.await
.unwrap();
let status = membership(&app, &first.customer).await;
assert_eq!(status["growth_total"], base);
assert_eq!(status["level"]["id"], bronze["id"]);
}
#[tokio::test]
#[serial]
async fn status_rederives_the_level_against_current_thresholds() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
let base = salt();
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
let silver = level(&app, &admin, base + 300, "Silver", "白银").await;
let f = order_fixture(&app, "mb-rederive", price_for(base + 100), 1, true).await;
assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]);
// Lowering a higher level's threshold under the customer's growth makes the
// derived status move even though `users.level` was written at accrual time.
let res = client()
.put(app.url(&format!(
"/api/admin/member-levels/{}",
silver["id"].as_str().unwrap()
)))
.bearer_auth(&admin)
.json(&serde_json::json!({
"name": { "en": "Silver", "zh": "白银" }, "icon": "medal",
"growth_threshold": base + 50, "benefits": { "en": "s", "zh": "s" }
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let status = membership(&app, &f.customer).await;
assert_eq!(status["growth_total"], base + 100);
assert_eq!(status["level"]["id"], silver["id"]);
assert_eq!(status["level"]["growth_threshold"], base + 50);
// The stored level is the one accrual assigned; the read re-derives.
let me: serde_json::Value = client()
.get(app.url("/api/auth/me"))
.bearer_auth(&f.customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let stored: Option<Uuid> = sqlx::query_scalar("SELECT level FROM users WHERE id = $1::uuid")
.bind(me["id"].as_str().unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(
stored,
Some(Uuid::parse_str(bronze["id"].as_str().unwrap()).unwrap())
);
}
#[tokio::test]
#[serial]
async fn deleting_a_held_level_is_rejected() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
let base = salt();
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
let f = order_fixture(&app, "mb-held", price_for(base), 1, true).await;
assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]);
let res = client()
.delete(app.url(&format!(
"/api/admin/member-levels/{}",
bronze["id"].as_str().unwrap()
)))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 409, "{:?}", res.text().await);
// The level and its member are untouched.
assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]);
}
#[tokio::test]
#[serial]
async fn growth_history_is_own_only_and_paginated() {
let app = spawn_app().await;
reset_levels(&app).await;
let admin = login_admin(&app).await;
// No levels needed: this only exercises the ledger and its scoping.
let alice = order_fixture(&app, "mb-alice", 5000, 1, true).await;
let bob = order_fixture(&app, "mb-bob", 5000, 1, true).await;
// A second completed order for Alice.
let (owner, _shop, _product, sku_id) =
setup_sellable(&app, &admin, "mb-alice-2", 5000, 50).await;
add_to_cart(&app, &alice.customer, &sku_id, 1).await;
let orders = checkout(&app, &alice.customer).await;
let order_id = orders[0]["id"].as_str().unwrap().to_string();
pay(&app, &alice.customer, &order_id).await;
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/shop/orders/{order_id}")))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
let res = client()
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
.bearer_auth(&owner)
.json(&serde_json::json!({
"carrier": "SF", "tracking_no": "T3",
"items": [{ "order_item_id": item_id, "qty": 1 }]
}))
.send()
.await
.unwrap();
let ship_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
client()
.post(app.url(&format!("/api/shop/shipments/{ship_id}/ship")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
client()
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
.bearer_auth(&alice.customer)
.send()
.await
.unwrap();
let first = growth_logs(&app, &alice.customer, 1, 1).await;
assert_eq!(first["total"], 2);
assert_eq!(first["items"].as_array().unwrap().len(), 1);
let second = growth_logs(&app, &alice.customer, 2, 1).await;
assert_eq!(second["items"].as_array().unwrap().len(), 1);
assert_ne!(first["items"][0]["id"], second["items"][0]["id"]);
// Bob only sees his own single entry.
let bob_logs = growth_logs(&app, &bob.customer, 1, 20).await;
assert_eq!(bob_logs["total"], 1);
let alice_ids: Vec<&str> = first["items"]
.as_array()
.unwrap()
.iter()
.chain(second["items"].as_array().unwrap())
.map(|e| e["id"].as_str().unwrap())
.collect();
assert!(!alice_ids.contains(&bob_logs["items"][0]["id"].as_str().unwrap()));
}