chore(api): apply rustfmt across modules and tests
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6c1357ec4d
commit
a4ef6ca49e
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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?))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user