feat: persist customer product and shop favorites through the live API

Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:51:45 +08:00
co-authored by Cursor
parent 94a64ec712
commit 6c1357ec4d
34 changed files with 1783 additions and 119 deletions
+25
View File
@@ -0,0 +1,25 @@
-- Customer favorites: one row per (user, product) or (user, shop).
-- Two nullable FKs plus a check keep the target shape honest; partial unique
-- indexes enforce uniqueness without letting NULL shop/product repeat.
CREATE TABLE favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
product_id UUID REFERENCES products (id) ON DELETE CASCADE,
shop_id UUID REFERENCES shops (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT favorites_exactly_one_target CHECK (
(product_id IS NOT NULL AND shop_id IS NULL)
OR (product_id IS NULL AND shop_id IS NOT NULL)
)
);
CREATE INDEX favorites_user_created_idx ON favorites (user_id, created_at DESC);
CREATE UNIQUE INDEX favorites_user_product_idx
ON favorites (user_id, product_id)
WHERE product_id IS NOT NULL;
CREATE UNIQUE INDEX favorites_user_shop_idx
ON favorites (user_id, shop_id)
WHERE shop_id IS NOT NULL;
+116
View File
@@ -0,0 +1,116 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use uuid::Uuid;
use crate::modules::shop::service::ShopProfileView;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FavoriteProductSummary {
pub id: Uuid,
pub shop_id: Uuid,
pub slug: String,
pub name: Value,
pub image: Option<String>,
pub price_minor: Option<i64>,
pub currency: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind")]
pub enum Favorite {
#[serde(rename = "product")]
Product {
id: Uuid,
user_id: Uuid,
created_at: DateTime<Utc>,
product: FavoriteProductSummary,
},
#[serde(rename = "shop")]
Shop {
id: Uuid,
user_id: Uuid,
created_at: DateTime<Utc>,
shop: ShopProfileView,
},
}
#[derive(Debug, sqlx::FromRow)]
pub struct ProductFavoriteRow {
pub id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub product_id: Uuid,
pub shop_id: Uuid,
pub slug: String,
pub name: Value,
pub image: Option<String>,
pub price_minor: Option<i64>,
pub currency: Option<String>,
}
impl ProductFavoriteRow {
pub fn into_favorite(self) -> Favorite {
Favorite::Product {
id: self.id,
user_id: self.user_id,
created_at: self.created_at,
product: FavoriteProductSummary {
id: self.product_id,
shop_id: self.shop_id,
slug: self.slug,
name: self.name,
image: self.image,
price_minor: self.price_minor,
currency: self.currency,
},
}
}
}
#[derive(Debug, sqlx::FromRow)]
pub struct ShopFavoriteRow {
pub id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub shop_id: Uuid,
pub slug: String,
pub name: Value,
pub company: Option<String>,
pub region: Option<String>,
pub address: Option<Value>,
pub logo: Option<String>,
pub banner: Option<String>,
pub notice: Option<Value>,
pub after_sale: Option<Value>,
pub score_rating: Option<f64>,
pub score_agreement: Option<f64>,
pub score_service: Option<f64>,
pub score_speed: Option<f64>,
}
impl ShopFavoriteRow {
pub fn into_favorite(self) -> Favorite {
Favorite::Shop {
id: self.id,
user_id: self.user_id,
created_at: self.created_at,
shop: ShopProfileView {
id: self.shop_id,
slug: self.slug,
name: self.name,
company: self.company,
region: self.region,
address: self.address,
logo: self.logo,
banner: self.banner,
notice: self.notice,
after_sale: self.after_sale,
score_rating: self.score_rating,
score_agreement: self.score_agreement,
score_service: self.score_service,
score_speed: self.score_speed,
},
}
}
}
+88
View File
@@ -0,0 +1,88 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, put},
Json, Router,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::http::Paged;
use crate::state::AppState;
use super::dto::Favorite;
use super::service;
#[derive(Debug, Deserialize)]
struct FavoriteListQuery {
kind: String,
target_id: Option<Uuid>,
page: Option<i64>,
per_page: Option<i64>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/favorites", get(list_favorites))
.route(
"/favorites/products/{product_id}",
put(add_product).delete(remove_product),
)
.route(
"/favorites/shops/{shop_id}",
put(add_shop).delete(remove_shop),
)
}
async fn list_favorites(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<FavoriteListQuery>,
) -> ApiResult<Json<Paged<Favorite>>> {
auth.require_customer()?;
Ok(Json(
service::list(&state, auth.id, &q.kind, q.target_id, q.page, q.per_page).await?,
))
}
async fn add_product(
State(state): State<AppState>,
auth: AuthUser,
Path(product_id): Path<Uuid>,
) -> ApiResult<Json<Favorite>> {
auth.require_customer()?;
Ok(Json(
service::add_product(&state, auth.id, product_id).await?,
))
}
async fn remove_product(
State(state): State<AppState>,
auth: AuthUser,
Path(product_id): Path<Uuid>,
) -> ApiResult<StatusCode> {
auth.require_customer()?;
service::remove_product(&state, auth.id, product_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn add_shop(
State(state): State<AppState>,
auth: AuthUser,
Path(shop_id): Path<Uuid>,
) -> ApiResult<Json<Favorite>> {
auth.require_customer()?;
Ok(Json(service::add_shop(&state, auth.id, shop_id).await?))
}
async fn remove_shop(
State(state): State<AppState>,
auth: AuthUser,
Path(shop_id): Path<Uuid>,
) -> ApiResult<StatusCode> {
auth.require_customer()?;
service::remove_shop(&state, auth.id, shop_id).await?;
Ok(StatusCode::NO_CONTENT)
}
+12
View File
@@ -0,0 +1,12 @@
mod dto;
mod handlers;
mod repo;
pub mod service;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
handlers::router()
}
+243
View File
@@ -0,0 +1,243 @@
use sqlx::{PgConnection, PgPool};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use super::dto::{Favorite, ProductFavoriteRow, ShopFavoriteRow};
const PRODUCT_VISIBLE: &str = "p.status = 'published' AND sh.status = 'active'";
const PRODUCT_FROM: &str = "FROM favorites f
JOIN products p ON p.id = f.product_id
JOIN shops sh ON sh.id = p.shop_id
LEFT JOIN LATERAL (
SELECT s.price_minor, s.currency
FROM skus s
WHERE s.product_id = p.id AND s.active = TRUE
ORDER BY s.price_minor ASC, s.sku_code ASC
LIMIT 1
) sku ON TRUE";
const PRODUCT_SELECT: &str = "SELECT f.id, f.user_id, f.created_at,
p.id AS product_id, p.shop_id, p.slug, p.name,
CASE WHEN jsonb_typeof(p.images) = 'array' AND jsonb_array_length(p.images) > 0
THEN p.images->>0 ELSE NULL END AS image,
sku.price_minor, sku.currency";
const SHOP_FROM: &str = "FROM favorites f
JOIN shops s ON s.id = f.shop_id
LEFT JOIN shop_profiles p ON p.shop_id = s.id";
const SHOP_SELECT: &str = "SELECT f.id, f.user_id, f.created_at,
s.id AS shop_id, s.slug, s.name,
p.company, p.region, p.address, p.logo, p.banner, p.notice, p.after_sale,
p.score_rating, p.score_agreement, p.score_service, p.score_speed";
pub async fn lock_visible_product(tx: &mut PgConnection, product_id: Uuid) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM products p
JOIN shops sh ON sh.id = p.shop_id
WHERE p.id = $1 AND p.status = 'published' AND sh.status = 'active'
FOR SHARE OF p, sh
)",
)
.bind(product_id)
.fetch_one(&mut *tx)
.await?)
}
pub async fn lock_visible_shop(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM shops
WHERE id = $1 AND status = 'active'
FOR SHARE
)",
)
.bind(shop_id)
.fetch_one(&mut *tx)
.await?)
}
pub async fn upsert_product(
tx: &mut PgConnection,
user_id: Uuid,
product_id: Uuid,
) -> ApiResult<Uuid> {
let inserted: Option<Uuid> = sqlx::query_scalar(
"INSERT INTO favorites (user_id, product_id)
VALUES ($1, $2)
ON CONFLICT (user_id, product_id) WHERE product_id IS NOT NULL
DO NOTHING
RETURNING id",
)
.bind(user_id)
.bind(product_id)
.fetch_optional(&mut *tx)
.await?;
if let Some(id) = inserted {
return Ok(id);
}
sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND product_id = $2")
.bind(user_id)
.bind(product_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))
}
pub async fn upsert_shop(tx: &mut PgConnection, user_id: Uuid, shop_id: Uuid) -> ApiResult<Uuid> {
let inserted: Option<Uuid> = sqlx::query_scalar(
"INSERT INTO favorites (user_id, shop_id)
VALUES ($1, $2)
ON CONFLICT (user_id, shop_id) WHERE shop_id IS NOT NULL
DO NOTHING
RETURNING id",
)
.bind(user_id)
.bind(shop_id)
.fetch_optional(&mut *tx)
.await?;
if let Some(id) = inserted {
return Ok(id);
}
sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND shop_id = $2")
.bind(user_id)
.bind(shop_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))
}
pub async fn delete_product(db: &PgPool, user_id: Uuid, product_id: Uuid) -> ApiResult<()> {
sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND product_id = $2")
.bind(user_id)
.bind(product_id)
.execute(db)
.await?;
Ok(())
}
pub async fn delete_shop(db: &PgPool, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> {
sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND shop_id = $2")
.bind(user_id)
.bind(shop_id)
.execute(db)
.await?;
Ok(())
}
pub async fn count_products(db: &PgPool, user_id: Uuid, target_id: Option<Uuid>) -> ApiResult<i64> {
Ok(sqlx::query_scalar(&format!(
"SELECT count(*) {PRODUCT_FROM}
WHERE f.user_id = $1 AND f.product_id IS NOT NULL
AND {PRODUCT_VISIBLE}
AND ($2::uuid IS NULL OR f.product_id = $2)"
))
.bind(user_id)
.bind(target_id)
.fetch_one(db)
.await?)
}
pub async fn count_shops(db: &PgPool, user_id: Uuid, target_id: Option<Uuid>) -> ApiResult<i64> {
Ok(sqlx::query_scalar(&format!(
"SELECT count(*) {SHOP_FROM}
WHERE f.user_id = $1 AND f.shop_id IS NOT NULL
AND s.status = 'active'
AND ($2::uuid IS NULL OR f.shop_id = $2)"
))
.bind(user_id)
.bind(target_id)
.fetch_one(db)
.await?)
}
pub async fn list_products(
db: &PgPool,
user_id: Uuid,
target_id: Option<Uuid>,
limit: i64,
offset: i64,
) -> ApiResult<Vec<Favorite>> {
let rows = sqlx::query_as::<_, ProductFavoriteRow>(&format!(
"{PRODUCT_SELECT} {PRODUCT_FROM}
WHERE f.user_id = $1 AND f.product_id IS NOT NULL
AND {PRODUCT_VISIBLE}
AND ($2::uuid IS NULL OR f.product_id = $2)
ORDER BY f.created_at DESC
LIMIT $3 OFFSET $4"
))
.bind(user_id)
.bind(target_id)
.bind(limit)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(ProductFavoriteRow::into_favorite)
.collect())
}
pub async fn list_shops(
db: &PgPool,
user_id: Uuid,
target_id: Option<Uuid>,
limit: i64,
offset: i64,
) -> ApiResult<Vec<Favorite>> {
let rows = sqlx::query_as::<_, ShopFavoriteRow>(&format!(
"{SHOP_SELECT} {SHOP_FROM}
WHERE f.user_id = $1 AND f.shop_id IS NOT NULL
AND s.status = 'active'
AND ($2::uuid IS NULL OR f.shop_id = $2)
ORDER BY f.created_at DESC
LIMIT $3 OFFSET $4"
))
.bind(user_id)
.bind(target_id)
.bind(limit)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(ShopFavoriteRow::into_favorite)
.collect())
}
pub async fn get_product_favorite(
tx: &mut PgConnection,
user_id: Uuid,
product_id: Uuid,
) -> ApiResult<Favorite> {
let row = sqlx::query_as::<_, ProductFavoriteRow>(&format!(
"{PRODUCT_SELECT} {PRODUCT_FROM}
WHERE f.user_id = $1 AND f.product_id = $2 AND {PRODUCT_VISIBLE}"
))
.bind(user_id)
.bind(product_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))?;
Ok(row.into_favorite())
}
pub async fn get_shop_favorite(
tx: &mut PgConnection,
user_id: Uuid,
shop_id: Uuid,
) -> ApiResult<Favorite> {
let row = sqlx::query_as::<_, ShopFavoriteRow>(&format!(
"{SHOP_SELECT} {SHOP_FROM}
WHERE f.user_id = $1 AND f.shop_id = $2 AND s.status = 'active'"
))
.bind(user_id)
.bind(shop_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("favorite".into()))?;
Ok(row.into_favorite())
}
+72
View File
@@ -0,0 +1,72 @@
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::state::AppState;
use super::dto::Favorite;
use super::repo;
pub async fn add_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<Favorite> {
let mut tx = state.db.begin().await?;
if !repo::lock_visible_product(&mut tx, product_id).await? {
return Err(ApiError::NotFound("product".into()));
}
repo::upsert_product(&mut tx, user_id, product_id).await?;
let favorite = repo::get_product_favorite(&mut tx, user_id, product_id).await?;
tx.commit().await?;
Ok(favorite)
}
pub async fn add_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<Favorite> {
let mut tx = state.db.begin().await?;
if !repo::lock_visible_shop(&mut tx, shop_id).await? {
return Err(ApiError::NotFound("shop".into()));
}
repo::upsert_shop(&mut tx, user_id, shop_id).await?;
let favorite = repo::get_shop_favorite(&mut tx, user_id, shop_id).await?;
tx.commit().await?;
Ok(favorite)
}
pub async fn remove_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<()> {
repo::delete_product(&state.db, user_id, product_id).await
}
pub async fn remove_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> {
repo::delete_shop(&state.db, user_id, shop_id).await
}
pub async fn list(
state: &AppState,
user_id: Uuid,
kind: &str,
target_id: Option<Uuid>,
page: Option<i64>,
per_page: Option<i64>,
) -> ApiResult<Paged<Favorite>> {
let page = clamp_page(page);
let per_page = clamp_per_page(per_page);
let offset = (page - 1) * per_page;
let (total, items) = match kind {
"product" => (
repo::count_products(&state.db, user_id, target_id).await?,
repo::list_products(&state.db, user_id, target_id, per_page, offset).await?,
),
"shop" => (
repo::count_shops(&state.db, user_id, target_id).await?,
repo::list_shops(&state.db, user_id, target_id, per_page, offset).await?,
),
other => {
return Err(ApiError::BadRequest(format!(
"kind must be product or shop, got {other}"
)))
}
};
Ok(Paged {
items,
total,
page,
per_page,
})
}
+2
View File
@@ -6,6 +6,7 @@ pub mod catalog;
pub mod content;
pub mod coupon;
pub mod currency;
pub mod favorite;
pub mod flash_sale;
pub mod fulfillment;
pub mod group_buying;
@@ -30,6 +31,7 @@ pub fn api_router() -> Router<AppState> {
.merge(content::router())
.merge(cart::router())
.merge(coupon::router())
.merge(favorite::router())
.merge(flash_sale::router())
.merge(group_buying::router())
.merge(order::router())
+464
View File
@@ -0,0 +1,464 @@
mod common;
use common::{
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, publish_product,
register_customer, spawn_app,
};
use serial_test::serial;
use std::time::Duration;
use uuid::Uuid;
async fn put_product(app: &common::TestApp, token: &str, product_id: &str) -> reqwest::Response {
client()
.put(app.url(&format!("/api/favorites/products/{product_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn put_shop(app: &common::TestApp, token: &str, shop_id: &str) -> reqwest::Response {
client()
.put(app.url(&format!("/api/favorites/shops/{shop_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn delete_product(app: &common::TestApp, token: &str, product_id: &str) -> reqwest::Response {
client()
.delete(app.url(&format!("/api/favorites/products/{product_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn delete_shop(app: &common::TestApp, token: &str, shop_id: &str) -> reqwest::Response {
client()
.delete(app.url(&format!("/api/favorites/shops/{shop_id}")))
.bearer_auth(token)
.send()
.await
.unwrap()
}
async fn list(
app: &common::TestApp,
token: &str,
kind: &str,
target_id: Option<&str>,
page: Option<i64>,
per_page: Option<i64>,
) -> serde_json::Value {
let mut req = client()
.get(app.url("/api/favorites"))
.query(&[("kind", kind)]);
if let Some(id) = target_id {
req = req.query(&[("target_id", id)]);
}
if let Some(p) = page {
req = req.query(&[("page", p)]);
}
if let Some(n) = per_page {
req = req.query(&[("per_page", n)]);
}
let res = req.bearer_auth(token).send().await.unwrap();
assert_eq!(res.status(), 200, "list: {:?}", res.text().await);
res.json().await.unwrap()
}
async fn sellable(
app: &common::TestApp,
admin: &str,
slug: &str,
price: i64,
) -> (String, String, String) {
let shop_id = create_shop(app, admin, slug).await;
let owner = make_shop_owner(app, admin, &shop_id).await;
let (product_id, _) = create_product_with_sku(app, &owner, slug, price, 10).await;
publish_product(app, &owner, &product_id).await;
(owner, shop_id, product_id)
}
#[tokio::test]
#[serial]
async fn check_constraint_rejects_invalid_target_shape() {
let app = spawn_app().await;
let (_token, user_id) = register_customer(&app, "fav-shape").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-shape", 1000).await;
let both = sqlx::query(
"INSERT INTO favorites (user_id, product_id, shop_id)
VALUES ($1::uuid, $2::uuid, $3::uuid)",
)
.bind(&user_id)
.bind(&product_id)
.bind(&shop_id)
.execute(&app.db)
.await;
assert!(both.is_err(), "both targets must be rejected");
let neither = sqlx::query("INSERT INTO favorites (user_id) VALUES ($1::uuid)")
.bind(&user_id)
.execute(&app.db)
.await;
assert!(neither.is_err(), "neither target must be rejected");
}
#[tokio::test]
#[serial]
async fn uniqueness_is_per_customer_and_target() {
let app = spawn_app().await;
let (_token, user_id) = register_customer(&app, "fav-uniq").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-uniq", 1000).await;
sqlx::query("INSERT INTO favorites (user_id, product_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&product_id)
.execute(&app.db)
.await
.unwrap();
let dup_product =
sqlx::query("INSERT INTO favorites (user_id, product_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&product_id)
.execute(&app.db)
.await;
assert!(dup_product.is_err());
sqlx::query("INSERT INTO favorites (user_id, shop_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&shop_id)
.execute(&app.db)
.await
.unwrap();
let dup_shop =
sqlx::query("INSERT INTO favorites (user_id, shop_id) VALUES ($1::uuid, $2::uuid)")
.bind(&user_id)
.bind(&shop_id)
.execute(&app.db)
.await;
assert!(dup_shop.is_err());
}
#[tokio::test]
#[serial]
async fn ownership_filters_list_and_remove() {
let app = spawn_app().await;
let (alice, _) = register_customer(&app, "fav-alice").await;
let (bob, _) = register_customer(&app, "fav-bob").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-own", 2500).await;
assert_eq!(put_product(&app, &alice, &product_id).await.status(), 200);
assert_eq!(put_shop(&app, &alice, &shop_id).await.status(), 200);
let bob_products = list(&app, &bob, "product", None, None, None).await;
assert_eq!(bob_products["total"], 0);
assert_eq!(bob_products["items"].as_array().unwrap().len(), 0);
assert_eq!(delete_product(&app, &bob, &product_id).await.status(), 204);
let alice_products = list(&app, &alice, "product", None, None, None).await;
assert_eq!(alice_products["total"], 1);
assert_eq!(alice_products["items"][0]["product"]["id"], product_id);
}
#[tokio::test]
#[serial]
async fn missing_or_unavailable_targets_are_not_found_on_add() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-miss").await;
let admin = login_admin(&app).await;
let (owner, shop_id, product_id) = sellable(&app, &admin, "fav-miss", 1000).await;
let missing = Uuid::new_v4();
assert_eq!(
put_product(&app, &token, &missing.to_string())
.await
.status(),
404
);
assert_eq!(
put_shop(&app, &token, &missing.to_string()).await.status(),
404
);
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/unpublish")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(put_product(&app, &token, &product_id).await.status(), 404);
let res = client()
.put(app.url(&format!("/api/admin/shops/{shop_id}/status")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "status": "suspended" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 404);
}
#[tokio::test]
#[serial]
async fn repeated_add_and_remove_are_idempotent() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-idem").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, product_id) = sellable(&app, &admin, "fav-idem", 1800).await;
let first = put_product(&app, &token, &product_id).await;
assert_eq!(first.status(), 200);
let first_id = first.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let second = put_product(&app, &token, &product_id).await;
assert_eq!(second.status(), 200);
let second_body: serde_json::Value = second.json().await.unwrap();
assert_eq!(second_body["id"], first_id);
assert_eq!(
list(&app, &token, "product", None, None, None).await["total"],
1
);
assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_id).await.status(), 200);
assert_eq!(
list(&app, &token, "shop", None, None, None).await["total"],
1
);
assert_eq!(delete_shop(&app, &token, &shop_id).await.status(), 204);
assert_eq!(delete_shop(&app, &token, &shop_id).await.status(), 204);
assert_eq!(
list(&app, &token, "shop", None, None, None).await["total"],
0
);
assert_eq!(
delete_product(&app, &token, &product_id).await.status(),
204
);
}
#[tokio::test]
#[serial]
async fn listing_hydrates_filters_and_paginates_visible_targets() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-list").await;
let admin = login_admin(&app).await;
let (owner, shop_a, product_a) = sellable(&app, &admin, "fav-lista", 500).await;
let (_owner_b, shop_b, product_b) = sellable(&app, &admin, "fav-listb", 1500).await;
let (_owner_c, _shop_c, product_c) = sellable(&app, &admin, "fav-listc", 900).await;
assert_eq!(put_product(&app, &token, &product_a).await.status(), 200);
assert_eq!(put_product(&app, &token, &product_b).await.status(), 200);
assert_eq!(put_product(&app, &token, &product_c).await.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_a).await.status(), 200);
assert_eq!(put_shop(&app, &token, &shop_b).await.status(), 200);
let products = list(&app, &token, "product", None, None, None).await;
assert_eq!(products["total"], 3);
let items = products["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0]["kind"], "product");
assert!(items
.iter()
.any(|row| { row["product"]["id"] == product_b && row["product"]["price_minor"] == 1500 }));
let filtered = list(&app, &token, "product", Some(&product_a), None, None).await;
assert_eq!(filtered["total"], 1);
assert_eq!(filtered["items"][0]["product"]["id"], product_a);
let shops = list(&app, &token, "shop", None, None, None).await;
assert_eq!(shops["total"], 2);
assert_eq!(shops["items"][0]["kind"], "shop");
let shop_filter = list(&app, &token, "shop", Some(&shop_b), None, None).await;
assert_eq!(shop_filter["total"], 1);
assert_eq!(shop_filter["items"][0]["shop"]["id"], shop_b);
let page1 = list(&app, &token, "product", None, Some(1), Some(1)).await;
let page2 = list(&app, &token, "product", None, Some(2), Some(1)).await;
assert_eq!(page1["total"], 3);
assert_eq!(page1["items"].as_array().unwrap().len(), 1);
assert_eq!(page2["items"].as_array().unwrap().len(), 1);
assert_ne!(page1["items"][0]["id"], page2["items"][0]["id"]);
let res = client()
.post(app.url(&format!("/api/shop/products/{product_a}/unpublish")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let after = list(&app, &token, "product", None, None, None).await;
assert_eq!(after["total"], 2);
assert!(after["items"]
.as_array()
.unwrap()
.iter()
.all(|row| row["product"]["id"] != product_a));
let res = client()
.put(app.url(&format!("/api/admin/shops/{shop_a}/status")))
.bearer_auth(&admin)
.json(&serde_json::json!({ "status": "suspended" }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let shops_after = list(&app, &token, "shop", None, None, None).await;
assert_eq!(shops_after["total"], 1);
assert_eq!(shops_after["items"][0]["shop"]["id"], shop_b);
}
#[tokio::test]
#[serial]
async fn non_customers_cannot_use_favorite_routes() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let res = client()
.get(app.url("/api/favorites"))
.query(&[("kind", "product")])
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 403);
}
#[tokio::test]
#[serial]
async fn add_holds_target_visible_until_favorite_commits() {
let app = spawn_app().await;
let (token, _) = register_customer(&app, "fav-atomic").await;
let admin = login_admin(&app).await;
let (_owner, shop_id, _product_id) = sellable(&app, &admin, "fav-atomic", 1000).await;
sqlx::query("DROP TRIGGER IF EXISTS favorites_atomic_delay ON favorites")
.execute(&app.db)
.await
.unwrap();
sqlx::query("DROP FUNCTION IF EXISTS favorites_atomic_delay()")
.execute(&app.db)
.await
.unwrap();
sqlx::query(
"CREATE FUNCTION favorites_atomic_delay() RETURNS trigger AS $$
BEGIN
PERFORM pg_advisory_xact_lock(915001);
RETURN NEW;
END
$$ LANGUAGE plpgsql",
)
.execute(&app.db)
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER favorites_atomic_delay BEFORE INSERT ON favorites
FOR EACH ROW EXECUTE FUNCTION favorites_atomic_delay()",
)
.execute(&app.db)
.await
.unwrap();
let mut lock_conn = app.db.acquire().await.unwrap();
sqlx::query("SELECT pg_advisory_lock(915001)")
.execute(&mut *lock_conn)
.await
.unwrap();
let base = app.base.clone();
let add_token = token.clone();
let add_shop_id = shop_id.clone();
let add = tokio::spawn(async move {
client()
.put(format!("{base}/api/favorites/shops/{add_shop_id}"))
.bearer_auth(add_token)
.send()
.await
.unwrap()
});
let insert_waiting = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let waiting: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND query LIKE 'INSERT INTO favorites%'
)",
)
.fetch_one(&app.db)
.await
.unwrap();
if waiting {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.is_ok();
let suspend_base = app.base.clone();
let suspend_admin = admin.clone();
let suspend_shop_id = shop_id.clone();
let mut suspend = tokio::spawn(async move {
client()
.put(format!(
"{suspend_base}/api/admin/shops/{suspend_shop_id}/status"
))
.bearer_auth(suspend_admin)
.json(&serde_json::json!({ "status": "suspended" }))
.send()
.await
.unwrap()
});
let status_changed_before_insert =
tokio::time::timeout(Duration::from_millis(150), &mut suspend)
.await
.is_ok();
sqlx::query("SELECT pg_advisory_unlock(915001)")
.execute(&mut *lock_conn)
.await
.unwrap();
let add_response = add.await.unwrap();
let suspend_response = if status_changed_before_insert {
None
} else {
Some(suspend.await.unwrap())
};
sqlx::query("DROP TRIGGER favorites_atomic_delay ON favorites")
.execute(&app.db)
.await
.unwrap();
sqlx::query("DROP FUNCTION favorites_atomic_delay()")
.execute(&app.db)
.await
.unwrap();
assert!(insert_waiting, "favorite insert never reached the trigger");
assert!(
!status_changed_before_insert,
"shop status changed while favorite creation was in flight"
);
assert_eq!(
add_response.status(),
200,
"add: {:?}",
add_response.text().await
);
assert_eq!(suspend_response.unwrap().status(), 200);
}