use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state::build_state}; pub const TEST_DB_URL: &str = "postgres://postgres:postgres@127.0.0.1:5432/vmall_test"; pub const TEST_REDIS_URL: &str = "redis://127.0.0.1:6379/"; pub struct TestApp { pub base: String, pub db: sqlx::PgPool, } impl TestApp { pub fn url(&self, path: &str) -> String { format!("{}{}", self.base, path) } } /// Spin up the full app against the test database on an ephemeral port. /// Migrations run once per process; domain tables are truncated per app. pub async fn spawn_app() -> TestApp { let config = Config { database_url: TEST_DB_URL.into(), redis_url: TEST_REDIS_URL.into(), jwt_secret: "test-secret".into(), port: 0, jwt_ttl_secs: 3600, }; sqlx::migrate!("./migrations") .run(&sqlx::PgPool::connect(TEST_DB_URL).await.unwrap()) .await .unwrap(); let state = build_state(&config).await.unwrap(); ensure_platform_admin(&state).await.unwrap(); let app = build_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); TestApp { base: format!("http://127.0.0.1:{port}"), db: state.db, } } pub fn client() -> reqwest::Client { reqwest::Client::new() } /// Register a fresh customer; returns (token, user_id). pub async fn register_customer(app: &TestApp, label: &str) -> (String, String) { let email = format!("{label}-{}@test.local", uuid::Uuid::new_v4()); let res = client() .post(app.url("/api/auth/register")) .json(&serde_json::json!({ "email": email, "password": "password123", "display_name": label, })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "register failed: {:?}", res.text().await); let body: serde_json::Value = res.json().await.unwrap(); ( body["token"].as_str().unwrap().to_string(), body["user"]["id"].as_str().unwrap().to_string(), ) } pub async fn login_admin(app: &TestApp) -> String { let res = client() .post(app.url("/api/auth/login")) .json(&serde_json::json!({ "email": "admin@vmall.local", "password": "admin1234", })) .send() .await .unwrap(); assert_eq!(res.status(), 200); res.json::().await.unwrap()["token"] .as_str() .unwrap() .to_string() } /// Create a shop via admin API; returns shop id. pub async fn create_shop(app: &TestApp, admin_token: &str, slug: &str) -> String { let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); let res = client() .post(app.url("/api/admin/shops")) .bearer_auth(admin_token) .json(&serde_json::json!({ "name": {"en": format!("Shop {slug}"), "zh": format!("店铺 {slug}")}, "slug": slug, })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "create shop: {:?}", res.text().await); res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string() } /// Register a user and promote them to shop_owner of `shop_id`; returns a /// fresh token carrying the shop role (issued after role change). pub async fn make_shop_owner(app: &TestApp, admin_token: &str, shop_id: &str) -> String { let email = format!("owner-{}@test.local", uuid::Uuid::new_v4()); let res = client() .post(app.url("/api/auth/register")) .json(&serde_json::json!({ "email": email, "password": "password123", "display_name": "owner", })) .send() .await .unwrap(); let user_id = res.json::().await.unwrap()["user"]["id"] .as_str() .unwrap() .to_string(); let res = client() .put(app.url(&format!("/api/admin/users/{user_id}/role"))) .bearer_auth(admin_token) .json(&serde_json::json!({ "role": "shop_owner", "shop_id": shop_id })) .send() .await .unwrap(); assert_eq!(res.status(), 200, "set role: {:?}", res.text().await); let res = client() .post(app.url("/api/auth/login")) .json(&serde_json::json!({ "email": email, "password": "password123" })) .send() .await .unwrap(); res.json::().await.unwrap()["token"] .as_str() .unwrap() .to_string() } /// Create a draft product with one priced SKU; returns product id. pub async fn create_product_with_sku( app: &TestApp, owner_token: &str, slug: &str, price_minor: i64, stock: i32, ) -> (String, String) { create_product_full(app, owner_token, slug, price_minor, stock, None, None).await } /// Same, but placed in `category_id` so category filtering can be exercised. pub async fn create_product_with_sku_in_category( app: &TestApp, owner_token: &str, slug: &str, price_minor: i64, stock: i32, category_id: Option<&str>, ) -> (String, String) { create_product_full(app, owner_token, slug, price_minor, stock, category_id, None).await } /// Same, with a category and a brand so both filters can be exercised. pub async fn create_product_full( app: &TestApp, owner_token: &str, slug: &str, price_minor: i64, stock: i32, category_id: Option<&str>, brand_id: Option<&str>, ) -> (String, String) { let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); let res = client() .post(app.url("/api/shop/products")) .bearer_auth(owner_token) .json(&serde_json::json!({ "slug": slug, "name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")}, "description": {"en": "desc en", "zh": "描述"}, "category_id": category_id, "brand_id": brand_id, })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "create product: {:?}", res.text().await); let product_id = res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string(); let res = client() .post(app.url(&format!("/api/shop/products/{product_id}/skus"))) .bearer_auth(owner_token) .json(&serde_json::json!({ "sku_code": format!("{slug}-default"), "price_minor": price_minor, "currency": "USD", "stock": stock, })) .send() .await .unwrap(); assert_eq!(res.status(), 200, "upsert sku: {:?}", res.text().await); (product_id, slug) } /// Publish a draft product so it appears in the public catalog. pub async fn publish_product(app: &TestApp, owner_token: &str, product_id: &str) { let res = client() .post(app.url(&format!("/api/shop/products/{product_id}/publish"))) .bearer_auth(owner_token) .send() .await .unwrap(); assert_eq!(res.status(), 200, "publish: {:?}", res.text().await); } /// Look up a seeded reference category id by slug. pub async fn category_id_by_slug(app: &TestApp, slug: &str) -> String { let res = client() .get(app.url("/api/categories")) .send() .await .unwrap(); assert_eq!(res.status(), 200); let cats: serde_json::Value = res.json().await.unwrap(); cats.as_array() .unwrap() .iter() .find(|c| c["slug"] == slug) .unwrap_or_else(|| panic!("seeded category {slug} missing"))["id"] .as_str() .unwrap() .to_string() } /// Full sellable fixture: shop + owner + published product with one SKU. /// Returns (owner_token, shop_id, product_id, sku_id). pub async fn setup_sellable( app: &TestApp, admin: &str, slug: &str, price_minor: i64, stock: i32, ) -> (String, String, String, String) { let shop_id = create_shop(app, admin, slug).await; let owner = make_shop_owner(app, admin, &shop_id).await; let (product_id, _) = create_product_with_sku(app, &owner, slug, price_minor, stock).await; let res = client() .post(app.url(&format!("/api/shop/products/{product_id}/publish"))) .bearer_auth(&owner) .send() .await .unwrap(); assert_eq!(res.status(), 200, "publish: {:?}", res.text().await); let sku_id: String = sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid") .bind(&product_id) .fetch_one(&app.db) .await .unwrap(); (owner, shop_id, product_id, sku_id) } pub async fn add_to_cart(app: &TestApp, token: &str, sku_id: &str, qty: i32) { let res = client() .post(app.url("/api/cart/items")) .bearer_auth(token) .json(&serde_json::json!({ "sku_id": sku_id, "qty": qty })) .send() .await .unwrap(); assert_eq!(res.status(), 200, "add to cart: {:?}", res.text().await); } pub async fn checkout(app: &TestApp, token: &str) -> Vec { let res = client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) .json(&serde_json::json!({ "shipping_address": { "recipient": "Test Recipient", "phone": "123456", "country": "US", "region": "CA", "city": "San Jose", "line1": "1 Test Way", "postal_code": "95131" }, "currency": "USD" })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); res.json::>().await.unwrap() } pub async fn pay(app: &TestApp, token: &str, order_id: &str) { let res = client() .post(app.url(&format!("/api/orders/{order_id}/pay"))) .bearer_auth(token) .send() .await .unwrap(); assert_eq!(res.status(), 200, "pay: {:?}", res.text().await); }