Files
vmall/apps/api/tests/content.rs
T
james 104737e4e1 feat(mall): serve home marketing content from the API
Wave 4 of replacing the fixed-data mock adapter, and the first capability the
mall never had a backend for: the home page's banners, promo tiles, quick links
and floor advert art move out of local arrays.

- four explicit tables (`banners`, `promos`, `quick_links`, `floor_adverts`)
  rather than one JSONB payload table, so Postgres enforces each shape
- a migration seeds them from the assets the page already rendered, so the flip
  is visually a no-op. Destinations are real routes now: the mock's promo links
  pointed at dangling `?category=c1` ids and its first banner used `sort=sales`,
  which the catalog API rejects
- `GET /api/content/home` is public and returns the four active, ordered lists,
  always including a key so a page can render a missing block
- `GET /api/admin/content` and `PUT /api/admin/content/{kind}` let a platform
  admin read everything and replace one kind transactionally, with positions
  reindexed from the submitted order and a rejected list changing nothing
- the mall's fixed-data adapter learns `getHomeContent`, and a `content` domain
  joins the per-domain switch so the rollback path still renders the page

Verified: 23 backend tests green including six new content tests; all three
frontends build; the home page renders the same four blocks as before, an admin
reorder and deactivation change the rendered carousel, and the fixed-data
rollback renders every block with the backend stopped.

OpenSpec change: openspec/changes/replace-mock-api-wave-4
2026-09-17 16:48:10 +00:00

195 lines
6.3 KiB
Rust

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<i64> = 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<String> {
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");
}