Contributed manifests were in no declared condition at all, which left §9a replication with no grant flowing through it: peers mirror each other's catalogues wholesale, and every hop of that was unlicensed. CC0 rather than a share-alike licence, because a share-alike works by asserting a right in the data and then conditioning its use. The position in docs/legal-posture.md §3 is that presence timings are facts rather than protectable expression — asserting copyright in them in order to license them would contradict that argument in the same repository, and that contradiction is worth more to an opponent than the licence is worth to us. CC0 also waives the sui generis database right by name, closing the EU-specific residual exposure from the contributor's side. The grant is taken at token issuance, and that is not incidental. There are no accounts, so there is no sign-up to attach terms to, and a manifest arrives over POST /manifests with no channel to negotiate over. Acquiring the contribute capability is the only moment a grant can be made, so POST /tokens now returns the licence and its terms alongside the token — a licence the server publishes but never delivers is one no contributor agreed to. The test pins the scope limit as well as the identifier. Bounding the grant to the manifest is the half that can fail silently: a reworded term reading onto the underlying work would purport to grant what no contributor can. Also records the settled code-licence position across all four repositories in the legal posture, correcting an earlier claim there that the plugin and extraction repos declared nothing. Both already carried LICENSE files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-019 | PR-006
1012 lines
40 KiB
Rust
1012 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");
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
|
|
#[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}"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|