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
160 lines
5.5 KiB
Rust
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");
|
|
}
|