#![allow(clippy::unwrap_used)]
use axum::http::{HeaderMap, HeaderValue};
use serde_json::json;
use super::{
helpers::{error_ndjson_line, ndjson_chunk},
*,
};
#[test]
fn error_ndjson_line_valid_json() {
let line = error_ndjson_line("something went wrong");
let s = String::from_utf8(line.to_vec()).unwrap();
assert!(s.ends_with('\n'));
let parsed: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(parsed["error"], "something went wrong");
}
#[test]
fn error_ndjson_line_escapes_special_chars() {
let line = error_ndjson_line("bad \"quote\" and \nnewline");
let s = String::from_utf8(line.to_vec()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert!(parsed["error"].as_str().unwrap().contains("quote"));
}
#[test]
fn ndjson_format_one_object_per_line() {
let rows = vec![
json!({"id": 1, "name": "Alice"}),
json!({"id": 2, "name": "Bob"}),
];
let mut ndjson = Vec::new();
for row in &rows {
let mut line = serde_json::to_vec(row).unwrap();
line.push(b'\n');
ndjson.extend_from_slice(&line);
}
let output = String::from_utf8(ndjson).unwrap();
let lines: Vec<&str> = output.trim_end().split('\n').collect();
assert_eq!(lines.len(), 2);
for line in &lines {
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(parsed.is_object());
}
}
#[test]
fn ndjson_no_envelope() {
let rows = vec![json!({"id": 1})];
let mut ndjson = Vec::new();
for row in &rows {
let mut line = serde_json::to_vec(row).unwrap();
line.push(b'\n');
ndjson.extend_from_slice(&line);
}
let output = String::from_utf8(ndjson).unwrap();
assert!(!output.contains("\"data\""));
assert!(!output.contains("\"meta\""));
assert!(!output.contains("\"links\""));
}
#[test]
fn ndjson_select_fields_applied() {
let rows = [json!({"id": 1, "name": "Alice"})];
let line = serde_json::to_string(&rows[0]).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
assert!(parsed.get("id").is_some());
assert!(parsed.get("name").is_some());
assert!(parsed.get("email").is_none());
}
#[test]
fn accepts_ndjson_true_for_exact_match() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("application/x-ndjson"));
assert!(accepts_ndjson(&headers));
}
#[test]
fn accepts_ndjson_true_in_list() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("application/json, application/x-ndjson"));
assert!(accepts_ndjson(&headers));
}
#[test]
fn accepts_ndjson_false_for_json() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("application/json"));
assert!(!accepts_ndjson(&headers));
}
#[test]
fn accepts_ndjson_false_when_missing() {
let headers = HeaderMap::new();
assert!(!accepts_ndjson(&headers));
}
#[test]
fn accepts_ndjson_case_insensitive() {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("Application/X-NDJSON"));
assert!(accepts_ndjson(&headers));
}
#[test]
fn ndjson_content_type_constant() {
assert_eq!(NDJSON_CONTENT_TYPE, "application/x-ndjson");
}
#[test]
fn ndjson_chunk_writes_one_line_per_row() {
let (bytes, failed) =
ndjson_chunk(vec![Ok(json!({"id": 1})), Ok(json!({"id": 2, "name": "Bob"}))]);
assert!(!failed);
let text = String::from_utf8(bytes.to_vec()).unwrap();
let lines: Vec<&str> = text.trim_end().split('\n').collect();
assert_eq!(lines.len(), 2);
for line in lines {
assert!(serde_json::from_str::<serde_json::Value>(line).unwrap().is_object());
}
assert!(!text.contains("\"data\""), "NDJSON carries no response envelope");
}
#[test]
fn ndjson_chunk_emits_preceding_rows_then_the_error() {
let (bytes, failed) = ndjson_chunk(vec![
Ok(json!({"id": 1})),
Err(fraiseql_core::error::FraiseQLError::Database {
message: "connection reset".to_string(),
sql_state: None,
}),
Ok(json!({"id": 3})),
]);
assert!(failed, "a failed group must terminate the export");
let text = String::from_utf8(bytes.to_vec()).unwrap();
let lines: Vec<&str> = text.trim_end().split('\n').collect();
assert_eq!(lines.len(), 2, "the row before the failure, then the error line");
assert_eq!(serde_json::from_str::<serde_json::Value>(lines[0]).unwrap()["id"], 1);
let err: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert!(err["error"].as_str().unwrap().contains("connection reset"));
}
#[cfg(any(feature = "export-csv", feature = "export-xlsx"))]
mod formula_injection {
use super::super::guard_formula_injection;
#[test]
fn guard_prefixes_equal_with_single_quote() {
assert_eq!(
guard_formula_injection("=HYPERLINK(\"http://evil\")"),
"'=HYPERLINK(\"http://evil\")"
);
}
#[test]
fn guard_prefixes_plus() {
assert_eq!(guard_formula_injection("+SUM(1+1)"), "'+SUM(1+1)");
}
#[test]
fn guard_prefixes_minus() {
assert_eq!(guard_formula_injection("-2+3"), "'-2+3");
}
#[test]
fn guard_prefixes_at_sign() {
assert_eq!(guard_formula_injection("@SUM(A1:A10)"), "'@SUM(A1:A10)");
}
#[test]
fn guard_prefixes_leading_tab() {
assert_eq!(guard_formula_injection("\t=cmd|'/C calc'!A0"), "'\t=cmd|'/C calc'!A0");
}
#[test]
fn guard_prefixes_leading_cr() {
assert_eq!(guard_formula_injection("\rmalicious"), "'\rmalicious");
}
#[test]
fn guard_passes_through_safe_strings() {
for safe in ["Alice", "1234", "(NULL)", "$100.00", "false", "\"quoted\""] {
assert_eq!(
guard_formula_injection(safe),
safe,
"safe input {safe:?} must pass through unchanged"
);
}
}
#[test]
fn guard_passes_through_empty_string() {
assert_eq!(guard_formula_injection(""), "");
}
}
#[cfg(any(feature = "export-csv", feature = "export-xlsx"))]
mod export_columns {
use fraiseql_core::{runtime::QueryMatch, schema::QueryDefinition};
use serde_json::json;
use super::super::helpers::{determine_columns, export_columns};
fn match_projecting(fields: &[&str]) -> QueryMatch {
QueryMatch::from_operation(
QueryDefinition::new("posts", "Post"),
fields.iter().map(|f| (*f).to_string()).collect(),
std::collections::HashMap::new(),
None,
)
.expect("a QueryMatch over a field list is infallible")
}
#[test]
fn the_header_is_the_projection_in_projection_order() {
assert_eq!(
export_columns(&match_projecting(&["email", "id", "name"])),
Some(vec!["email".to_string(), "id".to_string(), "name".to_string()]),
"not sorted, not re-derived: the columns the rows were projected by, in order"
);
}
#[test]
fn an_empty_projection_has_no_header_to_offer() {
assert!(export_columns(&match_projecting(&[])).is_none());
}
#[test]
fn determine_columns_prefers_the_projection() {
let rows = vec![json!({"id": 1, "name": "Alice", "email": "a@b"})];
let select = vec!["email".to_string(), "id".to_string()];
assert_eq!(determine_columns(Some(&select), &rows), vec!["email", "id"]);
}
#[test]
fn determine_columns_falls_back_to_sorted_first_row_keys() {
let rows = vec![json!({"id": 1, "name": "Alice"})];
assert_eq!(determine_columns(None, &rows), vec!["id", "name"]);
}
#[test]
fn determine_columns_fallback_is_sorted_regardless_of_key_insertion_order() {
let rows = vec![json!({"name": "Alice", "email": "a@b", "id": 1})];
assert_eq!(determine_columns(None, &rows), vec!["email", "id", "name"]);
}
#[test]
fn determine_columns_is_empty_with_no_rows_and_no_projection() {
assert!(determine_columns(None, &[]).is_empty());
}
}