#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use axum::http::HeaderMap;
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::RestHandler,
resource::RestRouteTable,
streaming::{csv, xlsx},
};
fn export_schema() -> CompiledSchema {
let mut posts = TestQueryBuilder::new("posts", "Post")
.returns_list(true)
.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,
..RestConfig::default()
});
schema.build_indexes();
schema
}
fn canned_rows() -> Vec<JsonbValue> {
vec![JsonbValue::new(
json!({ "pk_post_id": 1, "title": "hello" }),
)]
}
fn export_config() -> ExportConfig {
ExportConfig {
csv_include_bom: false,
..ExportConfig::default()
}
}
async fn csv_body(query_pairs: &[(&str, &str)]) -> String {
csv_body_over(query_pairs, canned_rows()).await
}
async fn csv_body_over(query_pairs: &[(&str, &str)], rows: Vec<JsonbValue>) -> String {
let schema = export_schema();
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 = csv::handle_csv_get(
&handler,
&export_config(),
"/posts",
query_pairs,
&HeaderMap::new(),
None,
)
.await
.expect("the CSV export must resolve; #1274 is about what it writes, not whether it runs");
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"));
}
String::from_utf8(bytes).expect("CSV output is UTF-8")
}
fn header_of(body: &str) -> &str {
body.lines().next().unwrap_or_default()
}
#[tokio::test]
async fn a_repeated_select_heads_the_csv_with_the_column_the_rows_were_projected_by() {
let body = csv_body(&[("select", "pk_post_id"), ("select", "title")]).await;
assert_eq!(
header_of(&body),
"title",
"the extractor resolves a repeated `?select=` last-wins, so `title` is what was \
projected and must be what the header names; naming `pk_post_id` puts a column \
in the file that no row can fill: {body:?}"
);
assert!(
body.contains("hello"),
"and the value under that header must be the projected one, not an empty cell \
indistinguishable from a genuine NULL: {body:?}"
);
}
#[tokio::test]
async fn a_single_select_still_heads_the_csv_with_what_it_named() {
let body = csv_body(&[("select", "pk_post_id")]).await;
assert_eq!(header_of(&body), "pk_post_id", "{body:?}");
assert!(body.contains('1'), "the projected value is written: {body:?}");
}
#[tokio::test]
async fn the_csv_header_follows_the_repeat_in_either_order() {
let body = csv_body(&[("select", "title"), ("select", "pk_post_id")]).await;
assert_eq!(header_of(&body), "pk_post_id", "{body:?}");
assert!(body.contains('1'), "the projected value is written: {body:?}");
}
#[tokio::test]
async fn an_unselected_csv_export_heads_with_the_types_declared_fields() {
let body = csv_body(&[]).await;
assert_eq!(
header_of(&body),
"title,pk_post_id",
"`RestFieldSpec::All` expands to the declared fields at the projection (#886), so \
that is the column list the rows can fill — and in projection order, not sorted: \
{body:?}"
);
assert!(body.contains("hello"), "{body:?}");
}
#[tokio::test]
async fn an_empty_csv_export_still_writes_its_header() {
let body = csv_body_over(&[], Vec::new()).await;
assert_eq!(
body.trim_end(),
"title,pk_post_id",
"an empty export names the columns it would have carried: {body:?}"
);
}
fn xlsx_sheet_xml(bytes: &[u8]) -> String {
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("an XLSX body is a ZIP archive");
let mut sheet = archive
.by_name("xl/worksheets/sheet1.xml")
.expect("a workbook has a first worksheet");
let mut xml = String::new();
std::io::Read::read_to_string(&mut sheet, &mut xml).expect("sheet1.xml is UTF-8");
xml
}
async fn xlsx_sheet(query_pairs: &[(&str, &str)]) -> String {
let schema = export_schema();
let adapter = Arc::new(FailingAdapter::new().with_response("v_post", canned_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 = xlsx::handle_xlsx_get(
&handler,
&export_config(),
"/posts",
query_pairs,
&HeaderMap::new(),
None,
)
.await
.expect("the XLSX export must resolve");
let xlsx::XlsxBody::Bytes(bytes) = response.body;
xlsx_sheet_xml(&bytes)
}
fn header_cells(xml: &str) -> Vec<String> {
let Some(row) = xml.split("<row ").nth(1).and_then(|r| r.split("</row>").next()) else {
return Vec::new();
};
row.split("<t>")
.skip(1)
.filter_map(|c| c.split("</t>").next())
.map(str::to_owned)
.collect()
}
#[tokio::test]
async fn a_repeated_select_heads_the_workbook_with_the_projected_column() {
let xml = xlsx_sheet(&[("select", "pk_post_id"), ("select", "title")]).await;
assert_eq!(
header_cells(&xml),
vec!["title"],
"the workbook's header must name the projected column and only it; naming \
`pk_post_id` left a sheet with a header and no rows: {xml}"
);
assert!(xml.contains("hello"), "the projected value reaches the sheet: {xml}");
}
#[tokio::test]
async fn a_single_select_still_heads_the_workbook_with_what_it_named() {
let xml = xlsx_sheet(&[("select", "pk_post_id")]).await;
assert_eq!(header_cells(&xml), vec!["pk_post_id"], "{xml}");
}
#[tokio::test]
async fn an_unselected_workbook_heads_with_the_types_declared_fields_in_order() {
let xml = xlsx_sheet(&[]).await;
assert_eq!(
header_cells(&xml),
vec!["title", "pk_post_id"],
"the workbook header is the projection, in projection order — not the first \
row's keys sorted: {xml}"
);
assert!(xml.contains("hello"), "{xml}");
}