tidaldb/tidal-server/tests/exact_rank.rs

168 lines
5.4 KiB
Rust

#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use axum::{
body::Body,
http::{Method, Request, StatusCode},
};
use tidal_server::{router::build_router, state::ServerState};
use tidaldb::TidalDb;
use tower::ServiceExt;
const AS_OF: &str = "2000000000000000000";
fn make_app() -> axum::Router {
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.with_profiles(profiles)
.open()
.unwrap();
build_router(
Arc::new(ServerState::new(db)),
Arc::new(tidal_server::cluster::security::ClusterCreds::unauthenticated()),
)
}
async fn post_rank(body: serde_json::Value) -> (StatusCode, serde_json::Value) {
let response = make_app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/rank")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, body)
}
fn rank_body(candidates: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"profile": "contest_qualified_hot",
"profile_version": 1,
"as_of_nanos": AS_OF,
"candidates": candidates,
})
}
#[tokio::test]
async fn rank_wire_contract_is_lossless_complete_and_deterministic() {
let (status, body) = post_rank(rank_body(&serde_json::json!([
{
"entity_id": 1,
"created_at_nanos": "1999913600000000000",
"signals": { "hearts": 8.0, "comments": 4.0, "exposure": 100.0 }
},
{
"entity_id": 9,
"created_at_nanos": "1999913600000000000",
"signals": { "hearts": 8.0, "comments": 4.0, "exposure": 100_000.0 }
}
])))
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
assert_eq!(body["profile"], "contest_qualified_hot");
assert_eq!(body["profile_version"], 1);
assert_eq!(body["as_of_nanos"], AS_OF);
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["entity_id"], 9);
assert_eq!(items[0]["rank"], 1);
assert_eq!(items[1]["entity_id"], 1);
assert_eq!(items[1]["rank"], 2);
assert_eq!(items[0]["score"], items[1]["score"]);
assert_ne!(
items[0]["components"]["response_rate"],
items[1]["components"]["response_rate"]
);
for item in items {
for field in [
"hearts",
"comments",
"exposure",
"engagement",
"response_rate",
"freshness",
"age_hours",
] {
assert!(
item["components"].get(field).is_some(),
"missing {field}: {item}"
);
}
}
}
#[tokio::test]
async fn rank_requires_a_pinned_clock() {
let mut request = rank_body(&serde_json::json!([]));
request.as_object_mut().unwrap().remove("as_of_nanos");
assert!(post_rank(request).await.0.is_client_error());
}
#[tokio::test]
async fn rank_rejects_unpinned_profile_invalid_candidates_and_numeric_timestamps() {
let mut wrong_version = rank_body(&serde_json::json!([]));
wrong_version["profile_version"] = serde_json::json!(2);
assert_eq!(post_rank(wrong_version).await.0, StatusCode::BAD_REQUEST);
let duplicate = serde_json::json!([
{ "entity_id": 1, "created_at_nanos": AS_OF, "signals": { "hearts": 1.0, "comments": 0.0, "exposure": 1.0 } },
{ "entity_id": 1, "created_at_nanos": AS_OF, "signals": { "hearts": 2.0, "comments": 0.0, "exposure": 2.0 } }
]);
assert_eq!(
post_rank(rank_body(&duplicate)).await.0,
StatusCode::BAD_REQUEST
);
let future = serde_json::json!([
{ "entity_id": 1, "created_at_nanos": "2000000000000000001", "signals": { "hearts": 1.0, "comments": 0.0, "exposure": 1.0 } }
]);
assert_eq!(
post_rank(rank_body(&future)).await.0,
StatusCode::BAD_REQUEST
);
let negative = serde_json::json!([
{ "entity_id": 1, "created_at_nanos": AS_OF, "signals": { "hearts": -1.0, "comments": 0.0, "exposure": 1.0 } }
]);
assert_eq!(
post_rank(rank_body(&negative)).await.0,
StatusCode::BAD_REQUEST
);
let mut numeric = rank_body(&serde_json::json!([]));
numeric["as_of_nanos"] = serde_json::json!(2_000_000_000_000_000_000u64);
assert!(post_rank(numeric).await.0.is_client_error());
}
#[tokio::test]
async fn rank_rejects_an_oversized_set_before_per_candidate_conversion() {
let candidates = (0..10_001)
.map(|entity_id| {
serde_json::json!({
"entity_id": entity_id,
"created_at_nanos": "not-a-timestamp",
"signals": { "hearts": 0.0, "comments": 0.0, "exposure": 0.0 }
})
})
.collect();
let (status, body) = post_rank(rank_body(&serde_json::Value::Array(candidates))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let text = body.to_string();
assert!(text.contains("at most 10000 candidates"), "{body}");
assert!(!text.contains("created_at_nanos"), "{body}");
}