fix(market): /trade/status must resolve the unlisted ids /tradePile advertises
Explains and fixes the Phase C partial failure WITHOUT changing a single wire field. The operator saw a difference between the one-item probe (Time Remaining "-") and the generalized rows (Time Remaining "Expired"). Cause: route coverage, not encoding. /tradePile advertised the unlisted tradeIds while ISVIEWTRADE (GET .../trade/status) resolved ids from the market store only -- and an unlisted pile member has no listing row, so the poll returned an empty auctionInfo. Observed live as `route=market-status requested=1 returned=0` repeating for the row the operator had selected, while that same id was present in /tradePile. The client polls status for the row it displays and degrades it when the answer is empty, which is also why no actions were offered. The probe showed "-" only because the client had not yet polled that id (logs of the time show only tradeIds=1000000097). So expires, tradeState, itemData.itemState and pile were all innocent. Nothing was guessed and no field changed: both routes now share one pile enumeration (Server::resolve_trade_pile), so an id advertised by /tradePile always resolves on /trade/status. The corpus predicted exactly this -- tradeId must resolve across /transfermarket, /tradePile, /watchList AND /trade/status; we had stability but not coverage. Same defect class as the original empty-trade/status bug. Status still answers only the ids actually asked about, and a real auction always wins over an inactive row for the same tradeId. Regression test covers all four cases. Records the downgraded conclusion: "inactive" is a CONFIRMED section/lifecycle discriminator; whether the full actionable contract is now complete is the operator's next test. itemState/pile recovery was queued on the assumption the encoding was incomplete -- neither was touched, and both remain the next candidates if actions are still absent. 342 tests pass, 0 failed, clippy clean. Verified live: the six inactive ids went from returned=0 to returned=6.
This commit is contained in:
@@ -587,6 +587,7 @@ pub async fn handle_market_status(
|
||||
query: Option<&str>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
unlisted: &[UnlistedCandidate],
|
||||
) -> WireResponse {
|
||||
let ids = trade_ids_from_query(query);
|
||||
let listings = if ids.is_empty() {
|
||||
@@ -603,10 +604,33 @@ pub async fn handle_market_status(
|
||||
}
|
||||
found
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
let mut auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
|
||||
// ISViewTrade MUST resolve the SAME tradeIds `/tradePile` advertises. The client
|
||||
// polls the row it is displaying and, for an unlisted pile member, there is no
|
||||
// listing row to find — so answering from the market store alone returned an empty
|
||||
// body and the client DEGRADED the row (Time Remaining rendered "Expired", and no
|
||||
// actions were offered). Observed live: `requested=1 returned=0` on repeat for the
|
||||
// selected row. A tradeId must resolve on every route that can be asked about it.
|
||||
let blocked = store.blocking_core_items().await.unwrap_or_default();
|
||||
let wanted: Option<Vec<i64>> = if ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ids.iter().filter_map(|s| s.parse::<i64>().ok()).collect())
|
||||
};
|
||||
for c in unlisted {
|
||||
if blocked.contains(&c.core_id) {
|
||||
continue;
|
||||
}
|
||||
let trade_id = TRADE_ID_BASE + c.item_id;
|
||||
let asked = wanted.as_ref().is_none_or(|w| w.contains(&trade_id));
|
||||
if asked && !auctions.iter().any(|a| a["tradeId"] == json!(trade_id)) {
|
||||
auctions.push(unlisted_record(c));
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
ids.len(),
|
||||
@@ -1429,7 +1453,7 @@ mod tests {
|
||||
seed_listing(&store, "900000031", 2500).await;
|
||||
|
||||
// No filter: answer with the player's own active pile.
|
||||
let all = parse(&handle_market_status(None, &econ, &store).await);
|
||||
let all = parse(&handle_market_status(None, &econ, &store, &[]).await);
|
||||
assert_eq!(
|
||||
all["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
@@ -1443,14 +1467,14 @@ mod tests {
|
||||
|
||||
// Explicit tradeIds filter returns exactly the requested auction.
|
||||
let one = parse(
|
||||
&handle_market_status(Some("tradeIds=900000031"), &econ, &store).await,
|
||||
&handle_market_status(Some("tradeIds=900000031"), &econ, &store, &[]).await,
|
||||
);
|
||||
assert_eq!(one["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
|
||||
// An unknown id is absent, not an error: the poll must never fail closed.
|
||||
let miss =
|
||||
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store).await);
|
||||
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store, &[]).await);
|
||||
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Garbage is skipped rather than poisoning the whole poll.
|
||||
@@ -1460,6 +1484,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trade_status_resolves_the_unlisted_ids_tradepile_advertises() {
|
||||
// A tradeId must resolve on EVERY route that can be asked about it. The client
|
||||
// polls /trade/status for the row it is displaying; when /tradePile advertised
|
||||
// an unlisted id but /trade/status answered with an empty body, the client
|
||||
// DEGRADED the row — Time Remaining rendered "Expired" and no actions were
|
||||
// offered. Observed live as `requested=1 returned=0` on repeat for the selected
|
||||
// row, while the same id was present in /tradePile.
|
||||
let (store, _d) = store_at("statusunlisted").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
let c = UnlistedCandidate {
|
||||
item_id: 100_000_122,
|
||||
core_id: "core-pile".into(),
|
||||
item_json: Some(r#"{"rating":90,"preferredPosition":"CM"}"#.into()),
|
||||
};
|
||||
let cands = std::slice::from_ref(&c);
|
||||
let trade_id = TRADE_ID_BASE + 100_000_122;
|
||||
|
||||
// Asked for explicitly: it must come back, not be silently absent.
|
||||
let one = parse(
|
||||
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store, cands)
|
||||
.await,
|
||||
);
|
||||
let recs = one["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "the polled unlisted id must resolve");
|
||||
assert_eq!(recs[0]["tradeId"], trade_id);
|
||||
assert_eq!(recs[0]["tradeState"], "inactive");
|
||||
assert_eq!(recs[0]["expires"], 0);
|
||||
|
||||
// Unfiltered poll: still present.
|
||||
let all = parse(&handle_market_status(None, &econ, &store, cands).await);
|
||||
assert_eq!(all["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
|
||||
// A DIFFERENT id was asked for: the unlisted row must not be volunteered.
|
||||
let other = parse(
|
||||
&handle_market_status(Some("tradeIds=900000999"), &econ, &store, cands).await,
|
||||
);
|
||||
assert_eq!(
|
||||
other["auctionInfo"].as_array().unwrap().len(),
|
||||
0,
|
||||
"only the ids actually asked about are answered"
|
||||
);
|
||||
|
||||
// Once the item owns a real auction, the auction wins and the id is not
|
||||
// duplicated by an inactive row.
|
||||
store
|
||||
.create_listing(
|
||||
&trade_id.to_string(),
|
||||
"169193",
|
||||
Some("core-pile"),
|
||||
Some(100_000_122),
|
||||
Some(169193),
|
||||
150,
|
||||
2500,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed = parse(
|
||||
&handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store, cands)
|
||||
.await,
|
||||
);
|
||||
let recs = listed["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "exactly one record for one tradeId");
|
||||
assert_eq!(recs[0]["tradeState"], "active");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buy_now_debits_mints_and_closes() {
|
||||
let (store, _d) = store_at("buy").await;
|
||||
|
||||
Reference in New Issue
Block a user