Add Phase 1 frxd implementation with conformance test suite

This commit is contained in:
George Coles
2026-09-15 03:07:17 -04:00
parent 81ef27179b
commit 82a95fb907
23 changed files with 7295 additions and 3 deletions
+924
View File
@@ -0,0 +1,924 @@
mod common;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use common::{
ask, client, collection, config_for, messages_of_type, poll, poll_messages, publish,
query_envelope, register, spawn_relay, spawn_relay_with_capacity, unicast,
};
use frxd::crypto::Keypair;
use frxd::index::LocalIndex;
use frxd::message::{
EXPOSURE_FULL, EXPOSURE_METADATA, Envelope, QueryBody, TYPE_AGGREGATE, TYPE_QUERY,
TYPE_RESPONSE,
};
use frxd::node::{Node, NodeHandle};
use serde_json::{Value, json};
fn keys(value: &Value) -> BTreeSet<String> {
value.as_object().expect("object").keys().cloned().collect()
}
async fn status(http: &reqwest::Client, addr: &str) -> Value {
http.get(format!("http://{addr}/v1/local/status"))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
fn corpus_dir(root: &Path, name: &str, files: &[(&str, &str)]) -> PathBuf {
let dir = root.join(format!("{name}-docs"));
fs::create_dir_all(&dir).unwrap();
for (file, text) in files {
fs::write(dir.join(file), text).unwrap();
}
dir
}
async fn start_node_with_corpus(
root: &Path,
name: &str,
relay_url: &str,
shared: bool,
exposure: &str,
files: &[(&str, &str)],
) -> NodeHandle {
let docs = corpus_dir(root, name, files);
let config = config_for(&root.join(name), name, relay_url);
{
let index = LocalIndex::open(&config.index_dir()).unwrap();
index
.add_collection(&collection("docs", &docs, shared, exposure))
.unwrap();
}
Node::start(config).await.unwrap()
}
async fn collect_responses(
http: &reqwest::Client,
relay_url: &str,
member: &str,
timeout_ms: u64,
) -> Vec<Value> {
let start = Instant::now();
let deadline = Duration::from_millis(timeout_ms);
let mut responses = Vec::new();
while start.elapsed() < deadline {
let messages = poll_messages(http, relay_url, member, 200).await;
responses.extend(messages_of_type(&messages, TYPE_RESPONSE));
if !responses.is_empty() {
break;
}
}
responses
}
async fn raw_query(
http: &reqwest::Client,
relay_url: &str,
asker: &Keypair,
text: &str,
timeout_ms: u64,
) -> Vec<Value> {
register(http, relay_url, &asker.public_hex()).await;
let envelope = query_envelope(asker, text, 5);
assert!(
publish(http, relay_url, &envelope)
.await
.status()
.is_success()
);
collect_responses(http, relay_url, &asker.public_hex(), timeout_ms).await
}
#[test]
fn envelope_field_set_is_fixed() {
let key = Keypair::generate();
let envelope = Envelope::new(&key, TYPE_QUERY, json!({"qid": "q", "text": "t"}));
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(
keys(&value),
BTreeSet::from([
"body".to_string(),
"from".to_string(),
"nonce".to_string(),
"sig".to_string(),
"ts".to_string(),
"type".to_string(),
])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ordering_travels_scores_dont() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[
("strong.txt", "rust rust rust rust search engine"),
("weak.txt", "rust notes"),
],
)
.await;
let alice = Keypair::generate();
let http = client();
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
assert_eq!(responses.len(), 1);
let results = responses[0]
.pointer("/body/results")
.and_then(Value::as_array)
.unwrap();
assert_eq!(results.len(), 2);
let first_url = results[0].get("url").and_then(Value::as_str).unwrap();
assert!(
first_url.contains("strong"),
"best match should lead the ordering: {first_url}"
);
for result in results {
assert!(!keys(result).contains("score"));
assert!(!keys(result).contains("rank"));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn metadata_exposure_withholds_content() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_METADATA,
&[("corpus.txt", "enrichment corpus about rust")],
)
.await;
let alice = Keypair::generate();
let http = client();
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
assert_eq!(responses.len(), 1);
let result = &responses[0]
.pointer("/body/results")
.and_then(Value::as_array)
.unwrap()[0];
assert_eq!(
result.get("exposure").and_then(Value::as_str),
Some(EXPOSURE_METADATA)
);
assert!(result.get("content").unwrap().is_null());
assert!(
!result
.get("summary")
.and_then(Value::as_str)
.unwrap()
.is_empty()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn full_exposure_carries_content() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("corpus.txt", "full exposure rust content body")],
)
.await;
let alice = Keypair::generate();
let http = client();
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
let result = &responses[0]
.pointer("/body/results")
.and_then(Value::as_array)
.unwrap()[0];
assert_eq!(
result.get("exposure").and_then(Value::as_str),
Some(EXPOSURE_FULL)
);
assert!(
result
.get("content")
.and_then(Value::as_str)
.unwrap()
.contains("full exposure rust content body")
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn private_collections_are_silent_egress() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
false,
EXPOSURE_FULL,
&[("private.txt", "rust private data")],
)
.await;
let alice = Keypair::generate();
let http = client();
let responses = raw_query(&http, &relay_url, &alice, "rust", 500).await;
assert!(responses.is_empty(), "private collection answered");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn one_result_is_a_conformant_response() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("only.txt", "solitary rust document")],
)
.await;
let alice = Keypair::generate();
let http = client();
let responses = raw_query(&http, &relay_url, &alice, "solitary", 700).await;
assert_eq!(responses.len(), 1);
assert_eq!(
responses[0]
.pointer("/body/results")
.and_then(Value::as_array)
.unwrap()
.len(),
1
);
assert_eq!(
responses[0]
.pointer("/body/truncated")
.and_then(Value::as_bool),
Some(false)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn empty_query_earns_silence() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "rust content")],
)
.await;
let alice = start_node_with_corpus(
root.path(),
"alice",
&relay_url,
false,
EXPOSURE_FULL,
&[("mine.txt", "alice rust")],
)
.await;
let response = client()
.post(format!("http://{}/v1/local/query", alice.addr))
.json(&json!({"text": "", "max_results": 5, "timeout_ms": 500}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(
response
.get("responses")
.and_then(Value::as_array)
.unwrap()
.is_empty()
);
assert!(bob.node.doc_count() > 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn entities_are_optional_hints() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "entity tagged rust document")],
)
.await;
let alice = Keypair::generate();
let http = client();
register(&http, &relay_url, &alice.public_hex()).await;
let body = json!({
"qid": "entities-test",
"text": "rust",
"entities": ["Q999999", "not-a-real-entity"],
"budget": {"max_results": 5}
});
let envelope = Envelope::new(&alice, TYPE_QUERY, body);
assert!(
publish(&http, &relay_url, &envelope)
.await
.status()
.is_success()
);
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
assert_eq!(responses.len(), 1);
assert_eq!(
responses[0].pointer("/body/qid").and_then(Value::as_str),
Some("entities-test")
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn trusted_keys_allowlist_filters_senders() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let alice = Keypair::generate();
let untrusted = Keypair::generate();
let docs = corpus_dir(root.path(), "bob", &[("doc.txt", "rust document")]);
let mut config = config_for(&root.path().join("bob"), "bob", &relay_url);
config.node.trusted_keys = vec![alice.public_hex()];
{
let index = LocalIndex::open(&config.index_dir()).unwrap();
index
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
.unwrap();
}
let _bob = Node::start(config).await.unwrap();
let http = client();
let trusted = raw_query(&http, &relay_url, &alice, "rust", 700).await;
assert_eq!(trusted.len(), 1, "trusted member got no response");
register(&http, &relay_url, &untrusted.public_hex()).await;
let envelope = query_envelope(&untrusted, "rust", 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
.status()
.is_success()
);
let denied = collect_responses(&http, &relay_url, &untrusted.public_hex(), 400).await;
assert!(denied.is_empty(), "untrusted member received a response");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn backpressure_is_visible_and_recoverable() {
let relay_url = spawn_relay_with_capacity(1).await;
let http = client();
let member = Keypair::generate();
let publisher = Keypair::generate();
register(&http, &relay_url, &member.public_hex()).await;
let first = query_envelope(&publisher, "one", 5);
assert_eq!(
publish(&http, &relay_url, &first).await.status(),
reqwest::StatusCode::ACCEPTED
);
let second = query_envelope(&publisher, "two", 5);
let response = publish(&http, &relay_url, &second).await;
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
Some("1")
);
assert_eq!(
poll_messages(&http, &relay_url, &member.public_hex(), 300)
.await
.len(),
1
);
assert_eq!(
poll_messages(&http, &relay_url, &publisher.public_hex(), 300)
.await
.len(),
1
);
let third = query_envelope(&publisher, "three", 5);
assert_eq!(
publish(&http, &relay_url, &third).await.status(),
reqwest::StatusCode::ACCEPTED
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn unicast_is_need_to_know() {
let relay_url = spawn_relay().await;
let http = client();
let alice = Keypair::generate();
let bob = Keypair::generate();
register(&http, &relay_url, &alice.public_hex()).await;
register(&http, &relay_url, &bob.public_hex()).await;
let query = query_envelope(&alice, "rust", 5);
assert!(
publish(&http, &relay_url, &query)
.await
.status()
.is_success()
);
let response = Envelope::new(
&bob,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
);
assert_eq!(
unicast(&http, &relay_url, &alice.public_hex(), &response)
.await
.status(),
reqwest::StatusCode::OK
);
let alice_messages = poll_messages(&http, &relay_url, &alice.public_hex(), 300).await;
assert_eq!(messages_of_type(&alice_messages, TYPE_RESPONSE).len(), 1);
let bob_messages = poll_messages(&http, &relay_url, &bob.public_hex(), 300).await;
assert!(
messages_of_type(&bob_messages, TYPE_RESPONSE).is_empty(),
"unicast response leaked to another member"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn unicast_to_unknown_member_is_visible() {
let relay_url = spawn_relay().await;
let http = client();
let sender = Keypair::generate();
let response = Envelope::new(
&sender,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
);
let stranger = Keypair::generate().public_hex();
assert_eq!(
unicast(&http, &relay_url, &stranger, &response)
.await
.status(),
reqwest::StatusCode::NOT_FOUND
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn tampered_envelopes_are_rejected() {
let relay_url = spawn_relay().await;
let http = client();
let key = Keypair::generate();
let mut envelope = query_envelope(&key, "rust", 5);
envelope.body = json!({"qid": "q1", "text": "forged", "budget": {"max_results": 5}});
let publish_response = publish(&http, &relay_url, &envelope).await;
assert_eq!(publish_response.status(), reqwest::StatusCode::BAD_REQUEST);
let mut response = Envelope::new(
&key,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
);
response.sig = "00".repeat(64);
let unicast_response = unicast(&http, &relay_url, &key.public_hex(), &response).await;
assert_eq!(unicast_response.status(), reqwest::StatusCode::BAD_REQUEST);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn invalid_poll_member_is_rejected() {
let relay_url = spawn_relay().await;
let response = poll(&client(), &relay_url, "not-hex-at-all", 30).await;
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn malformed_json_is_rejected() {
let relay_url = spawn_relay().await;
let http = client();
let response = http
.post(format!("{relay_url}/v1/publish"))
.header("content-type", "application/json")
.body("{not json")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let root = tempfile::tempdir().unwrap();
let config = config_for(&root.path().join("alice"), "alice", &relay_url);
let node = Node::start(config).await.unwrap();
let response = http
.post(format!("http://{}/v1/local/query", node.addr))
.header("content-type", "application/json")
.body("{not json")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn node_does_not_answer_its_own_queries() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "self query rust")],
)
.await;
let (_status, _raw, value) = ask(&client(), &bob.addr.to_string(), "rust", 5).await;
assert!(
value
.get("responses")
.and_then(Value::as_array)
.unwrap()
.is_empty(),
"node responded to itself: {value}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ingress_happens_only_locally() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "rust served by bob")],
)
.await;
let alice = start_node_with_corpus(
root.path(),
"alice",
&relay_url,
false,
EXPOSURE_FULL,
&[("mine.txt", "alice rust notes")],
)
.await;
let http = client();
let bob_before = status(&http, &bob.addr.to_string()).await;
let alice_before = status(&http, &alice.addr.to_string()).await;
let (_s, _r, value) = ask(&http, &alice.addr.to_string(), "rust", 5).await;
assert_eq!(
value
.get("responses")
.and_then(Value::as_array)
.unwrap()
.len(),
1
);
let bob_after = status(&http, &bob.addr.to_string()).await;
let alice_after = status(&http, &alice.addr.to_string()).await;
assert_eq!(bob_before.get("doc_count"), bob_after.get("doc_count"));
assert_eq!(alice_before.get("doc_count"), alice_after.get("doc_count"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn querier_and_responder_roles_coexist() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let alice = start_node_with_corpus(
root.path(),
"alice",
&relay_url,
true,
EXPOSURE_FULL,
&[("a.txt", "alice rust document")],
)
.await;
let bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("b.txt", "bob rust document")],
)
.await;
let http = client();
let alice_addr = alice.addr.to_string();
let bob_addr = bob.addr.to_string();
let alice_ask = async { common::ask(&http, &alice_addr, "rust", 5).await };
let bob_ask = async { common::ask(&http, &bob_addr, "rust", 5).await };
let (alice_result, bob_result) = tokio::join!(alice_ask, bob_ask);
for (name, (_status, _raw, value)) in [("alice", alice_result), ("bob", bob_result)] {
let responses = value.get("responses").and_then(Value::as_array).unwrap();
assert_eq!(responses.len(), 1, "{name} got {value}");
let member = responses[0].get("member").and_then(Value::as_str).unwrap();
assert!(!member.is_empty());
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn budget_is_clamped_and_never_exceeded() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[
("one.txt", "rust one"),
("two.txt", "rust two"),
("three.txt", "rust three"),
],
)
.await;
let alice = start_node_with_corpus(
root.path(),
"alice",
&relay_url,
false,
EXPOSURE_FULL,
&[("mine.txt", "alice doc")],
)
.await;
let http = client();
let (_s, _r, value) = ask(&http, &alice.addr.to_string(), "rust", 0).await;
let responses = value.get("responses").and_then(Value::as_array).unwrap();
assert_eq!(responses.len(), 1);
assert_eq!(
responses[0]
.get("results")
.and_then(Value::as_array)
.unwrap()
.len(),
1,
"zero budget must clamp to one, never send nothing or more"
);
let (_s, _r, value) = ask(&http, &alice.addr.to_string(), "rust", 1000).await;
let responses = value.get("responses").and_then(Value::as_array).unwrap();
assert_eq!(
responses[0]
.get("results")
.and_then(Value::as_array)
.unwrap()
.len(),
3
);
assert_eq!(
responses[0].get("truncated").and_then(Value::as_bool),
Some(false)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "aggregate test rust")],
)
.await;
let http = client();
let sender = Keypair::generate();
register(&http, &relay_url, &bob.pubkey).await;
let broadcast = Envelope::new(
&sender,
TYPE_AGGREGATE,
json!({"period": "2026-03", "sent": 1, "passed": 0, "cited": 0}),
);
assert_eq!(
publish(&http, &relay_url, &broadcast).await.status(),
reqwest::StatusCode::BAD_REQUEST,
"aggregates must not travel the broadcast channel"
);
let unicast_envelope = Envelope::new(
&sender,
TYPE_AGGREGATE,
json!({"period": "2026-03", "sent": 1, "passed": 0, "cited": 0}),
);
assert_eq!(
unicast(&http, &relay_url, &bob.pubkey, &unicast_envelope)
.await
.status(),
reqwest::StatusCode::OK
);
tokio::time::sleep(Duration::from_millis(200)).await;
let (_s, _r, value) = ask(&http, &bob.addr.to_string(), "ignored", 5).await;
assert_eq!(
value.pointer("/local/total").and_then(Value::as_u64),
Some(0)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn node_survives_unreachable_relay() {
let root = tempfile::tempdir().unwrap();
let alice = start_node_with_corpus(
root.path(),
"alice",
"http://127.0.0.1:1",
false,
EXPOSURE_FULL,
&[("mine.txt", "offline rust note")],
)
.await;
let (status_code, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
assert!(status_code.is_success());
assert_eq!(
value.pointer("/local/total").and_then(Value::as_u64),
Some(1)
);
assert!(
value
.get("responses")
.and_then(Value::as_array)
.unwrap()
.is_empty()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn duplicate_delivery_is_answered_once() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "duplicate rust doc")],
)
.await;
let alice = Keypair::generate();
let http = client();
register(&http, &relay_url, &alice.public_hex()).await;
let envelope = query_envelope(&alice, "rust", 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
.status()
.is_success()
);
assert!(
publish(&http, &relay_url, &envelope)
.await
.status()
.is_success()
);
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
assert_eq!(
responses.len(),
1,
"duplicate query produced duplicate responses"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn multiple_responders_merge_with_provenance() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("bob.txt", "bob rust contribution")],
)
.await;
let _carol = start_node_with_corpus(
root.path(),
"carol",
&relay_url,
true,
EXPOSURE_FULL,
&[("carol.txt", "carol rust contribution")],
)
.await;
let alice = start_node_with_corpus(
root.path(),
"alice",
&relay_url,
false,
EXPOSURE_FULL,
&[("mine.txt", "alice rust local")],
)
.await;
let (_s, _r, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
let responses = value.get("responses").and_then(Value::as_array).unwrap();
assert_eq!(responses.len(), 2, "expected both responders: {value}");
let members: BTreeSet<&str> = responses
.iter()
.map(|r| r.get("member").and_then(Value::as_str).unwrap())
.collect();
assert_eq!(members.len(), 2);
let merged = value.get("merged").and_then(Value::as_array).unwrap();
let provenances: BTreeSet<&str> = merged
.iter()
.map(|m| m.get("provenance").and_then(Value::as_str).unwrap())
.collect();
assert!(provenances.contains("local"));
assert_eq!(provenances.len(), 3);
}
#[test]
fn canonical_signing_bytes_are_stable() {
let mut envelope = Envelope {
msg_type: TYPE_QUERY.to_string(),
from: "aa".repeat(32),
ts: 1_700_000_000,
nonce: "00112233445566778899aabbccddeeff".to_string(),
body: json!({"text": "rust", "qid": "q1", "budget": {"max_results": 5}, "entities": []}),
sig: String::new(),
};
let first = frxd::crypto::signing_bytes(&envelope);
envelope.body =
json!({"entities": [], "budget": {"max_results": 5}, "qid": "q1", "text": "rust"});
let second = frxd::crypto::signing_bytes(&envelope);
assert_eq!(first, second);
assert_eq!(
String::from_utf8(first).unwrap(),
"FRX/0.3\nquery\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n1700000000\n00112233445566778899aabbccddeeff\n{\"budget\":{\"max_results\":5},\"entities\":[],\"qid\":\"q1\",\"text\":\"rust\"}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn query_body_without_entities_parses() {
let root = tempfile::tempdir().unwrap();
let relay_url = spawn_relay().await;
let _bob = start_node_with_corpus(
root.path(),
"bob",
&relay_url,
true,
EXPOSURE_FULL,
&[("doc.txt", "minimal body rust")],
)
.await;
let alice = Keypair::generate();
let http = client();
register(&http, &relay_url, &alice.public_hex()).await;
let envelope = Envelope::new(
&alice,
TYPE_QUERY,
json!({"qid": "min", "text": "rust", "budget": {"max_results": 5}}),
);
assert!(
publish(&http, &relay_url, &envelope)
.await
.status()
.is_success()
);
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
assert_eq!(responses.len(), 1);
}
#[test]
fn query_body_budget_is_the_only_quantity_knob() {
let value = serde_json::to_value(QueryBody::new("rust", 5)).unwrap();
assert_eq!(
keys(&value),
BTreeSet::from([
"qid".to_string(),
"text".to_string(),
"entities".to_string(),
"budget".to_string()
])
);
}