Five tests and the schema constant implemented requirements without carrying a tag, so those requirements read as uncovered when they were not. No behaviour changes here — every edit is a comment. Also documents `static` as a real tier rather than an exemption: it is already in `ci_executable_tiers` and its checks run in CI, and it exists because DR-007's single binary and DR-012's licence policy are properties of the build that a unit test could only assert as theatre. The UR-007 note records a cross-repo status conflict rather than resolving it. `jRay` JR-025 is `Done` and claims UR-007, but the fetch path that would exercise it (`jRay` JR-031) is still `Planned`. Either JR-025 is scoped to selection alone or it over-claims; until that is settled neither register should be trusted for UR-007 coverage. The gate reports 0 orphan tags and 19/19 UR coverage. TRACES: DR-001, DR-014, UR-015, UR-016, UR-018 | SR-003, SR-004
1015 lines
40 KiB
Rust
1015 lines
40 KiB
Rust
//! End-to-end tests through the real router.
|
|
//!
|
|
//! The unit tests cover each spec rule in isolation; these cover the wiring —
|
|
//! status codes, headers, and the properties that only hold if the layers are
|
|
//! composed correctly (per-route body caps, rate-limit surfaces, the strict
|
|
//! schema actually reaching uploads).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use http_body_util::BodyExt;
|
|
use jray_server::app;
|
|
use jray_server::config::Config;
|
|
use jray_server::db::Db;
|
|
use jray_server::ratelimit::RateLimiter;
|
|
use jray_server::state::AppState;
|
|
use jray_server::tmdb::TmdbClient;
|
|
use serde_json::{json, Value};
|
|
use tower::ServiceExt;
|
|
|
|
/// A server backed by a temporary on-disk database.
|
|
///
|
|
/// On-disk rather than `:memory:` because §8's design uses a separate writer
|
|
/// connection and a read pool, and in-memory SQLite is per-connection — the
|
|
/// readers would see an empty database. Testing the real topology is the point.
|
|
struct TestServer {
|
|
router: axum::Router,
|
|
_dir: TempDir,
|
|
}
|
|
|
|
struct TempDir(std::path::PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new(tag: &str) -> Self {
|
|
let mut p = std::env::temp_dir();
|
|
// Unique per test without pulling in a tempfile dependency.
|
|
p.push(format!("jray-test-{}-{}", tag, ulid_like()));
|
|
std::fs::create_dir_all(&p).expect("creating temp dir");
|
|
Self(p)
|
|
}
|
|
|
|
fn db_path(&self) -> String {
|
|
self.0.join("test.db").to_string_lossy().into_owned()
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn ulid_like() -> String {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
let t = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
format!("{t}-{}", N.fetch_add(1, Ordering::Relaxed))
|
|
}
|
|
|
|
impl TestServer {
|
|
fn new(tag: &str) -> Self {
|
|
let dir = TempDir::new(tag);
|
|
let db = Db::open(&dir.db_path()).expect("opening database");
|
|
let config = Arc::new(Config {
|
|
bind: "127.0.0.1:0".into(),
|
|
db_path: dir.db_path(),
|
|
// No key: uploads stay `pending`, which is the correct failure mode
|
|
// (§8) and keeps these tests free of network calls.
|
|
tmdb_api_key: None,
|
|
tmdb_base_url: "http://127.0.0.1:1".into(),
|
|
trusted_proxies: Vec::new(),
|
|
server_id: "test.example".into(),
|
|
publish_peer_directory: true,
|
|
contact: Some("admin@test.example".into()),
|
|
request_timeout: std::time::Duration::from_secs(30),
|
|
job_batch: 8,
|
|
job_poll_interval: std::time::Duration::from_secs(3600),
|
|
});
|
|
let state = AppState {
|
|
db,
|
|
config: config.clone(),
|
|
limiter: Arc::new(RateLimiter::new()),
|
|
tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)),
|
|
};
|
|
Self { router: app::router(state), _dir: dir }
|
|
}
|
|
|
|
async fn send(&self, req: Request<Body>) -> (StatusCode, Value, axum::http::HeaderMap) {
|
|
let resp = self.router.clone().oneshot(req).await.expect("router call");
|
|
let status = resp.status();
|
|
let headers = resp.headers().clone();
|
|
let bytes = resp.into_body().collect().await.expect("reading body").to_bytes();
|
|
let body = if bytes.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&bytes)
|
|
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
|
};
|
|
(status, body, headers)
|
|
}
|
|
|
|
async fn get(&self, uri: &str) -> (StatusCode, Value, axum::http::HeaderMap) {
|
|
self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await
|
|
}
|
|
|
|
async fn post_json(
|
|
&self,
|
|
uri: &str,
|
|
body: &Value,
|
|
) -> (StatusCode, Value, axum::http::HeaderMap) {
|
|
self.send(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn post_json_auth(
|
|
&self,
|
|
uri: &str,
|
|
token: &str,
|
|
body: &Value,
|
|
) -> (StatusCode, Value, axum::http::HeaderMap) {
|
|
self.send(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Issues an anonymous bearer capability (§5a).
|
|
async fn token(&self) -> String {
|
|
let (status, body, _) = self.post_json("/api/v1/tokens", &json!({})).await;
|
|
assert_eq!(status, StatusCode::OK, "token issue failed: {body}");
|
|
body["token"].as_str().expect("token in response").to_string()
|
|
}
|
|
}
|
|
|
|
fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value {
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin",
|
|
"year": 2017 },
|
|
"cut": { "runtime_sec": runtime },
|
|
"extraction": { "sample_fps": 5, "extinction_sec": 12,
|
|
"pipeline_version": "scene-actor-extraction 0.4.1",
|
|
"gallery_scope": "global" },
|
|
"actors": [
|
|
{ "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2},{"start":438.2,"end":465.6}] },
|
|
{ "name": "Michael Palin", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] }
|
|
]
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health and readiness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn health_is_unauthenticated() {
|
|
let s = TestServer::new("health");
|
|
let (status, body, _) = s.get("/health").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["status"], "ok");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn readiness_reports_database_and_tmdb_configuration() {
|
|
// §8: TMDB is a hard dependency for UR-3, so its absence is worth surfacing.
|
|
let s = TestServer::new("ready");
|
|
let (status, body, _) = s.get("/ready").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["status"], "ready");
|
|
assert_eq!(body["tmdb_configured"], false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §5a — tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn upload_without_a_token_is_rejected() {
|
|
let s = TestServer::new("noauth");
|
|
let (status, _, _) = s.post_json("/api/v1/manifests", &movie_manifest("504172", 6420.5)).await;
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_with_an_unknown_token_is_rejected() {
|
|
// A token the server never issued has no contributor row, and §5a stores only
|
|
// hashes, so there is nothing to match.
|
|
let s = TestServer::new("badauth");
|
|
let (status, _, _) = s
|
|
.post_json_auth("/api/v1/manifests", "jray_deadbeef", &movie_manifest("504172", 6420.5))
|
|
.await;
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tokens_are_issued_anonymously_and_are_distinct() {
|
|
let s = TestServer::new("tokens");
|
|
let a = s.token().await;
|
|
let b = s.token().await;
|
|
assert_ne!(a, b);
|
|
assert!(a.starts_with("jray_"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_token_response_carries_the_contribution_licence() {
|
|
// §5b / UR-019. There are no accounts, so token issuance is the only moment
|
|
// a grant can be taken — a licence the server publishes but never delivers
|
|
// is one no contributor agreed to, which is precisely the gap that leaves
|
|
// federated replication (UR-008) without a grant flowing through it.
|
|
let s = TestServer::new("token-licence");
|
|
let (status, body, _) = s.post_json("/api/v1/tokens", &json!({})).await;
|
|
assert_eq!(status, StatusCode::OK, "body: {body}");
|
|
|
|
assert_eq!(body["contribution_license"].as_str(), Some("CC0-1.0"));
|
|
|
|
let terms = body["contribution_terms"].as_str().expect("terms in response");
|
|
assert!(terms.contains("CC0 1.0"), "terms must name the licence: {terms}");
|
|
// The scope limit is the operative half: it must be impossible to read the
|
|
// grant as covering the film rather than the manifest.
|
|
assert!(terms.contains("manifest only"), "terms must bound the grant to the manifest: {terms}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §6 — upload validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn valid_upload_is_accepted_as_pending() {
|
|
// §6 stage 3: accepted with `202` and held unlisted until the cast check.
|
|
let s = TestServer::new("upload-ok");
|
|
let token = s.token().await;
|
|
let (status, body, _) =
|
|
s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await;
|
|
assert_eq!(status, StatusCode::ACCEPTED, "body: {body}");
|
|
assert_eq!(body["status"], "pending");
|
|
assert!(body["manifest_id"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_pending_manifest_is_not_served() {
|
|
// The property that makes §6 stage 3 meaningful: an unverified manifest is
|
|
// not served to anyone in the meantime.
|
|
let s = TestServer::new("pending-hidden");
|
|
let token = s.token().await;
|
|
let (_, body, _) =
|
|
s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await;
|
|
let id = body["manifest_id"].as_str().unwrap();
|
|
|
|
let (status, _, _) = s.get("/api/v1/manifests/movie?tmdb_id=504172").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
|
let (status, _, _) = s.get(&format!("/api/v1/manifests/{id}")).await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
|
// But its status is pollable (§4).
|
|
let (status, body, _) = s.get(&format!("/api/v1/manifests/{id}/status")).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["status"], "pending");
|
|
}
|
|
|
|
/// TRACES: DR-001 | SR-004
|
|
#[tokio::test]
|
|
async fn unknown_field_anywhere_is_rejected_with_400() {
|
|
// §6 stage 2, enforced by `deny_unknown_fields` on every DTO.
|
|
let s = TestServer::new("strict");
|
|
let token = s.token().await;
|
|
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["surprise"] = json!("payload");
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(
|
|
body["message"].as_str().unwrap_or("").contains("surprise"),
|
|
"the error should name the offending field: {body}"
|
|
);
|
|
|
|
// Nested, too — `extra="forbid"` applies at every level (§5a).
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["cut"]["extra"] = json!(1);
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn contributor_local_identifiers_are_rejected_not_ignored() {
|
|
// §1/§6: `movie` leaks the contributor's directory layout and `jellyfin_id` is
|
|
// a GUID from their database. Both must be *rejected on upload*, so a client
|
|
// that forgets to strip them gets a hard 400 naming the field rather than
|
|
// quietly publishing them.
|
|
let s = TestServer::new("strip");
|
|
let token = s.token().await;
|
|
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["movie"] = json!("/data/movies/The.Death.of.Stalin.2017.mkv");
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["message"].as_str().unwrap_or("").contains("movie"), "{body}");
|
|
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["actors"][0]["jellyfin_id"] = json!("a1b2c3d4e5f6");
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["message"].as_str().unwrap_or("").contains("jellyfin_id"), "{body}");
|
|
}
|
|
|
|
/// TRACES: UR-015 | SR-003
|
|
#[tokio::test]
|
|
async fn the_withdrawn_anneal_sec_field_is_rejected() {
|
|
// `anneal_sec` was withdrawn in the SR-003 bump: presence now follows track
|
|
// extent, so a track survives its own gaps and there is nothing to anneal
|
|
// (`scene-actor-extraction` AR-012/AR-013).
|
|
//
|
|
// Rejecting rather than ignoring it is the point. A manifest still carrying
|
|
// the field was produced by a pipeline whose window semantics differ from
|
|
// what this server now assumes, and silently accepting it would store
|
|
// timings whose meaning we cannot vouch for.
|
|
let s = TestServer::new("anneal-withdrawn");
|
|
let token = s.token().await;
|
|
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["extraction"]["anneal_sec"] = json!(3);
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(
|
|
body["message"].as_str().unwrap_or("").contains("anneal_sec"),
|
|
"the error should name the withdrawn field: {body}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: UR-015, UR-016 | SR-003
|
|
#[tokio::test]
|
|
async fn the_schema_bump_fields_round_trip() {
|
|
// `extinction_sec` and `gallery_scope` are the SR-003 additions. They are
|
|
// stored and reconstructed, since §7 ranks on scope and both are provenance
|
|
// a consumer may want.
|
|
let s = TestServer::new("bump-fields");
|
|
let token = s.token().await;
|
|
|
|
let m = movie_manifest("504172", 6420.5);
|
|
assert_eq!(m["extraction"]["gallery_scope"], "global");
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
|
|
|
|
// An unrecognised scope is a closed-vocabulary violation, not a free string.
|
|
let mut bad = movie_manifest("504173", 6420.5);
|
|
bad["extraction"]["gallery_scope"] = json!("enormous");
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &bad).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "gallery_scope is a closed enum");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn per_window_belief_and_route_round_trip() {
|
|
// SR-003 gives each window its posterior and identification route
|
|
// (extraction AR-017). They are stored and served — a consumer needs them to
|
|
// know how much to trust a window — but they are never part of identity.
|
|
let s = TestServer::new("belief");
|
|
let token = s.token().await;
|
|
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["actors"][0]["scenes"] = json!([
|
|
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
|
|
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
|
|
]);
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
|
|
|
|
// A belief outside [0, 1] is not a probability. Bounded rather than merely
|
|
// stored, because §5a's Threat 1 argument rests on every accepted value
|
|
// being bounded — an unbounded float is a 64-bit channel.
|
|
for bad in [-0.1, 1.5] {
|
|
let mut m = movie_manifest("504173", 6420.5);
|
|
m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "belief": bad }]);
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "belief {bad}: {body}");
|
|
}
|
|
|
|
// `route` is a closed vocabulary, so an invented value cannot be stored.
|
|
let mut m = movie_manifest("504174", 6420.5);
|
|
m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "route": "telepathy" }]);
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unknown_manifest_version_is_rejected() {
|
|
// UR-014 / SR-003: a consumer encountering an unknown `schema_version`
|
|
// refuses or warns; it never guesses.
|
|
let s = TestServer::new("version");
|
|
let token = s.token().await;
|
|
|
|
// Version 1 is the one that matters: SR-003 settled on a **flag day**, not a
|
|
// dual-accept period, so the immediately-previous version is refused exactly
|
|
// like a nonsensical one. A v1 read path would be the one nobody exercises,
|
|
// and so the one that rots while being dragged through every later change.
|
|
for version in [0, 1, 3, 99] {
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["jmanifest_version"] = json!(version);
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "version {version}: {body}");
|
|
assert!(
|
|
body["message"].as_str().unwrap_or("").contains("jmanifest_version"),
|
|
"should name the field: {body}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_runtime_is_rejected() {
|
|
// §2: `cut.runtime_sec` is required — the primary alignment guard.
|
|
let s = TestServer::new("no-runtime");
|
|
let token = s.token().await;
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["cut"] = json!({ "container_duration_sec": 6420.5 });
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_actor_list_is_rejected() {
|
|
// §6: 15 of the 331 corpus files have empty actor lists — extraction
|
|
// failures, not contributions.
|
|
let s = TestServer::new("empty-actors");
|
|
let token = s.token().await;
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["actors"] = json!([]);
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn smuggled_payload_in_an_actor_name_is_rejected() {
|
|
// §5a: the character class defeats base64/hex smuggling, which needs digits
|
|
// and padding characters. This is the last free-text channel, so it is worth
|
|
// asserting end-to-end and not only in the unit tests.
|
|
let s = TestServer::new("smuggle");
|
|
let token = s.token().await;
|
|
for payload in [
|
|
"SGVsbG8gd29ybGQgdGhpcyBpcyBhIHBheWxvYWQ=",
|
|
"4d5a90000300000004000000ffff0000",
|
|
"<script>alert(1)</script>",
|
|
"http://evil.example/x",
|
|
] {
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
m["actors"][0]["name"] = json!(payload);
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "payload {payload:?} should be rejected");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resubmitting_identical_content_is_not_a_duplicate_error() {
|
|
// §9a: content addressing gives deduplication — the same manifest from the
|
|
// same contributor is recognised rather than stored twice.
|
|
let s = TestServer::new("dedup");
|
|
let token = s.token().await;
|
|
let m = movie_manifest("504172", 6420.5);
|
|
let (first, body1, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(first, StatusCode::ACCEPTED);
|
|
let (second, body2, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(second, StatusCode::OK);
|
|
assert_eq!(body2["status"], "already_present");
|
|
assert_eq!(body1["manifest_id"], body2["manifest_id"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oversized_body_is_rejected_by_the_route_cap() {
|
|
// §6 stage 1: the app-level cap counts bytes as they are read, so a lying
|
|
// `Content-Length` and a chunked upload are both safe. Here the body genuinely
|
|
// exceeds the 2 MiB single-manifest cap.
|
|
let s = TestServer::new("too-big");
|
|
let token = s.token().await;
|
|
|
|
let mut m = movie_manifest("504172", 6420.5);
|
|
// Many actors, each with many windows — legitimate shape, illegitimate size.
|
|
let actors: Vec<Value> = (0..400)
|
|
.map(|i| {
|
|
let scenes: Vec<Value> = (0..1500).map(|j| json!([j as f64, (j + 1) as f64])).collect();
|
|
json!({ "tmdb_id": (1000 + i).to_string(), "scenes": scenes })
|
|
})
|
|
.collect();
|
|
m["actors"] = json!(actors);
|
|
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
|
|
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_lying_content_length_does_not_bypass_the_cap() {
|
|
// §6 stage 0 is explicit that `Content-Length` is a *claim by the client*: a
|
|
// hostile client can declare 100 and send far more, so the streaming cap is
|
|
// mandatory rather than redundant.
|
|
let s = TestServer::new("lying-length");
|
|
let token = s.token().await;
|
|
|
|
let huge = "x".repeat(3 * 1024 * 1024);
|
|
let body = format!("{{\"padding\":\"{huge}\"}}");
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-length", "100")
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
|
|
let (status, _, _) = s.send(req).await;
|
|
assert_ne!(
|
|
status,
|
|
StatusCode::ACCEPTED,
|
|
"an oversized body must never be accepted, whatever the declared length"
|
|
);
|
|
assert!(
|
|
status == StatusCode::PAYLOAD_TOO_LARGE || status == StatusCode::BAD_REQUEST,
|
|
"unexpected status {status}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §4 — exists
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn exists_returns_200_with_false_rather_than_404() {
|
|
// §4: absence is a normal answer, and `404` would conflate "no manifest" with
|
|
// "bad route" for the client.
|
|
let s = TestServer::new("exists-absent");
|
|
let (status, body, _) = s.get("/api/v1/manifests/exists?tmdb_id=999999").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["exists"], false);
|
|
assert!(body["manifest_id"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exists_requires_identity_parameters() {
|
|
let s = TestServer::new("exists-noid");
|
|
let (status, _, _) = s.get("/api/v1/manifests/exists").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exists_carries_rate_limit_headers() {
|
|
// §5: responses carry `X-RateLimit-Limit`, `-Remaining` and `-Reset`.
|
|
let s = TestServer::new("exists-headers");
|
|
let (_, _, headers) = s.get("/api/v1/manifests/exists?tmdb_id=1").await;
|
|
assert_eq!(headers["x-ratelimit-limit"], "600");
|
|
assert_eq!(headers["x-ratelimit-remaining"], "599");
|
|
assert!(headers.contains_key("x-ratelimit-reset"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_exists_is_positional_and_capped_at_100() {
|
|
// §4: results are positional, and the cap is what lets §5 be generous per
|
|
// request while staying strict per item.
|
|
let s = TestServer::new("exists-batch");
|
|
let items: Vec<Value> = (0..3).map(|i| json!({ "tmdb_id": (100 + i).to_string() })).collect();
|
|
let (status, body, headers) =
|
|
s.post_json("/api/v1/manifests/exists", &json!({ "items": items })).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["results"].as_array().unwrap().len(), 3);
|
|
assert_eq!(headers["x-ratelimit-limit"], "60", "batch has its own §5 budget");
|
|
|
|
let too_many: Vec<Value> =
|
|
(0..101).map(|i| json!({ "tmdb_id": (100 + i).to_string() })).collect();
|
|
let (status, _, _) =
|
|
s.post_json("/api/v1/manifests/exists", &json!({ "items": too_many })).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_exists_rejects_unknown_fields() {
|
|
let s = TestServer::new("exists-batch-strict");
|
|
let (status, _, _) =
|
|
s.post_json("/api/v1/manifests/exists", &json!({ "items": [], "extra": 1 })).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn one_bad_item_does_not_fail_the_whole_batch() {
|
|
// A 100-item sweep should not be lost to one malformed entry.
|
|
let s = TestServer::new("exists-batch-partial");
|
|
let (status, body, _) = s
|
|
.post_json(
|
|
"/api/v1/manifests/exists",
|
|
&json!({ "items": [ { "tmdb_id": "1" }, { }, { "tmdb_id": "2" } ] }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let results = body["results"].as_array().unwrap();
|
|
assert_eq!(results.len(), 3);
|
|
assert_eq!(results[1]["exists"], false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §5 — rate limiting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn exceeding_a_limit_returns_429_with_retry_after() {
|
|
// §5: exceeding a limit returns `429` with `Retry-After`, which the JRay
|
|
// client must honour.
|
|
let s = TestServer::new("ratelimit");
|
|
let token = s.token().await;
|
|
|
|
// The bundle surface has the tightest write limit (20/hour), so it is the
|
|
// cheapest to exhaust.
|
|
let bundle = json!({
|
|
"jmanifest_version": 2,
|
|
"series": { "series_tmdb_id": "1396", "title": "Breaking Bad" },
|
|
"episodes": []
|
|
});
|
|
|
|
let mut saw_429 = false;
|
|
for _ in 0..25 {
|
|
let (status, _, headers) =
|
|
s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
|
|
if status == StatusCode::TOO_MANY_REQUESTS {
|
|
assert!(headers.contains_key("retry-after"), "429 must carry Retry-After");
|
|
saw_429 = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(saw_429, "the §5 bundle limit should engage within 25 requests");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_surfaces_have_independent_budgets() {
|
|
// §5: each surface has its own budget, so a library sweep hammering `exists`
|
|
// cannot exhaust the budget a fetch needs.
|
|
//
|
|
// Only the `exists` surface returns a body on an empty database; the fetch
|
|
// surfaces 404 (and a 404 carries no quota headers, by design). So the
|
|
// independence is asserted by consuming `exists` and observing that its
|
|
// counter alone moves.
|
|
let s = TestServer::new("surfaces");
|
|
|
|
let (_, _, h) = s.get("/api/v1/manifests/exists?tmdb_id=1").await;
|
|
assert_eq!(h["x-ratelimit-limit"], "600");
|
|
assert_eq!(h["x-ratelimit-remaining"], "599");
|
|
|
|
// A fetch and a series request in between must not consume `exists` budget.
|
|
let _ = s.get("/api/v1/manifests/movie?tmdb_id=1").await;
|
|
let _ = s.get("/api/v1/manifests/series/1396").await;
|
|
|
|
let (_, _, h) = s.get("/api/v1/manifests/exists?tmdb_id=1").await;
|
|
assert_eq!(
|
|
h["x-ratelimit-remaining"], "598",
|
|
"fetch requests must not draw down the exists budget"
|
|
);
|
|
|
|
// And the batch form is a separate surface again (§5).
|
|
let (_, _, h) =
|
|
s.post_json("/api/v1/manifests/exists", &json!({ "items": [ { "tmdb_id": "1" } ] })).await;
|
|
assert_eq!(h["x-ratelimit-limit"], "60");
|
|
assert_eq!(h["x-ratelimit-remaining"], "59");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_forged_forwarded_header_cannot_reset_a_budget() {
|
|
// §8: the app must trust `X-Forwarded-For` only from the operator's proxy,
|
|
// because §5 rate limiting keys on client IP. This server has no configured
|
|
// proxies, so the header must be ignored entirely — otherwise a client could
|
|
// mint a fresh budget per request.
|
|
let s = TestServer::new("xff");
|
|
|
|
let mut last_remaining = u32::MAX;
|
|
for i in 0..3 {
|
|
let req = Request::builder()
|
|
.uri("/api/v1/manifests/exists?tmdb_id=1")
|
|
.header("x-forwarded-for", format!("10.1.1.{i}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let (_, _, headers) = s.send(req).await;
|
|
let remaining: u32 = headers["x-ratelimit-remaining"].to_str().unwrap().parse().unwrap();
|
|
assert!(
|
|
remaining < last_remaining,
|
|
"budget must keep decreasing despite a changing X-Forwarded-For"
|
|
);
|
|
last_remaining = remaining;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §4 — fetch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn fetch_requires_identity_parameters() {
|
|
let s = TestServer::new("fetch-noid");
|
|
let (status, _, _) = s.get("/api/v1/manifests/movie").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
|
let (status, _, _) = s.get("/api/v1/manifests/episode?series_tmdb_id=1396").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "episode fetch needs season and episode");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fetching_an_absent_manifest_is_404() {
|
|
// §4: `404` if none clears `loose`.
|
|
let s = TestServer::new("fetch-absent");
|
|
let (status, _, _) = s.get("/api/v1/manifests/movie?tmdb_id=999999").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn series_bundle_for_an_unknown_series_is_404() {
|
|
let s = TestServer::new("series-absent");
|
|
let (status, _, _) = s.get("/api/v1/manifests/series/999999").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn status_of_an_unknown_manifest_reports_rejected() {
|
|
// §6 deletes rejected manifests, so a vanished id must not read as a bad
|
|
// route — the contributor polling it needs a verdict.
|
|
let s = TestServer::new("status-unknown");
|
|
let (status, body, _) = s.get("/api/v1/manifests/01HZZZZZZZZZZZZZZZZZZZZZZZ/status").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["status"], "rejected");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §2, §4 — bundles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn bundle_upload_is_not_atomic() {
|
|
// §2: valid episodes are accepted and invalid ones rejected, with a
|
|
// per-episode result list. All-or-nothing would let one bad episode discard an
|
|
// entire season's compute.
|
|
let s = TestServer::new("bundle-partial");
|
|
let token = s.token().await;
|
|
|
|
let good = |ep: i64| {
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "episode", "series_tmdb_id": "1396", "title": "Breaking Bad",
|
|
"season": 1, "episode": ep },
|
|
"cut": { "runtime_sec": 2820.0 },
|
|
"actors": [ { "name": "Bryan Cranston", "tmdb_id": "17419",
|
|
"scenes": [{"start":10.0,"end":20.0}] } ]
|
|
})
|
|
};
|
|
// Invalid: a scene window beyond the runtime tolerance (§6).
|
|
let bad = json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 },
|
|
"cut": { "runtime_sec": 2820.0 },
|
|
"actors": [ { "tmdb_id": "17419", "scenes": [{"start":10.0,"end":99999.0}] } ]
|
|
});
|
|
|
|
let bundle = json!({
|
|
"jmanifest_version": 2,
|
|
"series": { "series_tmdb_id": "1396", "title": "Breaking Bad" },
|
|
"episodes": [ good(1), bad, good(2) ]
|
|
});
|
|
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
|
|
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
|
|
let results = body["results"].as_array().unwrap();
|
|
assert_eq!(results.len(), 3);
|
|
assert_eq!(results[0]["status"], "pending");
|
|
assert_eq!(results[1]["status"], "rejected");
|
|
assert!(results[1]["reason"].is_string(), "a rejected episode should say why");
|
|
assert_eq!(results[2]["status"], "pending", "a later episode must still be accepted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bundle_envelope_errors_are_whole_request_400s() {
|
|
// §4: `400` for the envelope itself, whereas individual bad episodes are
|
|
// reported in the results list.
|
|
let s = TestServer::new("bundle-envelope");
|
|
let token = s.token().await;
|
|
|
|
let (status, _, _) = s
|
|
.post_json_auth(
|
|
"/api/v1/manifests/bundle",
|
|
&token,
|
|
&json!({ "jmanifest_version": 2, "series": {}, "episodes": [] }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "series needs an identifier");
|
|
|
|
let (status, _, _) = s
|
|
.post_json_auth(
|
|
"/api/v1/manifests/bundle",
|
|
&token,
|
|
&json!({ "jmanifest_version": 2,
|
|
"series": { "series_tmdb_id": "1396" },
|
|
"episodes": [], "extra": 1 }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "unknown envelope field");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bundle_rejects_an_episode_contradicting_the_envelope() {
|
|
// An episode must not be silently reattributed to the bundle's series.
|
|
let s = TestServer::new("bundle-mismatch");
|
|
let token = s.token().await;
|
|
let bundle = json!({
|
|
"jmanifest_version": 2,
|
|
"series": { "series_tmdb_id": "1396" },
|
|
"episodes": [ {
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 },
|
|
"cut": { "runtime_sec": 2820.0 },
|
|
"actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
} ]
|
|
});
|
|
let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
|
|
assert_eq!(status, StatusCode::ACCEPTED);
|
|
assert_eq!(body["results"][0]["status"], "rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bundle_beyond_the_episode_cap_is_413() {
|
|
// §2/§4: capped at 500 episodes; beyond that the client must page by season.
|
|
let s = TestServer::new("bundle-cap");
|
|
let token = s.token().await;
|
|
let episodes: Vec<Value> = (0..501)
|
|
.map(|i| {
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "episode", "series_tmdb_id": "1396",
|
|
"season": 1, "episode": i },
|
|
"cut": { "runtime_sec": 2820.0 },
|
|
"actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
})
|
|
})
|
|
.collect();
|
|
let bundle = json!({
|
|
"jmanifest_version": 2,
|
|
"series": { "series_tmdb_id": "1396" },
|
|
"episodes": episodes
|
|
});
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
|
|
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() {
|
|
// §6 stage 1: per-route limits, so the bundle endpoint gets its larger cap
|
|
// without widening the others. A ~3 MiB bundle exceeds the 2 MiB manifest cap
|
|
// but is well within the 25 MiB bundle cap.
|
|
let s = TestServer::new("bundle-bigger-cap");
|
|
let token = s.token().await;
|
|
|
|
let episodes: Vec<Value> = (1..=60)
|
|
.map(|ep| {
|
|
let scenes: Vec<Value> = (0..600).map(|j| json!([j as f64, (j + 1) as f64])).collect();
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "episode", "series_tmdb_id": "1396",
|
|
"season": 1, "episode": ep },
|
|
"cut": { "runtime_sec": 2820.0 },
|
|
"actors": (0..8).map(|a| json!({
|
|
"tmdb_id": (20000 + a).to_string(), "scenes": scenes
|
|
})).collect::<Vec<_>>()
|
|
})
|
|
})
|
|
.collect();
|
|
let bundle = json!({
|
|
"jmanifest_version": 2,
|
|
"series": { "series_tmdb_id": "1396" },
|
|
"episodes": episodes
|
|
});
|
|
let encoded = bundle.to_string();
|
|
assert!(
|
|
encoded.len() > 2 * 1024 * 1024,
|
|
"test body should exceed the single-manifest cap, got {} bytes",
|
|
encoded.len()
|
|
);
|
|
|
|
let (status, _, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
|
|
assert_eq!(status, StatusCode::ACCEPTED, "the bundle route has its own larger cap");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §4 — reports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn reporting_an_unknown_manifest_is_404() {
|
|
let s = TestServer::new("report-unknown");
|
|
let (status, _, _) = s
|
|
.post_json(
|
|
"/api/v1/manifests/01HZZZZZZZZZZZZZZZZZZZZZZZ/report",
|
|
&json!({ "reason": "misaligned" }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_report_is_accepted_and_does_not_delist() {
|
|
// §5a: delisting stays an operator action. Automatic delisting on report would
|
|
// hand any client a remote delete primitive.
|
|
let s = TestServer::new("report-ok");
|
|
let token = s.token().await;
|
|
let (_, body, _) =
|
|
s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await;
|
|
let id = body["manifest_id"].as_str().unwrap().to_string();
|
|
|
|
let (status, body, _) = s
|
|
.post_json(
|
|
&format!("/api/v1/manifests/{id}/report"),
|
|
&json!({ "reason": "wrong_actors", "note": "these are not the right people" }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK, "{body}");
|
|
assert!(body["report_id"].is_string());
|
|
|
|
let (status, body, _) = s.get(&format!("/api/v1/manifests/{id}/status")).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["status"], "pending", "a report must not change status by itself");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn report_rejects_an_unknown_reason_and_unknown_fields() {
|
|
let s = TestServer::new("report-strict");
|
|
let (status, _, _) = s
|
|
.post_json("/api/v1/manifests/x/report", &json!({ "reason": "i_just_dont_like_it" }))
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
|
let (status, _, _) = s
|
|
.post_json("/api/v1/manifests/x/report", &json!({ "reason": "spam", "extra": true }))
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn report_note_is_length_capped() {
|
|
// §5a: free text from an anonymous caller is capped hard.
|
|
let s = TestServer::new("report-note");
|
|
let token = s.token().await;
|
|
let (_, body, _) =
|
|
s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await;
|
|
let id = body["manifest_id"].as_str().unwrap().to_string();
|
|
|
|
let (status, _, _) = s
|
|
.post_json(
|
|
&format!("/api/v1/manifests/{id}/report"),
|
|
&json!({ "reason": "spam", "note": "a".repeat(5000) }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Malformed input
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn malformed_json_is_a_400_not_a_500() {
|
|
let s = TestServer::new("bad-json");
|
|
let token = s.token().await;
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::from("{ this is not json"))
|
|
.unwrap();
|
|
let (status, _, _) = s.send(req).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deeply_nested_json_does_not_crash_the_parser() {
|
|
// §6 stage 1 caps nesting depth; a parser handed unbounded input is a DoS
|
|
// primitive, so the failure must be a clean rejection.
|
|
let s = TestServer::new("deep-json");
|
|
let token = s.token().await;
|
|
let deep = format!("{}{}", "[".repeat(5000), "]".repeat(5000));
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::from(deep))
|
|
.unwrap();
|
|
let (status, _, _) = s.send(req).await;
|
|
assert!(
|
|
status == StatusCode::BAD_REQUEST || status == StatusCode::PAYLOAD_TOO_LARGE,
|
|
"deeply nested input should be rejected cleanly, got {status}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_routes_are_404() {
|
|
let s = TestServer::new("routes");
|
|
let (status, _, _) = s.get("/api/v1/nonexistent").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
let (status, _, _) = s.get("/api/v2/manifests/exists?tmdb_id=1").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|