#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use axum::http::{HeaderMap, StatusCode};
use fraiseql_core::{
db::types::JsonbValue,
runtime::Executor,
schema::{CompiledSchema, FieldType, RestConfig},
};
use fraiseql_test_utils::{
failing_adapter::FailingAdapter,
schema_builder::{TestFieldBuilder, TestQueryBuilder, TestTypeBuilder},
};
use serde_json::json;
use crate::routes::rest::{
export_config::ExportConfig,
handler::{RestError, RestHandler},
resource::RestRouteTable,
streaming::csv,
};
fn export_schema(relay: bool, max_page_size: u64) -> CompiledSchema {
let mut posts = TestQueryBuilder::new("posts", "Post")
.returns_list(true)
.relay(relay)
.with_sql_source("v_post")
.build();
posts.rest_stream = true;
let post = TestTypeBuilder::new("Post", "v_post")
.with_field(TestFieldBuilder::new("title", FieldType::String).build())
.with_field(TestFieldBuilder::new("pk_post_id", FieldType::Int).build())
.build();
let mut schema = CompiledSchema::new();
schema.queries.push(posts);
schema.types.push(post);
schema.rest_config = Some(RestConfig {
enabled: true,
require_auth: false,
max_page_size,
..RestConfig::default()
});
schema.build_indexes();
schema
}
fn relay_export_schema() -> CompiledSchema {
export_schema(true, RestConfig::default().max_page_size)
}
fn canned_rows(count: usize) -> Vec<JsonbValue> {
(1..=count)
.map(|n| JsonbValue::new(json!({ "pk_post_id": n, "title": format!("row-{n}") })))
.collect()
}
fn export_config() -> ExportConfig {
ExportConfig {
csv_include_bom: false,
..ExportConfig::default()
}
}
async fn csv_export(query_pairs: &[(&str, &str)]) -> Result<String, RestError> {
Box::pin(csv_export_over(relay_export_schema(), canned_rows(1), query_pairs)).await
}
async fn csv_export_over(
schema: CompiledSchema,
rows: Vec<JsonbValue>,
query_pairs: &[(&str, &str)],
) -> Result<String, RestError> {
let adapter = Arc::new(FailingAdapter::new().with_response("v_post", rows));
let executor = Arc::new(Executor::new(schema.clone(), adapter));
let route_table = RestRouteTable::from_compiled_schema(&schema).unwrap();
let rest_config = schema.rest_config.clone().unwrap();
let handler = RestHandler::new(&executor, &schema, &rest_config, &route_table);
let response = Box::pin(csv::handle_csv_get(
&handler,
&export_config(),
"/posts",
query_pairs,
&HeaderMap::new(),
None,
))
.await?;
let csv::CsvBody::Stream(mut stream) = response.body;
let mut bytes = Vec::new();
while let Some(chunk) = futures::StreamExt::next(&mut stream).await {
bytes.extend_from_slice(&chunk.expect("the CSV stream is infallible"));
}
Ok(String::from_utf8(bytes).expect("CSV output is UTF-8"))
}
#[tokio::test]
async fn a_relay_route_exports_a_request_that_named_no_cursor() {
let body = csv_export(&[]).await.unwrap_or_else(|err| {
panic!(
"#1273: a bare export request on a relay route carries no cursor parameter, so \
there is no page for the export to refuse — `resolve_pagination` fills `first` \
with the server's default page size and the refusal used to read that. Answered \
{}: {}",
err.status, err.message
)
});
assert_eq!(
body.lines().next().unwrap_or_default(),
"title,pk_post_id",
"the export is the whole relation in declared field order — relay is a pagination \
mode, and an export has no pages: {body:?}"
);
assert!(body.contains("row-1"), "and the rows are the view's rows: {body:?}");
}
#[tokio::test]
async fn a_relay_route_export_is_bounded_by_first() {
let body = csv_export_over(relay_export_schema(), canned_rows(25), &[("first", "10")])
.await
.unwrap_or_else(|err| {
panic!(
"`?first=10` bounds an export total on a relay route, as `?limit=10` does on an \
offset one. Answered {}: {}",
err.status, err.message
)
});
let data_rows = body.lines().skip(1).filter(|l| !l.trim().is_empty()).count();
assert_eq!(
data_rows, 10,
"the export is bounded by the count the client sent, unclamped by max_page_size \
(`export_rows` applies it to the stream): {body:?}"
);
}
#[tokio::test]
async fn a_relay_route_refuses_an_export_that_named_a_cursor_position() {
let err = csv_export(&[("after", "Y3Vyc29yOjE=")])
.await
.expect_err("`?after=` names a position in a page sequence an export does not have");
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert!(
err.message.contains("pagination not available"),
"the refusal states its own diagnosis: {}",
err.message
);
}
#[tokio::test]
async fn a_relay_route_refuses_an_export_that_paged_backwards() {
for (name, value) in [("last", "10"), ("before", "Y3Vyc29yOjE=")] {
let err = match csv_export(&[(name, value)]).await {
Ok(body) => panic!("`?{name}=` must be refused by an export, got a body: {body:?}"),
Err(err) => err,
};
assert_eq!(err.status, StatusCode::BAD_REQUEST, "?{name}=");
assert!(
err.message.contains("pagination not available"),
"?{name}= is refused with its own diagnosis: {}",
err.message
);
}
}
#[tokio::test]
async fn an_export_total_is_not_bounded_by_max_page_size() {
let body = csv_export_over(export_schema(false, 3), canned_rows(5), &[("limit", "5")])
.await
.expect("`?limit=` bounds an export total; it does not make the request unexportable");
let data_rows = body.lines().skip(1).filter(|l| !l.is_empty()).count();
assert_eq!(
data_rows, 5,
"the client asked for 5 rows and `max_page_size` is 3 — a body of 3 rows means the \
export read the resolved plan, which is a page, instead of the request: {body:?}"
);
}
#[tokio::test]
async fn an_export_total_below_the_row_count_truncates_the_body() {
let body = csv_export_over(export_schema(false, 3), canned_rows(5), &[("limit", "2")])
.await
.expect("`?limit=2` is a total bound an export honours");
let data_rows = body.lines().skip(1).filter(|l| !l.is_empty()).count();
assert_eq!(data_rows, 2, "?limit=2 bounds the export to 2 rows: {body:?}");
}
#[tokio::test]
async fn a_repeated_export_limit_is_bounded_by_the_occurrence_that_won() {
let body = csv_export_over(
export_schema(false, 10),
canned_rows(5),
&[("limit", "2"), ("limit", "4")],
)
.await
.expect("a repeated `?limit=` is not a refusal");
let data_rows = body.lines().skip(1).filter(|l| !l.is_empty()).count();
assert_eq!(
data_rows, 4,
"the extractor resolves a repeated `?limit=` last-wins, so 4 is the figure the plan \
and the projection were built from and must be the figure the export is cut at; \
2 means a second parser is still reading the query string: {body:?}"
);
}