Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions rivet-cli/src/serve/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ fn default_limit() -> u32 {
#[derive(Serialize)]
struct ArtifactsResponse {
total: usize,
count: usize,
truncated: bool,
artifacts: Vec<ApiArtifact>,
}

Expand Down Expand Up @@ -584,9 +586,13 @@ pub(crate) async fn artifacts(

let total = results.len();
let page: Vec<ApiArtifact> = results.into_iter().skip(offset).take(limit).collect();
let count = page.len();
let truncated = count < total;

Json(ArtifactsResponse {
total,
count,
truncated,
artifacts: page,
})
.into_response()
Expand Down Expand Up @@ -619,6 +625,8 @@ struct ApiDiagnostic {
#[derive(Serialize)]
struct DiagnosticsResponse {
total: usize,
count: usize,
truncated: bool,
diagnostics: Vec<ApiDiagnostic>,
}

Expand Down Expand Up @@ -686,9 +694,13 @@ pub(crate) async fn diagnostics(

let total = results.len();
let page: Vec<ApiDiagnostic> = results.into_iter().skip(offset).take(limit).collect();
let count = page.len();
let truncated = count < total;

Json(DiagnosticsResponse {
total,
count,
truncated,
diagnostics: page,
})
}
Expand Down
146 changes: 146 additions & 0 deletions rivet-cli/tests/serve_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,80 @@ fn api_artifacts_pagination() {
child.wait().ok();
}

/// #832 / REQ-303: silent truncation is a weak-green defect. The response
/// must carry the data needed for a consumer to detect a partial view:
/// `count` alongside `total`, and `truncated: true` when the page did not
/// return the full set. Otherwise every consumer reading `artifacts` as the
/// full set is silently wrong.
///
/// The endpoint caps `limit` at 1000 internally, so on a fixture whose
/// `total` exceeds the cap the endpoint cannot return an untruncated
/// window at all — which is *precisely why* this signal exists. The test
/// asserts the shape rather than the specific full-vs-partial state:
/// truncated iff count < total, and count == artifacts.len(), on both a
/// small window (forces truncated=true) and, when the fixture fits, a
/// window that covers the whole store (verifies truncated=false).
///
/// rivet: verifies REQ-303
#[test]
fn api_artifacts_truncation_signal() {
let (mut child, port) = start_server();

// Small window: truncated. count == 1, truncated: true.
let (status, body, _headers) = fetch(port, "/api/v1/artifacts?limit=1", false);
assert_eq!(status, 200);
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
let total = json["total"].as_u64().unwrap();
let count_small = json["count"].as_u64().expect("count field required");
let truncated_small = json["truncated"]
.as_bool()
.expect("truncated field required");
assert!(
total > 1,
"premise: fixture must hold more than one artifact"
);
assert_eq!(
count_small,
json["artifacts"].as_array().unwrap().len() as u64,
"count must equal artifacts.len()",
);
assert_eq!(count_small, 1, "limit=1 must return exactly one artifact");
assert!(
truncated_small,
"limit=1 on a >1-artifact fixture must report truncated: true"
);

// Full window: only when the fixture fits under the endpoint's
// internal `.min(1000)` cap. When it doesn't, an untruncated response
// is unreachable from this endpoint — exactly the state this signal
// exists to make legible — so we skip this leg rather than assert an
// impossibility.
const ENDPOINT_LIMIT_CAP: u64 = 1000;
if total <= ENDPOINT_LIMIT_CAP {
let url = format!("/api/v1/artifacts?limit={total}");
let (status, body, _headers) = fetch(port, &url, false);
assert_eq!(status, 200);
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
let count_full = json["count"].as_u64().expect("count field required");
let truncated_full = json["truncated"]
.as_bool()
.expect("truncated field required");
assert_eq!(
count_full,
json["artifacts"].as_array().unwrap().len() as u64,
"count must equal artifacts.len()",
);
assert_eq!(count_full, total, "full window: count must equal total");
assert!(
!truncated_full,
"full window: truncated must be false when count == total"
);
}

child.kill().ok();
child.wait().ok();
}

#[test]
fn api_artifacts_search() {
let (mut child, port) = start_server();
Expand Down Expand Up @@ -843,6 +917,78 @@ fn api_diagnostics_response_shape() {
child.wait().ok();
}

/// #832 / REQ-303: the diagnostics endpoint has the same `.min(1000)` cap
/// as the artifacts endpoint and needs the same truncation signal. The
/// consequence a client cares about — "the array I got back may not be the
/// full set" — is identical, so the response shape is aligned. Same
/// full-window caveat as `api_artifacts_truncation_signal`: when `total`
/// exceeds the endpoint's cap, an untruncated response is unreachable
/// and the "full window" leg is skipped.
///
/// rivet: verifies REQ-303
#[test]
fn api_diagnostics_truncation_signal() {
let (mut child, port) = start_server();

// First call: discover the fixture's `total` and check shape.
let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?limit=1", false);
assert_eq!(status, 200);
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
let total = json["total"].as_u64().unwrap();
let count_small = json["count"].as_u64().expect("count field required");
let truncated_small = json["truncated"]
.as_bool()
.expect("truncated field required");
assert_eq!(
count_small,
json["diagnostics"].as_array().unwrap().len() as u64,
"count must equal diagnostics.len()"
);

// The fixture doesn't guarantee any diagnostics, so branch on `total`
// rather than presume shape.
if total > 1 {
assert_eq!(count_small, 1, "limit=1 must return exactly one diagnostic");
assert!(
truncated_small,
"limit=1 with total > 1 must report truncated: true"
);
} else {
assert_eq!(count_small, total);
assert!(!truncated_small);
}

// Full window: only when the fixture fits under the endpoint's cap.
const ENDPOINT_LIMIT_CAP: u64 = 1000;
if total <= ENDPOINT_LIMIT_CAP {
let url = if total == 0 {
"/api/v1/diagnostics".to_string()
} else {
format!("/api/v1/diagnostics?limit={total}")
};
let (status, body, _headers) = fetch(port, &url, false);
assert_eq!(status, 200);
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
let count_full = json["count"].as_u64().expect("count field required");
let truncated_full = json["truncated"]
.as_bool()
.expect("truncated field required");
assert_eq!(
count_full,
json["diagnostics"].as_array().unwrap().len() as u64,
"count must equal diagnostics.len()"
);
assert_eq!(count_full, total, "full window: count must equal total");
assert!(
!truncated_full,
"full window: truncated must be false when count == total"
);
}

child.kill().ok();
child.wait().ok();
}

#[test]
fn api_diagnostics_filter_severity() {
let (mut child, port) = start_server();
Expand Down
Loading