refactor(api): split Axum handlers into handler/service/repo modules
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>
This commit is contained in:
co-authored by
Cursor
parent
e457339847
commit
5e866d08f4
@@ -0,0 +1,207 @@
|
||||
mod common;
|
||||
|
||||
use common::{client, register_customer, spawn_app};
|
||||
use serial_test::serial;
|
||||
|
||||
fn addr_body(recipient: &str, city: &str, is_default: bool) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"recipient": recipient,
|
||||
"phone": "+1 555 0100",
|
||||
"country": "US",
|
||||
"region": "California",
|
||||
"city": city,
|
||||
"line1": "1 Infinite Loop",
|
||||
"postal_code": "95014",
|
||||
"is_default": is_default,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(app: &common::TestApp, token: &str, body: serde_json::Value) -> serde_json::Value {
|
||||
let res = client()
|
||||
.post(app.url("/api/addresses"))
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "create failed: {:?}", res.text().await);
|
||||
res.json().await.unwrap()
|
||||
}
|
||||
|
||||
async fn list(app: &common::TestApp, token: &str) -> Vec<serde_json::Value> {
|
||||
let res = client()
|
||||
.get(app.url("/api/addresses"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
res.json().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn address_crud_roundtrip() {
|
||||
let app = spawn_app().await;
|
||||
let (token, _user_id) = register_customer(&app, "addr-crud").await;
|
||||
|
||||
let created = create(&app, &token, addr_body("Alice", "Cupertino", false)).await;
|
||||
let id = created["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(created["city"], "Cupertino");
|
||||
// The first address of an account is always the default.
|
||||
assert_eq!(created["is_default"], true);
|
||||
|
||||
let rows = list(&app, &token).await;
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["id"], id);
|
||||
|
||||
let res = client()
|
||||
.put(app.url(&format!("/api/addresses/{id}")))
|
||||
.bearer_auth(&token)
|
||||
.json(&addr_body("Alice B", "Sunnyvale", false))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let updated: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(updated["city"], "Sunnyvale");
|
||||
assert_eq!(updated["recipient"], "Alice B");
|
||||
|
||||
let res = client()
|
||||
.delete(app.url(&format!("/api/addresses/{id}")))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let remaining: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn single_default_invariant() {
|
||||
let app = spawn_app().await;
|
||||
let (token, _user_id) = register_customer(&app, "addr-default").await;
|
||||
|
||||
let first = create(&app, &token, addr_body("A", "Cupertino", true)).await;
|
||||
let first_id = first["id"].as_str().unwrap().to_string();
|
||||
let second = create(&app, &token, addr_body("B", "Sunnyvale", true)).await;
|
||||
let second_id = second["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Creating a new default must clear the previous one.
|
||||
let rows = list(&app, &token).await;
|
||||
let defaults: Vec<_> = rows.iter().filter(|r| r["is_default"] == true).collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0]["id"], second_id);
|
||||
|
||||
// Setting the first one back as default flips the flag atomically.
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/addresses/{first_id}/default")))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let rows = list(&app, &token).await;
|
||||
let defaults: Vec<_> = rows.iter().filter(|r| r["is_default"] == true).collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0]["id"], first_id);
|
||||
|
||||
// Deleting the current default promotes the most recent remaining row.
|
||||
let res = client()
|
||||
.delete(app.url(&format!("/api/addresses/{first_id}")))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let remaining: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0]["id"], second_id);
|
||||
assert_eq!(remaining[0]["is_default"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cross_user_access_is_404() {
|
||||
let app = spawn_app().await;
|
||||
let (token_a, _) = register_customer(&app, "addr-a").await;
|
||||
let (token_b, _) = register_customer(&app, "addr-b").await;
|
||||
|
||||
let created = create(&app, &token_a, addr_body("A", "Cupertino", true)).await;
|
||||
let id = created["id"].as_str().unwrap().to_string();
|
||||
|
||||
let res = client()
|
||||
.put(app.url(&format!("/api/addresses/{id}")))
|
||||
.bearer_auth(&token_b)
|
||||
.json(&addr_body("Hijack", "Nowhere", true))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
|
||||
let res = client()
|
||||
.delete(app.url(&format!("/api/addresses/{id}")))
|
||||
.bearer_auth(&token_b)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/addresses/{id}/default")))
|
||||
.bearer_auth(&token_b)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn unauthenticated_requests_are_rejected() {
|
||||
let app = spawn_app().await;
|
||||
for (method, path) in [
|
||||
("GET", "/api/addresses".to_string()),
|
||||
("POST", "/api/addresses".to_string()),
|
||||
(
|
||||
"PUT",
|
||||
format!("/api/addresses/{}", uuid::Uuid::new_v4()),
|
||||
),
|
||||
(
|
||||
"DELETE",
|
||||
format!("/api/addresses/{}", uuid::Uuid::new_v4()),
|
||||
),
|
||||
] {
|
||||
let res = client()
|
||||
.request(method.parse().unwrap(), app.url(&path))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 401, "{method} {path} must require auth");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn missing_fields_are_400() {
|
||||
let app = spawn_app().await;
|
||||
let (token, _) = register_customer(&app, "addr-invalid").await;
|
||||
let res = client()
|
||||
.post(app.url("/api/addresses"))
|
||||
.bearer_auth(&token)
|
||||
.json(&serde_json::json!({
|
||||
"recipient": "",
|
||||
"phone": "+1 555 0100",
|
||||
"country": "US",
|
||||
"region": "California",
|
||||
"city": "Cupertino",
|
||||
"line1": "1 Infinite Loop",
|
||||
"postal_code": "95014",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state::build_state};
|
||||
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/";
|
||||
@@ -14,9 +14,7 @@ impl TestApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub async fn spawn_state() -> vmall_api::state::AppState {
|
||||
let config = Config {
|
||||
database_url: TEST_DB_URL.into(),
|
||||
redis_url: TEST_REDIS_URL.into(),
|
||||
@@ -24,11 +22,17 @@ pub async fn spawn_app() -> TestApp {
|
||||
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();
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
mod common;
|
||||
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
use vmall_api::error::ApiError;
|
||||
use vmall_api::modules::cart;
|
||||
use vmall_api::modules::identity::service::{self as identity, RegisterInput};
|
||||
use vmall_api::modules::order::{self, AddressBody};
|
||||
use vmall_api::state::AppState;
|
||||
|
||||
fn address() -> AddressBody {
|
||||
AddressBody {
|
||||
recipient: "Test".into(),
|
||||
phone: "123".into(),
|
||||
country: "US".into(),
|
||||
region: "CA".into(),
|
||||
city: "SJ".into(),
|
||||
line1: "1 Way".into(),
|
||||
postal_code: "95131".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_user(state: &AppState, label: &str) -> Uuid {
|
||||
let email = format!("{label}-{}@test.local", Uuid::new_v4());
|
||||
let (user, _) = identity::register(
|
||||
state,
|
||||
RegisterInput {
|
||||
email,
|
||||
password: "password123".into(),
|
||||
display_name: label.into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
user.id
|
||||
}
|
||||
|
||||
async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32) -> Uuid {
|
||||
let slug = format!("{slug}-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let shop_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(serde_json::json!({"en": slug, "zh": slug}))
|
||||
.bind(&slug)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let product_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO products (shop_id, slug, name, status)
|
||||
VALUES ($1, $2, $3, 'published') RETURNING id",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.bind(&slug)
|
||||
.bind(serde_json::json!({"en": slug, "zh": slug}))
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO skus (product_id, sku_code, price_minor, currency, stock, active)
|
||||
VALUES ($1, $2, $3, 'USD', $4, TRUE) RETURNING id",
|
||||
)
|
||||
.bind(product_id)
|
||||
.bind(format!("{slug}-sku"))
|
||||
.bind(price_minor)
|
||||
.bind(stock)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkout_rejects_empty_cart() {
|
||||
let state = common::spawn_state().await;
|
||||
let user_id = register_user(&state, "empty-cart").await;
|
||||
let err = order::checkout(&state, user_id, address(), "USD".into())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkout_rejects_insufficient_stock() {
|
||||
let state = common::spawn_state().await;
|
||||
let user_id = register_user(&state, "low-stock").await;
|
||||
let sku_id = sellable_sku(&state, "low", 1000, 1).await;
|
||||
cart::service::add_item(&state, user_id, sku_id, 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = order::checkout(&state, user_id, address(), "USD".into())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkout_splits_per_shop() {
|
||||
let state = common::spawn_state().await;
|
||||
let user_id = register_user(&state, "split").await;
|
||||
let sku_a = sellable_sku(&state, "sa", 1000, 5).await;
|
||||
let sku_b = sellable_sku(&state, "sb", 2000, 5).await;
|
||||
cart::service::add_item(&state, user_id, sku_a, 2)
|
||||
.await
|
||||
.unwrap();
|
||||
cart::service::add_item(&state, user_id, sku_b, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let orders = order::checkout(&state, user_id, address(), "USD".into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(orders.len(), 2);
|
||||
let totals: Vec<i64> = orders.iter().map(|o| o.order.total_minor).collect();
|
||||
assert!(totals.contains(&2000) && totals.contains(&2000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn pay_and_cancel_require_pending_payment() {
|
||||
let state = common::spawn_state().await;
|
||||
let user_id = register_user(&state, "status").await;
|
||||
let sku_id = sellable_sku(&state, "st", 500, 3).await;
|
||||
cart::service::add_item(&state, user_id, sku_id, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let orders = order::checkout(&state, user_id, address(), "USD".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let id = orders[0].order.id;
|
||||
order::service::pay(&state, user_id, id).await.unwrap();
|
||||
let err = order::service::pay(&state, user_id, id).await.unwrap_err();
|
||||
assert!(matches!(err, ApiError::Conflict(_)));
|
||||
let err = order::service::cancel(&state, user_id, id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ApiError::Conflict(_)));
|
||||
}
|
||||
Reference in New Issue
Block a user