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 { pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash) 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) .unwrap_or(false)
} }
+1 -2
View File
@@ -39,7 +39,6 @@ impl Config {
} }
pub fn database_url_for(db: &str) -> anyhow::Result<String> { pub fn database_url_for(db: &str) -> anyhow::Result<String> {
let base = let base = std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?;
std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?;
Ok(format!("{base}/{db}")) 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; use serde_json::json;
#[derive(Debug, thiserror::Error)] #[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 error;
pub mod http; pub mod http;
pub mod models; pub mod models;
pub mod money;
pub mod modules; pub mod modules;
pub mod money;
pub mod seed; pub mod seed;
pub mod state; pub mod state;
+3 -3
View File
@@ -294,7 +294,8 @@ pub struct CustomerAccountEntry {
pub created_at: DateTime<Utc>, 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. /// Lifecycle of a customer-owned coupon snapshot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -503,8 +504,7 @@ pub struct CollectiveGroupMember {
pub joined_at: DateTime<Utc>, pub joined_at: DateTime<Utc>,
} }
pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = "id, group_id, order_id, user_id, joined_at";
"id, group_id, order_id, user_id, joined_at";
#[derive(Debug, Clone, Serialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct AddressBookEntry { pub struct AddressBookEntry {
+1 -2
View File
@@ -3,8 +3,7 @@ use uuid::Uuid;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::models::{ use crate::models::{
CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, CUSTOMER_ACCOUNT_ENTRY_COLUMNS,
CUSTOMER_ACCOUNT_ENTRY_COLUMNS,
}; };
/// Monetary accounts are opened in the platform base currency. Multi-currency /// 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 amount = positive(amount_minor)?;
let account = account_for(tx, user_id, kind, currency).await?; let account = account_for(tx, user_id, kind, currency).await?;
let updated = repo::credit_atomic(tx, account.id, amount).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. /// 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 amount = positive(amount_minor)?;
let account = account_for(tx, user_id, kind, currency).await?; let account = account_for(tx, user_id, kind, currency).await?;
let updated = repo::debit_guarded(tx, account.id, amount).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 /// 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 from_updated = repo::debit_guarded(tx, from, amount).await?;
let to_updated = repo::credit_atomic(tx, to, 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?; let into = append(tx, to, amount, to_updated.balance_minor, reason, reference).await?;
Ok((out, into)) Ok((out, into))
} }
@@ -206,9 +230,7 @@ fn summarize(accounts: &[CustomerAccount]) -> ApiResult<AccountSummary> {
let currency = available let currency = available
.currency .currency
.clone() .clone()
.ok_or_else(|| { .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("available account has no currency")))?;
ApiError::Internal(anyhow::anyhow!("available account has no currency"))
})?;
Ok(AccountSummary { Ok(AccountSummary {
balance_minor: available.balance_minor, balance_minor: available.balance_minor,
frozen_minor: frozen.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> { fn positive(amount_minor: i64) -> ApiResult<i64> {
if amount_minor <= 0 { if amount_minor <= 0 {
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest("amount_minor must be positive".into()));
"amount_minor must be positive".into(),
));
} }
Ok(amount_minor) Ok(amount_minor)
} }
+1 -5
View File
@@ -21,11 +21,7 @@ pub async fn list_for_user<'e, E: PgExecutor<'e>>(
.await?) .await?)
} }
pub async fn get_own( pub async fn get_own(db: &sqlx::PgPool, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
db: &sqlx::PgPool,
user_id: Uuid,
id: Uuid,
) -> ApiResult<AddressBookEntry> {
sqlx::query_as::<_, AddressBookEntry>(&format!( sqlx::query_as::<_, AddressBookEntry>(&format!(
"SELECT {ADDR_COLS} "SELECT {ADDR_COLS}
FROM addresses WHERE id = $1 AND user_id = $2" FROM addresses WHERE id = $1 AND user_id = $2"
+2 -10
View File
@@ -52,11 +52,7 @@ pub async fn update(
Ok(row) Ok(row)
} }
pub async fn set_default( pub async fn set_default(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
state: &AppState,
user_id: Uuid,
id: Uuid,
) -> ApiResult<AddressBookEntry> {
repo::get_own(&state.db, user_id, id).await?; repo::get_own(&state.db, user_id, id).await?;
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
repo::clear_defaults(&mut tx, user_id).await?; repo::clear_defaults(&mut tx, user_id).await?;
@@ -65,11 +61,7 @@ pub async fn set_default(
Ok(row) Ok(row)
} }
pub async fn delete( pub async fn delete(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Vec<AddressBookEntry>> {
state: &AppState,
user_id: Uuid,
id: Uuid,
) -> ApiResult<Vec<AddressBookEntry>> {
let existing = repo::get_own(&state.db, user_id, id).await?; let existing = repo::get_own(&state.db, user_id, id).await?;
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
repo::delete(&mut tx, id).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())); return Err(ApiError::BadRequest("title is required".into()));
} }
if body.kind == InvoiceKind::Company 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( return Err(ApiError::BadRequest(
"tax_no is required for company invoices".into(), "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 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 { if qty <= 0 {
return Err(ApiError::BadRequest("qty must be > 0".into())); 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 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 { 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? { if !store::is_purchasable(state, sku_id).await? {
return Err(ApiError::BadRequest("sku is not purchasable".into())); 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<()> { pub async fn clear_cart(state: &AppState, user_id: Uuid) -> ApiResult<()> {
let mut conn = state.redis.clone(); 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(()) Ok(())
} }
+3 -1
View File
@@ -369,7 +369,9 @@ pub async fn upsert_sku(
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
if !currency_ok { if !currency_ok {
return Err(ApiError::BadRequest(format!("unknown currency: {currency}"))); return Err(ApiError::BadRequest(format!(
"unknown currency: {currency}"
)));
} }
Ok(sqlx::query_as::<_, Sku>( Ok(sqlx::query_as::<_, Sku>(
"INSERT INTO skus (product_id, sku_code, attributes, price_minor, currency, stock, active) "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> { 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!( let banners = sqlx::query_as::<_, Banner>(&format!(
"SELECT id, image, url, position, active FROM banners{filter} ORDER BY position, created_at" "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> { pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResult<ContentView> {
if !matches!( if !matches!(kind, "banners" | "promos" | "quick-links" | "floor-adverts") {
kind, return Err(ApiError::BadRequest(format!(
"banners" | "promos" | "quick-links" | "floor-adverts" "unknown content kind: {kind}"
) { )));
return Err(ApiError::BadRequest(format!("unknown content kind: {kind}")));
} }
let mut tx = state.db.begin().await?; 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.image, "image")?;
non_empty(&item.url, "url")?; 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}")) sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
+1 -4
View File
@@ -34,10 +34,7 @@ async fn list_claimable(
Ok(Json(service::list_claimable(&state, shop_id).await?)) Ok(Json(service::list_claimable(&state, shop_id).await?))
} }
async fn list_mine( async fn list_mine(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<Vec<Coupon>>> {
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<Coupon>>> {
auth.require_customer()?; auth.require_customer()?;
Ok(Json(service::list_mine(&state, auth.id).await?)) 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. /// Lock the template row for a claim so two customers cannot both take the last one.
pub async fn lock_template( pub async fn lock_template(tx: &mut PgConnection, id: Uuid) -> ApiResult<CouponTemplate> {
tx: &mut PgConnection,
id: Uuid,
) -> ApiResult<CouponTemplate> {
sqlx::query_as::<_, CouponTemplate>(&format!( sqlx::query_as::<_, CouponTemplate>(&format!(
"SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE" "SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE"
)) ))
@@ -212,11 +209,7 @@ pub async fn lock_owned_for_update(
.await?) .await?)
} }
pub async fn redeem( pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
tx: &mut PgConnection,
coupon_id: Uuid,
order_id: Uuid,
) -> ApiResult<Coupon> {
sqlx::query_as::<_, Coupon>(&format!( sqlx::query_as::<_, Coupon>(&format!(
"UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now() "UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now()
WHERE id = $1 AND status = 'claimed' WHERE id = $1 AND status = 'claimed'
+5 -9
View File
@@ -113,14 +113,14 @@ pub fn checkout_discount(
} }
let now = Utc::now(); let now = Utc::now();
if now < coupon.starts_at || now > coupon.ends_at { 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 let from = currencies
.iter() .iter()
.find(|c| c.code == coupon.currency) .find(|c| c.code == coupon.currency)
.ok_or_else(|| { .ok_or_else(|| ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)))?;
ApiError::BadRequest(format!("currency {} is disabled", coupon.currency))
})?;
let amount = convert_minor(coupon.amount_minor, from, target)?; let amount = convert_minor(coupon.amount_minor, from, target)?;
let threshold = convert_minor(coupon.threshold_minor, from, target)?; let threshold = convert_minor(coupon.threshold_minor, from, target)?;
if subtotal_minor < threshold { if subtotal_minor < threshold {
@@ -132,11 +132,7 @@ pub fn checkout_discount(
Ok(amount.min(subtotal_minor)) Ok(amount.min(subtotal_minor))
} }
pub async fn redeem( pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
tx: &mut PgConnection,
coupon_id: Uuid,
order_id: Uuid,
) -> ApiResult<Coupon> {
repo::redeem(tx, coupon_id, order_id).await repo::redeem(tx, coupon_id, order_id).await
} }
+4 -3
View File
@@ -39,9 +39,10 @@ async fn convert(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<ConvertQuery>, Query(q): Query<ConvertQuery>,
) -> ApiResult<Json<Value>> { ) -> ApiResult<Json<Value>> {
let (amount_minor, currency) = let (amount_minor, currency) = service::convert(&state, q.amount_minor, &q.from, &q.to).await?;
service::convert(&state, q.amount_minor, &q.from, &q.to).await?; Ok(Json(
Ok(Json(json!({ "amount_minor": amount_minor, "currency": currency }))) json!({ "amount_minor": amount_minor, "currency": currency }),
))
} }
async fn list_all_currencies( async fn list_all_currencies(
+8 -2
View File
@@ -57,7 +57,9 @@ pub async fn upsert(
use crate::error::ApiError; use crate::error::ApiError;
let code = code.to_uppercase(); let code = code.to_uppercase();
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_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 { if rate_to_base <= rust_decimal::Decimal::ZERO {
return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
@@ -82,7 +84,11 @@ pub async fn upsert(
.await?) .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; use crate::error::ApiError;
if rate <= rust_decimal::Decimal::ZERO { if rate <= rust_decimal::Decimal::ZERO {
return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); 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( async fn public_active(State(state): State<AppState>) -> ApiResult<Json<Vec<PublicSessionView>>> {
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PublicSessionView>>> {
Ok(Json(service::public_active(&state).await?)) Ok(Json(service::public_active(&state).await?))
} }
@@ -98,9 +96,7 @@ async fn shop_update_item(
Json(body): Json<ItemInput>, Json(body): Json<ItemInput>,
) -> ApiResult<Json<FlashSaleItem>> { ) -> ApiResult<Json<FlashSaleItem>> {
let shop_id = auth.require_shop()?; let shop_id = auth.require_shop()?;
Ok(Json( Ok(Json(service::update_item(&state, shop_id, id, body).await?))
service::update_item(&state, shop_id, id, body).await?,
))
} }
async fn shop_delete_item( async fn shop_delete_item(
+5 -7
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 /// The group-buying capability lands after this one, so its overlap check is
/// dormant until its table exists. /// dormant until its table exists.
pub async fn group_buying_table_exists<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<bool> { 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") Ok(
sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL")
.fetch_one(exec) .fetch_one(exec)
.await?) .await?,
)
} }
pub async fn overlapping_group_buying_exists( 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. /// Activity units this customer already holds on non-cancelled orders.
pub async fn purchased_qty( pub async fn purchased_qty(tx: &mut PgConnection, user_id: Uuid, item_id: Uuid) -> ApiResult<i64> {
tx: &mut PgConnection,
user_id: Uuid,
item_id: Uuid,
) -> ApiResult<i64> {
Ok(sqlx::query_scalar( Ok(sqlx::query_scalar(
"SELECT COALESCE(SUM(oi.qty), 0) "SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi FROM order_items oi
+1 -3
View File
@@ -33,9 +33,7 @@ async fn confirm_delivered(
auth: AuthUser, auth: AuthUser,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> ApiResult<Json<ShipmentView>> { ) -> ApiResult<Json<ShipmentView>> {
Ok(Json( Ok(Json(service::confirm_delivered(&state, auth.id, id).await?))
service::confirm_delivered(&state, auth.id, id).await?,
))
} }
async fn create_shipment( async fn create_shipment(
+6 -2
View File
@@ -125,10 +125,14 @@ pub async fn create(
body: ShipmentBody, body: ShipmentBody,
) -> ApiResult<ShipmentView> { ) -> ApiResult<ShipmentView> {
if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() { 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) { 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 mut tx = state.db.begin().await?;
let order = order_repo::lock_for_shop(&mut tx, shop_id, order_id).await?; let order = order_repo::lock_for_shop(&mut tx, shop_id, order_id).await?;
+12 -15
View File
@@ -2,9 +2,7 @@ use sqlx::{PgConnection, PgExecutor};
use uuid::Uuid; use uuid::Uuid;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::models::{ use crate::models::{CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS};
CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS,
};
use super::dto::{ActivityInput, ActivityView, OpenGroupView}; 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())) .ok_or_else(|| ApiError::NotFound("group-buying activity".into()))
} }
pub async fn get_by_id<'e, E: PgExecutor<'e>>( pub async fn get_by_id<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<GroupBuyingActivity> {
exec: E,
id: Uuid,
) -> ApiResult<GroupBuyingActivity> {
sqlx::query_as::<_, GroupBuyingActivity>( sqlx::query_as::<_, GroupBuyingActivity>(
"SELECT id, shop_id, sku_id, name, description, image, group_price_minor, currency, "SELECT id, shop_id, sku_id, name, description, image, group_price_minor, currency,
required_members, starts_at, ends_at, group_lifetime_hours, enabled, required_members, starts_at, ends_at, group_lifetime_hours, enabled,
@@ -170,8 +165,7 @@ pub async fn update(
} }
pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> { pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let result = let result = sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
.bind(id) .bind(id)
.bind(shop_id) .bind(shop_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -232,10 +226,7 @@ pub async fn open_groups_for_activities(
} }
/// Joining only reads the group: a seat is claimed at payment, not checkout. /// Joining only reads the group: a seat is claimed at payment, not checkout.
pub async fn get_group<'e, E: PgExecutor<'e>>( pub async fn get_group<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<CollectiveGroup> {
exec: E,
id: Uuid,
) -> ApiResult<CollectiveGroup> {
sqlx::query_as::<_, CollectiveGroup>(&format!( sqlx::query_as::<_, CollectiveGroup>(&format!(
"SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1" "SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1"
)) ))
@@ -262,7 +253,9 @@ pub async fn insert_group(
/// Record which pending order opened the group. /// Record which pending order opened the group.
pub async fn set_leader(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid) -> ApiResult<()> { 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") sqlx::query(
"UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1",
)
.bind(group_id) .bind(group_id)
.bind(order_id) .bind(order_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -300,7 +293,11 @@ pub async fn insert_member(
} }
/// One paid seat: increment and become successful exactly at capacity. /// 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!( Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
"UPDATE collective_groups "UPDATE collective_groups
SET paid_member_count = paid_member_count + 1, 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 .await
.is_ok(); .is_ok();
let mut redis = state.redis.clone(); let mut redis = state.redis.clone();
let redis_ok = redis let redis_ok = redis.set::<_, _, ()>("vmall:ready:ping", "1").await.is_ok();
.set::<_, _, ()>("vmall:ready:ping", "1")
.await
.is_ok();
Ok(Json(json!({ "db": db_ok, "redis": redis_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::Deserialize;
use serde_json::Value; use serde_json::Value;
@@ -49,7 +54,9 @@ async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<LoginBody>, Json(body): Json<LoginBody>,
) -> ApiResult<Json<Value>> { ) -> 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>> { async fn me(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<UserPublic>> {
+6 -4
View File
@@ -38,15 +38,17 @@ pub async fn find_by_id<'e, E: PgExecutor<'e>>(
exec: E, exec: E,
id: Uuid, id: Uuid,
) -> Result<Option<User>, sqlx::Error> { ) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(&format!( sqlx::query_as::<_, User>(&format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"))
"SELECT {USER_COLUMNS} FROM users WHERE id = $1"
))
.bind(id) .bind(id)
.fetch_optional(exec) .fetch_optional(exec)
.await .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 page = clamp_page(page);
let per_page = clamp_per_page(per_page); let per_page = clamp_per_page(per_page);
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users") let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
+15 -17
View File
@@ -68,7 +68,8 @@ pub async fn list_page(
offset: i64, offset: i64,
) -> ApiResult<Vec<Order>> { ) -> ApiResult<Vec<Order>> {
Ok(match scope { Ok(match scope {
OrderScope::User(user_id) => sqlx::query_as::<_, Order>(&format!( OrderScope::User(user_id) => {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders WHERE user_id = $1 "SELECT {ORDER_COLS} FROM orders WHERE user_id = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3" ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)) ))
@@ -76,8 +77,10 @@ pub async fn list_page(
.bind(per_page) .bind(per_page)
.bind(offset) .bind(offset)
.fetch_all(db) .fetch_all(db)
.await?, .await?
OrderScope::Shop(shop_id) => sqlx::query_as::<_, Order>(&format!( }
OrderScope::Shop(shop_id) => {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders "SELECT {ORDER_COLS} FROM orders
WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2) WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4" ORDER BY created_at DESC LIMIT $3 OFFSET $4"
@@ -87,14 +90,17 @@ pub async fn list_page(
.bind(per_page) .bind(per_page)
.bind(offset) .bind(offset)
.fetch_all(db) .fetch_all(db)
.await?, .await?
OrderScope::Admin => sqlx::query_as::<_, Order>(&format!( }
OrderScope::Admin => {
sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2" "SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2"
)) ))
.bind(per_page) .bind(per_page)
.bind(offset) .bind(offset)
.fetch_all(db) .fetch_all(db)
.await?, .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())) .ok_or_else(|| ApiError::NotFound("order".into()))
} }
pub async fn lock_for_shop( pub async fn lock_for_shop(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<Order> {
tx: &mut PgConnection,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<Order> {
sqlx::query_as::<_, Order>(&format!( sqlx::query_as::<_, Order>(&format!(
"SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE" "SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE"
)) ))
@@ -198,9 +200,7 @@ pub async fn insert_item(
} }
pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> { pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> {
let result = sqlx::query( let result = sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2")
"UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2",
)
.bind(sku_id) .bind(sku_id)
.bind(qty) .bind(qty)
.execute(&mut *tx) .execute(&mut *tx)
@@ -233,9 +233,7 @@ pub async fn mark_paid(tx: &mut PgConnection, id: Uuid) -> ApiResult<Order> {
.bind(id) .bind(id)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| ApiError::Conflict("order not payable (missing or wrong status)".into()))
ApiError::Conflict("order not payable (missing or wrong status)".into())
})
} }
pub async fn cancel(tx: &mut PgConnection, user_id: Uuid, id: Uuid) -> ApiResult<Order> { 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::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged}; use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::OrderStatus; use crate::models::OrderStatus;
use crate::money::convert_minor;
use crate::modules::group_buying::GroupBuyIntent; use crate::modules::group_buying::GroupBuyIntent;
use crate::modules::{cart, coupon, flash_sale, group_buying}; use crate::modules::{cart, coupon, flash_sale, group_buying};
use crate::money::convert_minor;
use crate::state::AppState; use crate::state::AppState;
use super::dto::{AddressBody, OrderScope, OrderView}; use super::dto::{AddressBody, OrderScope, OrderView};
@@ -45,14 +45,7 @@ pub async fn list(
let page = clamp_page(page); let page = clamp_page(page);
let per_page = clamp_per_page(per_page); let per_page = clamp_per_page(per_page);
let total = repo::count(&state.db, scope, status).await?; let total = repo::count(&state.db, scope, status).await?;
let orders = repo::list_page( let orders = repo::list_page(&state.db, scope, status, per_page, (page - 1) * per_page).await?;
&state.db,
scope,
status,
per_page,
(page - 1) * per_page,
)
.await?;
let items = repo::attach_items(&state.db, orders).await?; let items = repo::attach_items(&state.db, orders).await?;
Ok(Paged { Ok(Paged {
items, items,
@@ -186,9 +179,7 @@ pub async fn checkout(
let index = lines let index = lines
.iter() .iter()
.position(|line| line.sku_id == intent.sku_id) .position(|line| line.sku_id == intent.sku_id)
.ok_or_else(|| { .ok_or_else(|| ApiError::BadRequest("group-buying SKU is not in the cart".into()))?;
ApiError::BadRequest("group-buying SKU is not in the cart".into())
})?;
let qty = lines[index].normal_qty + lines[index].activity_qty; let qty = lines[index].normal_qty + lines[index].activity_qty;
if qty != 1 { if qty != 1 {
return Err(ApiError::BadRequest( 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 address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?;
let mut created = Vec::new(); let mut created = Vec::new();
for shop_id in shop_order { for shop_id in shop_order {
let shop_lines: Vec<&CheckoutLine> = let shop_lines: Vec<&CheckoutLine> = lines
lines.iter().filter(|line| line.shop_id == shop_id).collect(); .iter()
.filter(|line| line.shop_id == shop_id)
.collect();
let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0); let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0);
let selected_coupon = coupon_by_shop.get(&shop_id).copied(); let selected_coupon = coupon_by_shop.get(&shop_id).copied();
// Eligibility and the discount are resolved server-side; the client // 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 // The whole ordered quantity leaves SKU stock; only the activity
// portion also leaves reserved activity stock. // 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); 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 mut tx = state.db.begin().await?;
let order = repo::lock_for_user(&mut tx, user_id, id).await?; let order = repo::lock_for_user(&mut tx, user_id, id).await?;
if order.status != OrderStatus::PendingPayment { 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. // A group order claims its paid seat in the same transaction as payment.
if let Some(group_id) = order.group_id { 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. // Public catalog; redemption and history are customer-scoped.
.route("/points/products", get(list_published)) .route("/points/products", get(list_published))
.route("/points/redemptions", get(list_mine).post(redeem)) .route("/points/redemptions", get(list_mine).post(redeem))
.route( .route("/admin/points/products", get(admin_list).post(admin_create))
"/admin/points/products",
get(admin_list).post(admin_create),
)
.route("/admin/points/products/{id}", put(admin_update)) .route("/admin/points/products/{id}", put(admin_update))
.route("/admin/points/products/{id}/publish", post(admin_publish)) .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", get(admin_orders))
.route("/admin/points/orders/{id}/fulfill", post(admin_fulfill)) .route("/admin/points/orders/{id}/fulfill", post(admin_fulfill))
.route("/admin/points/orders/{id}/cancel", post(admin_cancel)) .route("/admin/points/orders/{id}/cancel", post(admin_cancel))
} }
async fn list_published( async fn list_published(State(state): State<AppState>) -> ApiResult<Json<Vec<IntegralProduct>>> {
State(state): State<AppState>,
) -> ApiResult<Json<Vec<IntegralProduct>>> {
Ok(Json(service::list_published(&state).await?)) 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::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged}; use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::{ use crate::models::{
IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, INTEGRAL_ORDER_COLUMNS,
INTEGRAL_ORDER_COLUMNS, INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS, INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS,
}; };
use super::dto::{IntegralProductInput, RedemptionView}; 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 /// Lock a published product for redemption. An unpublished or missing product
/// is a 404 so customers cannot probe the draft catalog. /// is a 404 so customers cannot probe the draft catalog.
pub async fn lock_published( pub async fn lock_published(tx: &mut PgConnection, id: Uuid) -> ApiResult<IntegralProduct> {
tx: &mut PgConnection,
id: Uuid,
) -> ApiResult<IntegralProduct> {
sqlx::query_as::<_, IntegralProduct>(&format!( sqlx::query_as::<_, IntegralProduct>(&format!(
"SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products "SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products
WHERE id = $1 AND published = TRUE 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())) .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 ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect();
let items = if ids.is_empty() { let items = if ids.is_empty() {
Vec::new() Vec::new()
+1 -3
View File
@@ -163,9 +163,7 @@ async fn transition(
fn validate_product(body: &IntegralProductInput) -> ApiResult<()> { fn validate_product(body: &IntegralProductInput) -> ApiResult<()> {
bilingual(&body.name, "name")?; bilingual(&body.name, "name")?;
if body.points_price <= 0 { if body.points_price <= 0 {
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest("points_price must be positive".into()));
"points_price must be positive".into(),
));
} }
if body.stock < 0 { if body.stock < 0 {
return Err(ApiError::BadRequest("stock must not be negative".into())); return Err(ApiError::BadRequest("stock must not be negative".into()));
+1 -3
View File
@@ -34,9 +34,7 @@ const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name,
const SHOP_COLS: &str = "id, name, slug, status, created_at"; const SHOP_COLS: &str = "id, name, slug, status, created_at";
pub async fn get_by_id(state: &AppState, id: Uuid) -> ApiResult<Shop> { pub async fn get_by_id(state: &AppState, id: Uuid) -> ApiResult<Shop> {
sqlx::query_as::<_, Shop>(&format!( sqlx::query_as::<_, Shop>(&format!("SELECT {SHOP_COLS} FROM shops WHERE id = $1"))
"SELECT {SHOP_COLS} FROM shops WHERE id = $1"
))
.bind(id) .bind(id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await? .await?
+1 -2
View File
@@ -8,8 +8,7 @@ use crate::state::AppState;
/// Idempotent dev seed: platform admin account. /// Idempotent dev seed: platform admin account.
pub async fn ensure_platform_admin(state: &AppState) -> anyhow::Result<()> { pub async fn ensure_platform_admin(state: &AppState) -> anyhow::Result<()> {
let email = "admin@vmall.local"; let email = "admin@vmall.local";
let exists: bool = let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(email) .bind(email)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
+10 -2
View File
@@ -146,7 +146,11 @@ async fn summary_is_owned_and_entries_are_append_only() {
.send() .send()
.await .await
.unwrap(); .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() let res = client()
.get(app.url("/api/me/stats/entries")) .get(app.url("/api/me/stats/entries"))
@@ -154,7 +158,11 @@ async fn summary_is_owned_and_entries_are_append_only() {
.send() .send()
.await .await
.unwrap(); .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] #[tokio::test]
+2 -8
View File
@@ -165,14 +165,8 @@ async fn unauthenticated_requests_are_rejected() {
for (method, path) in [ for (method, path) in [
("GET", "/api/addresses".to_string()), ("GET", "/api/addresses".to_string()),
("POST", "/api/addresses".to_string()), ("POST", "/api/addresses".to_string()),
( ("PUT", format!("/api/addresses/{}", uuid::Uuid::new_v4())),
"PUT", ("DELETE", format!("/api/addresses/{}", uuid::Uuid::new_v4())),
format!("/api/addresses/{}", uuid::Uuid::new_v4()),
),
(
"DELETE",
format!("/api/addresses/{}", uuid::Uuid::new_v4()),
),
] { ] {
let res = client() let res = client()
.request(method.parse().unwrap(), app.url(&path)) .request(method.parse().unwrap(), app.url(&path))
+37 -11
View File
@@ -1,9 +1,9 @@
mod common; mod common;
use common::{ use common::{
add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku, add_to_cart, category_id_by_slug, checkout, client, create_product_full,
create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay, create_product_with_sku, create_product_with_sku_in_category, create_shop, login_admin,
publish_product, register_customer, spawn_app, make_shop_owner, pay, publish_product, register_customer, spawn_app,
}; };
use serial_test::serial; use serial_test::serial;
@@ -61,7 +61,10 @@ async fn publish_lifecycle_and_public_visibility() {
.await .await
.unwrap(); .unwrap();
assert_eq!(res.status(), 200); 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 res = client().get(app.url("/api/products")).send().await.unwrap();
let list: serde_json::Value = res.json().await.unwrap(); let list: serde_json::Value = res.json().await.unwrap();
@@ -154,7 +157,10 @@ async fn currency_conversion_math() {
.send() .send()
.await .await
.unwrap(); .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 // disabled currency rejected
let admin = login_admin(&app).await; let admin = login_admin(&app).await;
@@ -226,10 +232,22 @@ async fn category_subtree_listing_and_price_sort() {
assert_eq!(res.status(), 200); assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap(); let body: serde_json::Value = res.json().await.unwrap();
let listed = listed_ids(&body); let listed = listed_ids(&body);
assert!(listed.contains(&leaf), "grandchild product missing from root listing"); assert!(
assert!(listed.contains(&sibling), "child product missing from root listing"); listed.contains(&leaf),
assert!(!listed.contains(&unrelated), "product from another root category leaked in"); "grandchild product missing from root listing"
assert_eq!(body["total"], 2, "total must count the subtree, not only the root"); );
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. // A mid-level category covers its own subtree and nothing else.
let res = client() let res = client()
@@ -355,7 +373,11 @@ async fn brand_filter_and_real_sales() {
.await .await
.unwrap(); .unwrap();
let body: serde_json::Value = res.json().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); assert_eq!(sold(&body, &p_beta), 0);
// Paying makes the units count, and the sales sort follows them. // Paying makes the units count, and the sales sort follows them.
@@ -378,7 +400,11 @@ async fn brand_filter_and_real_sales() {
.await .await
.unwrap(); .unwrap();
let body: serde_json::Value = res.json().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. // Comments still have no model, so that sort stays refused.
let res = client() let res = client()
+10 -1
View File
@@ -168,7 +168,16 @@ pub async fn create_product_with_sku_in_category(
stock: i32, stock: i32,
category_id: Option<&str>, category_id: Option<&str>,
) -> (String, String) { ) -> (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. /// 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() .send()
.await .await
.unwrap(); .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() res.json().await.unwrap()
} }
@@ -59,7 +63,10 @@ async fn home_content_is_public_and_ordered() {
.collect(); .collect();
let mut sorted = positions.clone(); let mut sorted = positions.clone();
sorted.sort_unstable(); 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] #[tokio::test]
@@ -83,14 +90,20 @@ async fn admin_replace_round_trips_and_reorders() {
.map(|b| b["image"].as_str().unwrap().to_string()) .map(|b| b["image"].as_str().unwrap().to_string())
.collect() .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. // The submitted order decides the stored order and the positions.
let flipped = serde_json::json!([ let flipped = serde_json::json!([
{"image": "/mock/b.svg", "url": "/collective"}, {"image": "/mock/b.svg", "url": "/collective"},
{"image": "/mock/a.svg", "url": "/seckill"} {"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; let content = public_content(&app).await;
assert_eq!(images(&content), vec!["/mock/b.svg", "/mock/a.svg"]); assert_eq!(images(&content), vec!["/mock/b.svg", "/mock/a.svg"]);
assert_eq!(content["banners"][0]["position"], 0); assert_eq!(content["banners"][0]["position"], 0);
@@ -116,7 +129,11 @@ async fn inactive_rows_are_hidden_from_the_public_read() {
.iter() .iter()
.map(|b| b["image"].as_str().unwrap()) .map(|b| b["image"].as_str().unwrap())
.collect(); .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. // The admin read keeps it, so a disabled block stays editable.
let res = client() let res = client()
@@ -153,7 +170,11 @@ async fn invalid_entry_is_rejected_without_touching_stored_content() {
.iter() .iter()
.map(|b| b["image"].as_str().unwrap()) .map(|b| b["image"].as_str().unwrap())
.collect(); .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] #[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(); let shop_id = coupon["shop_id"].as_str().unwrap().to_string();
add_to_cart(&app, &token, &sku, 2).await; add_to_cart(&app, &token, &sku, 2).await;
let res = checkout_with( let res = checkout_with(&app, &token, HashMap::from([(shop_id, coupon_id.clone())])).await;
&app,
&token,
HashMap::from([(shop_id, coupon_id.clone())]),
)
.await;
assert_eq!(res.status(), 201); assert_eq!(res.status(), 201);
let orders: Vec<serde_json::Value> = res.json().await.unwrap(); let orders: Vec<serde_json::Value> = res.json().await.unwrap();
let order_id = orders[0]["id"].as_str().unwrap().to_string(); let order_id = orders[0]["id"].as_str().unwrap().to_string();
+36 -11
View File
@@ -173,7 +173,10 @@ async fn inactive_window_falls_back_to_normal_price() {
let item = &orders[0]["items"][0]; let item = &orders[0]["items"][0];
assert_eq!(item["unit_price_minor"], 1000, "normal price applies"); assert_eq!(item["unit_price_minor"], 1000, "normal price applies");
assert!(item["flash_sale_item_id"].is_null()); 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] #[tokio::test]
@@ -182,7 +185,8 @@ async fn cross_shop_sku_and_overlapping_sessions_are_rejected() {
let app = spawn_app().await; let app = spawn_app().await;
let admin = login_admin(&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_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 (starts, ends) = active_window();
let session = create_session(&app, &owner_a, &starts, &ends).await; 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. // A second overlapping session cannot list the same SKU.
let session_two = create_session(&app, &owner_a, &starts, &ends).await; 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; 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; let _ = shop_a;
} }
@@ -263,9 +271,8 @@ async fn reserved_stock_admits_one_activity_price() {
assert_eq!(reserved, 0, "the single reserved unit is consumed"); assert_eq!(reserved, 0, "the single reserved unit is consumed");
assert_eq!(sold, 1); assert_eq!(sold, 1);
let activity: i64 = sqlx::query_scalar( let activity: i64 =
"SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1", sqlx::query_scalar("SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1")
)
.bind(Uuid::parse_str(&item_id).unwrap()) .bind(Uuid::parse_str(&item_id).unwrap())
.fetch_one(&app.db) .fetch_one(&app.db)
.await .await
@@ -304,7 +311,10 @@ async fn per_customer_limit_splits_the_line() {
.filter(|i| i["flash_sale_item_id"].is_null()) .filter(|i| i["flash_sale_item_id"].is_null())
.collect(); .collect();
assert_eq!(activity.len(), 1); 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!(activity[0]["unit_price_minor"], 400);
assert_eq!(standard.len(), 1); assert_eq!(standard.len(), 1);
assert_eq!(standard[0]["qty"], 2); 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 res = checkout_with(&app, &token, HashMap::new()).await;
let orders: Vec<serde_json::Value> = res.json().await.unwrap(); let orders: Vec<serde_json::Value> = res.json().await.unwrap();
assert!(orders[0]["items"][0]["flash_sale_item_id"].is_null()); 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; let (_, sold) = flash_item_state(&app, &item_id).await;
assert_eq!(sold, 1); 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 (owner, shop, _product, sku) = setup_sellable(&app, &admin, "fs-cp", 1000, 5).await;
let (starts, ends) = active_window(); let (starts, ends) = active_window();
let session = create_session(&app, &owner, &starts, &ends).await; 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 template = create_template(&app, &owner, 100, 0).await;
let (token, _) = register_customer(&app, "fs-cp").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; add_to_cart(&app, &token, &sku, 1).await;
let res = checkout_with(&app, &token, HashMap::from([(shop, coupon)])).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] #[tokio::test]
@@ -375,7 +397,10 @@ async fn cancel_restores_reserved_stock_and_coupon() {
for order in &orders { for order in &orders {
let res = client() 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) .bearer_auth(&token)
.send() .send()
.await .await
+14 -5
View File
@@ -165,7 +165,8 @@ async fn activity_requires_an_own_sku_and_two_members() {
let app = spawn_app().await; let app = spawn_app().await;
let admin = login_admin(&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_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. // Another shop's SKU is refused.
let res = create_activity_body(&app, &owner_a, &sku_b, 700, 2, 24).await; 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() .json()
.await .await
.unwrap(); .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]["id"], group_id.as_str());
assert_eq!(found["open_groups"][0]["paid_member_count"], 0); assert_eq!(found["open_groups"][0]["paid_member_count"], 0);
@@ -317,7 +321,9 @@ async fn expired_and_cancelled_groups_are_not_joinable() {
.await .await
.unwrap(); .unwrap();
assert_eq!(pay(&app, &token_a, &order_a).await, 200); 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") sqlx::query(
"UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1",
)
.bind(group_id) .bind(group_id)
.execute(&app.db) .execute(&app.db)
.await .await
@@ -473,7 +479,8 @@ async fn activity_rejects_a_sku_with_an_overlapping_flash_sale() {
async fn cancelling_an_elapsed_empty_group_records_expired() { async fn cancelling_an_elapsed_empty_group_records_expired() {
let app = spawn_app().await; let app = spawn_app().await;
let admin = login_admin(&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 activity = create_activity(&app, &owner, &sku, 700, 3, 24).await;
let (token, _) = register_customer(&app, "gb-precedence").await; let (token, _) = register_customer(&app, "gb-precedence").await;
@@ -486,7 +493,9 @@ async fn cancelling_an_elapsed_empty_group_records_expired() {
.unwrap(); .unwrap();
// The lifetime ends before the opener cancels. // The lifetime ends before the opener cancels.
sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1") sqlx::query(
"UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1",
)
.bind(group_id) .bind(group_id)
.execute(&app.db) .execute(&app.db)
.await .await
+34 -7
View File
@@ -37,9 +37,8 @@ async fn register_user(state: &AppState, label: &str) -> Uuid {
async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32) -> 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 slug = format!("{slug}-{}", &Uuid::new_v4().simple().to_string()[..8]);
let shop_id: Uuid = sqlx::query_scalar( let shop_id: Uuid =
"INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING id", sqlx::query_scalar("INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING id")
)
.bind(serde_json::json!({"en": slug, "zh": slug})) .bind(serde_json::json!({"en": slug, "zh": slug}))
.bind(&slug) .bind(&slug)
.fetch_one(&state.db) .fetch_one(&state.db)
@@ -73,7 +72,14 @@ async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32
async fn checkout_rejects_empty_cart() { async fn checkout_rejects_empty_cart() {
let state = common::spawn_state().await; let state = common::spawn_state().await;
let user_id = register_user(&state, "empty-cart").await; let user_id = register_user(&state, "empty-cart").await;
let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) let err = order::checkout(
&state,
user_id,
address(),
"USD".into(),
Default::default(),
None,
)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty"))); assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
@@ -107,7 +113,14 @@ async fn checkout_rejects_insufficient_stock() {
cart::service::add_item(&state, user_id, sku_id, 2) cart::service::add_item(&state, user_id, sku_id, 2)
.await .await
.unwrap(); .unwrap();
let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) let err = order::checkout(
&state,
user_id,
address(),
"USD".into(),
Default::default(),
None,
)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock"))); assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
@@ -126,7 +139,14 @@ async fn checkout_splits_per_shop() {
cart::service::add_item(&state, user_id, sku_b, 1) cart::service::add_item(&state, user_id, sku_b, 1)
.await .await
.unwrap(); .unwrap();
let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) let orders = order::checkout(
&state,
user_id,
address(),
"USD".into(),
Default::default(),
None,
)
.await .await
.unwrap(); .unwrap();
assert_eq!(orders.len(), 2); assert_eq!(orders.len(), 2);
@@ -143,7 +163,14 @@ async fn pay_and_cancel_require_pending_payment() {
cart::service::add_item(&state, user_id, sku_id, 1) cart::service::add_item(&state, user_id, sku_id, 1)
.await .await
.unwrap(); .unwrap();
let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default(), None) let orders = order::checkout(
&state,
user_id,
address(),
"USD".into(),
Default::default(),
None,
)
.await .await
.unwrap(); .unwrap();
let id = orders[0].order.id; let id = orders[0].order.id;
+24 -7
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)); assert!(shop_ids.contains(&shop_a) && shop_ids.contains(&shop_b));
for item in items { for item in items {
assert!( 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" "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; let orders = checkout(&app, &buyer).await;
@@ -71,10 +76,13 @@ async fn checkout_splits_orders_per_shop_and_clears_cart() {
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["items"] assert_eq!(
res.json::<serde_json::Value>().await.unwrap()["items"]
.as_array() .as_array()
.unwrap() .unwrap()
.len(), 0); .len(),
0
);
} }
#[tokio::test] #[tokio::test]
@@ -193,7 +201,10 @@ async fn fulfillment_flow_partial_then_complete() {
.send() .send()
.await .await
.unwrap(); .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 // second shipment covers remainder → shipped after mark
let res = client() let res = client()
@@ -222,7 +233,10 @@ async fn fulfillment_flow_partial_then_complete() {
.send() .send()
.await .await
.unwrap(); .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 // confirm both deliveries → completed
for sid in [&shipment1_id, &shipment2_id] { for sid in [&shipment1_id, &shipment2_id] {
@@ -240,7 +254,10 @@ async fn fulfillment_flow_partial_then_complete() {
.send() .send()
.await .await
.unwrap(); .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] #[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(); tx.commit().await.unwrap();
} }
async fn redeem( async fn redeem(app: &TestApp, token: &str, product_id: &str, qty: i32) -> reqwest::Response {
app: &TestApp,
token: &str,
product_id: &str,
qty: i32,
) -> reqwest::Response {
client() client()
.post(app.url("/api/points/redemptions")) .post(app.url("/api/points/redemptions"))
.bearer_auth(token) .bearer_auth(token)
@@ -295,11 +290,7 @@ async fn fulfillment_transitions_are_validated() {
let (token, user_id) = register_customer(&app, "pm-flow").await; let (token, user_id) = register_customer(&app, "pm-flow").await;
credit_points(&app.state, uuid(&user_id), 2_000).await; credit_points(&app.state, uuid(&user_id), 2_000).await;
let first: serde_json::Value = redeem(&app, &token, &id, 1) let first: serde_json::Value = redeem(&app, &token, &id, 1).await.json().await.unwrap();
.await
.json()
.await
.unwrap();
let first_id = first["id"].as_str().unwrap().to_string(); let first_id = first["id"].as_str().unwrap().to_string();
let res = client() let res = client()
@@ -329,11 +320,7 @@ async fn fulfillment_transitions_are_validated() {
assert_eq!(res.status(), 409); assert_eq!(res.status(), 409);
// A pending order can be cancelled once. // A pending order can be cancelled once.
let second: serde_json::Value = redeem(&app, &token, &id, 1) let second: serde_json::Value = redeem(&app, &token, &id, 1).await.json().await.unwrap();
.await
.json()
.await
.unwrap();
let second_id = second["id"].as_str().unwrap().to_string(); let second_id = second["id"].as_str().unwrap().to_string();
let res = client() let res = client()
.post(app.url(&format!("/api/admin/points/orders/{second_id}/cancel"))) .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> { fn find<'a>(shops: &'a serde_json::Value, id: &str) -> Option<&'a serde_json::Value> {
shops shops.as_array().unwrap().iter().find(|s| s["id"] == id)
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == id)
} }
async fn set_profile( async fn set_profile(
@@ -45,7 +41,10 @@ async fn shop_without_a_profile_is_still_listed() {
let shops = list_shops(&app).await; let shops = list_shops(&app).await;
let shop = find(&shops, &shop_id).expect("a shop with no profile must still be listed"); let shop = find(&shops, &shop_id).expect("a shop with no profile must still be listed");
assert!(shop["name"]["en"].is_string()); 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()); 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 shop_id = create_shop(&app, &admin, "shop-suspended").await;
let shops = list_shops(&app).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() client()
.put(app.url(&format!("/api/admin/shops/{shop_id}/status"))) .put(app.url(&format!("/api/admin/shops/{shop_id}/status")))
@@ -107,7 +109,10 @@ async fn suspended_or_unknown_shops_are_not_public() {
.await .await
.unwrap(); .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() let res = client()
.get(app.url(&format!("/api/shops/{slug}"))) .get(app.url(&format!("/api/shops/{slug}")))
.send() .send()
@@ -120,7 +125,11 @@ async fn suspended_or_unknown_shops_are_not_public() {
.send() .send()
.await .await
.unwrap(); .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] #[tokio::test]
@@ -134,7 +143,10 @@ async fn incomplete_bilingual_text_is_refused_without_writing() {
"company": "Kept Co.", "company": "Kept Co.",
"notice": {"en": "Original notice", "zh": "原始公告"} "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!({ let bad = serde_json::json!({
"company": "Changed Co.", "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"); assert_eq!(res.status(), 400, "a label missing zh must be refused");
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone(); 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"], "原始公告"); assert_eq!(shop["notice"]["zh"], "原始公告");
} }
@@ -160,6 +175,10 @@ async fn profile_writes_require_a_platform_admin() {
let body = serde_json::json!({ "company": "Nope" }); let body = serde_json::json!({ "company": "Nope" });
for token in [&owner, &customer] { for token in [&owner, &customer] {
let res = set_profile(&app, token, &shop_id, body.clone()).await; 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"
);
} }
} }