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.
575 lines
19 KiB
Rust
575 lines
19 KiB
Rust
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()));
|
|
}
|