#![allow(clippy::unwrap_used)]
use fraiseql_core::schema::{
DeleteResponse, DeprecationInfo, FieldType, MutationDefinition, MutationOperation, RestConfig,
};
use fraiseql_test_utils::schema_builder::{TestFieldBuilder, TestSchemaBuilder, TestTypeBuilder};
use super::*;
use crate::routes::rest::resource::{
HttpMethod, MountedRoutes, RestRoute, RestRouteTable, RouteSource,
};
fn mutation(name: &str, op: MutationOperation) -> MutationDefinition {
let mut m = MutationDefinition::new(name, "User");
m.operation = op;
m.sql_source = Some(format!("fn_{name}"));
if name != "create_user" {
m.arguments
.push(fraiseql_core::schema::ArgumentDefinition::new("id", FieldType::Int));
}
if name.starts_with("update") {
m.arguments
.push(fraiseql_core::schema::ArgumentDefinition::new("name", FieldType::String));
m.arguments
.push(fraiseql_core::schema::ArgumentDefinition::new("email", FieldType::String));
}
m
}
fn rest_schema() -> CompiledSchema {
let table = "users".to_string();
let mut users_query = fraiseql_core::schema::QueryDefinition::new("users", "User");
users_query.returns_list = true;
users_query.auto_params = fraiseql_core::schema::AutoParams::all();
users_query.sql_source = Some("v_user".to_string());
let mut schema = TestSchemaBuilder::new()
.with_query(users_query)
.with_simple_query("user", "User", false)
.with_mutation(mutation(
"create_user",
MutationOperation::Insert {
table: table.clone(),
},
))
.with_mutation(mutation(
"update_user",
MutationOperation::Update {
table: table.clone(),
},
))
.with_mutation(mutation("delete_user", MutationOperation::Delete { table }))
.with_type(
TestTypeBuilder::new("User", "v_user")
.with_field(TestFieldBuilder::new("pk_user_id", FieldType::Int).build())
.with_field(TestFieldBuilder::new("name", FieldType::String).build())
.with_field(TestFieldBuilder::nullable("email", FieldType::String).build())
.build(),
)
.build();
schema.rest_config = Some(RestConfig {
enabled: true,
require_auth: true,
..RestConfig::default()
});
schema
}
fn generate(schema: &CompiledSchema) -> serde_json::Value {
let route_table = RestRouteTable::from_compiled_schema(schema).unwrap();
let mounted = MountedRoutes::write_surface(schema, &route_table);
generate_openapi(schema, &route_table, false, &mounted).unwrap()
}
fn generate_read_only(schema: &CompiledSchema) -> serde_json::Value {
let route_table = RestRouteTable::from_compiled_schema(schema).unwrap();
let mounted = MountedRoutes::read_surface(&route_table);
generate_openapi(schema, &route_table, false, &mounted).unwrap()
}
#[test]
fn spec_is_valid_openapi_303() {
let spec = generate(&rest_schema());
assert_eq!(spec["openapi"], "3.0.3");
}
#[test]
fn spec_has_info_title_and_version() {
let spec = generate(&rest_schema());
assert!(spec["info"]["title"].is_string());
assert!(spec["info"]["version"].is_string());
}
#[test]
fn spec_has_paths_and_components() {
let spec = generate(&rest_schema());
assert!(spec["paths"].is_object());
assert!(spec["components"].is_object());
assert!(spec["components"]["schemas"].is_object());
}
#[test]
fn spec_has_server_entry() {
let spec = generate(&rest_schema());
assert!(spec["servers"].is_array());
assert_eq!(spec["servers"][0]["url"], "/rest/v1");
}
#[test]
fn type_definition_produces_component_schema() {
let spec = generate(&rest_schema());
let user_schema = &spec["components"]["schemas"]["User"];
assert_eq!(user_schema["type"], "object");
assert!(user_schema["properties"]["name"].is_object());
assert!(user_schema["properties"]["email"].is_object());
}
#[test]
fn scalar_fields_map_to_json_schema_types() {
let spec = generate(&rest_schema());
let props = &spec["components"]["schemas"]["User"]["properties"];
assert_eq!(props["name"]["type"], "string");
assert_eq!(props["pk_user_id"]["type"], "integer");
}
#[test]
fn nested_object_produces_ref() {
let mut schema = rest_schema();
schema.types.push(
TestTypeBuilder::new("Address", "v_address")
.with_field(TestFieldBuilder::new("city", FieldType::String).build())
.build(),
);
for td in &mut schema.types {
if td.name == "User" {
td.fields.push(
TestFieldBuilder::new("address", FieldType::Object("Address".to_string())).build(),
);
}
}
let spec = generate(&schema);
let addr_prop = &spec["components"]["schemas"]["User"]["properties"]["address"];
assert_eq!(addr_prop["$ref"], "#/components/schemas/Address");
assert!(spec["components"]["schemas"]["Address"].is_object());
}
#[test]
fn enum_field_produces_ref() {
let mut schema = rest_schema();
schema.enums.push(fraiseql_core::schema::EnumDefinition {
name: "Status".to_string(),
values: vec![
fraiseql_core::schema::EnumValueDefinition {
name: "ACTIVE".to_string(),
description: None,
deprecation: None,
},
fraiseql_core::schema::EnumValueDefinition {
name: "INACTIVE".to_string(),
description: None,
deprecation: None,
},
],
description: None,
});
for td in &mut schema.types {
if td.name == "User" {
td.fields.push(
TestFieldBuilder::new("status", FieldType::Enum("Status".to_string())).build(),
);
}
}
let spec = generate(&schema);
let status_prop = &spec["components"]["schemas"]["User"]["properties"]["status"];
assert_eq!(status_prop["$ref"], "#/components/schemas/Status");
let enum_schema = &spec["components"]["schemas"]["Status"];
assert_eq!(enum_schema["type"], "string");
let enum_vals = enum_schema["enum"].as_array().unwrap();
assert_eq!(enum_vals.len(), 2);
}
#[test]
fn list_query_produces_get_collection_path() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let users_path = paths.keys().find(|k| *k == "/users");
assert!(users_path.is_some(), "Expected /users path");
assert!(paths["/users"]["get"].is_object());
}
#[test]
fn single_query_produces_get_by_id_path() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let user_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users"));
assert!(
user_path.is_some(),
"Expected /users/{{pk_user_id}} path, found: {:?}",
paths.keys().collect::<Vec<_>>()
);
}
#[test]
fn collection_get_has_pagination_params() {
let spec = generate(&rest_schema());
let params = &spec["paths"]["/users"]["get"]["parameters"];
let param_names: Vec<&str> =
params.as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
assert!(param_names.contains(&"limit"));
assert!(param_names.contains(&"offset"));
assert!(param_names.contains(&"select"));
assert!(param_names.contains(&"sort"));
}
#[test]
fn relay_query_has_cursor_params() {
let mut schema = rest_schema();
for q in &mut schema.queries {
if q.name == "users" {
q.relay = true;
}
}
let spec = generate(&schema);
let params = &spec["paths"]["/users"]["get"]["parameters"];
let param_names: Vec<&str> =
params.as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
assert!(param_names.contains(&"first"));
assert!(param_names.contains(&"after"));
assert!(param_names.contains(&"last"));
assert!(param_names.contains(&"before"));
assert!(!param_names.contains(&"limit"));
}
#[test]
fn insert_mutation_produces_post_path() {
let spec = generate(&rest_schema());
assert!(spec["paths"]["/users"]["post"].is_object());
}
#[test]
fn update_mutation_produces_put_and_patch() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
assert!(paths[id_path]["put"].is_object() || paths[id_path]["patch"].is_object());
}
#[test]
fn delete_mutation_produces_delete_path() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
assert!(paths[id_path]["delete"].is_object());
}
#[test]
fn post_has_request_body() {
let spec = generate(&rest_schema());
let post_op = &spec["paths"]["/users"]["post"];
assert!(post_op["requestBody"].is_object());
assert!(post_op["requestBody"]["content"]["application/json"].is_object());
}
#[test]
fn put_has_422_response() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
if let Some(put_op) = paths[id_path].get("put") {
assert!(put_op["responses"]["422"].is_object());
}
}
#[test]
fn custom_mutation_produces_post_action() {
let mut schema = rest_schema();
schema.mutations.push({
let mut m = MutationDefinition::new("archiveUser", "User");
m.operation = MutationOperation::Custom;
m.sql_source = Some("fn_archive_user".to_string());
m
});
let spec = generate(&schema);
let paths = spec["paths"].as_object().unwrap();
let action_path = paths
.keys()
.find(|k| k.contains("archive"))
.expect("Expected an archive action path");
assert!(paths[action_path]["post"].is_object());
}
#[test]
fn deprecated_operation_has_deprecated_flag() {
let mut schema = rest_schema();
for q in &mut schema.queries {
if q.name == "user" {
q.deprecation = Some(DeprecationInfo {
reason: Some("Use v2".to_string()),
});
}
}
let spec = generate(&schema);
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
let get_op = &paths[id_path]["get"];
assert_eq!(get_op["deprecated"], true);
}
#[test]
fn auth_required_produces_security_schemes() {
let schema = rest_schema();
let spec = generate(&schema);
assert!(spec["components"]["securitySchemes"]["BearerAuth"].is_object());
}
#[test]
fn auth_required_adds_401_403_to_responses() {
let schema = rest_schema();
let spec = generate(&schema);
let get_op = &spec["paths"]["/users"]["get"];
assert!(get_op["responses"]["401"].is_object());
assert!(get_op["responses"]["403"].is_object());
}
#[test]
fn no_auth_omits_security_schemes() {
let mut schema = rest_schema();
schema.rest_config = Some(RestConfig {
enabled: true,
require_auth: false,
..RestConfig::default()
});
let spec = generate(&schema);
assert!(spec["components"]["securitySchemes"].is_null());
}
#[test]
fn collection_get_has_prefer_header() {
let spec = generate(&rest_schema());
let params = &spec["paths"]["/users"]["get"]["parameters"];
let has_prefer = params.as_array().unwrap().iter().any(|p| p["name"] == "Prefer");
assert!(has_prefer);
}
#[test]
fn delete_has_prefer_header() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
if let Some(delete_op) = paths[id_path].get("delete") {
let has_prefer = delete_op["parameters"]
.as_array()
.is_some_and(|arr| arr.iter().any(|p| p["name"] == "Prefer"));
assert!(has_prefer);
}
}
#[test]
fn filter_params_document_bracket_operators() {
let spec = generate(&rest_schema());
let params = spec["paths"]["/users"]["get"]["parameters"].as_array().unwrap();
let filter_param = params
.iter()
.find(|p| p["name"].as_str().is_some_and(|n| n.contains("[operator]")));
assert!(filter_param.is_some(), "Expected bracket operator param");
let desc = filter_param.unwrap()["description"].as_str().unwrap();
assert!(desc.contains("eq"));
assert!(desc.contains("like"));
}
#[test]
fn openapi_json_endpoint_present() {
let spec = generate(&rest_schema());
assert!(spec["paths"]["/openapi.json"]["get"].is_object());
}
#[test]
fn delete_no_content_mode() {
let spec = generate(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
if let Some(delete_op) = paths[id_path].get("delete") {
assert!(delete_op["responses"]["204"].is_object());
}
}
#[test]
fn delete_entity_mode() {
let mut schema = rest_schema();
schema.rest_config = Some(RestConfig {
enabled: true,
delete_response: DeleteResponse::Entity,
..RestConfig::default()
});
let spec = generate(&schema);
let paths = spec["paths"].as_object().unwrap();
let id_path = paths.keys().find(|k| k.contains('{') && k.starts_with("/users")).unwrap();
if let Some(delete_op) = paths[id_path].get("delete") {
assert!(delete_op["responses"]["200"].is_object());
}
}
#[test]
fn error_schema_present() {
let spec = generate(&rest_schema());
let error_schema = &spec["components"]["schemas"]["Error"];
assert_eq!(error_schema["type"], "object");
assert!(error_schema["properties"]["error"].is_object());
}
#[test]
fn missing_rest_config_returns_error() {
let schema = TestSchemaBuilder::new().build();
let route_table = RestRouteTable {
base_path: "/rest/v1".to_string(),
resources: vec![],
diagnostics: vec![],
};
let result = generate_openapi(&schema, &route_table, false, &MountedRoutes::default());
assert!(result.is_err());
}
#[test]
fn empty_route_table_produces_minimal_spec() {
let mut schema = TestSchemaBuilder::new().build();
schema.rest_config = Some(RestConfig {
enabled: true,
..RestConfig::default()
});
let route_table = RestRouteTable {
base_path: "/rest/v1".to_string(),
resources: vec![],
diagnostics: vec![],
};
let spec = generate_openapi(&schema, &route_table, false, &MountedRoutes::default()).unwrap();
assert_eq!(spec["openapi"], "3.0.3");
let paths = spec["paths"].as_object().unwrap();
assert_eq!(paths.len(), 1);
}
#[test]
fn bulk_update_produces_collection_patch() {
let spec = generate(&rest_schema());
let patch_op = &spec["paths"]["/users"]["patch"];
assert!(patch_op.is_object(), "Expected PATCH on /users");
assert_eq!(patch_op["operationId"], "bulk_update_users");
assert!(patch_op["responses"]["200"].is_object());
assert!(patch_op["responses"]["400"].is_object());
}
#[test]
fn bulk_delete_produces_collection_delete() {
let spec = generate(&rest_schema());
let delete_op = &spec["paths"]["/users"]["delete"];
assert!(delete_op.is_object(), "Expected DELETE on /users");
assert_eq!(delete_op["operationId"], "bulk_delete_users");
assert!(delete_op["responses"]["200"].is_object());
assert!(delete_op["responses"]["400"].is_object());
}
#[test]
fn a_read_only_mount_advertises_reads_but_no_writes() {
let spec = generate_read_only(&rest_schema());
let paths = spec["paths"].as_object().unwrap();
let mut reads = 0_usize;
let mut writes = Vec::new();
for (path, item) in paths {
if path == "/openapi.json" {
continue;
}
for method in item.as_object().unwrap().keys() {
match method.as_str() {
"get" => reads += 1,
other => writes.push(format!("{other} {path}")),
}
}
}
assert!(reads > 0, "the read-only document must still describe its GETs");
assert!(writes.is_empty(), "read-only mount advertised write operations: {writes:?}");
}
#[test]
fn nothing_mounted_produces_no_operations() {
let schema = rest_schema();
let route_table = RestRouteTable::from_compiled_schema(&schema).unwrap();
let spec = generate_openapi(&schema, &route_table, false, &MountedRoutes::default()).unwrap();
let paths = spec["paths"].as_object().unwrap();
assert_eq!(
paths.len(),
1,
"only the /openapi.json self-reference may remain, got {paths:?}"
);
assert!(paths.contains_key("/openapi.json"));
}
#[test]
fn post_body_supports_array_for_bulk_insert() {
let spec = generate(&rest_schema());
let post_body =
&spec["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"];
assert!(post_body["oneOf"].is_array(), "Expected oneOf schema for bulk insert support");
let variants = post_body["oneOf"].as_array().unwrap();
assert_eq!(variants.len(), 2);
assert_eq!(variants[1]["type"], "array");
}
#[test]
fn post_has_prefer_header_for_upsert() {
let spec = generate(&rest_schema());
let params = &spec["paths"]["/users"]["post"]["parameters"];
let has_prefer = params.as_array().unwrap().iter().any(|p| p["name"] == "Prefer");
assert!(has_prefer, "POST should have Prefer header for upsert/bulk preferences");
}
#[test]
fn info_has_default_title() {
let spec = generate(&rest_schema());
assert_eq!(spec["info"]["title"], "FraiseQL REST API");
}
#[test]
fn info_has_default_version() {
let spec = generate(&rest_schema());
assert_eq!(spec["info"]["version"], "1.0.0");
}
#[test]
fn collection_get_has_logical_operator_params() {
let spec = generate(&rest_schema());
let params = spec["paths"]["/users"]["get"]["parameters"].as_array().unwrap();
let param_names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
assert!(param_names.contains(&"or"), "Expected `or` logical param");
assert!(param_names.contains(&"and"), "Expected `and` logical param");
assert!(param_names.contains(&"not"), "Expected `not` logical param");
}
#[test]
fn fts_enabled_resource_has_search_param() {
let schema = rest_schema();
let spec = generate(&schema);
let params = spec["paths"]["/users"]["get"]["parameters"].as_array().unwrap();
let search_param = params.iter().find(|p| p["name"] == "search");
assert!(search_param.is_some(), "Expected `search` param on FTS-enabled resource");
let desc = search_param.unwrap()["description"].as_str().unwrap();
assert!(desc.contains("name"), "Expected field name in search description: {desc}");
}
#[test]
fn non_fts_resource_has_no_search_param() {
let mut users_query = fraiseql_core::schema::QueryDefinition::new("counters", "Counter");
users_query.returns_list = true;
users_query.auto_params = fraiseql_core::schema::AutoParams::all();
users_query.sql_source = Some("v_counter".to_string());
let mut schema = TestSchemaBuilder::new()
.with_query(users_query)
.with_type(
TestTypeBuilder::new("Counter", "v_counter")
.with_field(TestFieldBuilder::new("pk_id", FieldType::Int).build())
.with_field(TestFieldBuilder::new("value", FieldType::Int).build())
.build(),
)
.build();
schema.rest_config = Some(RestConfig::default());
let spec = generate(&schema);
let params = spec["paths"]["/counters"]["get"]["parameters"].as_array().unwrap();
let search_param = params.iter().find(|p| p["name"] == "search");
assert!(search_param.is_none(), "Non-FTS resource should not have search param");
}
#[test]
fn a_query_that_accepts_no_filter_has_no_search_param() {
let mut schema = rest_schema();
let users = schema
.queries
.iter_mut()
.find(|q| q.name == "users")
.expect("the fixture's list query");
users.auto_params.has_where = false;
schema.build_indexes();
let spec = generate(&schema);
let params = spec["paths"]["/users"]["get"]["parameters"].as_array().unwrap();
let names: Vec<&str> = params.iter().filter_map(|p| p["name"].as_str()).collect();
assert!(
!names.contains(&"search"),
"`?search=` is a filter, and this query accepts none: {names:?}"
);
assert!(
!names.contains(&"filter") && !names.contains(&"or"),
"nor the filter parameters it was always beside: {names:?}"
);
assert!(
names.contains(&"limit"),
"pagination is not filtering and is still published: {names:?}"
);
}
use super::format::{
capitalize, extract_action, method_to_string, should_have_prefer_header, to_snake,
};
#[test]
fn capitalize_test() {
assert_eq!(capitalize("users"), "Users");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("a"), "A");
}
#[test]
fn to_snake_test() {
assert_eq!(to_snake("Users"), "users");
assert_eq!(to_snake("UserProfile"), "user_profile");
assert_eq!(to_snake("API"), "a_p_i");
}
#[test]
fn extract_action_suffix() {
assert_eq!(extract_action("archiveUser", "User"), "archive");
assert_eq!(extract_action("deleteUser", "User"), "delete");
}
#[test]
fn extract_action_prefix() {
assert_eq!(extract_action("userArchive", "User"), "archive");
assert_eq!(extract_action("userDelete", "User"), "delete");
}
#[test]
fn extract_action_fallback() {
assert_eq!(extract_action("complexAction", "Other"), "complex-action");
}
#[test]
fn method_to_string_all() {
assert_eq!(method_to_string(HttpMethod::Get), "get");
assert_eq!(method_to_string(HttpMethod::Post), "post");
assert_eq!(method_to_string(HttpMethod::Put), "put");
assert_eq!(method_to_string(HttpMethod::Patch), "patch");
assert_eq!(method_to_string(HttpMethod::Delete), "delete");
}
#[test]
fn should_have_prefer_header_get_collection() {
let mut route = RestRoute {
method: HttpMethod::Get,
path: "/users".to_string(),
source: RouteSource::Query {
name: "users".to_string(),
},
update_coverage: None,
success_status: 200,
};
assert!(should_have_prefer_header(&route));
route.path = "/users/{id}".to_string();
assert!(!should_have_prefer_header(&route));
}
#[test]
fn should_have_prefer_header_post() {
let route = RestRoute {
method: HttpMethod::Post,
path: "/users".to_string(),
source: RouteSource::Mutation {
name: "createUser".to_string(),
},
update_coverage: None,
success_status: 201,
};
assert!(should_have_prefer_header(&route));
}
#[test]
fn should_have_prefer_header_put() {
let route = RestRoute {
method: HttpMethod::Put,
path: "/users/{id}".to_string(),
source: RouteSource::Mutation {
name: "updateUser".to_string(),
},
update_coverage: None,
success_status: 200,
};
assert!(!should_have_prefer_header(&route));
}
#[test]
fn list_endpoint_advertises_ndjson_content_type() {
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"];
assert!(content["application/json"].is_object());
assert!(content["application/x-ndjson"].is_object());
}
#[cfg(feature = "export-csv")]
#[test]
fn list_endpoint_advertises_text_csv_when_export_csv_enabled() {
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"];
assert!(
content["text/csv"].is_object(),
"expected text/csv in list response content map"
);
assert_eq!(content["text/csv"]["schema"]["type"], "string");
assert_eq!(content["text/csv"]["schema"]["format"], "binary");
}
#[cfg(not(feature = "export-csv"))]
#[test]
fn list_endpoint_omits_text_csv_when_export_csv_disabled() {
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"];
assert!(
content.get("text/csv").is_none(),
"text/csv should not be advertised when export-csv feature is off",
);
}
#[cfg(feature = "export-csv")]
#[test]
fn single_resource_endpoint_does_not_advertise_text_csv() {
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users/{id}"]["get"]["responses"]["200"]["content"];
assert!(
content.get("text/csv").is_none(),
"text/csv is a list-only response — single resource endpoints should not advertise it",
);
}
#[test]
fn no_export_representation_promises_embedded_relationships() {
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"];
let mut checked = 0;
#[allow(clippy::single_element_loop)]
for media in [
"application/x-ndjson",
#[cfg(feature = "export-csv")]
"text/csv",
#[cfg(feature = "export-xlsx")]
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
] {
let description = content[media]["description"].as_str().unwrap_or_default();
assert!(
!description.is_empty(),
"{media} must carry a description saying what it does not embed: {content}"
);
assert!(
!description.contains("serialised as JSON inside a single cell"),
"{media} still promises to carry an embed: {description}"
);
assert!(
description.contains("?rel.field=value"),
"{media} refuses the filters on an embed too (#1275); a document naming only the \
embed leaves a client expecting its filter to apply: {description}"
);
assert!(
description.contains("are not available on this representation"),
"{media} must say the exports do not carry embeds, so the type `$ref` above is not read as a promise: {description}"
);
checked += 1;
}
assert!(checked >= 1, "no export representation was checked");
}
#[cfg(feature = "export-xlsx")]
#[test]
fn list_endpoint_advertises_xlsx_when_export_xlsx_enabled() {
const XLSX_MIME: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"];
assert!(
content[XLSX_MIME].is_object(),
"expected XLSX MIME in list response content map"
);
assert_eq!(content[XLSX_MIME]["schema"]["type"], "string");
assert_eq!(content[XLSX_MIME]["schema"]["format"], "binary");
}
#[cfg(not(feature = "export-xlsx"))]
#[test]
fn list_endpoint_omits_xlsx_when_export_xlsx_disabled() {
const XLSX_MIME: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users"]["get"]["responses"]["200"]["content"];
assert!(
content.get(XLSX_MIME).is_none(),
"XLSX MIME should not be advertised when export-xlsx feature is off",
);
}
#[cfg(feature = "export-xlsx")]
#[test]
fn single_resource_endpoint_does_not_advertise_xlsx() {
const XLSX_MIME: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
let spec = generate(&rest_schema());
let content = &spec["paths"]["/users/{id}"]["get"]["responses"]["200"]["content"];
assert!(
content.get(XLSX_MIME).is_none(),
"XLSX MIME is a list-only response — single resource endpoints should not advertise it",
);
}
fn stream_operation(spec: &serde_json::Value) -> &serde_json::Value {
let op = &spec["paths"]["/users/stream"]["get"];
assert!(op.is_object(), "the stream path must be documented; paths: {}", spec["paths"]);
op
}
fn last_event_id_param(spec: &serde_json::Value) -> &serde_json::Value {
stream_operation(spec)["parameters"]
.as_array()
.expect("the stream operation must declare parameters")
.iter()
.find(|p| p["name"] == "Last-Event-ID")
.expect("the stream operation must document Last-Event-ID")
}
#[test]
fn the_document_describes_the_resumption_the_handler_performs() {
let spec = generate(&rest_schema());
let description = last_event_id_param(&spec)["description"]
.as_str()
.expect("the parameter must carry a description");
assert!(
description.contains("replays"),
"the document must say the header resumes the stream: {description}"
);
assert!(
description.contains("at-least-once") && description.contains("dedup"),
"a client must learn from the document that an event can repeat across a \
reconnect, and what to dedup on: {description}"
);
let responses = &stream_operation(&spec)["responses"];
for (status, code) in [
("400", "RESUME_POINT_INVALID"),
("410", "RESUME_POINT_UNKNOWN"),
("413", "RESUME_TOO_FAR_BEHIND"),
("501", "RESUMPTION_UNSUPPORTED"),
] {
let described = responses[status]["description"]
.as_str()
.expect("the stream operation must document this response");
assert!(
described.contains(code),
"the {status} response must name the code a client branches on: {described}"
);
}
}
#[test]
fn the_document_states_that_the_wire_id_is_the_change_spine_sequence() {
let spec = generate(&rest_schema());
let ok = stream_operation(&spec)["responses"]["200"]["description"]
.as_str()
.expect("the 200 must carry a description");
assert!(
ok.contains("seq"),
"a client cannot use an id whose meaning is undocumented: {ok}"
);
}
fn multi_tenant_rest_schema(require_auth: bool) -> CompiledSchema {
let mut schema = rest_schema();
let mut security = fraiseql_core::schema::SecurityConfig::new();
security.multi_tenant = true;
schema.security = Some(security);
schema.rest_config = Some(RestConfig {
enabled: true,
require_auth,
..RestConfig::default()
});
schema
}
fn stream_403_description(spec: &serde_json::Value) -> String {
stream_operation(spec)["responses"]["403"]["description"]
.as_str()
.unwrap_or_default()
.to_string()
}
#[test]
fn the_tenant_refusal_is_documented_exactly_where_it_can_occur() {
let single = stream_403_description(&generate(&rest_schema()));
assert!(
!single.contains("tenant"),
"a single-tenant deployment cannot refuse for want of a tenant: {single}"
);
let multi = stream_403_description(&generate(&multi_tenant_rest_schema(true)));
assert!(
multi.contains("tenant"),
"a multi-tenant deployment refuses an untenanted caller and must say why: {multi}"
);
}
#[test]
fn the_tenant_refusal_is_documented_even_with_auth_disabled() {
let spec = generate(&multi_tenant_rest_schema(false));
assert!(
spec["components"]["securitySchemes"]["BearerAuth"].is_null(),
"this case is only meaningful with the security posture off"
);
assert!(
stream_403_description(&spec).contains("tenant"),
"an anonymous request to a multi-tenant deployment is refused; the document \
must say so: {}",
stream_operation(&spec)["responses"]
);
}