From a4ef6ca49e1319ee2089083e847c85e778c96ecf Mon Sep 17 00:00:00 2001 From: Chengdong Zhang Date: Mon, 21 Sep 2026 18:52:52 +0800 Subject: [PATCH] chore(api): apply rustfmt across modules and tests Co-authored-by: Cursor --- apps/api/src/auth.rs | 6 +- apps/api/src/config.rs | 3 +- apps/api/src/error.rs | 12 +++- apps/api/src/lib.rs | 2 +- apps/api/src/models.rs | 6 +- apps/api/src/modules/account/repo.rs | 3 +- apps/api/src/modules/account/service.rs | 38 +++++++--- apps/api/src/modules/address/repo.rs | 6 +- apps/api/src/modules/address/service.rs | 12 +--- apps/api/src/modules/billing/service.rs | 6 +- apps/api/src/modules/cart/service.rs | 18 ++++- apps/api/src/modules/cart/store.rs | 4 +- apps/api/src/modules/catalog/service.rs | 4 +- apps/api/src/modules/content/service.rs | 21 ++++-- apps/api/src/modules/coupon/handlers.rs | 5 +- apps/api/src/modules/coupon/repo.rs | 11 +-- apps/api/src/modules/coupon/service.rs | 14 ++-- apps/api/src/modules/currency/handlers.rs | 7 +- apps/api/src/modules/currency/service.rs | 10 ++- apps/api/src/modules/flash_sale/handlers.rs | 8 +-- apps/api/src/modules/flash_sale/repo.rs | 14 ++-- apps/api/src/modules/fulfillment/handlers.rs | 4 +- apps/api/src/modules/fulfillment/service.rs | 8 ++- apps/api/src/modules/group_buying/repo.rs | 43 ++++++----- apps/api/src/modules/health.rs | 5 +- apps/api/src/modules/identity/handlers.rs | 11 ++- apps/api/src/modules/identity/repo.rs | 16 +++-- apps/api/src/modules/order/repo.rs | 76 ++++++++++---------- apps/api/src/modules/order/service.rs | 28 ++++---- apps/api/src/modules/points/handlers.rs | 14 ++-- apps/api/src/modules/points/repo.rs | 14 ++-- apps/api/src/modules/points/service.rs | 4 +- apps/api/src/modules/shop/service.rs | 12 ++-- apps/api/src/seed.rs | 9 ++- apps/api/tests/accounts.rs | 12 +++- apps/api/tests/addresses.rs | 10 +-- apps/api/tests/catalog.rs | 48 ++++++++++--- apps/api/tests/common/mod.rs | 11 ++- apps/api/tests/content.rs | 33 +++++++-- apps/api/tests/coupons.rs | 7 +- apps/api/tests/flash_sales.rs | 55 ++++++++++---- apps/api/tests/group_buying.rs | 35 +++++---- apps/api/tests/order_service.rs | 67 +++++++++++------ apps/api/tests/orders.rs | 35 ++++++--- apps/api/tests/points.rs | 19 +---- apps/api/tests/shops.rs | 43 +++++++---- 46 files changed, 495 insertions(+), 334 deletions(-) diff --git a/apps/api/src/auth.rs b/apps/api/src/auth.rs index 8c50a99..38fe5eb 100644 --- a/apps/api/src/auth.rs +++ b/apps/api/src/auth.rs @@ -25,7 +25,11 @@ pub fn hash_password(password: &str) -> Result { pub fn verify_password(password: &str, hash: &str) -> bool { PasswordHash::new(hash) - .map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()) + .map(|h| { + Argon2::default() + .verify_password(password.as_bytes(), &h) + .is_ok() + }) .unwrap_or(false) } diff --git a/apps/api/src/config.rs b/apps/api/src/config.rs index 30b2087..9c07bb5 100644 --- a/apps/api/src/config.rs +++ b/apps/api/src/config.rs @@ -39,7 +39,6 @@ impl Config { } pub fn database_url_for(db: &str) -> anyhow::Result { - let base = - std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?; + let base = std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?; Ok(format!("{base}/{db}")) } diff --git a/apps/api/src/error.rs b/apps/api/src/error.rs index 4565b3d..b89565f 100644 --- a/apps/api/src/error.rs +++ b/apps/api/src/error.rs @@ -1,4 +1,8 @@ -use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; use serde_json::json; #[derive(Debug, thiserror::Error)] @@ -40,7 +44,11 @@ impl IntoResponse for ApiError { ) } }; - (status, Json(json!({ "error": { "code": code, "message": message } }))).into_response() + ( + status, + Json(json!({ "error": { "code": code, "message": message } })), + ) + .into_response() } } diff --git a/apps/api/src/lib.rs b/apps/api/src/lib.rs index 4f792ae..c9e771f 100644 --- a/apps/api/src/lib.rs +++ b/apps/api/src/lib.rs @@ -3,8 +3,8 @@ pub mod config; pub mod error; pub mod http; pub mod models; -pub mod money; pub mod modules; +pub mod money; pub mod seed; pub mod state; diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index d374733..1cb5c88 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -294,7 +294,8 @@ pub struct CustomerAccountEntry { pub created_at: DateTime, } -pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at"; +pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = + "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at"; /// Lifecycle of a customer-owned coupon snapshot. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -503,8 +504,7 @@ pub struct CollectiveGroupMember { pub joined_at: DateTime, } -pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = - "id, group_id, order_id, user_id, joined_at"; +pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = "id, group_id, order_id, user_id, joined_at"; #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct AddressBookEntry { diff --git a/apps/api/src/modules/account/repo.rs b/apps/api/src/modules/account/repo.rs index cfbefcd..c846d13 100644 --- a/apps/api/src/modules/account/repo.rs +++ b/apps/api/src/modules/account/repo.rs @@ -3,8 +3,7 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::models::{ - CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, - CUSTOMER_ACCOUNT_ENTRY_COLUMNS, + CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, CUSTOMER_ACCOUNT_ENTRY_COLUMNS, }; /// Monetary accounts are opened in the platform base currency. Multi-currency diff --git a/apps/api/src/modules/account/service.rs b/apps/api/src/modules/account/service.rs index c79408c..c46a302 100644 --- a/apps/api/src/modules/account/service.rs +++ b/apps/api/src/modules/account/service.rs @@ -43,7 +43,15 @@ pub async fn credit( let amount = positive(amount_minor)?; let account = account_for(tx, user_id, kind, currency).await?; let updated = repo::credit_atomic(tx, account.id, amount).await?; - append(tx, account.id, amount, updated.balance_minor, reason, reference).await + append( + tx, + account.id, + amount, + updated.balance_minor, + reason, + reference, + ) + .await } /// Subtract `amount_minor` only when the account can cover it; 409 otherwise. @@ -59,7 +67,15 @@ pub async fn debit( let amount = positive(amount_minor)?; let account = account_for(tx, user_id, kind, currency).await?; let updated = repo::debit_guarded(tx, account.id, amount).await?; - append(tx, account.id, -amount, updated.balance_minor, reason, reference).await + append( + tx, + account.id, + -amount, + updated.balance_minor, + reason, + reference, + ) + .await } /// Credit once per `reason`, in its own transaction. Demo seeding uses this so @@ -142,7 +158,15 @@ async fn transfer( let from_updated = repo::debit_guarded(tx, from, amount).await?; let to_updated = repo::credit_atomic(tx, to, amount).await?; - let out = append(tx, from, -amount, from_updated.balance_minor, reason, reference).await?; + let out = append( + tx, + from, + -amount, + from_updated.balance_minor, + reason, + reference, + ) + .await?; let into = append(tx, to, amount, to_updated.balance_minor, reason, reference).await?; Ok((out, into)) } @@ -206,9 +230,7 @@ fn summarize(accounts: &[CustomerAccount]) -> ApiResult { let currency = available .currency .clone() - .ok_or_else(|| { - ApiError::Internal(anyhow::anyhow!("available account has no currency")) - })?; + .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("available account has no currency")))?; Ok(AccountSummary { balance_minor: available.balance_minor, frozen_minor: frozen.balance_minor, @@ -226,9 +248,7 @@ fn find(accounts: &[CustomerAccount], kind: AccountKind) -> ApiResult<&CustomerA fn positive(amount_minor: i64) -> ApiResult { if amount_minor <= 0 { - return Err(ApiError::BadRequest( - "amount_minor must be positive".into(), - )); + return Err(ApiError::BadRequest("amount_minor must be positive".into())); } Ok(amount_minor) } diff --git a/apps/api/src/modules/address/repo.rs b/apps/api/src/modules/address/repo.rs index 52daf05..862e310 100644 --- a/apps/api/src/modules/address/repo.rs +++ b/apps/api/src/modules/address/repo.rs @@ -21,11 +21,7 @@ pub async fn list_for_user<'e, E: PgExecutor<'e>>( .await?) } -pub async fn get_own( - db: &sqlx::PgPool, - user_id: Uuid, - id: Uuid, -) -> ApiResult { +pub async fn get_own(db: &sqlx::PgPool, user_id: Uuid, id: Uuid) -> ApiResult { sqlx::query_as::<_, AddressBookEntry>(&format!( "SELECT {ADDR_COLS} FROM addresses WHERE id = $1 AND user_id = $2" diff --git a/apps/api/src/modules/address/service.rs b/apps/api/src/modules/address/service.rs index 09a007e..7db49bd 100644 --- a/apps/api/src/modules/address/service.rs +++ b/apps/api/src/modules/address/service.rs @@ -52,11 +52,7 @@ pub async fn update( Ok(row) } -pub async fn set_default( - state: &AppState, - user_id: Uuid, - id: Uuid, -) -> ApiResult { +pub async fn set_default(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { repo::get_own(&state.db, user_id, id).await?; let mut tx = state.db.begin().await?; repo::clear_defaults(&mut tx, user_id).await?; @@ -65,11 +61,7 @@ pub async fn set_default( Ok(row) } -pub async fn delete( - state: &AppState, - user_id: Uuid, - id: Uuid, -) -> ApiResult> { +pub async fn delete(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult> { let existing = repo::get_own(&state.db, user_id, id).await?; let mut tx = state.db.begin().await?; repo::delete(&mut tx, id).await?; diff --git a/apps/api/src/modules/billing/service.rs b/apps/api/src/modules/billing/service.rs index aaf992e..08f73e2 100644 --- a/apps/api/src/modules/billing/service.rs +++ b/apps/api/src/modules/billing/service.rs @@ -90,7 +90,11 @@ pub async fn request( return Err(ApiError::BadRequest("title is required".into())); } if body.kind == InvoiceKind::Company - && body.tax_no.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true) + && body + .tax_no + .as_ref() + .map(|t| t.trim().is_empty()) + .unwrap_or(true) { return Err(ApiError::BadRequest( "tax_no is required for company invoices".into(), diff --git a/apps/api/src/modules/cart/service.rs b/apps/api/src/modules/cart/service.rs index 76646e8..e64f760 100644 --- a/apps/api/src/modules/cart/service.rs +++ b/apps/api/src/modules/cart/service.rs @@ -9,7 +9,12 @@ pub async fn get(state: &AppState, user_id: Uuid) -> ApiResult { store::cart_view(state, user_id).await } -pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult { +pub async fn add_item( + state: &AppState, + user_id: Uuid, + sku_id: Uuid, + qty: i32, +) -> ApiResult { if qty <= 0 { return Err(ApiError::BadRequest("qty must be > 0".into())); } @@ -26,9 +31,16 @@ pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) - store::cart_view(state, user_id).await } -pub async fn set_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult { +pub async fn set_item( + state: &AppState, + user_id: Uuid, + sku_id: Uuid, + qty: i32, +) -> ApiResult { if qty <= 0 { - return Err(ApiError::BadRequest("qty must be > 0; use DELETE to remove".into())); + return Err(ApiError::BadRequest( + "qty must be > 0; use DELETE to remove".into(), + )); } if !store::is_purchasable(state, sku_id).await? { return Err(ApiError::BadRequest("sku is not purchasable".into())); diff --git a/apps/api/src/modules/cart/store.rs b/apps/api/src/modules/cart/store.rs index 7ca91c1..2a2f6b6 100644 --- a/apps/api/src/modules/cart/store.rs +++ b/apps/api/src/modules/cart/store.rs @@ -35,7 +35,9 @@ pub async fn set_qty(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> pub async fn clear_cart(state: &AppState, user_id: Uuid) -> ApiResult<()> { let mut conn = state.redis.clone(); - conn.del::<_, ()>(key(user_id)).await.map_err(ApiError::from)?; + conn.del::<_, ()>(key(user_id)) + .await + .map_err(ApiError::from)?; Ok(()) } diff --git a/apps/api/src/modules/catalog/service.rs b/apps/api/src/modules/catalog/service.rs index 00e9a93..8af69bf 100644 --- a/apps/api/src/modules/catalog/service.rs +++ b/apps/api/src/modules/catalog/service.rs @@ -369,7 +369,9 @@ pub async fn upsert_sku( .fetch_one(&state.db) .await?; if !currency_ok { - return Err(ApiError::BadRequest(format!("unknown currency: {currency}"))); + return Err(ApiError::BadRequest(format!( + "unknown currency: {currency}" + ))); } Ok(sqlx::query_as::<_, Sku>( "INSERT INTO skus (product_id, sku_code, attributes, price_minor, currency, stock, active) diff --git a/apps/api/src/modules/content/service.rs b/apps/api/src/modules/content/service.rs index 6f50754..8ae8ba9 100644 --- a/apps/api/src/modules/content/service.rs +++ b/apps/api/src/modules/content/service.rs @@ -50,7 +50,11 @@ pub struct ContentView { } pub async fn load_content(state: &AppState, active_only: bool) -> ApiResult { - let filter = if active_only { " WHERE active = TRUE" } else { "" }; + let filter = if active_only { + " WHERE active = TRUE" + } else { + "" + }; let banners = sqlx::query_as::<_, Banner>(&format!( "SELECT id, image, url, position, active FROM banners{filter} ORDER BY position, created_at" )) @@ -130,11 +134,10 @@ fn default_active() -> bool { } pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResult { - if !matches!( - kind, - "banners" | "promos" | "quick-links" | "floor-adverts" - ) { - return Err(ApiError::BadRequest(format!("unknown content kind: {kind}"))); + if !matches!(kind, "banners" | "promos" | "quick-links" | "floor-adverts") { + return Err(ApiError::BadRequest(format!( + "unknown content kind: {kind}" + ))); } let mut tx = state.db.begin().await?; @@ -146,7 +149,11 @@ pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResul non_empty(&item.image, "image")?; non_empty(&item.url, "url")?; } - let table = if kind == "banners" { "banners" } else { "promos" }; + let table = if kind == "banners" { + "banners" + } else { + "promos" + }; sqlx::query(&format!("DELETE FROM {table}")) .execute(&mut *tx) .await?; diff --git a/apps/api/src/modules/coupon/handlers.rs b/apps/api/src/modules/coupon/handlers.rs index d1b6300..8dc3526 100644 --- a/apps/api/src/modules/coupon/handlers.rs +++ b/apps/api/src/modules/coupon/handlers.rs @@ -34,10 +34,7 @@ async fn list_claimable( Ok(Json(service::list_claimable(&state, shop_id).await?)) } -async fn list_mine( - State(state): State, - auth: AuthUser, -) -> ApiResult>> { +async fn list_mine(State(state): State, auth: AuthUser) -> ApiResult>> { auth.require_customer()?; Ok(Json(service::list_mine(&state, auth.id).await?)) } diff --git a/apps/api/src/modules/coupon/repo.rs b/apps/api/src/modules/coupon/repo.rs index 2147a8e..89f7b14 100644 --- a/apps/api/src/modules/coupon/repo.rs +++ b/apps/api/src/modules/coupon/repo.rs @@ -112,10 +112,7 @@ pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult } /// Lock the template row for a claim so two customers cannot both take the last one. -pub async fn lock_template( - tx: &mut PgConnection, - id: Uuid, -) -> ApiResult { +pub async fn lock_template(tx: &mut PgConnection, id: Uuid) -> ApiResult { sqlx::query_as::<_, CouponTemplate>(&format!( "SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE" )) @@ -212,11 +209,7 @@ pub async fn lock_owned_for_update( .await?) } -pub async fn redeem( - tx: &mut PgConnection, - coupon_id: Uuid, - order_id: Uuid, -) -> ApiResult { +pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult { sqlx::query_as::<_, Coupon>(&format!( "UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now() WHERE id = $1 AND status = 'claimed' diff --git a/apps/api/src/modules/coupon/service.rs b/apps/api/src/modules/coupon/service.rs index 60667e9..438a77b 100644 --- a/apps/api/src/modules/coupon/service.rs +++ b/apps/api/src/modules/coupon/service.rs @@ -113,14 +113,14 @@ pub fn checkout_discount( } let now = Utc::now(); if now < coupon.starts_at || now > coupon.ends_at { - return Err(ApiError::Conflict("coupon is outside its active window".into())); + return Err(ApiError::Conflict( + "coupon is outside its active window".into(), + )); } let from = currencies .iter() .find(|c| c.code == coupon.currency) - .ok_or_else(|| { - ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)) - })?; + .ok_or_else(|| ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)))?; let amount = convert_minor(coupon.amount_minor, from, target)?; let threshold = convert_minor(coupon.threshold_minor, from, target)?; if subtotal_minor < threshold { @@ -132,11 +132,7 @@ pub fn checkout_discount( Ok(amount.min(subtotal_minor)) } -pub async fn redeem( - tx: &mut PgConnection, - coupon_id: Uuid, - order_id: Uuid, -) -> ApiResult { +pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult { repo::redeem(tx, coupon_id, order_id).await } diff --git a/apps/api/src/modules/currency/handlers.rs b/apps/api/src/modules/currency/handlers.rs index 87b3eaf..395dc50 100644 --- a/apps/api/src/modules/currency/handlers.rs +++ b/apps/api/src/modules/currency/handlers.rs @@ -39,9 +39,10 @@ async fn convert( State(state): State, Query(q): Query, ) -> ApiResult> { - let (amount_minor, currency) = - service::convert(&state, q.amount_minor, &q.from, &q.to).await?; - Ok(Json(json!({ "amount_minor": amount_minor, "currency": currency }))) + let (amount_minor, currency) = service::convert(&state, q.amount_minor, &q.from, &q.to).await?; + Ok(Json( + json!({ "amount_minor": amount_minor, "currency": currency }), + )) } async fn list_all_currencies( diff --git a/apps/api/src/modules/currency/service.rs b/apps/api/src/modules/currency/service.rs index 484341e..b710e71 100644 --- a/apps/api/src/modules/currency/service.rs +++ b/apps/api/src/modules/currency/service.rs @@ -57,7 +57,9 @@ pub async fn upsert( use crate::error::ApiError; let code = code.to_uppercase(); if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) { - return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into())); + return Err(ApiError::BadRequest( + "code must be a 3-letter ISO code".into(), + )); } if rate_to_base <= rust_decimal::Decimal::ZERO { return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); @@ -82,7 +84,11 @@ pub async fn upsert( .await?) } -pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult { +pub async fn set_rate( + state: &AppState, + code: &str, + rate: rust_decimal::Decimal, +) -> ApiResult { use crate::error::ApiError; if rate <= rust_decimal::Decimal::ZERO { return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); diff --git a/apps/api/src/modules/flash_sale/handlers.rs b/apps/api/src/modules/flash_sale/handlers.rs index 02c055d..e7a78c7 100644 --- a/apps/api/src/modules/flash_sale/handlers.rs +++ b/apps/api/src/modules/flash_sale/handlers.rs @@ -30,9 +30,7 @@ pub fn router() -> Router { ) } -async fn public_active( - State(state): State, -) -> ApiResult>> { +async fn public_active(State(state): State) -> ApiResult>> { Ok(Json(service::public_active(&state).await?)) } @@ -98,9 +96,7 @@ async fn shop_update_item( Json(body): Json, ) -> ApiResult> { let shop_id = auth.require_shop()?; - Ok(Json( - service::update_item(&state, shop_id, id, body).await?, - )) + Ok(Json(service::update_item(&state, shop_id, id, body).await?)) } async fn shop_delete_item( diff --git a/apps/api/src/modules/flash_sale/repo.rs b/apps/api/src/modules/flash_sale/repo.rs index 8f3a31a..4f64e72 100644 --- a/apps/api/src/modules/flash_sale/repo.rs +++ b/apps/api/src/modules/flash_sale/repo.rs @@ -258,9 +258,11 @@ pub async fn overlapping_sale_exists<'e, E: PgExecutor<'e>>( /// The group-buying capability lands after this one, so its overlap check is /// dormant until its table exists. pub async fn group_buying_table_exists<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult { - Ok(sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL") - .fetch_one(exec) - .await?) + Ok( + sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL") + .fetch_one(exec) + .await?, + ) } pub async fn overlapping_group_buying_exists( @@ -356,11 +358,7 @@ pub async fn lock_active_items( } /// Activity units this customer already holds on non-cancelled orders. -pub async fn purchased_qty( - tx: &mut PgConnection, - user_id: Uuid, - item_id: Uuid, -) -> ApiResult { +pub async fn purchased_qty(tx: &mut PgConnection, user_id: Uuid, item_id: Uuid) -> ApiResult { Ok(sqlx::query_scalar( "SELECT COALESCE(SUM(oi.qty), 0) FROM order_items oi diff --git a/apps/api/src/modules/fulfillment/handlers.rs b/apps/api/src/modules/fulfillment/handlers.rs index c54183c..40c7eeb 100644 --- a/apps/api/src/modules/fulfillment/handlers.rs +++ b/apps/api/src/modules/fulfillment/handlers.rs @@ -33,9 +33,7 @@ async fn confirm_delivered( auth: AuthUser, Path(id): Path, ) -> ApiResult> { - Ok(Json( - service::confirm_delivered(&state, auth.id, id).await?, - )) + Ok(Json(service::confirm_delivered(&state, auth.id, id).await?)) } async fn create_shipment( diff --git a/apps/api/src/modules/fulfillment/service.rs b/apps/api/src/modules/fulfillment/service.rs index c00add6..d12aae8 100644 --- a/apps/api/src/modules/fulfillment/service.rs +++ b/apps/api/src/modules/fulfillment/service.rs @@ -125,10 +125,14 @@ pub async fn create( body: ShipmentBody, ) -> ApiResult { if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() { - return Err(ApiError::BadRequest("carrier and tracking_no are required".into())); + return Err(ApiError::BadRequest( + "carrier and tracking_no are required".into(), + )); } if body.items.is_empty() || body.items.iter().any(|i| i.qty <= 0) { - return Err(ApiError::BadRequest("items must be non-empty with qty > 0".into())); + return Err(ApiError::BadRequest( + "items must be non-empty with qty > 0".into(), + )); } let mut tx = state.db.begin().await?; let order = order_repo::lock_for_shop(&mut tx, shop_id, order_id).await?; diff --git a/apps/api/src/modules/group_buying/repo.rs b/apps/api/src/modules/group_buying/repo.rs index 7af9ac4..1382914 100644 --- a/apps/api/src/modules/group_buying/repo.rs +++ b/apps/api/src/modules/group_buying/repo.rs @@ -2,9 +2,7 @@ use sqlx::{PgConnection, PgExecutor}; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; -use crate::models::{ - CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS, -}; +use crate::models::{CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS}; use super::dto::{ActivityInput, ActivityView, OpenGroupView}; @@ -89,10 +87,7 @@ pub async fn get_own<'e, E: PgExecutor<'e>>( .ok_or_else(|| ApiError::NotFound("group-buying activity".into())) } -pub async fn get_by_id<'e, E: PgExecutor<'e>>( - exec: E, - id: Uuid, -) -> ApiResult { +pub async fn get_by_id<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult { sqlx::query_as::<_, GroupBuyingActivity>( "SELECT id, shop_id, sku_id, name, description, image, group_price_minor, currency, required_members, starts_at, ends_at, group_lifetime_hours, enabled, @@ -170,12 +165,11 @@ pub async fn update( } pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> { - let result = - sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2") - .bind(id) - .bind(shop_id) - .execute(&mut *tx) - .await?; + let result = sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2") + .bind(id) + .bind(shop_id) + .execute(&mut *tx) + .await?; if result.rows_affected() == 0 { return Err(ApiError::NotFound("group-buying activity".into())); } @@ -232,10 +226,7 @@ pub async fn open_groups_for_activities( } /// Joining only reads the group: a seat is claimed at payment, not checkout. -pub async fn get_group<'e, E: PgExecutor<'e>>( - exec: E, - id: Uuid, -) -> ApiResult { +pub async fn get_group<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult { sqlx::query_as::<_, CollectiveGroup>(&format!( "SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1" )) @@ -262,11 +253,13 @@ pub async fn insert_group( /// Record which pending order opened the group. pub async fn set_leader(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid) -> ApiResult<()> { - sqlx::query("UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1") - .bind(group_id) - .bind(order_id) - .execute(&mut *tx) - .await?; + sqlx::query( + "UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1", + ) + .bind(group_id) + .bind(order_id) + .execute(&mut *tx) + .await?; Ok(()) } @@ -300,7 +293,11 @@ pub async fn insert_member( } /// One paid seat: increment and become successful exactly at capacity. -pub async fn claim_seat(tx: &mut PgConnection, group_id: Uuid, required: i32) -> ApiResult { +pub async fn claim_seat( + tx: &mut PgConnection, + group_id: Uuid, + required: i32, +) -> ApiResult { Ok(sqlx::query_as::<_, CollectiveGroup>(&format!( "UPDATE collective_groups SET paid_member_count = paid_member_count + 1, diff --git a/apps/api/src/modules/health.rs b/apps/api/src/modules/health.rs index 031b31e..9405641 100644 --- a/apps/api/src/modules/health.rs +++ b/apps/api/src/modules/health.rs @@ -19,9 +19,6 @@ pub async fn ready(State(state): State) -> ApiResult> { .await .is_ok(); let mut redis = state.redis.clone(); - let redis_ok = redis - .set::<_, _, ()>("vmall:ready:ping", "1") - .await - .is_ok(); + let redis_ok = redis.set::<_, _, ()>("vmall:ready:ping", "1").await.is_ok(); Ok(Json(json!({ "db": db_ok, "redis": redis_ok }))) } diff --git a/apps/api/src/modules/identity/handlers.rs b/apps/api/src/modules/identity/handlers.rs index 54f2529..606208f 100644 --- a/apps/api/src/modules/identity/handlers.rs +++ b/apps/api/src/modules/identity/handlers.rs @@ -1,4 +1,9 @@ -use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; use serde::Deserialize; use serde_json::Value; @@ -49,7 +54,9 @@ async fn login( State(state): State, Json(body): Json, ) -> ApiResult> { - Ok(Json(service::login(&state, &body.email, &body.password).await?)) + Ok(Json( + service::login(&state, &body.email, &body.password).await?, + )) } async fn me(State(state): State, auth: AuthUser) -> ApiResult> { diff --git a/apps/api/src/modules/identity/repo.rs b/apps/api/src/modules/identity/repo.rs index 5e9929d..cc44f23 100644 --- a/apps/api/src/modules/identity/repo.rs +++ b/apps/api/src/modules/identity/repo.rs @@ -38,15 +38,17 @@ pub async fn find_by_id<'e, E: PgExecutor<'e>>( exec: E, id: Uuid, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, User>(&format!( - "SELECT {USER_COLUMNS} FROM users WHERE id = $1" - )) - .bind(id) - .fetch_optional(exec) - .await + sqlx::query_as::<_, User>(&format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1")) + .bind(id) + .fetch_optional(exec) + .await } -pub async fn list_users(db: &PgPool, page: Option, per_page: Option) -> ApiResult> { +pub async fn list_users( + db: &PgPool, + page: Option, + per_page: Option, +) -> ApiResult> { let page = clamp_page(page); let per_page = clamp_per_page(per_page); let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users") diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index cc7467e..c898aad 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -68,33 +68,39 @@ pub async fn list_page( offset: i64, ) -> ApiResult> { Ok(match scope { - OrderScope::User(user_id) => sqlx::query_as::<_, Order>(&format!( - "SELECT {ORDER_COLS} FROM orders WHERE user_id = $1 + OrderScope::User(user_id) => { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" - )) - .bind(user_id) - .bind(per_page) - .bind(offset) - .fetch_all(db) - .await?, - OrderScope::Shop(shop_id) => sqlx::query_as::<_, Order>(&format!( - "SELECT {ORDER_COLS} FROM orders + )) + .bind(user_id) + .bind(per_page) + .bind(offset) + .fetch_all(db) + .await? + } + OrderScope::Shop(shop_id) => { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4" - )) - .bind(shop_id) - .bind(status) - .bind(per_page) - .bind(offset) - .fetch_all(db) - .await?, - OrderScope::Admin => sqlx::query_as::<_, Order>(&format!( - "SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2" - )) - .bind(per_page) - .bind(offset) - .fetch_all(db) - .await?, + )) + .bind(shop_id) + .bind(status) + .bind(per_page) + .bind(offset) + .fetch_all(db) + .await? + } + OrderScope::Admin => { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2" + )) + .bind(per_page) + .bind(offset) + .fetch_all(db) + .await? + } }) } @@ -120,11 +126,7 @@ pub async fn get_for_shop(db: &PgPool, shop_id: Uuid, id: Uuid) -> ApiResult ApiResult { +pub async fn lock_for_shop(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult { sqlx::query_as::<_, Order>(&format!( "SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE" )) @@ -198,13 +200,11 @@ pub async fn insert_item( } pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> { - let result = sqlx::query( - "UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2", - ) - .bind(sku_id) - .bind(qty) - .execute(&mut *tx) - .await?; + let result = sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2") + .bind(sku_id) + .bind(qty) + .execute(&mut *tx) + .await?; if result.rows_affected() == 0 { return Err(ApiError::Conflict("insufficient stock".into())); } @@ -233,9 +233,7 @@ pub async fn mark_paid(tx: &mut PgConnection, id: Uuid) -> ApiResult { .bind(id) .fetch_optional(&mut *tx) .await? - .ok_or_else(|| { - ApiError::Conflict("order not payable (missing or wrong status)".into()) - }) + .ok_or_else(|| ApiError::Conflict("order not payable (missing or wrong status)".into())) } pub async fn cancel(tx: &mut PgConnection, user_id: Uuid, id: Uuid) -> ApiResult { diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index 196af91..babde06 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -5,9 +5,9 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::http::{clamp_page, clamp_per_page, Paged}; use crate::models::OrderStatus; -use crate::money::convert_minor; use crate::modules::group_buying::GroupBuyIntent; use crate::modules::{cart, coupon, flash_sale, group_buying}; +use crate::money::convert_minor; use crate::state::AppState; use super::dto::{AddressBody, OrderScope, OrderView}; @@ -45,14 +45,7 @@ pub async fn list( let page = clamp_page(page); let per_page = clamp_per_page(per_page); let total = repo::count(&state.db, scope, status).await?; - let orders = repo::list_page( - &state.db, - scope, - status, - per_page, - (page - 1) * per_page, - ) - .await?; + let orders = repo::list_page(&state.db, scope, status, per_page, (page - 1) * per_page).await?; let items = repo::attach_items(&state.db, orders).await?; Ok(Paged { items, @@ -186,9 +179,7 @@ pub async fn checkout( let index = lines .iter() .position(|line| line.sku_id == intent.sku_id) - .ok_or_else(|| { - ApiError::BadRequest("group-buying SKU is not in the cart".into()) - })?; + .ok_or_else(|| ApiError::BadRequest("group-buying SKU is not in the cart".into()))?; let qty = lines[index].normal_qty + lines[index].activity_qty; if qty != 1 { return Err(ApiError::BadRequest( @@ -248,8 +239,10 @@ pub async fn checkout( let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?; let mut created = Vec::new(); for shop_id in shop_order { - let shop_lines: Vec<&CheckoutLine> = - lines.iter().filter(|line| line.shop_id == shop_id).collect(); + let shop_lines: Vec<&CheckoutLine> = lines + .iter() + .filter(|line| line.shop_id == shop_id) + .collect(); let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0); let selected_coupon = coupon_by_shop.get(&shop_id).copied(); // Eligibility and the discount are resolved server-side; the client @@ -326,7 +319,8 @@ pub async fn checkout( } // The whole ordered quantity leaves SKU stock; only the activity // portion also leaves reserved activity stock. - repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty).await?; + repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty) + .await?; } created.push(order); } @@ -340,7 +334,9 @@ pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult Router { // Public catalog; redemption and history are customer-scoped. .route("/points/products", get(list_published)) .route("/points/redemptions", get(list_mine).post(redeem)) - .route( - "/admin/points/products", - get(admin_list).post(admin_create), - ) + .route("/admin/points/products", get(admin_list).post(admin_create)) .route("/admin/points/products/{id}", put(admin_update)) .route("/admin/points/products/{id}/publish", post(admin_publish)) - .route("/admin/points/products/{id}/unpublish", post(admin_unpublish)) + .route( + "/admin/points/products/{id}/unpublish", + post(admin_unpublish), + ) .route("/admin/points/orders", get(admin_orders)) .route("/admin/points/orders/{id}/fulfill", post(admin_fulfill)) .route("/admin/points/orders/{id}/cancel", post(admin_cancel)) } -async fn list_published( - State(state): State, -) -> ApiResult>> { +async fn list_published(State(state): State) -> ApiResult>> { Ok(Json(service::list_published(&state).await?)) } diff --git a/apps/api/src/modules/points/repo.rs b/apps/api/src/modules/points/repo.rs index dd3a1cd..4a3c0c1 100644 --- a/apps/api/src/modules/points/repo.rs +++ b/apps/api/src/modules/points/repo.rs @@ -6,8 +6,8 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::http::{clamp_page, clamp_per_page, Paged}; use crate::models::{ - IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, - INTEGRAL_ORDER_COLUMNS, INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS, + IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, INTEGRAL_ORDER_COLUMNS, + INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS, }; use super::dto::{IntegralProductInput, RedemptionView}; @@ -35,10 +35,7 @@ pub async fn list_all<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult ApiResult { +pub async fn lock_published(tx: &mut PgConnection, id: Uuid) -> ApiResult { sqlx::query_as::<_, IntegralProduct>(&format!( "SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products WHERE id = $1 AND published = TRUE @@ -242,7 +239,10 @@ pub async fn transition( .ok_or_else(|| ApiError::Conflict("redemption order is not in the expected state".into())) } -pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult> { +pub async fn attach_items( + db: &PgPool, + orders: Vec, +) -> ApiResult> { let ids: Vec = orders.iter().map(|o| o.id).collect(); let items = if ids.is_empty() { Vec::new() diff --git a/apps/api/src/modules/points/service.rs b/apps/api/src/modules/points/service.rs index 6394d9d..0e1594a 100644 --- a/apps/api/src/modules/points/service.rs +++ b/apps/api/src/modules/points/service.rs @@ -163,9 +163,7 @@ async fn transition( fn validate_product(body: &IntegralProductInput) -> ApiResult<()> { bilingual(&body.name, "name")?; if body.points_price <= 0 { - return Err(ApiError::BadRequest( - "points_price must be positive".into(), - )); + return Err(ApiError::BadRequest("points_price must be positive".into())); } if body.stock < 0 { return Err(ApiError::BadRequest("stock must not be negative".into())); diff --git a/apps/api/src/modules/shop/service.rs b/apps/api/src/modules/shop/service.rs index 3200537..5cdbbc1 100644 --- a/apps/api/src/modules/shop/service.rs +++ b/apps/api/src/modules/shop/service.rs @@ -34,13 +34,11 @@ const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name, const SHOP_COLS: &str = "id, name, slug, status, created_at"; pub async fn get_by_id(state: &AppState, id: Uuid) -> ApiResult { - sqlx::query_as::<_, Shop>(&format!( - "SELECT {SHOP_COLS} FROM shops WHERE id = $1" - )) - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("shop".into())) + sqlx::query_as::<_, Shop>(&format!("SELECT {SHOP_COLS} FROM shops WHERE id = $1")) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::NotFound("shop".into())) } pub async fn list_admin(state: &AppState) -> ApiResult> { diff --git a/apps/api/src/seed.rs b/apps/api/src/seed.rs index 47accd1..0115a23 100644 --- a/apps/api/src/seed.rs +++ b/apps/api/src/seed.rs @@ -8,11 +8,10 @@ use crate::state::AppState; /// Idempotent dev seed: platform admin account. pub async fn ensure_platform_admin(state: &AppState) -> anyhow::Result<()> { let email = "admin@vmall.local"; - let exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") - .bind(email) - .fetch_one(&state.db) - .await?; + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") + .bind(email) + .fetch_one(&state.db) + .await?; if exists { return Ok(()); } diff --git a/apps/api/tests/accounts.rs b/apps/api/tests/accounts.rs index 613cf08..aeadfcf 100644 --- a/apps/api/tests/accounts.rs +++ b/apps/api/tests/accounts.rs @@ -146,7 +146,11 @@ async fn summary_is_owned_and_entries_are_append_only() { .send() .await .unwrap(); - assert_eq!(res.status(), status, "{method} /api/me/stats must not mutate"); + assert_eq!( + res.status(), + status, + "{method} /api/me/stats must not mutate" + ); } let res = client() .get(app.url("/api/me/stats/entries")) @@ -154,7 +158,11 @@ async fn summary_is_owned_and_entries_are_append_only() { .send() .await .unwrap(); - assert_eq!(res.status(), 404, "the public ledger listing stays out of scope"); + assert_eq!( + res.status(), + 404, + "the public ledger listing stays out of scope" + ); } #[tokio::test] diff --git a/apps/api/tests/addresses.rs b/apps/api/tests/addresses.rs index 81c8d42..c648c8d 100644 --- a/apps/api/tests/addresses.rs +++ b/apps/api/tests/addresses.rs @@ -165,14 +165,8 @@ async fn unauthenticated_requests_are_rejected() { 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()), - ), + ("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)) diff --git a/apps/api/tests/catalog.rs b/apps/api/tests/catalog.rs index 3ee16bb..77c671f 100644 --- a/apps/api/tests/catalog.rs +++ b/apps/api/tests/catalog.rs @@ -1,9 +1,9 @@ mod common; use common::{ - add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku, - create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay, - publish_product, register_customer, spawn_app, + add_to_cart, category_id_by_slug, checkout, client, create_product_full, + create_product_with_sku, create_product_with_sku_in_category, create_shop, login_admin, + make_shop_owner, pay, publish_product, register_customer, spawn_app, }; use serial_test::serial; @@ -61,7 +61,10 @@ async fn publish_lifecycle_and_public_visibility() { .await .unwrap(); assert_eq!(res.status(), 200); - assert_eq!(res.json::().await.unwrap()["status"], "published"); + assert_eq!( + res.json::().await.unwrap()["status"], + "published" + ); let res = client().get(app.url("/api/products")).send().await.unwrap(); let list: serde_json::Value = res.json().await.unwrap(); @@ -154,7 +157,10 @@ async fn currency_conversion_math() { .send() .await .unwrap(); - assert_eq!(res.json::().await.unwrap()["amount_minor"], 2999); + assert_eq!( + res.json::().await.unwrap()["amount_minor"], + 2999 + ); // disabled currency rejected let admin = login_admin(&app).await; @@ -226,10 +232,22 @@ async fn category_subtree_listing_and_price_sort() { assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let listed = listed_ids(&body); - assert!(listed.contains(&leaf), "grandchild product missing from root listing"); - assert!(listed.contains(&sibling), "child product missing from root listing"); - assert!(!listed.contains(&unrelated), "product from another root category leaked in"); - assert_eq!(body["total"], 2, "total must count the subtree, not only the root"); + assert!( + listed.contains(&leaf), + "grandchild product missing from root listing" + ); + assert!( + listed.contains(&sibling), + "child product missing from root listing" + ); + assert!( + !listed.contains(&unrelated), + "product from another root category leaked in" + ); + assert_eq!( + body["total"], 2, + "total must count the subtree, not only the root" + ); // A mid-level category covers its own subtree and nothing else. let res = client() @@ -355,7 +373,11 @@ async fn brand_filter_and_real_sales() { .await .unwrap(); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(sold(&body, &p_alpha), 0, "pending payment must not count as sold"); + assert_eq!( + sold(&body, &p_alpha), + 0, + "pending payment must not count as sold" + ); assert_eq!(sold(&body, &p_beta), 0); // Paying makes the units count, and the sales sort follows them. @@ -378,7 +400,11 @@ async fn brand_filter_and_real_sales() { .await .unwrap(); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(ids(&body)[0], p_alpha, "sales sort puts the sold product first"); + assert_eq!( + ids(&body)[0], + p_alpha, + "sales sort puts the sold product first" + ); // Comments still have no model, so that sort stays refused. let res = client() diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index 73a2f84..3e8b31a 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -168,7 +168,16 @@ pub async fn create_product_with_sku_in_category( stock: i32, category_id: Option<&str>, ) -> (String, String) { - create_product_full(app, owner_token, slug, price_minor, stock, category_id, None).await + 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. diff --git a/apps/api/tests/content.rs b/apps/api/tests/content.rs index 3c5db0a..9a746c9 100644 --- a/apps/api/tests/content.rs +++ b/apps/api/tests/content.rs @@ -28,7 +28,11 @@ async fn public_content(app: &common::TestApp) -> serde_json::Value { .send() .await .unwrap(); - assert_eq!(res.status(), 200, "public content read must be unauthenticated"); + assert_eq!( + res.status(), + 200, + "public content read must be unauthenticated" + ); res.json().await.unwrap() } @@ -59,7 +63,10 @@ async fn home_content_is_public_and_ordered() { .collect(); let mut sorted = positions.clone(); sorted.sort_unstable(); - assert_eq!(positions, sorted, "content must come back in position order"); + assert_eq!( + positions, sorted, + "content must come back in position order" + ); } #[tokio::test] @@ -83,14 +90,20 @@ async fn admin_replace_round_trips_and_reorders() { .map(|b| b["image"].as_str().unwrap().to_string()) .collect() }; - assert_eq!(images(&public_content(&app).await), vec!["/mock/a.svg", "/mock/b.svg"]); + assert_eq!( + images(&public_content(&app).await), + vec!["/mock/a.svg", "/mock/b.svg"] + ); // The submitted order decides the stored order and the positions. let flipped = serde_json::json!([ {"image": "/mock/b.svg", "url": "/collective"}, {"image": "/mock/a.svg", "url": "/seckill"} ]); - assert_eq!(replace(&app, &admin, "banners", flipped).await.status(), 200); + assert_eq!( + replace(&app, &admin, "banners", flipped).await.status(), + 200 + ); let content = public_content(&app).await; assert_eq!(images(&content), vec!["/mock/b.svg", "/mock/a.svg"]); assert_eq!(content["banners"][0]["position"], 0); @@ -116,7 +129,11 @@ async fn inactive_rows_are_hidden_from_the_public_read() { .iter() .map(|b| b["image"].as_str().unwrap()) .collect(); - assert_eq!(visible, vec!["/mock/on.svg"], "inactive rows must not be public"); + assert_eq!( + visible, + vec!["/mock/on.svg"], + "inactive rows must not be public" + ); // The admin read keeps it, so a disabled block stays editable. let res = client() @@ -153,7 +170,11 @@ async fn invalid_entry_is_rejected_without_touching_stored_content() { .iter() .map(|b| b["image"].as_str().unwrap()) .collect(); - assert_eq!(images, vec!["/mock/keep.svg"], "a rejected list must change nothing"); + assert_eq!( + images, + vec!["/mock/keep.svg"], + "a rejected list must change nothing" + ); } #[tokio::test] diff --git a/apps/api/tests/coupons.rs b/apps/api/tests/coupons.rs index 15e4bab..98e3746 100644 --- a/apps/api/tests/coupons.rs +++ b/apps/api/tests/coupons.rs @@ -335,12 +335,7 @@ async fn cancelling_a_pending_order_restores_its_coupon() { let shop_id = coupon["shop_id"].as_str().unwrap().to_string(); add_to_cart(&app, &token, &sku, 2).await; - let res = checkout_with( - &app, - &token, - HashMap::from([(shop_id, coupon_id.clone())]), - ) - .await; + let res = checkout_with(&app, &token, HashMap::from([(shop_id, coupon_id.clone())])).await; assert_eq!(res.status(), 201); let orders: Vec = res.json().await.unwrap(); let order_id = orders[0]["id"].as_str().unwrap().to_string(); diff --git a/apps/api/tests/flash_sales.rs b/apps/api/tests/flash_sales.rs index 5947585..aecf5be 100644 --- a/apps/api/tests/flash_sales.rs +++ b/apps/api/tests/flash_sales.rs @@ -173,7 +173,10 @@ async fn inactive_window_falls_back_to_normal_price() { let item = &orders[0]["items"][0]; assert_eq!(item["unit_price_minor"], 1000, "normal price applies"); assert!(item["flash_sale_item_id"].is_null()); - assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0); + assert_eq!( + activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, + 0 + ); } #[tokio::test] @@ -182,7 +185,8 @@ async fn cross_shop_sku_and_overlapping_sessions_are_rejected() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner_a, shop_a, _product_a, sku_a) = setup_sellable(&app, &admin, "fs-a", 1000, 5).await; - let (_owner_b, _shop_b, _product_b, sku_b) = setup_sellable(&app, &admin, "fs-b", 1000, 5).await; + let (_owner_b, _shop_b, _product_b, sku_b) = + setup_sellable(&app, &admin, "fs-b", 1000, 5).await; let (starts, ends) = active_window(); let session = create_session(&app, &owner_a, &starts, &ends).await; @@ -195,7 +199,11 @@ async fn cross_shop_sku_and_overlapping_sessions_are_rejected() { // A second overlapping session cannot list the same SKU. let session_two = create_session(&app, &owner_a, &starts, &ends).await; let res = add_item(&app, &owner_a, &session_two, &sku_a, 400, 5, 3).await; - assert_eq!(res.status(), 409, "overlapping sale for the same SKU is refused"); + assert_eq!( + res.status(), + 409, + "overlapping sale for the same SKU is refused" + ); let _ = shop_a; } @@ -263,13 +271,12 @@ async fn reserved_stock_admits_one_activity_price() { assert_eq!(reserved, 0, "the single reserved unit is consumed"); assert_eq!(sold, 1); - let activity: i64 = sqlx::query_scalar( - "SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1", - ) - .bind(Uuid::parse_str(&item_id).unwrap()) - .fetch_one(&app.db) - .await - .unwrap(); + let activity: i64 = + sqlx::query_scalar("SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1") + .bind(Uuid::parse_str(&item_id).unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); assert_eq!(activity, 1, "exactly one line got the activity price"); } @@ -304,7 +311,10 @@ async fn per_customer_limit_splits_the_line() { .filter(|i| i["flash_sale_item_id"].is_null()) .collect(); assert_eq!(activity.len(), 1); - assert_eq!(activity[0]["qty"], 1, "only the allowance gets the activity price"); + assert_eq!( + activity[0]["qty"], 1, + "only the allowance gets the activity price" + ); assert_eq!(activity[0]["unit_price_minor"], 400); assert_eq!(standard.len(), 1); assert_eq!(standard[0]["qty"], 2); @@ -316,7 +326,10 @@ async fn per_customer_limit_splits_the_line() { let res = checkout_with(&app, &token, HashMap::new()).await; let orders: Vec = res.json().await.unwrap(); assert!(orders[0]["items"][0]["flash_sale_item_id"].is_null()); - assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0); + assert_eq!( + activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, + 0 + ); let (_, sold) = flash_item_state(&app, &item_id).await; assert_eq!(sold, 1); @@ -330,7 +343,12 @@ async fn coupon_is_rejected_on_a_flash_priced_shop_order() { let (owner, shop, _product, sku) = setup_sellable(&app, &admin, "fs-cp", 1000, 5).await; let (starts, ends) = active_window(); let session = create_session(&app, &owner, &starts, &ends).await; - assert_eq!(add_item(&app, &owner, &session, &sku, 400, 5, 5).await.status(), 201); + assert_eq!( + add_item(&app, &owner, &session, &sku, 400, 5, 5) + .await + .status(), + 201 + ); let template = create_template(&app, &owner, 100, 0).await; let (token, _) = register_customer(&app, "fs-cp").await; @@ -338,7 +356,11 @@ async fn coupon_is_rejected_on_a_flash_priced_shop_order() { add_to_cart(&app, &token, &sku, 1).await; let res = checkout_with(&app, &token, HashMap::from([(shop, coupon)])).await; - assert_eq!(res.status(), 409, "activity pricing and coupons are exclusive"); + assert_eq!( + res.status(), + 409, + "activity pricing and coupons are exclusive" + ); } #[tokio::test] @@ -375,7 +397,10 @@ async fn cancel_restores_reserved_stock_and_coupon() { for order in &orders { let res = client() - .post(app.url(&format!("/api/orders/{}/cancel", order["id"].as_str().unwrap()))) + .post(app.url(&format!( + "/api/orders/{}/cancel", + order["id"].as_str().unwrap() + ))) .bearer_auth(&token) .send() .await diff --git a/apps/api/tests/group_buying.rs b/apps/api/tests/group_buying.rs index 21e7876..4187160 100644 --- a/apps/api/tests/group_buying.rs +++ b/apps/api/tests/group_buying.rs @@ -165,7 +165,8 @@ async fn activity_requires_an_own_sku_and_two_members() { let app = spawn_app().await; let admin = login_admin(&app).await; let (owner_a, _shop_a, _p, sku_a) = setup_sellable(&app, &admin, "gb-valid-a", 1000, 10).await; - let (_owner_b, _shop_b, _p2, sku_b) = setup_sellable(&app, &admin, "gb-valid-b", 1000, 10).await; + let (_owner_b, _shop_b, _p2, sku_b) = + setup_sellable(&app, &admin, "gb-valid-b", 1000, 10).await; // Another shop's SKU is refused. let res = create_activity_body(&app, &owner_a, &sku_b, 700, 2, 24).await; @@ -225,7 +226,10 @@ async fn open_then_join_completes_a_group() { .json() .await .unwrap(); - let found = active.iter().find(|a| a["id"] == activity.as_str()).unwrap(); + let found = active + .iter() + .find(|a| a["id"] == activity.as_str()) + .unwrap(); assert_eq!(found["open_groups"][0]["id"], group_id.as_str()); assert_eq!(found["open_groups"][0]["paid_member_count"], 0); @@ -317,11 +321,13 @@ async fn expired_and_cancelled_groups_are_not_joinable() { .await .unwrap(); assert_eq!(pay(&app, &token_a, &order_a).await, 200); - sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1") - .bind(group_id) - .execute(&app.db) - .await - .unwrap(); + sqlx::query( + "UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1", + ) + .bind(group_id) + .execute(&app.db) + .await + .unwrap(); // A committed read records the expiry (a failed checkout rolls its own // sweep back, so discovery is what persists it). @@ -473,7 +479,8 @@ async fn activity_rejects_a_sku_with_an_overlapping_flash_sale() { async fn cancelling_an_elapsed_empty_group_records_expired() { let app = spawn_app().await; let admin = login_admin(&app).await; - let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-precedence", 1000, 10).await; + let (owner, _shop, _product, sku) = + setup_sellable(&app, &admin, "gb-precedence", 1000, 10).await; let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await; let (token, _) = register_customer(&app, "gb-precedence").await; @@ -486,11 +493,13 @@ async fn cancelling_an_elapsed_empty_group_records_expired() { .unwrap(); // The lifetime ends before the opener cancels. - sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1") - .bind(group_id) - .execute(&app.db) - .await - .unwrap(); + sqlx::query( + "UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1", + ) + .bind(group_id) + .execute(&app.db) + .await + .unwrap(); assert_eq!(cancel(&app, &token, &order).await, 200); // Both conditions hold; an elapsed group is expired, not relabelled cancelled. diff --git a/apps/api/tests/order_service.rs b/apps/api/tests/order_service.rs index 62d517a..f5f8cb5 100644 --- a/apps/api/tests/order_service.rs +++ b/apps/api/tests/order_service.rs @@ -37,14 +37,13 @@ async fn register_user(state: &AppState, label: &str) -> Uuid { 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 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", @@ -73,9 +72,16 @@ async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32 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(), Default::default(), None) - .await - .unwrap_err(); + let err = order::checkout( + &state, + user_id, + address(), + "USD".into(), + Default::default(), + None, + ) + .await + .unwrap_err(); assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty"))); } @@ -107,9 +113,16 @@ async fn checkout_rejects_insufficient_stock() { cart::service::add_item(&state, user_id, sku_id, 2) .await .unwrap(); - let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) - .await - .unwrap_err(); + let err = order::checkout( + &state, + user_id, + address(), + "USD".into(), + Default::default(), + None, + ) + .await + .unwrap_err(); assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock"))); } @@ -126,9 +139,16 @@ async fn checkout_splits_per_shop() { cart::service::add_item(&state, user_id, sku_b, 1) .await .unwrap(); - let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) - .await - .unwrap(); + let orders = order::checkout( + &state, + user_id, + address(), + "USD".into(), + Default::default(), + None, + ) + .await + .unwrap(); assert_eq!(orders.len(), 2); let totals: Vec = orders.iter().map(|o| o.order.total_minor).collect(); assert!(totals.contains(&2000) && totals.contains(&2000)); @@ -143,9 +163,16 @@ async fn pay_and_cancel_require_pending_payment() { cart::service::add_item(&state, user_id, sku_id, 1) .await .unwrap(); - let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) - .await - .unwrap(); + let orders = order::checkout( + &state, + user_id, + address(), + "USD".into(), + Default::default(), + None, + ) + .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(); diff --git a/apps/api/tests/orders.rs b/apps/api/tests/orders.rs index 42911db..3100b86 100644 --- a/apps/api/tests/orders.rs +++ b/apps/api/tests/orders.rs @@ -45,10 +45,15 @@ async fn checkout_splits_orders_per_shop_and_clears_cart() { assert!(shop_ids.contains(&shop_a) && shop_ids.contains(&shop_b)); for item in items { assert!( - item["shop_name"]["en"].as_str().is_some_and(|s| !s.is_empty()), + item["shop_name"]["en"] + .as_str() + .is_some_and(|s| !s.is_empty()), "line must carry a bilingual shop name" ); - assert_eq!(item["stock"], 5, "stock is the SKU's, before checkout decrements it"); + assert_eq!( + item["stock"], 5, + "stock is the SKU's, before checkout decrements it" + ); } let orders = checkout(&app, &buyer).await; @@ -71,10 +76,13 @@ async fn checkout_splits_orders_per_shop_and_clears_cart() { .send() .await .unwrap(); - assert_eq!(res.json::().await.unwrap()["items"] - .as_array() - .unwrap() - .len(), 0); + assert_eq!( + res.json::().await.unwrap()["items"] + .as_array() + .unwrap() + .len(), + 0 + ); } #[tokio::test] @@ -193,7 +201,10 @@ async fn fulfillment_flow_partial_then_complete() { .send() .await .unwrap(); - assert_eq!(res.json::().await.unwrap()["status"], "fulfilling"); + assert_eq!( + res.json::().await.unwrap()["status"], + "fulfilling" + ); // second shipment covers remainder → shipped after mark let res = client() @@ -222,7 +233,10 @@ async fn fulfillment_flow_partial_then_complete() { .send() .await .unwrap(); - assert_eq!(res.json::().await.unwrap()["status"], "shipped"); + assert_eq!( + res.json::().await.unwrap()["status"], + "shipped" + ); // confirm both deliveries → completed for sid in [&shipment1_id, &shipment2_id] { @@ -240,7 +254,10 @@ async fn fulfillment_flow_partial_then_complete() { .send() .await .unwrap(); - assert_eq!(res.json::().await.unwrap()["status"], "completed"); + assert_eq!( + res.json::().await.unwrap()["status"], + "completed" + ); } #[tokio::test] diff --git a/apps/api/tests/points.rs b/apps/api/tests/points.rs index 25ed79d..373a08e 100644 --- a/apps/api/tests/points.rs +++ b/apps/api/tests/points.rs @@ -58,12 +58,7 @@ async fn credit_points(state: &AppState, user_id: Uuid, amount: i64) { tx.commit().await.unwrap(); } -async fn redeem( - app: &TestApp, - token: &str, - product_id: &str, - qty: i32, -) -> reqwest::Response { +async fn redeem(app: &TestApp, token: &str, product_id: &str, qty: i32) -> reqwest::Response { client() .post(app.url("/api/points/redemptions")) .bearer_auth(token) @@ -295,11 +290,7 @@ async fn fulfillment_transitions_are_validated() { let (token, user_id) = register_customer(&app, "pm-flow").await; credit_points(&app.state, uuid(&user_id), 2_000).await; - let first: serde_json::Value = redeem(&app, &token, &id, 1) - .await - .json() - .await - .unwrap(); + let first: serde_json::Value = redeem(&app, &token, &id, 1).await.json().await.unwrap(); let first_id = first["id"].as_str().unwrap().to_string(); let res = client() @@ -329,11 +320,7 @@ async fn fulfillment_transitions_are_validated() { assert_eq!(res.status(), 409); // A pending order can be cancelled once. - let second: serde_json::Value = redeem(&app, &token, &id, 1) - .await - .json() - .await - .unwrap(); + let second: serde_json::Value = redeem(&app, &token, &id, 1).await.json().await.unwrap(); let second_id = second["id"].as_str().unwrap().to_string(); let res = client() .post(app.url(&format!("/api/admin/points/orders/{second_id}/cancel"))) diff --git a/apps/api/tests/shops.rs b/apps/api/tests/shops.rs index 78e1e31..d127a39 100644 --- a/apps/api/tests/shops.rs +++ b/apps/api/tests/shops.rs @@ -13,11 +13,7 @@ async fn list_shops(app: &common::TestApp) -> serde_json::Value { } fn find<'a>(shops: &'a serde_json::Value, id: &str) -> Option<&'a serde_json::Value> { - shops - .as_array() - .unwrap() - .iter() - .find(|s| s["id"] == id) + shops.as_array().unwrap().iter().find(|s| s["id"] == id) } async fn set_profile( @@ -45,7 +41,10 @@ async fn shop_without_a_profile_is_still_listed() { let shops = list_shops(&app).await; let shop = find(&shops, &shop_id).expect("a shop with no profile must still be listed"); assert!(shop["name"]["en"].is_string()); - assert!(shop["logo"].is_null(), "nothing may be invented for a missing profile"); + assert!( + shop["logo"].is_null(), + "nothing may be invented for a missing profile" + ); assert!(shop["score_rating"].is_null()); } @@ -97,7 +96,10 @@ async fn suspended_or_unknown_shops_are_not_public() { let shop_id = create_shop(&app, &admin, "shop-suspended").await; let shops = list_shops(&app).await; - let slug = find(&shops, &shop_id).unwrap()["slug"].as_str().unwrap().to_string(); + let slug = find(&shops, &shop_id).unwrap()["slug"] + .as_str() + .unwrap() + .to_string(); client() .put(app.url(&format!("/api/admin/shops/{shop_id}/status"))) @@ -107,7 +109,10 @@ async fn suspended_or_unknown_shops_are_not_public() { .await .unwrap(); - assert!(find(&list_shops(&app).await, &shop_id).is_none(), "suspended shops are hidden"); + assert!( + find(&list_shops(&app).await, &shop_id).is_none(), + "suspended shops are hidden" + ); let res = client() .get(app.url(&format!("/api/shops/{slug}"))) .send() @@ -120,7 +125,11 @@ async fn suspended_or_unknown_shops_are_not_public() { .send() .await .unwrap(); - assert_eq!(res.status(), 404, "an unknown slug is a 404, not an empty profile"); + assert_eq!( + res.status(), + 404, + "an unknown slug is a 404, not an empty profile" + ); } #[tokio::test] @@ -134,7 +143,10 @@ async fn incomplete_bilingual_text_is_refused_without_writing() { "company": "Kept Co.", "notice": {"en": "Original notice", "zh": "原始公告"} }); - assert_eq!(set_profile(&app, &admin, &shop_id, good).await.status(), 200); + assert_eq!( + set_profile(&app, &admin, &shop_id, good).await.status(), + 200 + ); let bad = serde_json::json!({ "company": "Changed Co.", @@ -144,7 +156,10 @@ async fn incomplete_bilingual_text_is_refused_without_writing() { assert_eq!(res.status(), 400, "a label missing zh must be refused"); let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone(); - assert_eq!(shop["company"], "Kept Co.", "the rejected write changed nothing"); + assert_eq!( + shop["company"], "Kept Co.", + "the rejected write changed nothing" + ); assert_eq!(shop["notice"]["zh"], "原始公告"); } @@ -160,6 +175,10 @@ async fn profile_writes_require_a_platform_admin() { let body = serde_json::json!({ "company": "Nope" }); for token in [&owner, &customer] { let res = set_profile(&app, token, &shop_id, body.clone()).await; - assert_eq!(res.status(), 403, "only platform admins may write a profile"); + assert_eq!( + res.status(), + 403, + "only platform admins may write a profile" + ); } }