#![allow(clippy::unwrap_used)]
use proptest::prelude::*;
use rust_supabase_sdk::select::{
Filter, FilterGroup, LogicalOperator, Operator, SelectQuery, Sort, SortDirection,
};
use rust_supabase_sdk::SupabaseClient;
fn arb_safe_value() -> impl Strategy<Value = String> {
prop_oneof![
"[ -~]{0,200}",
Just("".to_string()),
Just(" ".to_string()),
Just("a&b=c".to_string()),
Just("100%".to_string()),
Just("hello world".to_string()),
Just("O'Brien".to_string()),
Just("'; DROP TABLE users; --".to_string()),
Just("\n\t\r".to_string()),
Just("a,b,c".to_string()),
Just("(parens)".to_string()),
]
}
fn arb_column() -> impl Strategy<Value = String> {
"[a-z_][a-z0-9_]{0,30}"
}
fn arb_operator() -> impl Strategy<Value = Operator> {
prop_oneof![
Just(Operator::Eq),
Just(Operator::Neq),
Just(Operator::Lt),
Just(Operator::Gt),
Just(Operator::Lte),
Just(Operator::Gte),
Just(Operator::Like),
]
}
fn arb_filter() -> impl Strategy<Value = Filter> {
(arb_column(), arb_operator(), arb_safe_value()).prop_map(|(c, o, v)| Filter {
column: c,
operator: o,
value: v,
})
}
fn arb_logical() -> impl Strategy<Value = LogicalOperator> {
prop_oneof![Just(LogicalOperator::And), Just(LogicalOperator::Or)]
}
fn arb_sort() -> impl Strategy<Value = Sort> {
(
arb_column(),
prop_oneof![Just(SortDirection::Asc), Just(SortDirection::Desc)],
)
.prop_map(|(c, d)| Sort::new(&c, d))
}
proptest! {
#[test]
fn filter_value_round_trips_via_urlencoding(value in arb_safe_value()) {
let filter = Filter::new("col", Operator::Eq, &value);
let q = filter.to_query();
let prefix = "col=eq.";
prop_assert!(q.starts_with(prefix), "missing prefix: {q}");
let encoded = &q[prefix.len()..];
let decoded = urlencoding::decode(encoded).unwrap();
prop_assert_eq!(decoded.as_ref(), value.as_str());
}
#[test]
fn select_query_is_well_formed(
filters in prop::collection::vec(arb_filter(), 0..5),
logical in arb_logical(),
sorts in prop::collection::vec(arb_sort(), 0..3),
) {
let mut q = SelectQuery::new();
if !filters.is_empty() {
q.filter = Some(FilterGroup::new(logical, filters));
}
q.sorts = sorts;
let s = q.to_query_string();
prop_assert!(s.starts_with("select="), "doesn't start with select=: {s}");
prop_assert!(!s.contains("&&"), "double-ampersand: {s}");
prop_assert!(!s.ends_with('&'), "trailing ampersand: {s}");
prop_assert!(!s.contains('\n'), "raw newline: {s}");
prop_assert!(!s.contains('\r'), "raw CR: {s}");
}
#[test]
fn single_filter_and_group_equivalent(f in arb_filter()) {
let bare = f.to_query();
let group = FilterGroup::new(LogicalOperator::And, vec![f.clone()]);
let group_str = group.to_query_string();
prop_assert_eq!(bare, group_str);
}
#[test]
fn builder_eq_round_trips(value in arb_safe_value()) {
let client = SupabaseClient::new("https://example.supabase.co", "anon", None);
let path = client.from("t").select("*").eq("col", value.clone()).build_path();
let prefix = "/rest/v1/t?select=%2A&col=eq.";
prop_assert!(path.starts_with(prefix), "unexpected path: {path}");
let encoded = &path[prefix.len()..];
let decoded = urlencoding::decode(encoded).unwrap();
prop_assert_eq!(decoded.as_ref(), value.as_str());
}
#[test]
fn chained_filters_have_consistent_param_count(
cols in prop::collection::vec(arb_column(), 1..6),
) {
let client = SupabaseClient::new("https://example.supabase.co", "anon", None);
let mut q = client.from("t").select("*");
for c in &cols {
q = q.eq(c, "v");
}
let path = q.build_path();
let qstr = path.split_once('?').unwrap().1;
let params: Vec<&str> = qstr.split('&').collect();
prop_assert_eq!(params.len(), cols.len() + 1);
prop_assert_eq!(params[0], "select=%2A");
}
#[test]
fn or_filter_always_wraps_in_parens(inner in "[a-z]{1,8}\\.eq\\.[a-z]{1,8}") {
let client = SupabaseClient::new("https://example.supabase.co", "anon", None);
let path = client.from("t").select("*").or(&inner).build_path();
prop_assert!(path.contains("or="), "missing or=: {path}");
prop_assert!(path.contains("%28"), "missing encoded '(': {path}");
prop_assert!(path.contains("%29"), "missing encoded ')': {path}");
}
#[test]
fn in_list_size_preserved(items in prop::collection::vec("[a-z]{1,8}", 1..6)) {
let client = SupabaseClient::new("https://example.supabase.co", "anon", None);
let items_strs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let path = client.from("t").select("*").in_("c", items_strs.clone()).build_path();
let comma_count = path.matches("%2C").count();
prop_assert_eq!(comma_count, items.len().saturating_sub(1));
}
#[test]
fn order_emits_stable_param(col in arb_column(), ascending: bool) {
let client = SupabaseClient::new("https://example.supabase.co", "anon", None);
let path = client.from("t").select("*").order(&col, ascending).build_path();
prop_assert!(path.contains("order="), "missing order=: {path}");
let needle = if ascending { ".asc" } else { ".desc" };
prop_assert!(path.contains(needle), "missing {needle} in {path}");
}
#[test]
fn limit_offset_well_formed(n in 0u64..1_000_000) {
let client = SupabaseClient::new("https://example.supabase.co", "anon", None);
let p1 = client.from("t").select("*").limit(n).build_path();
let p2 = client.from("t").select("*").offset(n).build_path();
prop_assert!(p1.contains(&format!("limit={n}")), "limit not found: {p1}");
prop_assert!(p2.contains(&format!("offset={n}")), "offset not found: {p2}");
}
}
#[test]
fn empty_string_value_does_not_break_filter() {
let f = Filter::new("col", Operator::Eq, "");
let q = f.to_query();
assert_eq!(q, "col=eq.");
}
#[test]
fn newline_in_value_is_encoded() {
let f = Filter::new("col", Operator::Eq, "line1\nline2");
let q = f.to_query();
assert!(q.contains("%0A"), "{q}");
assert!(!q.contains('\n'), "{q}");
}
#[test]
fn quote_injection_value_is_encoded() {
let f = Filter::new("col", Operator::Eq, "'; DROP TABLE users; --");
let q = f.to_query();
assert!(q.contains("%27"), "{q}");
assert!(q.contains("%3B"), "{q}");
}
#[test]
fn very_long_value_does_not_panic() {
let big = "a".repeat(10_000);
let f = Filter::new("col", Operator::Eq, &big);
let q = f.to_query();
assert!(q.starts_with("col=eq."));
assert_eq!(q.len(), "col=eq.".len() + big.len());
}
#[test]
fn unicode_value_is_percent_encoded() {
let f = Filter::new("col", Operator::Eq, "café");
let q = f.to_query();
assert!(q.contains("%C3%A9"), "{q}");
}