// Integration-test exemption (same posture as the other tidal-server tests): // unwrap on known-good fixtures is idiomatic here. #![allow(clippy::unwrap_used)] //! End-to-end integration test for the standalone data path through the //! blocking-work offload (`crate::offload::offload_read`). //! //! CRITICAL 8 moved the standalone `feed`/`search` engine calls onto the tokio //! blocking pool. This test drives the real handlers in-process via //! `tower::ServiceExt::oneshot` (no TCP bind) — POST /items + /signals, then GET //! /feed — and asserts a ranked result comes back, proving the offload path is //! wired correctly and returns the same answers as an inline call would. 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; fn make_app() -> axum::Router { // Default schema + built-in profiles (incl. `for_you`), same setup as // `run_standalone` in main.rs. No API key so requests need no auth. let (schema, profiles) = tidal_server::config::load_schema(None).unwrap(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .with_profiles(profiles) .open() .unwrap(); let state = Arc::new(ServerState::new(db)); build_router(state, None) } async fn post_json(app: &axum::Router, uri: &str, body: serde_json::Value) -> StatusCode { let response = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) .await .unwrap(); response.status() } async fn get_json(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) { let response = app .clone() .oneshot( Request::builder() .method(Method::GET) .uri(uri) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let status = response.status(); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); (status, json) } /// POST /items + /signals then GET /feed returns ranked items via the offload /// path. Proves the blocking engine call runs on the blocking pool and produces /// a correct ranked result end to end. #[tokio::test] async fn feed_returns_ranked_items_through_offload() { let app = make_app(); // Write three items with a created_at so the `for_you` Hot sort has an age. for id in 1u64..=3 { let status = post_json( &app, "/items", serde_json::json!({ "entity_id": id, "metadata": { "created_at": "1700000000", "category": "tech" } }), ) .await; assert_eq!(status, StatusCode::CREATED, "item {id} create"); } // Drive view signals so the offloaded scoring path has something to rank. // Item 3 gets the most weight, so it should out-rank the rest. for (id, weight) in [(1u64, 1.0), (2, 2.0), (3, 5.0)] { let status = post_json( &app, "/signals", serde_json::json!({ "entity_id": id, "signal": "view", "weight": weight }), ) .await; assert_eq!(status, StatusCode::NO_CONTENT, "signal for item {id}"); } // GET /feed — this is the handler that now offloads `retrieve` to the // blocking pool. Assert it returns a ranked, non-empty result set. let (status, body) = get_json(&app, "/feed?profile=for_you&limit=10").await; assert_eq!(status, StatusCode::OK, "feed body: {body}"); let items = body .get("items") .and_then(serde_json::Value::as_array) .expect("feed response has an items array"); assert!( !items.is_empty(), "feed should rank the written items: {body}" ); // Ranks must be contiguous starting at 1 (the engine's rank is 1-based) — a // real ordered result, not a degenerate empty/echo response. for (idx, item) in items.iter().enumerate() { let rank = item .get("rank") .and_then(serde_json::Value::as_u64) .unwrap(); assert_eq!(rank as usize, idx + 1, "ranks contiguous from 1"); assert!( item.get("score") .and_then(serde_json::Value::as_f64) .is_some(), "each item carries a score" ); } let total = body .get("total_candidates") .and_then(serde_json::Value::as_u64) .expect("total_candidates present"); assert!(total >= 3, "all three items were candidates: {body}"); } /// GET /feed succeeds even with no data — the offload path must not hang or 500 /// on an empty engine; it returns an empty ranked result. #[tokio::test] async fn empty_feed_succeeds_through_offload() { let app = make_app(); let (status, body) = get_json(&app, "/feed?profile=for_you&limit=10").await; assert_eq!(status, StatusCode::OK, "feed body: {body}"); let items = body .get("items") .and_then(serde_json::Value::as_array) .expect("items array present"); assert!(items.is_empty(), "no items written, feed is empty"); }