tidaldb/tidal-server/tests/standalone_offload.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

160 lines
5.5 KiB
Rust

// 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");
}