// Integration-test exemption (same posture as the other tidal-server tests): // unwrap on known-good fixtures + rank index casts are idiomatic here. #![allow(clippy::unwrap_used, clippy::cast_possible_truncation)] //! End-to-end integration coverage for the STANDALONE data surface. //! //! `middleware.rs` covers auth / body-limit / request-ID and //! `standalone_offload.rs` covers the `/feed` offload round trip, but neither //! drives the full data path through the HTTP surface nor pins the //! engine-result → DTO mapping. This test: //! //! 1. Drives the real handlers in-process via `tower::ServiceExt::oneshot` //! (no TCP bind): POST /items → POST /embeddings → POST /signals → GET /feed //! → GET /search. //! 2. Asserts the [`crate::dto`] shape: `signals` is OMITTED when empty and //! PRESENT when a profile populates the snapshot; search items carry //! `bm25_score`. //! 3. Asserts the standalone region-rejection path: any `?region=` param is a //! 400 in both `/feed` and `/search` (region routing is a cluster concept). 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`/`trending`), 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 { 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() .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) } /// Full standalone round trip: items + embeddings + signals, then a ranked /// /feed and a /search over the indexed titles. Pins the DTO shape in both /// directions. #[tokio::test] async fn items_signals_feed_search_round_trip() { let app = make_app(); // Two items with searchable titles + a created_at for the Hot sort age. for (id, title) in [(1u64, "jazz piano nocturne"), (2u64, "ambient jazz drift")] { let status = post_json( &app, "/items", serde_json::json!({ "entity_id": id, "metadata": { "created_at": "1700000000", "title": title, "category": "music" } }), ) .await; assert_eq!(status, StatusCode::CREATED, "item {id} create"); let status = post_json( &app, "/embeddings", serde_json::json!({ "entity_id": id, "values": vec![0.1f32; 128] }), ) .await; assert_eq!(status, StatusCode::NO_CONTENT, "item {id} embedding"); } // Drive view signals so the ranking profile has a signal snapshot to expose. // Item 2 gets more weight so it should out-rank item 1. for (id, weight) in [(1u64, 1.0), (2u64, 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 ──────────────────────────────────────────────────────────── // `for_you` boosts the `view` signal, so the feed items must carry a // populated `signals` array (the DTO present-when-populated branch). 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 has items array"); assert!( !items.is_empty(), "feed should rank the written items: {body}" ); // Ranks contiguous from 1; the heavier-weighted item 2 ranks first. 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" ); } assert_eq!( items[0] .get("entity_id") .and_then(serde_json::Value::as_u64), Some(2), "heavier-weighted item ranks first: {body}" ); // DTO: at least one ranked item must expose a populated `signals` array // (the `for_you` view boost), proving the present-when-populated branch. let any_with_signals = items.iter().any(|item| { item.get("signals") .and_then(serde_json::Value::as_array) .is_some_and(|s| !s.is_empty()) }); assert!( any_with_signals, "a boosted profile must populate the signals snapshot: {body}" ); // ── GET /search ────────────────────────────────────────────────────────── // The background text syncer commits on a ~2s cadence (ephemeral index, // manual reload), and the `/search` handler reloads the reader on every // call. Poll the real endpoint until the committed docs are visible — a // deterministic end-to-end wait on the real syncer, not a fixed sleep. let body = poll_search_until_nonempty(&app, "/search?query=jazz&limit=10").await; let results = body .get("items") .and_then(serde_json::Value::as_array) .expect("search has items array"); assert!( !results.is_empty(), "search should match the jazz titles within the syncer window: {body}" ); for (idx, item) in results.iter().enumerate() { let rank = item .get("rank") .and_then(serde_json::Value::as_u64) .unwrap(); assert_eq!(rank as usize, idx + 1, "search ranks contiguous from 1"); } // A pure-text query must surface a BM25 component in the DTO for the // matched items (the `skip_serializing_if = Option::is_none` field is // present when scored). let any_bm25 = results.iter().any(|item| { item.get("bm25_score") .and_then(serde_json::Value::as_f64) .is_some() }); assert!(any_bm25, "text search must expose bm25_score: {body}"); } /// Re-issue a `/search` against the real handler until it returns a non-empty /// `items` array or a bounded deadline elapses. Each call asserts 200 and /// reloads the text reader (the handler does), so this waits on the real /// background syncer commit (≈2s cadence) without a blind fixed sleep. async fn poll_search_until_nonempty(app: &axum::Router, uri: &str) -> serde_json::Value { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); loop { let (status, body) = get_json(app, uri).await; assert_eq!(status, StatusCode::OK, "search body: {body}"); let nonempty = body .get("items") .and_then(serde_json::Value::as_array) .is_some_and(|a| !a.is_empty()); if nonempty || std::time::Instant::now() >= deadline { return body; } // The text syncer runs on its own OS thread, so a brief blocking sleep // here (between awaits, not during one) does not stall the commit — it // just paces the retry. `tokio::time` is not enabled for this crate, so // a std sleep is the right tool. std::thread::sleep(std::time::Duration::from_millis(100)); } } /// DTO: an item with NO signals must serialize with `signals` ABSENT (the /// skip-when-empty branch). Uses the `new` profile (sort-by-recency, no boosts), /// so no signal snapshot is attached even though the item exists. #[tokio::test] async fn feed_omits_signals_when_empty() { let app = make_app(); let status = post_json( &app, "/items", serde_json::json!({ "entity_id": 42, "metadata": { "created_at": "1700000000", "title": "no signals here" } }), ) .await; assert_eq!(status, StatusCode::CREATED); // `new` is a built-in sort-by-recency profile with no signal boosts, so the // ranked item carries an empty snapshot → `signals` omitted from the JSON. let (status, body) = get_json(&app, "/feed?profile=new&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 has items array"); assert!(!items.is_empty(), "the item should still rank: {body}"); for item in items { assert!( item.get("signals").is_none(), "empty signal snapshot must be OMITTED, not [] : {item}" ); } } /// Standalone region rejection: any `?region=` param is a 400 on `/feed`. /// Region routing is a cluster-only concept; a standalone server must reject it /// loudly rather than silently ignore it. #[tokio::test] async fn feed_rejects_region_param() { let app = make_app(); let (status, body) = get_json(&app, "/feed?profile=for_you®ion=us-east").await; assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}"); let err = body .get("error") .and_then(serde_json::Value::as_str) .unwrap_or(""); assert!( err.contains("region routing requires cluster mode"), "error must explain region routing is cluster-only: {body}" ); } /// Standalone region rejection also applies to `/search`. #[tokio::test] async fn search_rejects_region_param() { let app = make_app(); let (status, body) = get_json(&app, "/search?query=jazz®ion=us-east").await; assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}"); let err = body .get("error") .and_then(serde_json::Value::as_str) .unwrap_or(""); assert!( err.contains("region routing requires cluster mode"), "error must explain region routing is cluster-only: {body}" ); }