feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable.
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app,
|
||||
TestApp,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Membership suite: levels have globally unique growth thresholds in a database
|
||||
// that is never truncated, so every test derives its thresholds from a per-run
|
||||
// random salt. Base currency is USD (exponent 2): an order of `threshold * 100`
|
||||
// minor units contributes exactly `threshold` whole growth units.
|
||||
|
||||
/// Random base well above any realistic fixture, unique per test.
|
||||
fn salt() -> i64 {
|
||||
100_000_000 + (Uuid::new_v4().as_u128() % 1_000_000) as i64
|
||||
}
|
||||
|
||||
/// Minor-unit price that accrues exactly `growth_units` growth value in USD.
|
||||
fn price_for(growth_units: i64) -> i64 {
|
||||
growth_units * 100
|
||||
}
|
||||
|
||||
/// member_levels is written only by these tests, so clearing it (and the
|
||||
/// derived `users.level`) before each test keeps threshold-order assertions
|
||||
/// deterministic in the shared, never-truncated test database.
|
||||
async fn reset_levels(app: &TestApp) {
|
||||
sqlx::query("UPDATE users SET level = NULL")
|
||||
.execute(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM member_levels")
|
||||
.execute(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct OrderFixture {
|
||||
customer: String,
|
||||
owner: String,
|
||||
shop_id: String,
|
||||
order_id: String,
|
||||
item_id: String,
|
||||
qty: i32,
|
||||
/// Set when the fixture shipped the order, so a retry can target it.
|
||||
ship_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn level(
|
||||
app: &TestApp,
|
||||
admin: &str,
|
||||
growth_threshold: i64,
|
||||
en: &str,
|
||||
zh: &str,
|
||||
) -> serde_json::Value {
|
||||
let res = client()
|
||||
.post(app.url("/api/admin/member-levels"))
|
||||
.bearer_auth(admin)
|
||||
.json(&serde_json::json!({
|
||||
"name": { "en": en, "zh": zh },
|
||||
"icon": "star",
|
||||
"growth_threshold": growth_threshold,
|
||||
"benefits": { "en": format!("{en} benefits"), "zh": format!("{zh}权益") }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "{:?}", res.text().await);
|
||||
res.json().await.unwrap()
|
||||
}
|
||||
|
||||
/// A paid order, optionally shipped + buyer-confirmed (completed).
|
||||
async fn order_fixture(
|
||||
app: &TestApp,
|
||||
label: &str,
|
||||
price_minor: i64,
|
||||
qty: i32,
|
||||
complete: bool,
|
||||
) -> OrderFixture {
|
||||
let admin = login_admin(app).await;
|
||||
let (owner, shop_id, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 50).await;
|
||||
let (customer, _) = register_customer(app, label).await;
|
||||
add_to_cart(app, &customer, &sku_id, qty).await;
|
||||
let orders = checkout(app, &customer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap().to_string();
|
||||
pay(app, &customer, &order_id).await;
|
||||
|
||||
let detail: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/shop/orders/{order_id}")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
|
||||
|
||||
let mut ship_id: Option<String> = None;
|
||||
if complete {
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "SF", "tracking_no": "T1",
|
||||
"items": [{ "order_item_id": item_id, "qty": qty }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "{:?}", res.text().await);
|
||||
let id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/shipments/{id}/ship")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
200
|
||||
);
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shipments/{id}/confirm-delivered")))
|
||||
.bearer_auth(&customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
ship_id = Some(id);
|
||||
}
|
||||
|
||||
OrderFixture {
|
||||
customer,
|
||||
owner,
|
||||
shop_id,
|
||||
order_id,
|
||||
item_id,
|
||||
qty,
|
||||
ship_id,
|
||||
}
|
||||
}
|
||||
|
||||
async fn membership(app: &TestApp, token: &str) -> serde_json::Value {
|
||||
let res = client()
|
||||
.get(app.url("/api/membership"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
res.json().await.unwrap()
|
||||
}
|
||||
|
||||
async fn growth_logs(app: &TestApp, token: &str, page: i64, per_page: i64) -> serde_json::Value {
|
||||
client()
|
||||
.get(app.url("/api/membership/growth-logs"))
|
||||
.query(&[
|
||||
("page", page.to_string()),
|
||||
("per_page", per_page.to_string()),
|
||||
])
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn level_catalog_crud_and_constraints() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
let base = salt();
|
||||
|
||||
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
|
||||
let silver = level(&app, &admin, base + 200, "Silver", "白银").await;
|
||||
assert_eq!(bronze["growth_threshold"], base);
|
||||
|
||||
// Thresholds order the list.
|
||||
let listed: Vec<serde_json::Value> = client()
|
||||
.get(app.url("/api/admin/member-levels"))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let thresholds: Vec<i64> = listed
|
||||
.iter()
|
||||
.filter(|l| l["id"] == bronze["id"] || l["id"] == silver["id"])
|
||||
.map(|l| l["growth_threshold"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(thresholds, vec![base, base + 200]);
|
||||
|
||||
// Duplicate threshold is a conflict, on create and on edit.
|
||||
let res = client()
|
||||
.post(app.url("/api/admin/member-levels"))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({
|
||||
"name": { "en": "Copy", "zh": "副本" }, "icon": "star",
|
||||
"growth_threshold": base, "benefits": { "en": "b", "zh": "b" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409, "{:?}", res.text().await);
|
||||
let res = client()
|
||||
.put(app.url(&format!(
|
||||
"/api/admin/member-levels/{}",
|
||||
silver["id"].as_str().unwrap()
|
||||
)))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({
|
||||
"name": { "en": "Silver", "zh": "白银" }, "icon": "star",
|
||||
"growth_threshold": base, "benefits": { "en": "b", "zh": "b" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409, "{:?}", res.text().await);
|
||||
|
||||
// Bilingual content is mandatory.
|
||||
let res = client()
|
||||
.post(app.url("/api/admin/member-levels"))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({
|
||||
"name": { "en": "Only English" }, "icon": "star",
|
||||
"growth_threshold": base + 900, "benefits": { "en": "b", "zh": "b" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400, "{:?}", res.text().await);
|
||||
|
||||
// Editing to a unique threshold persists.
|
||||
let res = client()
|
||||
.put(app.url(&format!(
|
||||
"/api/admin/member-levels/{}",
|
||||
silver["id"].as_str().unwrap()
|
||||
)))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({
|
||||
"name": { "en": "Silver", "zh": "白银" }, "icon": "medal",
|
||||
"growth_threshold": base + 250, "benefits": { "en": "b", "zh": "b" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
assert_eq!(
|
||||
res.json::<serde_json::Value>().await.unwrap()["growth_threshold"],
|
||||
base + 250
|
||||
);
|
||||
|
||||
// An unused level deletes; a customer-facing one is protected (next test).
|
||||
let res = client()
|
||||
.delete(app.url(&format!(
|
||||
"/api/admin/member-levels/{}",
|
||||
silver["id"].as_str().unwrap()
|
||||
)))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 204, "{:?}", res.text().await);
|
||||
|
||||
// Customers cannot manage levels.
|
||||
let (customer, _) = register_customer(&app, "mb-forbidden").await;
|
||||
assert_eq!(
|
||||
client()
|
||||
.get(app.url("/api/admin/member-levels"))
|
||||
.bearer_auth(&customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn completion_accrues_growth_and_upgrades_at_the_threshold() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
let base = salt();
|
||||
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
|
||||
|
||||
let f = order_fixture(&app, "mb-exact", price_for(base), 1, true).await;
|
||||
|
||||
let status = membership(&app, &f.customer).await;
|
||||
assert_eq!(status["growth_total"], base);
|
||||
assert_eq!(status["level"]["id"], bronze["id"]);
|
||||
assert_eq!(status["level"]["growth_threshold"], base);
|
||||
assert!(status["next_level"].is_null());
|
||||
|
||||
let logs = growth_logs(&app, &f.customer, 1, 20).await;
|
||||
assert_eq!(logs["total"], 1);
|
||||
let entry = &logs["items"][0];
|
||||
assert_eq!(entry["delta"], base);
|
||||
assert_eq!(entry["growth_total"], base);
|
||||
assert_eq!(entry["reason"], "order_complete");
|
||||
assert_eq!(entry["reference_type"], "order");
|
||||
assert_eq!(entry["reference_id"], f.order_id);
|
||||
|
||||
// A retried completion cannot accrue twice: the guarded delivery transition
|
||||
// rejects the repeat before the accrual hook can run.
|
||||
let ship_id = f.ship_id.as_deref().expect("fixture shipped the order");
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
|
||||
.bearer_auth(&f.customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409, "{:?}", res.text().await);
|
||||
assert_eq!(membership(&app, &f.customer).await["growth_total"], base);
|
||||
assert_eq!(growth_logs(&app, &f.customer, 1, 20).await["total"], 1);
|
||||
let _ = (f.item_id, f.qty, f.shop_id, f.owner);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn jumping_two_thresholds_lands_on_the_highest_level() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
let base = salt();
|
||||
let _bronze = level(&app, &admin, base, "Bronze", "青铜").await;
|
||||
let silver = level(&app, &admin, base + 200, "Silver", "白银").await;
|
||||
|
||||
// Growth clears both thresholds in one accrual.
|
||||
let f = order_fixture(&app, "mb-jump", price_for(base + 250), 1, true).await;
|
||||
let status = membership(&app, &f.customer).await;
|
||||
assert_eq!(status["growth_total"], base + 250);
|
||||
assert_eq!(status["level"]["id"], silver["id"]);
|
||||
assert_eq!(status["level"]["growth_threshold"], base + 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn below_every_threshold_holds_no_level_until_a_later_accrual_qualifies() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
let base = salt();
|
||||
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
|
||||
|
||||
// A sub-unit order truncates to zero growth, which is below every defined
|
||||
// threshold regardless of other fixtures in the shared database.
|
||||
let first = order_fixture(&app, "mb-below", 1, 1, true).await;
|
||||
let status = membership(&app, &first.customer).await;
|
||||
assert_eq!(status["growth_total"], 0);
|
||||
assert!(status["level"].is_null());
|
||||
assert_eq!(status["next_level"]["id"], bronze["id"]);
|
||||
assert_eq!(status["next_level"]["remaining"], base);
|
||||
|
||||
// The zero-value accrual is still one immutable ledger entry.
|
||||
let logs = growth_logs(&app, &first.customer, 1, 20).await;
|
||||
assert_eq!(logs["total"], 1);
|
||||
assert_eq!(logs["items"][0]["delta"], 0);
|
||||
|
||||
// A later qualifying accrual assigns the level.
|
||||
let (owner, _shop, _product, sku_id) =
|
||||
setup_sellable(&app, &admin, "mb-later", price_for(base), 50).await;
|
||||
add_to_cart(&app, &first.customer, &sku_id, 1).await;
|
||||
let orders = checkout(&app, &first.customer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap().to_string();
|
||||
pay(&app, &first.customer, &order_id).await;
|
||||
let detail: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/shop/orders/{order_id}")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "SF", "tracking_no": "T2",
|
||||
"items": [{ "order_item_id": item_id, "qty": 1 }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let ship_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/shipments/{ship_id}/ship")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
client()
|
||||
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
|
||||
.bearer_auth(&first.customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = membership(&app, &first.customer).await;
|
||||
assert_eq!(status["growth_total"], base);
|
||||
assert_eq!(status["level"]["id"], bronze["id"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn status_rederives_the_level_against_current_thresholds() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
let base = salt();
|
||||
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
|
||||
let silver = level(&app, &admin, base + 300, "Silver", "白银").await;
|
||||
let f = order_fixture(&app, "mb-rederive", price_for(base + 100), 1, true).await;
|
||||
assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]);
|
||||
|
||||
// Lowering a higher level's threshold under the customer's growth makes the
|
||||
// derived status move even though `users.level` was written at accrual time.
|
||||
let res = client()
|
||||
.put(app.url(&format!(
|
||||
"/api/admin/member-levels/{}",
|
||||
silver["id"].as_str().unwrap()
|
||||
)))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({
|
||||
"name": { "en": "Silver", "zh": "白银" }, "icon": "medal",
|
||||
"growth_threshold": base + 50, "benefits": { "en": "s", "zh": "s" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
|
||||
let status = membership(&app, &f.customer).await;
|
||||
assert_eq!(status["growth_total"], base + 100);
|
||||
assert_eq!(status["level"]["id"], silver["id"]);
|
||||
assert_eq!(status["level"]["growth_threshold"], base + 50);
|
||||
|
||||
// The stored level is the one accrual assigned; the read re-derives.
|
||||
let me: serde_json::Value = client()
|
||||
.get(app.url("/api/auth/me"))
|
||||
.bearer_auth(&f.customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let stored: Option<Uuid> = sqlx::query_scalar("SELECT level FROM users WHERE id = $1::uuid")
|
||||
.bind(me["id"].as_str().unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored,
|
||||
Some(Uuid::parse_str(bronze["id"].as_str().unwrap()).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn deleting_a_held_level_is_rejected() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
let base = salt();
|
||||
let bronze = level(&app, &admin, base, "Bronze", "青铜").await;
|
||||
let f = order_fixture(&app, "mb-held", price_for(base), 1, true).await;
|
||||
assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]);
|
||||
|
||||
let res = client()
|
||||
.delete(app.url(&format!(
|
||||
"/api/admin/member-levels/{}",
|
||||
bronze["id"].as_str().unwrap()
|
||||
)))
|
||||
.bearer_auth(&admin)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409, "{:?}", res.text().await);
|
||||
// The level and its member are untouched.
|
||||
assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn growth_history_is_own_only_and_paginated() {
|
||||
let app = spawn_app().await;
|
||||
reset_levels(&app).await;
|
||||
let admin = login_admin(&app).await;
|
||||
|
||||
// No levels needed: this only exercises the ledger and its scoping.
|
||||
let alice = order_fixture(&app, "mb-alice", 5000, 1, true).await;
|
||||
let bob = order_fixture(&app, "mb-bob", 5000, 1, true).await;
|
||||
|
||||
// A second completed order for Alice.
|
||||
let (owner, _shop, _product, sku_id) =
|
||||
setup_sellable(&app, &admin, "mb-alice-2", 5000, 50).await;
|
||||
add_to_cart(&app, &alice.customer, &sku_id, 1).await;
|
||||
let orders = checkout(&app, &alice.customer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap().to_string();
|
||||
pay(&app, &alice.customer, &order_id).await;
|
||||
let detail: serde_json::Value = client()
|
||||
.get(app.url(&format!("/api/shop/orders/{order_id}")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "SF", "tracking_no": "T3",
|
||||
"items": [{ "order_item_id": item_id, "qty": 1 }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let ship_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/shipments/{ship_id}/ship")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
client()
|
||||
.post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered")))
|
||||
.bearer_auth(&alice.customer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = growth_logs(&app, &alice.customer, 1, 1).await;
|
||||
assert_eq!(first["total"], 2);
|
||||
assert_eq!(first["items"].as_array().unwrap().len(), 1);
|
||||
let second = growth_logs(&app, &alice.customer, 2, 1).await;
|
||||
assert_eq!(second["items"].as_array().unwrap().len(), 1);
|
||||
assert_ne!(first["items"][0]["id"], second["items"][0]["id"]);
|
||||
|
||||
// Bob only sees his own single entry.
|
||||
let bob_logs = growth_logs(&app, &bob.customer, 1, 20).await;
|
||||
assert_eq!(bob_logs["total"], 1);
|
||||
let alice_ids: Vec<&str> = first["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.chain(second["items"].as_array().unwrap())
|
||||
.map(|e| e["id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(!alice_ids.contains(&bob_logs["items"][0]["id"].as_str().unwrap()));
|
||||
}
|
||||
Reference in New Issue
Block a user