mod common; use common::{client, create_shop, login_admin, make_shop_owner, register_customer, spawn_app}; use serial_test::serial; /// The suite shares one database and `replace_content` rewrites a kind, so every /// test here writes the state it asserts instead of relying on the seed, and /// none of them submits an empty list. async fn replace( app: &common::TestApp, token: &str, kind: &str, items: serde_json::Value, ) -> reqwest::Response { client() .put(app.url(&format!("/api/admin/content/{kind}"))) .bearer_auth(token) .json(&items) .send() .await .unwrap() } async fn public_content(app: &common::TestApp) -> serde_json::Value { let res = client() .get(app.url("/api/content/home")) .send() .await .unwrap(); assert_eq!(res.status(), 200, "public content read must be unauthenticated"); res.json().await.unwrap() } #[tokio::test] #[serial] async fn home_content_is_public_and_ordered() { let app = spawn_app().await; let content = public_content(&app).await; for kind in ["banners", "promos", "quick_links", "floor_adverts"] { assert!( content[kind].is_array(), "{kind} must always be present, even when empty" ); } // No test in this file empties a kind, so these stay populated. for kind in ["banners", "promos", "quick_links"] { assert!( !content[kind].as_array().unwrap().is_empty(), "{kind} should carry content" ); } let positions: Vec = content["banners"] .as_array() .unwrap() .iter() .map(|b| b["position"].as_i64().unwrap()) .collect(); let mut sorted = positions.clone(); sorted.sort_unstable(); assert_eq!(positions, sorted, "content must come back in position order"); } #[tokio::test] #[serial] async fn admin_replace_round_trips_and_reorders() { let app = spawn_app().await; let admin = login_admin(&app).await; let first = serde_json::json!([ {"image": "/mock/a.svg", "url": "/seckill"}, {"image": "/mock/b.svg", "url": "/collective"} ]); let res = replace(&app, &admin, "banners", first).await; assert_eq!(res.status(), 200, "{:?}", res.text().await); let images = |v: &serde_json::Value| -> Vec { v["banners"] .as_array() .unwrap() .iter() .map(|b| b["image"].as_str().unwrap().to_string()) .collect() }; 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); let content = public_content(&app).await; assert_eq!(images(&content), vec!["/mock/b.svg", "/mock/a.svg"]); assert_eq!(content["banners"][0]["position"], 0); assert_eq!(content["banners"][1]["position"], 1); } #[tokio::test] #[serial] async fn inactive_rows_are_hidden_from_the_public_read() { let app = spawn_app().await; let admin = login_admin(&app).await; let items = serde_json::json!([ {"image": "/mock/on.svg", "url": "/seckill"}, {"image": "/mock/off.svg", "url": "/collective", "active": false} ]); assert_eq!(replace(&app, &admin, "banners", items).await.status(), 200); let public = public_content(&app).await; let visible: Vec<&str> = public["banners"] .as_array() .unwrap() .iter() .map(|b| b["image"].as_str().unwrap()) .collect(); 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() .get(app.url("/api/admin/content")) .bearer_auth(&admin) .send() .await .unwrap(); let all: serde_json::Value = res.json().await.unwrap(); assert_eq!(all["banners"].as_array().unwrap().len(), 2); } #[tokio::test] #[serial] async fn invalid_entry_is_rejected_without_touching_stored_content() { let app = spawn_app().await; let admin = login_admin(&app).await; let good = serde_json::json!([{"image": "/mock/keep.svg", "url": "/seckill"}]); assert_eq!(replace(&app, &admin, "banners", good).await.status(), 200); // Second entry is missing its image. let bad = serde_json::json!([ {"image": "/mock/ok.svg", "url": "/seckill"}, {"url": "/collective"} ]); let res = replace(&app, &admin, "banners", bad).await; assert_eq!(res.status(), 400); let content = public_content(&app).await; let images: Vec<&str> = content["banners"] .as_array() .unwrap() .iter() .map(|b| b["image"].as_str().unwrap()) .collect(); assert_eq!(images, vec!["/mock/keep.svg"], "a rejected list must change nothing"); } #[tokio::test] #[serial] async fn quick_link_labels_must_be_bilingual() { let app = spawn_app().await; let admin = login_admin(&app).await; let one_sided = serde_json::json!([ {"label": {"en": "Only English"}, "url": "/user", "glyph": "M12 2l8 4v6z"} ]); let res = replace(&app, &admin, "quick-links", one_sided).await; assert_eq!(res.status(), 400, "a label missing zh must be refused"); } #[tokio::test] #[serial] async fn content_writes_require_a_platform_admin() { let app = spawn_app().await; let admin = login_admin(&app).await; let (customer, _) = register_customer(&app, "content-cust").await; let shop_id = create_shop(&app, &admin, "shop-content").await; let owner = make_shop_owner(&app, &admin, &shop_id).await; let items = serde_json::json!([{"image": "/mock/x.svg", "url": "/seckill"}]); for token in [&customer, &owner] { let res = replace(&app, token, "banners", items.clone()).await; assert_eq!(res.status(), 403, "only platform admins may write content"); } let res = client() .put(app.url("/api/admin/content/not-a-kind")) .bearer_auth(&admin) .json(&items) .send() .await .unwrap(); assert_eq!(res.status(), 400, "an unknown kind is a client error"); }