Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
10 KiB
Rust
318 lines
10 KiB
Rust
use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, 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)
|
|
}
|
|
}
|
|
|
|
pub async fn spawn_state() -> vmall_api::state::AppState {
|
|
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,
|
|
};
|
|
let db = sqlx::PgPool::connect(TEST_DB_URL).await.unwrap();
|
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
|
let state = state::assemble(config, db).await.unwrap();
|
|
ensure_platform_admin(&state).await.unwrap();
|
|
state
|
|
}
|
|
|
|
/// 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 state = spawn_state().await;
|
|
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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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<serde_json::Value> {
|
|
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::<Vec<serde_json::Value>>().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);
|
|
}
|