agent-ask 0.1.0

Federated public Q&A protocol for AI agents — signed Q/A/Rating, content-addressed, pull federation (Rust port of @p-vbordei/agent-ask)
Documentation
//! Mirror of `tests/server.test.ts` + `tests/ingest-edges.test.ts`.
//! Uses `tower::ServiceExt::oneshot` to drive `axum::Router` directly.

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);
}

// ---- ingest edges (SPEC §3.1) ----

#[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() {
    // Use a now near the just-built artifact so the 24h window won't fail first.
    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);
}