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
195 lines
6.3 KiB
Rust
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");
|
|
}
|