use agent_ask::{
build_answer, build_question, build_rating, cid_of, create_app, generate_keypair, AppState,
BuildAnswerOpts, BuildQuestionOpts, BuildRatingOpts, Store,
};
use axum::body::{Body, Bytes};
use axum::http::{Request, StatusCode};
use chrono::{TimeZone, Utc};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> (Store, axum::Router) {
let s = Store::open(":memory:").unwrap();
let app = create_app(AppState::new(s.clone()));
(s, app)
}
fn app_with_now(now_iso: &str) -> (Store, axum::Router) {
let s = Store::open(":memory:").unwrap();
let fixed = chrono::DateTime::parse_from_rfc3339(now_iso).unwrap().with_timezone(&Utc);
let app = create_app(AppState::with_now_fn(s.clone(), move || fixed));
(s, app)
}
async fn post_json(app: axum::Router, path: &str, body: &Value) -> (StatusCode, Value) {
let bytes = serde_json::to_vec(body).unwrap();
let req = Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json")
.header("content-length", bytes.len().to_string())
.body(Body::from(bytes))
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
(status, parsed)
}
async fn post_raw(app: axum::Router, path: &str, body: Bytes, with_cl: bool) -> StatusCode {
let mut req = Request::builder().method("POST").uri(path)
.header("content-type", "application/json");
if with_cl {
req = req.header("content-length", body.len().to_string());
}
let req = req.body(Body::from(body)).unwrap();
app.oneshot(req).await.unwrap().status()
}
async fn get(app: axum::Router, path: &str) -> (StatusCode, axum::http::HeaderMap, Bytes) {
let req = Request::builder().method("GET").uri(path).body(Body::empty()).unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let headers = res.headers().clone();
let body = res.into_body().collect().await.unwrap().to_bytes();
(status, headers, body)
}
#[tokio::test]
async fn post_questions_201() {
let (store, app) = app();
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![], ..Default::default()
}).unwrap();
let cid = cid_of(&q).unwrap();
let (st, body) = post_json(app, "/questions", &q).await;
assert_eq!(st, StatusCode::CREATED);
assert_eq!(body, json!({"cid": cid}));
assert!(store.has_artifact(&cid).unwrap());
}
#[tokio::test]
async fn post_questions_tampered_400() {
let (_, app) = app();
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![], ..Default::default()
}).unwrap();
let mut t = q.clone();
t["body"] = json!("mutated");
let (st, _) = post_json(app, "/questions", &t).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_answers_requires_known_q() {
let (_, app) = app();
let kp = generate_keypair();
let a = build_answer(&kp, BuildAnswerOpts {
question_cid: "bafkmissing".into(), body: "orphan".into(), ..Default::default()
}).unwrap();
let (st, _) = post_json(app, "/answers", &a).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_ratings_requires_known_target() {
let (_, app) = app();
let kp = generate_keypair();
let r = build_rating(&kp, BuildRatingOpts {
target_cid: "bafkmissing".into(), score: 1, ..Default::default()
}).unwrap();
let (st, _) = post_json(app, "/ratings", &r).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_questions_kind_mismatch_400() {
let (_, app) = app();
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![], ..Default::default()
}).unwrap();
let mut t = q.clone();
t["kind"] = json!("answer");
let (st, _) = post_json(app, "/questions", &t).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_artifact_returns_stored() {
let (store, app) = app();
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec!["x".into()], ..Default::default()
}).unwrap();
let cid = cid_of(&q).unwrap();
store.insert_artifact(&q).unwrap();
let (st, _, body) = get(app, &format!("/artifact/{cid}")).await;
assert_eq!(st, StatusCode::OK);
let parsed: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed, q);
assert!(parsed.get("cid").is_none());
}
#[tokio::test]
async fn get_artifact_404() {
let (_, app) = app();
let (st, _, _) = get(app, "/artifact/bafkunknown").await;
assert_eq!(st, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_questions_filter_tag() {
let (store, app) = app();
let kp = generate_keypair();
let q1 = build_question(&kp, BuildQuestionOpts {
title: "a".into(), body: "b".into(), tags: vec!["x".into()], ..Default::default()
}).unwrap();
let q2 = build_question(&kp, BuildQuestionOpts {
title: "b".into(), body: "b".into(), tags: vec!["y".into()], ..Default::default()
}).unwrap();
store.insert_artifact(&q1).unwrap();
store.insert_artifact(&q2).unwrap();
let (st, _, body) = get(app, "/questions?tag=x").await;
assert_eq!(st, StatusCode::OK);
let parsed: Vec<Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0]["id"], q1["id"]);
}
#[tokio::test]
async fn get_feed_ndjson() {
let (store, app) = app();
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![], ..Default::default()
}).unwrap();
store.insert_artifact(&q).unwrap();
let (st, headers, body) = get(app, "/feed").await;
assert_eq!(st, StatusCode::OK);
let ct = headers.get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("application/x-ndjson"));
let text = std::str::from_utf8(&body).unwrap();
let lines: Vec<&str> = text.trim().split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1);
let parsed: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed, q);
}
#[tokio::test]
async fn get_feed_since() {
let (store, app) = app();
let kp = generate_keypair();
let q1 = build_question(&kp, BuildQuestionOpts {
title: "a".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-04-01T00:00:00Z".into()), ..Default::default()
}).unwrap();
let q2 = build_question(&kp, BuildQuestionOpts {
title: "b".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-05-01T00:00:00Z".into()), ..Default::default()
}).unwrap();
store.insert_artifact(&q1).unwrap();
store.insert_artifact(&q2).unwrap();
let (_, _, body) = get(app, "/feed?since=2026-04-15T00:00:00Z").await;
let text = std::str::from_utf8(&body).unwrap();
let lines: Vec<&str> = text.trim().split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1);
let parsed: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed["id"], q2["id"]);
}
#[tokio::test]
async fn post_413_with_content_length() {
let (_, app) = app();
let huge: String = "x".repeat(64 * 1024 + 100);
let body = serde_json::to_vec(&json!({"junk": huge})).unwrap();
let st = post_raw(app, "/questions", body.into(), true).await;
assert_eq!(st, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn post_413_no_content_length() {
let (_, app) = app();
let huge: String = "x".repeat(64 * 1024 + 100);
let body = serde_json::to_vec(&json!({"junk": huge})).unwrap();
let st = post_raw(app, "/questions", body.into(), false).await;
assert_eq!(st, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn post_invalid_json_400() {
let (_, app) = app();
let st = post_raw(app, "/questions", Bytes::from_static(b"{not json"), true).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_empty_body_400() {
let (_, app) = app();
let st = post_raw(app, "/questions", Bytes::new(), true).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_48h_past() {
let (_, app) = app_with_now("2026-04-24T12:00:00Z");
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-04-22T11:59:00Z".into()), ..Default::default()
}).unwrap();
let (st, body) = post_json(app, "/questions", &q).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let err = body["error"].as_str().unwrap();
assert!(err.contains("24h"), "actual: {err}");
}
#[tokio::test]
async fn rejects_48h_future() {
let (_, app) = app_with_now("2026-04-24T12:00:00Z");
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-04-26T12:01:00Z".into()), ..Default::default()
}).unwrap();
let (st, _) = post_json(app, "/questions", &q).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn accepts_23h_boundary() {
let (_, app) = app_with_now("2026-04-24T12:00:00Z");
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-04-25T11:00:00Z".into()), ..Default::default()
}).unwrap();
let (st, _) = post_json(app, "/questions", &q).await;
assert_eq!(st, StatusCode::CREATED);
}
#[tokio::test]
async fn rejects_author_pubkey_mismatch() {
let (_, app) = app();
let kp = generate_keypair();
let other = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![], ..Default::default()
}).unwrap();
let mut t = q.clone();
t["author_did"] = json!(other.did);
let (st, _) = post_json(app, "/questions", &t).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_fractional_seconds() {
let (_, app) = app_with_now("2026-04-25T12:00:00Z");
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-04-25T12:00:00.000Z".into()), ..Default::default()
}).unwrap();
let (st, body) = post_json(app, "/questions", &q).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().unwrap().contains("created_at"));
}
#[tokio::test]
async fn rejects_plus_offset() {
let (_, app) = app_with_now("2026-04-25T12:00:00Z");
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![],
created_at: Some("2026-04-25T12:00:00+00:00".into()), ..Default::default()
}).unwrap();
let (st, _) = post_json(app, "/questions", &q).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_rating_unknown_target() {
let (_, app) = app();
let kp = generate_keypair();
let r = build_rating(&kp, BuildRatingOpts {
target_cid: "bafkdeadbeef".into(), score: 1, ..Default::default()
}).unwrap();
let (st, _) = post_json(app, "/ratings", &r).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn duplicate_post_idempotent() {
let (_, app) = app();
let kp = generate_keypair();
let q = build_question(&kp, BuildQuestionOpts {
title: "t".into(), body: "b".into(), tags: vec![], ..Default::default()
}).unwrap();
let (st1, b1) = post_json(app.clone(), "/questions", &q).await;
let (st2, b2) = post_json(app, "/questions", &q).await;
assert_eq!(st1, StatusCode::CREATED);
assert_eq!(st2, StatusCode::CREATED);
assert_eq!(b1["cid"], b2["cid"]);
}
#[tokio::test]
async fn _warmup_year() {
let _ = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0);
}