168 lines
5.4 KiB
Rust
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}");
|
|
}
|