use std::path::PathBuf;
use std::sync::Arc;
use agentstatedeveloper_core::{
AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Effect,
EffectCategory, EffectDecl, Engine, FeedbackEntry, FeedbackStore, FeedbackVerdict, IndexStore,
LedgerEntry, LedgerKind, LedgerStore, Position, Symbol, SymbolKind,
};
use agentstatedeveloper_mcp::build_router;
use agentstategraph::CommitOptions;
use agentstategraph_core::{IntentCategory, TAG_GIT_REVISION, TAG_PIN_STATE};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use chrono::{Duration, TimeZone, Utc};
use http_body_util::BodyExt;
use tokio::sync::Mutex;
use tower::ServiceExt;
fn sym_id(qname: &str) -> String {
format!("id::{}", qname)
}
fn make_symbol(qname: &str, file: &str, line: u32) -> Symbol {
Symbol {
symbol_id: sym_id(qname),
symbol_fp: format!("fp::{}", qname),
qname: qname.to_string(),
language: "python".to_string(),
kind: SymbolKind::Function,
file: file.to_string(),
start: Position { line, col: 0 },
end: Position {
line: line + 10,
col: 0,
},
signature: Some(format!("def {}()", qname.rsplit('.').next().unwrap())),
doc: Some(format!("Doc for {}.", qname)),
}
}
fn commit(engine: &Engine, agent: &str, intent: IntentCategory, description: &str, n: u32) {
commit_tagged(engine, agent, intent, description, n, Vec::new())
}
fn commit_tagged(
engine: &Engine,
agent: &str,
intent: IntentCategory,
description: &str,
n: u32,
tags: Vec<String>,
) {
let opts = CommitOptions::new(agent, intent, description).with_tags(tags);
engine
.repo
.set_json(
&engine.ref_name,
&format!("/test/marker/m{}", n),
&serde_json::json!({ "n": n }),
opts,
)
.expect("commit marker");
}
fn put_feedback(
engine: &Engine,
qname: &str,
entry_id: &str,
query: &str,
verdict: FeedbackVerdict,
author: &str,
note: Option<&str>,
expires_in_days: Option<i64>,
) {
let store = AsgFeedbackStore::from_engine(engine);
let created = Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap();
let entry = FeedbackEntry {
entry_id: entry_id.to_string(),
symbol_id: sym_id(qname),
symbol_qname: qname.to_string(),
query: query.to_string(),
verdict,
author: author.to_string(),
created_at: created,
note: note.map(str::to_string),
file_scope: None,
expires_at: expires_in_days.map(|d| Utc::now() + Duration::days(d)),
withdrawn_at: None,
withdrawn_by: None,
withdrawn_reason: None,
};
store
.record(&engine.ref_name, &entry, "metrics-test")
.expect("record feedback");
}
fn fixture_engine() -> Engine {
let engine = Engine::open_in_memory().expect("open in-memory engine");
let index_store = AsgIndexStore::new(&engine.repo);
for (qname, file, line) in [
("app.main", "app.py", 1),
("pay.charge_card", "pay.py", 10),
("net.post", "net.py", 20),
("util.log", "util.py", 30),
] {
index_store
.put_symbol(
&engine.ref_name,
&make_symbol(qname, file, line),
"metrics-test",
)
.expect("put symbol");
}
{
use agentstatedeveloper_core::EffectStore;
let store = AsgEffectStore::new(&engine.repo);
let decl = EffectDecl {
symbol_id: sym_id("net.post"),
declared: vec![Effect::new(EffectCategory::IoNetOut)],
transitive: Vec::new(),
verification: None,
confidence: None,
runtime: None,
matched_policy: None,
};
store
.put_effects(&engine.ref_name, &sym_id("net.post"), &decl, "metrics-test")
.expect("put effects");
}
{
let store = AsgLedgerStore::new(&engine.repo);
let entry = LedgerEntry {
entry_id: "e-own-1".to_string(),
symbol_id: sym_id("pay.charge_card"),
kind: LedgerKind::Ownership,
summary: "payments team owns this".to_string(),
body: None,
author: Author {
kind: AuthorKind::Agent,
id: "metrics-test".to_string(),
},
confidence: Some(0.9),
evidence: Vec::new(),
supersedes: Vec::new(),
created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
tags: vec!["ctx:plan-t".to_string()],
matched_policy: None,
role: None,
command: None,
};
store
.append_entry(&engine.ref_name, &entry, "metrics-test")
.expect("append ledger entry");
}
commit(
&engine,
"alice",
IntentCategory::Refine,
"widen the parser",
1,
);
commit(
&engine,
"alice",
IntentCategory::Refine,
"tighten the lexer",
2,
);
commit(
&engine,
"bob",
IntentCategory::Refine,
"rename the adapter",
3,
);
commit_tagged(
&engine,
"alice",
IntentCategory::Checkpoint,
"checkpoint: parser stable",
4,
vec![TAG_PIN_STATE.to_string()],
);
commit_tagged(
&engine,
"bob",
IntentCategory::Checkpoint,
"checkpoint: adapter stable",
5,
vec![format!("{TAG_GIT_REVISION}feed1234abcd5678")],
);
put_feedback(
&engine,
"pay.charge_card",
"fb-1",
"charge a card",
FeedbackVerdict::Useful,
"alice",
Some("exactly the entry point"),
None,
);
put_feedback(
&engine,
"util.log",
"fb-2",
"charge a card",
FeedbackVerdict::Noisy,
"bob",
None,
None,
);
put_feedback(
&engine,
"net.post",
"fb-3",
"outbound http",
FeedbackVerdict::Useful,
"alice",
None,
Some(-1),
);
engine
}
async fn router() -> axum::Router {
build_router(
Arc::new(Mutex::new(fixture_engine())),
PathBuf::from(":memory:"),
None,
None,
true,
)
}
async fn get_body(app: axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
let resp = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_else(
|_| serde_json::json!({"_raw": String::from_utf8_lossy(&bytes).to_string()}),
);
(status, value)
}
fn facet_pairs(body: &serde_json::Value, axis: &str) -> Vec<(String, u64)> {
body["facets"][axis]
.as_array()
.unwrap_or_else(|| panic!("facet axis {} missing; body={}", axis, body))
.iter()
.map(|f| {
(
f["value"].as_str().unwrap().to_string(),
f["count"].as_u64().unwrap(),
)
})
.collect()
}
const FIXTURE_MILESTONES: u64 = 3;
#[tokio::test]
async fn milestones_lists_checkpoint_commits_with_facets() {
let (status, body) = get_body(router().await, "/api/v1/history/milestones").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["total"], FIXTURE_MILESTONES, "body={}", body);
assert_eq!(
facet_pairs(&body, "kinds"),
vec![("checkpoint".into(), FIXTURE_MILESTONES)],
"body={}",
body
);
let agents = facet_pairs(&body, "agents");
for who in ["alice", "bob"] {
assert_eq!(
agents.iter().find(|(v, _)| v == who).map(|(_, c)| *c),
Some(1),
"{} should own exactly one milestone; body={}",
who,
body
);
}
}
#[tokio::test]
async fn milestones_report_whether_each_pins_a_state_root() {
let (_, body) = get_body(router().await, "/api/v1/history/milestones").await;
for m in body["items"].as_array().unwrap() {
assert_eq!(
m["pins_state"],
m["state_root"].is_string(),
"pins_state must track state_root; body={}",
body
);
}
let pinned: Vec<_> = body["items"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["pins_state"] == true)
.collect();
let unpinned: Vec<_> = body["items"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["pins_state"] == false)
.collect();
assert_eq!(pinned.len(), 1, "body={}", body);
assert_eq!(pinned[0]["description"], "checkpoint: parser stable");
assert!(pinned[0]["state_root"].is_string(), "body={}", body);
assert_eq!(unpinned.len(), 2, "body={}", body);
let routine = unpinned
.iter()
.find(|m| m["description"] == "checkpoint: adapter stable")
.expect("bob's routine checkpoint is on the spine");
assert!(routine["state_root"].is_null(), "body={}", body);
assert_eq!(
routine["git_sha"], "feed1234abcd5678",
"an unpinned milestone must still say what to rebuild from; body={}",
body
);
assert_eq!(body["unpinned"], 2, "body={}", body);
}
#[tokio::test]
async fn milestones_free_text_search_narrows_results() {
let (status, body) = get_body(router().await, "/api/v1/history/milestones?q=adapter").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["total"], 1, "body={}", body);
assert_eq!(body["items"][0]["agent"], "bob", "body={}", body);
assert!(
body["items"][0]["description"]
.as_str()
.unwrap()
.contains("adapter"),
"body={}",
body
);
}
#[tokio::test]
async fn milestones_agent_filter_does_not_collapse_its_own_facet() {
let (status, body) = get_body(router().await, "/api/v1/history/milestones?agent=alice").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["total"], 1, "body={}", body);
let agents = facet_pairs(&body, "agents");
assert_eq!(
agents.iter().find(|(v, _)| v == "bob").map(|(_, c)| *c),
Some(1),
"bob's chip must keep its count while alice is selected; body={}",
body
);
assert_eq!(
agents.len() as u64,
FIXTURE_MILESTONES,
"every agent should still be offered; body={}",
body
);
}
#[tokio::test]
async fn milestones_agent_filter_is_case_insensitive() {
let (_, body) = get_body(router().await, "/api/v1/history/milestones?agent=ALICE").await;
assert_eq!(body["total"], 1, "body={}", body);
}
#[tokio::test]
async fn milestones_paginate_without_changing_total() {
let (_, page1) = get_body(
router().await,
"/api/v1/history/milestones?limit=1&offset=0",
)
.await;
let (_, page2) = get_body(
router().await,
"/api/v1/history/milestones?limit=1&offset=1",
)
.await;
assert_eq!(page1["total"], FIXTURE_MILESTONES);
assert_eq!(
page2["total"], FIXTURE_MILESTONES,
"total describes the set, not the page"
);
assert_eq!(page1["items"].as_array().unwrap().len(), 1);
assert_eq!(page2["items"].as_array().unwrap().len(), 1);
assert_ne!(
page1["items"][0]["commit"], page2["items"][0]["commit"],
"pages must not overlap"
);
}
#[tokio::test]
async fn rollup_groups_by_agent_and_intent() {
let (status, body) = get_body(router().await, "/api/v1/history/rollup").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
let agents = facet_pairs(&body, "agents");
assert_eq!(
agents.iter().find(|(v, _)| v == "alice").map(|(_, c)| *c),
Some(3),
"body={}",
body
);
assert_eq!(
agents.iter().find(|(v, _)| v == "bob").map(|(_, c)| *c),
Some(2),
"body={}",
body
);
let alice_rows: Vec<&serde_json::Value> = body["items"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["agent"] == "alice")
.collect();
let by_intent = |intent: &str| -> i64 {
alice_rows
.iter()
.filter(|r| r["intent"] == intent)
.map(|r| r["commits"].as_i64().unwrap())
.sum()
};
assert_eq!(by_intent("Refine"), 2, "body={}", body);
assert_eq!(by_intent("Checkpoint"), 1, "body={}", body);
}
#[tokio::test]
async fn rollup_totals_describe_the_filtered_set_not_the_page() {
let (_, all) = get_body(router().await, "/api/v1/history/rollup").await;
let full_total = all["totals"]["commits"].as_i64().unwrap();
let (_, paged) = get_body(router().await, "/api/v1/history/rollup?limit=1").await;
assert_eq!(paged["items"].as_array().unwrap().len(), 1);
assert_eq!(
paged["totals"]["commits"].as_i64().unwrap(),
full_total,
"a one-row page must still sum the whole filtered set"
);
}
#[tokio::test]
async fn rollup_agent_filter_restricts_totals() {
let (status, body) = get_body(router().await, "/api/v1/history/rollup?agent=bob").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["totals"]["commits"], 2, "body={}", body);
}
#[tokio::test]
async fn rollup_date_range_excludes_everything_outside_it() {
let (_, body) = get_body(
router().await,
"/api/v1/history/rollup?from=1999-01-01&to=1999-12-31",
)
.await;
assert_eq!(body["total"], 0, "body={}", body);
assert_eq!(body["totals"]["commits"], 0, "body={}", body);
}
#[tokio::test]
async fn commits_marks_milestone_commits_as_on_spine() {
let (status, body) = get_body(router().await, "/api/v1/commits").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["on_spine"], FIXTURE_MILESTONES, "body={}", body);
let pinned: Vec<&serde_json::Value> = body["items"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["on_spine"] == true)
.collect();
assert_eq!(pinned.len() as u64, FIXTURE_MILESTONES, "body={}", body);
for c in &pinned {
assert_eq!(c["intent"], "Checkpoint", "body={}", body);
}
for who in ["alice", "bob"] {
assert!(
pinned.iter().any(|c| c["agent"] == who),
"{}'s checkpoint should be pinned; body={}",
who,
body
);
}
}
#[tokio::test]
async fn commits_on_spine_counts_the_filtered_set() {
let (_, body) = get_body(router().await, "/api/v1/commits?q=widen").await;
let total = body["total"].as_u64().unwrap();
let on_spine = body["on_spine"].as_u64().unwrap();
assert_eq!(total, 1, "body={}", body);
assert!(
on_spine <= total,
"on_spine ({}) must not exceed total ({}); body={}",
on_spine,
total,
body
);
assert_eq!(on_spine, 0, "the matched Refine commit is not pinned");
}
#[tokio::test]
async fn commits_milestone_filter_selects_each_side() {
let (_, pinned) = get_body(router().await, "/api/v1/commits?milestone=1").await;
let (_, unpinned) = get_body(router().await, "/api/v1/commits?milestone=0").await;
assert_eq!(pinned["total"], FIXTURE_MILESTONES, "body={}", pinned);
assert!(
pinned["items"]
.as_array()
.unwrap()
.iter()
.all(|c| c["on_spine"] == true),
"body={}",
pinned
);
assert!(
unpinned["items"]
.as_array()
.unwrap()
.iter()
.all(|c| c["on_spine"] == false),
"body={}",
unpinned
);
let scanned = pinned["scanned"].as_u64().unwrap();
assert_eq!(
pinned["total"].as_u64().unwrap() + unpinned["total"].as_u64().unwrap(),
scanned,
"the two sides must partition the walk"
);
}
#[tokio::test]
async fn commits_walk_reports_scan_window_honestly() {
let (_, capped) = get_body(router().await, "/api/v1/commits?scan=2").await;
assert_eq!(capped["scanned"], 2, "body={}", capped);
assert_eq!(
capped["capped"], true,
"a walk cut short must say so; body={}",
capped
);
let (_, full) = get_body(router().await, "/api/v1/commits?scan=1000").await;
assert_eq!(
full["capped"], false,
"a walk that drained the frontier is not capped; body={}",
full
);
}
#[tokio::test]
async fn commits_reports_the_rollups_own_total_for_comparison() {
let (_, body) = get_body(router().await, "/api/v1/commits?scan=1000").await;
assert_eq!(
body["distilled"].as_i64().unwrap(),
body["scanned"].as_i64().unwrap(),
"nothing is unreachable in a fresh store; body={}",
body
);
}
#[tokio::test]
async fn commits_agent_filter_narrows_results() {
let (_, body) = get_body(router().await, "/api/v1/commits?agent=bob").await;
assert_eq!(body["total"], 2, "body={}", body);
assert!(
body["items"]
.as_array()
.unwrap()
.iter()
.all(|c| c["agent"] == "bob"),
"body={}",
body
);
}
#[tokio::test]
async fn feedback_lists_entries_with_verdict_and_author_facets() {
let (status, body) = get_body(router().await, "/api/v1/feedback").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["total"], 3, "body={}", body);
let verdicts = facet_pairs(&body, "verdicts");
assert_eq!(
verdicts
.iter()
.find(|(v, _)| v == "Useful")
.map(|(_, c)| *c),
Some(2),
"body={}",
body
);
let authors = facet_pairs(&body, "authors");
assert_eq!(
authors.iter().find(|(v, _)| v == "alice").map(|(_, c)| *c),
Some(2),
"body={}",
body
);
}
#[tokio::test]
async fn feedback_flags_lapsed_entries_rather_than_hiding_them() {
let (_, body) = get_body(router().await, "/api/v1/feedback").await;
let items = body["items"].as_array().unwrap();
let expired: Vec<&serde_json::Value> = items.iter().filter(|e| e["expired"] == true).collect();
assert_eq!(
expired.len(),
1,
"the lapsed entry must still be listed; body={}",
body
);
assert_eq!(expired[0]["entry_id"], "fb-3", "body={}", body);
}
#[tokio::test]
async fn feedback_search_covers_query_symbol_author_and_note() {
let (_, by_note) = get_body(router().await, "/api/v1/feedback?q=entry%20point").await;
assert_eq!(by_note["total"], 1, "body={}", by_note);
assert_eq!(by_note["items"][0]["entry_id"], "fb-1");
let (_, by_query) = get_body(router().await, "/api/v1/feedback?q=charge%20a%20card").await;
assert_eq!(by_query["total"], 2, "body={}", by_query);
}
#[tokio::test]
async fn feedback_verdict_filter_selects_one_kind() {
let (_, body) = get_body(router().await, "/api/v1/feedback?verdict=noisy").await;
assert_eq!(body["total"], 1, "body={}", body);
assert_eq!(body["items"][0]["verdict"], "Noisy", "body={}", body);
}
#[tokio::test]
async fn feedback_symbol_filter_matches_a_qname_substring() {
let (_, body) = get_body(router().await, "/api/v1/feedback?symbol=charge").await;
assert_eq!(body["total"], 1, "body={}", body);
assert_eq!(body["items"][0]["symbol_qname"], "pay.charge_card");
}
#[tokio::test]
async fn index_health_reports_the_asg_symbol_count() {
let (status, body) = get_body(router().await, "/api/v1/index-health").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["symbols"]["asg"], 4, "body={}", body);
assert_eq!(body["ref_name"], "main", "body={}", body);
}
#[tokio::test]
async fn scorecard_returns_the_full_cli_shape() {
let (status, body) = get_body(router().await, "/api/v1/scorecard").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
for key in [
"capability_scores",
"scores",
"data_quality",
"details",
"token_economy",
] {
assert!(body.get(key).is_some(), "missing {}; body={}", key, body);
}
for dim in [
"truth",
"feedback",
"change",
"uncertainty",
"workflow",
"overall",
] {
let v = body["capability_scores"][dim]
.as_u64()
.unwrap_or_else(|| panic!("dimension {} not a number; body={}", dim, body));
assert!(v <= 100, "{} out of range: {}", dim, v);
}
assert_eq!(body["details"]["total_symbols"], 4, "body={}", body);
assert_eq!(body["details"]["owned_symbols"], 1, "body={}", body);
assert_eq!(body["details"]["feedback_entries"], 3, "body={}", body);
assert_eq!(
body["details"]["ctx_tagged_ledger_entries"], 1,
"body={}",
body
);
}
#[tokio::test]
async fn scorecard_token_economy_compares_index_against_source() {
let (_, body) = get_body(router().await, "/api/v1/scorecard").await;
let te = &body["token_economy"];
assert!(
te["structured_tokens"].as_u64().unwrap() > 0,
"body={}",
body
);
assert!(
te["source_read_tokens_est"].as_u64().unwrap() > te["structured_tokens"].as_u64().unwrap(),
"reading source should cost more than the index; body={}",
body
);
}
#[tokio::test]
async fn scorecard_drill_down_lists_gap_symbols() {
let (status, body) =
get_body(router().await, "/api/v1/scorecard?drill_down=truth&limit=2").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
let drill = &body["drill_down"];
assert_eq!(drill["dimension"], "truth", "body={}", body);
let shown = drill["shown"].as_u64().unwrap();
let total_gaps = drill["total_gaps"].as_u64().unwrap();
assert!(shown <= 2, "limit not honored; body={}", body);
assert_eq!(
drill["omitted"].as_u64().unwrap(),
total_gaps - shown,
"omitted must account for the truncation; body={}",
body
);
assert_eq!(
drill["gap_symbols"].as_array().unwrap().len() as u64,
shown,
"body={}",
body
);
}
#[tokio::test]
async fn scorecard_path_filter_that_matches_nothing_says_so() {
let (status, body) = get_body(router().await, "/api/v1/scorecard?paths=no/such/dir/**").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert!(
body["note"]
.as_str()
.unwrap_or_default()
.contains("paths filter"),
"body={}",
body
);
assert_eq!(body["capability_scores"]["overall"], 0, "body={}", body);
assert_eq!(body["details"]["total_symbols"], 0, "body={}", body);
assert_eq!(
body["data_quality"]["scope"],
serde_json::json!(["no/such/dir/**"]),
"the filter in force should be echoed back; body={}",
body
);
}
#[tokio::test]
async fn scorecard_path_filter_scopes_the_symbol_set() {
let (_, body) = get_body(router().await, "/api/v1/scorecard?paths=pay.py").await;
assert_eq!(body["details"]["total_symbols"], 1, "body={}", body);
assert_eq!(body["details"]["owned_symbols"], 1, "body={}", body);
}
#[tokio::test]
async fn scorecard_payload_matches_core_exactly() {
use agentstatedeveloper_core::scorecard::{ScorecardOptions, compute};
let engine = fixture_engine();
let expected = compute(
&engine,
std::path::Path::new(":memory:"),
&ScorecardOptions {
drill_limit: 50,
..Default::default()
},
)
.to_json();
let app = build_router(
Arc::new(Mutex::new(engine)),
PathBuf::from(":memory:"),
None,
None,
true,
);
let (status, body) = get_body(app, "/api/v1/scorecard").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
for block in [
"capability_scores",
"scores",
"data_quality",
"details",
"token_economy",
] {
assert_eq!(
body[block], expected[block],
"{block} drifted from core::scorecard; body={body}"
);
}
assert!(body["timestamp"].is_string(), "body={}", body);
}
async fn post_body(app: axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_else(
|_| serde_json::json!({"_raw": String::from_utf8_lossy(&bytes).to_string()}),
);
(status, value)
}
#[tokio::test]
async fn feedback_listing_exposes_lifecycle_state() {
let (status, body) = get_body(router().await, "/api/v1/feedback").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
let item = &body["items"][0];
for field in ["expired", "withdrawn", "inert"] {
assert!(
item.get(field).is_some_and(|v| v.is_boolean()),
"{field} missing from the listing; body={body}"
);
}
}
#[tokio::test]
async fn withdrawing_returns_the_updated_entry() {
let (status, body) = post_body(router().await, "/api/v1/feedback/fb-1/withdraw").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["entry_id"], "fb-1", "body={}", body);
assert_eq!(body["withdrawn"], true, "body={}", body);
assert_eq!(body["inert"], true, "body={}", body);
assert!(body["withdrawn_at"].is_string(), "body={}", body);
assert_eq!(body["withdrawn_by"], "asd-lens", "body={}", body);
}
#[tokio::test]
async fn expiring_returns_the_updated_entry() {
let (status, body) = post_body(router().await, "/api/v1/feedback/fb-1/expire").await;
assert_eq!(status, StatusCode::OK, "body={}", body);
assert_eq!(body["expired"], true, "body={}", body);
assert_eq!(body["inert"], true, "body={}", body);
assert_eq!(
body["withdrawn"], false,
"expire must not withdraw; body={}",
body
);
}
#[tokio::test]
async fn acting_on_an_unknown_entry_is_a_404_not_a_500() {
for action in ["withdraw", "expire"] {
let (status, body) = post_body(
router().await,
&format!("/api/v1/feedback/fb-nope/{action}"),
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"{action} on a missing entry; body={body}"
);
}
}
#[tokio::test]
async fn a_withdrawn_entry_stays_in_the_listing() {
let app = router().await;
let (_, before) = get_body(app.clone(), "/api/v1/feedback").await;
let n = before["total"].as_u64().unwrap();
let (status, _) = post_body(app.clone(), "/api/v1/feedback/fb-1/withdraw").await;
assert_eq!(status, StatusCode::OK);
let (_, after) = get_body(app, "/api/v1/feedback").await;
assert_eq!(
after["total"].as_u64().unwrap(),
n,
"entry vanished from the list"
);
let row = after["items"]
.as_array()
.unwrap()
.iter()
.find(|e| e["entry_id"] == "fb-1")
.expect("fb-1 still listed");
assert_eq!(row["withdrawn"], true);
}