chore(api): apply rustfmt across modules and tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:52:52 +08:00
co-authored by Cursor
parent 6c1357ec4d
commit a4ef6ca49e
46 changed files with 495 additions and 334 deletions
+5 -1
View File
@@ -25,7 +25,11 @@ pub fn hash_password(password: &str) -> Result<String, ApiError> {
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)
}
+1 -2
View File
@@ -39,7 +39,6 @@ impl Config {
}
pub fn database_url_for(db: &str) -> anyhow::Result<String> {
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}"))
}
+10 -2
View File
@@ -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()
}
}
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -294,7 +294,8 @@ pub struct CustomerAccountEntry {
pub created_at: DateTime<Utc>,
}
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<Utc>,
}
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 {
+1 -2
View File
@@ -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
+29 -9
View File
@@ -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<AccountSummary> {
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<i64> {
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)
}
+1 -5
View File
@@ -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<AddressBookEntry> {
pub async fn get_own(db: &sqlx::PgPool, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
sqlx::query_as::<_, AddressBookEntry>(&format!(
"SELECT {ADDR_COLS}
FROM addresses WHERE id = $1 AND user_id = $2"
+2 -10
View File
@@ -52,11 +52,7 @@ pub async fn update(
Ok(row)
}
pub async fn set_default(
state: &AppState,
user_id: Uuid,
id: Uuid,
) -> ApiResult<AddressBookEntry> {
pub async fn set_default(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
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<Vec<AddressBookEntry>> {
pub async fn delete(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Vec<AddressBookEntry>> {
let existing = repo::get_own(&state.db, user_id, id).await?;
let mut tx = state.db.begin().await?;
repo::delete(&mut tx, id).await?;
+5 -1
View File
@@ -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(),
+15 -3
View File
@@ -9,7 +9,12 @@ pub async fn get(state: &AppState, user_id: Uuid) -> ApiResult<CartView> {
store::cart_view(state, user_id).await
}
pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult<CartView> {
pub async fn add_item(
state: &AppState,
user_id: Uuid,
sku_id: Uuid,
qty: i32,
) -> ApiResult<CartView> {
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<CartView> {
pub async fn set_item(
state: &AppState,
user_id: Uuid,
sku_id: Uuid,
qty: i32,
) -> ApiResult<CartView> {
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()));
+3 -1
View File
@@ -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(())
}
+3 -1
View File
@@ -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)
+14 -7
View File
@@ -50,7 +50,11 @@ pub struct ContentView {
}
pub async fn load_content(state: &AppState, active_only: bool) -> ApiResult<ContentView> {
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<ContentView> {
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?;
+1 -4
View File
@@ -34,10 +34,7 @@ async fn list_claimable(
Ok(Json(service::list_claimable(&state, shop_id).await?))
}
async fn list_mine(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<Coupon>>> {
async fn list_mine(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<Vec<Coupon>>> {
auth.require_customer()?;
Ok(Json(service::list_mine(&state, auth.id).await?))
}
+2 -9
View File
@@ -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<CouponTemplate> {
pub async fn lock_template(tx: &mut PgConnection, id: Uuid) -> ApiResult<CouponTemplate> {
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<Coupon> {
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
sqlx::query_as::<_, Coupon>(&format!(
"UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now()
WHERE id = $1 AND status = 'claimed'
+5 -9
View File
@@ -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<Coupon> {
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
repo::redeem(tx, coupon_id, order_id).await
}
+4 -3
View File
@@ -39,9 +39,10 @@ async fn convert(
State(state): State<AppState>,
Query(q): Query<ConvertQuery>,
) -> ApiResult<Json<Value>> {
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(
+8 -2
View File
@@ -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<Currency> {
pub async fn set_rate(
state: &AppState,
code: &str,
rate: rust_decimal::Decimal,
) -> ApiResult<Currency> {
use crate::error::ApiError;
if rate <= rust_decimal::Decimal::ZERO {
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
+2 -6
View File
@@ -30,9 +30,7 @@ pub fn router() -> Router<AppState> {
)
}
async fn public_active(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PublicSessionView>>> {
async fn public_active(State(state): State<AppState>) -> ApiResult<Json<Vec<PublicSessionView>>> {
Ok(Json(service::public_active(&state).await?))
}
@@ -98,9 +96,7 @@ async fn shop_update_item(
Json(body): Json<ItemInput>,
) -> ApiResult<Json<FlashSaleItem>> {
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(
+6 -8
View File
@@ -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<bool> {
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<i64> {
pub async fn purchased_qty(tx: &mut PgConnection, user_id: Uuid, item_id: Uuid) -> ApiResult<i64> {
Ok(sqlx::query_scalar(
"SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi
+1 -3
View File
@@ -33,9 +33,7 @@ async fn confirm_delivered(
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ShipmentView>> {
Ok(Json(
service::confirm_delivered(&state, auth.id, id).await?,
))
Ok(Json(service::confirm_delivered(&state, auth.id, id).await?))
}
async fn create_shipment(
+6 -2
View File
@@ -125,10 +125,14 @@ pub async fn create(
body: ShipmentBody,
) -> ApiResult<ShipmentView> {
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?;
+20 -23
View File
@@ -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<GroupBuyingActivity> {
pub async fn get_by_id<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<GroupBuyingActivity> {
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<CollectiveGroup> {
pub async fn get_group<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<CollectiveGroup> {
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<CollectiveGroup> {
pub async fn claim_seat(
tx: &mut PgConnection,
group_id: Uuid,
required: i32,
) -> ApiResult<CollectiveGroup> {
Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
"UPDATE collective_groups
SET paid_member_count = paid_member_count + 1,
+1 -4
View File
@@ -19,9 +19,6 @@ pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<Value>> {
.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 })))
}
+9 -2
View File
@@ -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<AppState>,
Json(body): Json<LoginBody>,
) -> ApiResult<Json<Value>> {
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<AppState>, auth: AuthUser) -> ApiResult<Json<UserPublic>> {
+9 -7
View File
@@ -38,15 +38,17 @@ pub async fn find_by_id<'e, E: PgExecutor<'e>>(
exec: E,
id: Uuid,
) -> Result<Option<User>, 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<i64>, per_page: Option<i64>) -> ApiResult<Paged<User>> {
pub async fn list_users(
db: &PgPool,
page: Option<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<User>> {
let page = clamp_page(page);
let per_page = clamp_per_page(per_page);
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
+37 -39
View File
@@ -68,33 +68,39 @@ pub async fn list_page(
offset: i64,
) -> ApiResult<Vec<Order>> {
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<Ord
.ok_or_else(|| ApiError::NotFound("order".into()))
}
pub async fn lock_for_shop(
tx: &mut PgConnection,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<Order> {
pub async fn lock_for_shop(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<Order> {
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<Order> {
.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<Order> {
+12 -16
View File
@@ -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<OrderVi
let mut tx = state.db.begin().await?;
let order = repo::lock_for_user(&mut tx, user_id, id).await?;
if order.status != OrderStatus::PendingPayment {
return Err(ApiError::Conflict("order not payable (wrong status)".into()));
return Err(ApiError::Conflict(
"order not payable (wrong status)".into(),
));
}
// A group order claims its paid seat in the same transaction as payment.
if let Some(group_id) = order.group_id {
+6 -8
View File
@@ -21,21 +21,19 @@ pub fn router() -> Router<AppState> {
// 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<AppState>,
) -> ApiResult<Json<Vec<IntegralProduct>>> {
async fn list_published(State(state): State<AppState>) -> ApiResult<Json<Vec<IntegralProduct>>> {
Ok(Json(service::list_published(&state).await?))
}
+7 -7
View File
@@ -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<Vec<IntegralP
/// Lock a published product for redemption. An unpublished or missing product
/// is a 404 so customers cannot probe the draft catalog.
pub async fn lock_published(
tx: &mut PgConnection,
id: Uuid,
) -> ApiResult<IntegralProduct> {
pub async fn lock_published(tx: &mut PgConnection, id: Uuid) -> ApiResult<IntegralProduct> {
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<IntegralOrder>) -> ApiResult<Vec<RedemptionView>> {
pub async fn attach_items(
db: &PgPool,
orders: Vec<IntegralOrder>,
) -> ApiResult<Vec<RedemptionView>> {
let ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect();
let items = if ids.is_empty() {
Vec::new()
+1 -3
View File
@@ -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()));
+5 -7
View File
@@ -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<Shop> {
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<Vec<Shop>> {
+4 -5
View File
@@ -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(());
}
+10 -2
View File
@@ -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]
+2 -8
View File
@@ -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))
+37 -11
View File
@@ -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::<serde_json::Value>().await.unwrap()["status"], "published");
assert_eq!(
res.json::<serde_json::Value>().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::<serde_json::Value>().await.unwrap()["amount_minor"], 2999);
assert_eq!(
res.json::<serde_json::Value>().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()
+10 -1
View File
@@ -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.
+27 -6
View File
@@ -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]
+1 -6
View File
@@ -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<serde_json::Value> = res.json().await.unwrap();
let order_id = orders[0]["id"].as_str().unwrap().to_string();
+40 -15
View File
@@ -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<serde_json::Value> = 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
+22 -13
View File
@@ -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.
+47 -20
View File
@@ -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<i64> = 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();
+26 -9
View File
@@ -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::<serde_json::Value>().await.unwrap()["items"]
.as_array()
.unwrap()
.len(), 0);
assert_eq!(
res.json::<serde_json::Value>().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::<serde_json::Value>().await.unwrap()["status"], "fulfilling");
assert_eq!(
res.json::<serde_json::Value>().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::<serde_json::Value>().await.unwrap()["status"], "shipped");
assert_eq!(
res.json::<serde_json::Value>().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::<serde_json::Value>().await.unwrap()["status"], "completed");
assert_eq!(
res.json::<serde_json::Value>().await.unwrap()["status"],
"completed"
);
}
#[tokio::test]
+3 -16
View File
@@ -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")))
+31 -12
View File
@@ -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"
);
}
}