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, } 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 = 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::().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 = client() .get(app.url("/api/admin/member-levels")) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); let thresholds: Vec = 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::().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::().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 = 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::().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())); }