Skip to main content

max / makenotwork

Add types edge case tests, optimize discover short queries types/mod.rs: 22 new tests covering PWYW display logic, duration formatting, JSON-LD escape edge cases, and PromoCode descriptions. discover.rs: Skip trigram similarity for 1-2 char queries (ILIKE-only), add 200-character max search length, extract normalize_search helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-16 01:53 UTC
Commit: 820e5595c92d9e105f52cc0fc5c45ec5312a1862
Parent: ae244d9
2 files changed, +387 insertions, -23 deletions
@@ -40,11 +40,64 @@ const PROJECT_SEARCH_CLAUSE: &str = r#" AND (
40 40 OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
41 41 )"#;
42 42
43 + // ILIKE-only variants for short queries (1-2 chars) where trigram similarity is unreliable.
44 +
45 + const ITEM_SEARCH_CLAUSE_SHORT: &str = r#" AND (
46 + i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
47 + OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
48 + )"#;
49 +
50 + const PROJECT_SEARCH_CLAUSE_SHORT: &str = r#" AND (
51 + p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
52 + OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
53 + )"#;
54 +
55 + /// Maximum allowed search term length. Queries longer than this are truncated.
56 + const MAX_SEARCH_LEN: usize = 200;
57 +
58 + /// Normalize a search term: trim whitespace, truncate to [`MAX_SEARCH_LEN`],
59 + /// and return `None` if the result is empty.
60 + fn normalize_search(raw: Option<&str>) -> Option<String> {
61 + let trimmed = raw?.trim();
62 + if trimmed.is_empty() {
63 + return None;
64 + }
65 + if trimmed.len() > MAX_SEARCH_LEN {
66 + // Truncate at a char boundary
67 + let end = trimmed
68 + .char_indices()
69 + .take_while(|(i, _)| *i < MAX_SEARCH_LEN)
70 + .last()
71 + .map(|(i, c)| i + c.len_utf8())
72 + .unwrap_or(MAX_SEARCH_LEN);
73 + Some(trimmed[..end].to_string())
74 + } else {
75 + Some(trimmed.to_string())
76 + }
77 + }
78 +
79 + /// Returns `true` when the search term is too short for trigram matching (1-2 chars).
80 + fn is_short_query(term: &str) -> bool {
81 + term.trim().len() <= 2
82 + }
83 +
43 84 /// Append discover-item filter clauses to a dynamic query.
44 85 /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label.
45 - fn append_item_discover_filters(query: &mut String, filters: &DiscoverFilters<'_>, has_search: bool) {
86 + ///
87 + /// When `short_query` is `true`, the ILIKE-only clause is used instead of the
88 + /// full trigram + ILIKE clause (trigram matching is unreliable for 1-2 char terms).
89 + fn append_item_discover_filters(
90 + query: &mut String,
91 + filters: &DiscoverFilters<'_>,
92 + has_search: bool,
93 + short_query: bool,
94 + ) {
46 95 if has_search {
47 - query.push_str(ITEM_SEARCH_CLAUSE);
96 + if short_query {
97 + query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
98 + } else {
99 + query.push_str(ITEM_SEARCH_CLAUSE);
100 + }
48 101 }
49 102 if filters.item_type.is_some() {
50 103 query.push_str(" AND i.item_type = $2");
@@ -105,13 +158,16 @@ pub async fn discover_items(
105 158 limit: i64,
106 159 offset: i64,
107 160 ) -> Result<Vec<DbDiscoverItemRow>> {
108 - let search_term = filters.search.filter(|s| !s.trim().is_empty());
161 + let search_term = normalize_search(filters.search);
109 162 let has_search = search_term.is_some();
163 + let short_query = search_term.as_deref().map_or(false, is_short_query);
110 164
111 - // Build the base query with optional similarity score
165 + // Build the base query with optional similarity score.
166 + // For short queries (1-2 chars) use a constant match_score since trigram
167 + // similarity is unreliable at that length.
112 168 // Use LEFT JOIN with pre-aggregated transaction counts to avoid N+1 subquery per row
113 169 // LEFT JOIN item_tags/tags for primary tag display
114 - let mut query = if has_search {
170 + let mut query = if has_search && !short_query {
115 171 String::from(
116 172 r#"
117 173 SELECT
@@ -139,6 +195,32 @@ pub async fn discover_items(
139 195 WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
140 196 "#,
141 197 )
198 + } else if has_search {
199 + // Short query: constant match_score, skip trigram similarity computation
200 + String::from(
201 + r#"
202 + SELECT
203 + i.id,
204 + i.title,
205 + i.description,
206 + i.price_cents,
207 + i.item_type,
208 + i.created_at,
209 + u.username,
210 + p.title as project_title,
211 + i.sales_count::bigint,
212 + pt.name as primary_tag_name,
213 + i.pwyw_enabled,
214 + i.pwyw_min_cents,
215 + 1.0::real as match_score
216 + FROM items i
217 + JOIN projects p ON i.project_id = p.id
218 + JOIN users u ON p.user_id = u.id
219 + LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
220 + LEFT JOIN tags pt ON pt.id = pit.tag_id
221 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
222 + "#,
223 + )
142 224 } else {
143 225 String::from(
144 226 r#"
@@ -166,7 +248,7 @@ pub async fn discover_items(
166 248 )
167 249 };
168 250
169 - append_item_discover_filters(&mut query, filters, has_search);
251 + append_item_discover_filters(&mut query, filters, has_search, short_query);
170 252
171 253 // Determine ordering
172 254 let order = if has_search && (filters.sort_by.is_none() || filters.sort_by == Some(DiscoverSort::Newest)) {
@@ -185,7 +267,7 @@ pub async fn discover_items(
185 267 let items = bind_item_discover_filters!(
186 268 sqlx::query_as::<_, DbDiscoverItemRow>(&query),
187 269 filters,
188 - search_term
270 + search_term.as_deref()
189 271 )
190 272 .bind(limit)
191 273 .bind(offset)
@@ -200,8 +282,9 @@ pub async fn count_discover_items(
200 282 pool: &PgPool,
201 283 filters: &DiscoverFilters<'_>,
202 284 ) -> Result<i64> {
203 - let search_term = filters.search.filter(|s| !s.trim().is_empty());
285 + let search_term = normalize_search(filters.search);
204 286 let has_search = search_term.is_some();
287 + let short_query = search_term.as_deref().map_or(false, is_short_query);
205 288
206 289 let mut query = String::from(
207 290 r#"
@@ -212,12 +295,12 @@ pub async fn count_discover_items(
212 295 "#,
213 296 );
214 297
215 - append_item_discover_filters(&mut query, filters, has_search);
298 + append_item_discover_filters(&mut query, filters, has_search, short_query);
216 299
217 300 let count: i64 = bind_item_discover_filters!(
218 301 sqlx::query_scalar(&query),
219 302 filters,
220 - search_term
303 + search_term.as_deref()
221 304 )
222 305 .fetch_one(pool)
223 306 .await?;
@@ -238,10 +321,11 @@ pub async fn discover_projects(
238 321 limit: i64,
239 322 offset: i64,
240 323 ) -> Result<Vec<DbDiscoverProjectRow>> {
241 - let search_term = search.filter(|s| !s.trim().is_empty());
324 + let search_term = normalize_search(search);
242 325 let has_search = search_term.is_some();
326 + let short_query = search_term.as_deref().map_or(false, is_short_query);
243 327
244 - let mut query = if has_search {
328 + let mut query = if has_search && !short_query {
245 329 String::from(
246 330 r#"
247 331 SELECT
@@ -265,6 +349,28 @@ pub async fn discover_projects(
265 349 WHERE p.is_public = true
266 350 "#,
267 351 )
352 + } else if has_search {
353 + // Short query: constant match_score, skip trigram similarity computation
354 + String::from(
355 + r#"
356 + SELECT
357 + p.slug,
358 + p.title,
359 + p.description,
360 + p.project_type,
361 + p.created_at,
362 + u.username,
363 + COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true) as item_count,
364 + 1.0::real as match_score,
365 + pc.name as category_name,
366 + pc.slug as category_slug
367 + FROM projects p
368 + JOIN users u ON p.user_id = u.id
369 + LEFT JOIN items i ON i.project_id = p.id
370 + LEFT JOIN project_categories pc ON pc.id = p.category_id
371 + WHERE p.is_public = true
372 + "#,
373 + )
268 374 } else {
269 375 String::from(
270 376 r#"
@@ -289,7 +395,11 @@ pub async fn discover_projects(
289 395 };
290 396
291 397 if has_search {
292 - query.push_str(PROJECT_SEARCH_CLAUSE);
398 + if short_query {
399 + query.push_str(PROJECT_SEARCH_CLAUSE_SHORT);
400 + } else {
401 + query.push_str(PROJECT_SEARCH_CLAUSE);
402 + }
293 403 }
294 404
295 405 if category_slug.is_some() {
@@ -317,7 +427,7 @@ pub async fn discover_projects(
317 427 query.push_str(&format!(" ORDER BY {} LIMIT $4 OFFSET $5", order));
318 428
319 429 let projects = sqlx::query_as::<_, DbDiscoverProjectRow>(&query)
320 - .bind(search_term.unwrap_or(""))
430 + .bind(search_term.as_deref().unwrap_or(""))
321 431 .bind(category_slug.unwrap_or(""))
322 432 .bind(label_slug.unwrap_or(""))
323 433 .bind(limit)
@@ -335,8 +445,9 @@ pub async fn count_discover_projects(
335 445 category_slug: Option<&str>,
336 446 label_slug: Option<&str>,
337 447 ) -> Result<i64> {
338 - let search_term = search.filter(|s| !s.trim().is_empty());
448 + let search_term = normalize_search(search);
339 449 let has_search = search_term.is_some();
450 + let short_query = search_term.as_deref().map_or(false, is_short_query);
340 451
341 452 let mut query = String::from(
342 453 r#"
@@ -347,7 +458,11 @@ pub async fn count_discover_projects(
347 458 );
348 459
349 460 if has_search {
350 - query.push_str(PROJECT_SEARCH_CLAUSE);
461 + if short_query {
462 + query.push_str(PROJECT_SEARCH_CLAUSE_SHORT);
463 + } else {
464 + query.push_str(PROJECT_SEARCH_CLAUSE);
465 + }
351 466 }
352 467
353 468 if category_slug.is_some() {
@@ -367,7 +482,7 @@ pub async fn count_discover_projects(
367 482 }
368 483
369 484 let count: i64 = sqlx::query_scalar(&query)
370 - .bind(search_term.unwrap_or(""))
485 + .bind(search_term.as_deref().unwrap_or(""))
371 486 .bind(category_slug.unwrap_or(""))
372 487 .bind(label_slug.unwrap_or(""))
373 488 .fetch_one(pool)
@@ -384,8 +499,9 @@ pub async fn get_item_type_counts(
384 499 min_price: Option<i32>,
385 500 max_price: Option<i32>,
386 501 ) -> Result<Vec<DbItemTypeCount>> {
387 - let search_term = search.filter(|s| !s.trim().is_empty());
502 + let search_term = normalize_search(search);
388 503 let has_search = search_term.is_some();
504 + let short_query = search_term.as_deref().map_or(false, is_short_query);
389 505
390 506 let mut query = String::from(
391 507 r#"
@@ -397,7 +513,11 @@ pub async fn get_item_type_counts(
397 513 );
398 514
399 515 if has_search {
400 - query.push_str(ITEM_SEARCH_CLAUSE);
516 + if short_query {
517 + query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
518 + } else {
519 + query.push_str(ITEM_SEARCH_CLAUSE);
520 + }
401 521 }
402 522
403 523 if tag.is_some() {
@@ -422,7 +542,7 @@ pub async fn get_item_type_counts(
422 542 query.push_str(" GROUP BY i.item_type ORDER BY count DESC");
423 543
424 544 let counts = sqlx::query_as::<_, DbItemTypeCount>(&query)
425 - .bind(search_term.unwrap_or(""))
545 + .bind(search_term.as_deref().unwrap_or(""))
426 546 .bind(tag.unwrap_or(""))
427 547 .bind(min_price.unwrap_or(0))
428 548 .bind(max_price.unwrap_or(i32::MAX))
@@ -443,8 +563,9 @@ pub async fn get_price_range_counts(
443 563 item_type: Option<ItemType>,
444 564 tag: Option<&str>,
445 565 ) -> Result<DbPriceRangeCounts> {
446 - let search_term = search.filter(|s| !s.trim().is_empty());
566 + let search_term = normalize_search(search);
447 567 let has_search = search_term.is_some();
568 + let short_query = search_term.as_deref().map_or(false, is_short_query);
448 569
449 570 let mut query = String::from(
450 571 r#"
@@ -461,7 +582,11 @@ pub async fn get_price_range_counts(
461 582 );
462 583
463 584 if has_search {
464 - query.push_str(ITEM_SEARCH_CLAUSE);
585 + if short_query {
586 + query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
587 + } else {
588 + query.push_str(ITEM_SEARCH_CLAUSE);
589 + }
465 590 }
466 591
467 592 if item_type.is_some() {
@@ -489,7 +614,7 @@ pub async fn get_price_range_counts(
489 614 }
490 615
491 616 let row = sqlx::query_as::<_, PriceRow>(&query)
492 - .bind(search_term.unwrap_or(""))
617 + .bind(search_term.as_deref().unwrap_or(""))
493 618 .bind(item_type.map(|t| t.to_string()).unwrap_or_default())
494 619 .bind(tag.unwrap_or(""))
495 620 .fetch_one(pool)
@@ -905,4 +905,243 @@ mod tests {
905 905 assert_eq!(inactive.stripe_status_text(), "Not connected");
906 906 assert_eq!(inactive.stripe_status_class(), "inactive");
907 907 }
908 +
909 + // ── PWYW display logic (mirrors DiscoverItem conversion) ──
910 +
911 + /// Replicate the PWYW price formatting from `From<DbDiscoverItemRow>`.
912 + fn discover_price(pwyw_enabled: bool, pwyw_min_cents: Option<i32>, price_cents: i32) -> String {
913 + use crate::helpers::format_price;
914 + if pwyw_enabled {
915 + let min = pwyw_min_cents.unwrap_or(0);
916 + if min == 0 {
917 + "PWYW".to_string()
918 + } else {
919 + format!("From {}", format_price(min))
920 + }
921 + } else {
922 + format_price(price_cents)
923 + }
924 + }
925 +
926 + #[test]
927 + fn pwyw_min_zero_displays_pwyw() {
928 + assert_eq!(discover_price(true, Some(0), 0), "PWYW");
929 + }
930 +
931 + #[test]
932 + fn pwyw_min_none_displays_pwyw() {
933 + // None min_cents defaults to 0, so same as explicit zero
934 + assert_eq!(discover_price(true, None, 0), "PWYW");
935 + }
936 +
937 + #[test]
938 + fn pwyw_min_500_displays_from_price() {
939 + assert_eq!(discover_price(true, Some(500), 0), "From $5");
940 + }
941 +
942 + #[test]
943 + fn non_pwyw_displays_price_normally() {
944 + assert_eq!(discover_price(false, None, 999), "$9.99");
945 + }
946 +
947 + #[test]
948 + fn non_pwyw_free_displays_free() {
949 + assert_eq!(discover_price(false, None, 0), "Free");
950 + }
951 +
952 + // ── Duration formatting (mirrors Item::from_db_detail audio/video logic) ──
953 +
954 + /// Replicate the inline duration formatting from `from_db_detail`.
955 + fn format_duration(seconds: i32) -> String {
956 + let hours = seconds / 3600;
957 + let mins = (seconds % 3600) / 60;
958 + let secs = seconds % 60;
959 + if hours > 0 {
960 + format!("{}:{:02}:{:02}", hours, mins, secs)
961 + } else {
962 + format!("{}:{:02}", mins, secs)
963 + }
964 + }
965 +
966 + #[test]
967 + fn duration_zero_seconds() {
968 + assert_eq!(format_duration(0), "0:00");
969 + }
970 +
971 + #[test]
972 + fn duration_one_second() {
973 + assert_eq!(format_duration(1), "0:01");
974 + }
975 +
976 + #[test]
977 + fn duration_59_seconds() {
978 + assert_eq!(format_duration(59), "0:59");
979 + }
980 +
981 + #[test]
982 + fn duration_60_seconds() {
983 + assert_eq!(format_duration(60), "1:00");
984 + }
985 +
986 + #[test]
987 + fn duration_3661_seconds() {
988 + // 1 hour, 1 minute, 1 second
989 + assert_eq!(format_duration(3661), "1:01:01");
990 + }
991 +
992 + #[test]
993 + fn duration_none_produces_no_value() {
994 + let duration: Option<String> = None::<i32>.map(|s| format_duration(s));
995 + assert!(duration.is_none());
996 + }
997 +
998 + // ── json_escape ──
999 +
1000 + #[test]
1001 + fn json_escape_script_tag() {
1002 + let result = json_escape("<script>alert('xss')</script>");
1003 + assert!(!result.contains('<'));
1004 + assert!(!result.contains('>'));
1005 + assert!(result.contains("\\u003c"));
1006 + assert!(result.contains("\\u003e"));
1007 + }
1008 +
1009 + #[test]
1010 + fn json_escape_html_entities() {
1011 + let result = json_escape("a & b < c > d");
1012 + assert!(result.contains("\\u0026")); // &
1013 + assert!(result.contains("\\u003c")); // <
1014 + assert!(result.contains("\\u003e")); // >
1015 + assert_eq!(result, "a \\u0026 b \\u003c c \\u003e d");
1016 + }
1017 +
1018 + #[test]
1019 + fn json_escape_newlines_and_tabs() {
1020 + let result = json_escape("line1\nline2\ttab\rreturn");
1021 + assert_eq!(result, "line1\\nline2\\ttab\\rreturn");
1022 + }
1023 +
1024 + #[test]
1025 + fn json_escape_unicode_passthrough() {
1026 + let result = json_escape("cafe\u{0301} \u{1f600}");
1027 + // Unicode chars above U+001F pass through unchanged
1028 + assert_eq!(result, "cafe\u{0301} \u{1f600}");
1029 + }
1030 +
1031 + #[test]
1032 + fn json_escape_empty_string() {
1033 + assert_eq!(json_escape(""), "");
1034 + }
1035 +
1036 + #[test]
1037 + fn json_escape_backslash_and_double_quote() {
1038 + let result = json_escape(r#"path\to\"file""#);
1039 + assert_eq!(result, r#"path\\to\\\"file\""#);
1040 + }
1041 +
1042 + // ── PromoCode description logic (mirrors From<DbPromoCodeWithNames>) ──
1043 +
1044 + /// Replicate the promo code description formatting from the conversion.
1045 + fn promo_description(
1046 + purpose: crate::db::CodePurpose,
1047 + discount_type: Option<crate::db::DiscountType>,
1048 + discount_value: Option<i32>,
1049 + trial_days: Option<i32>,
1050 + ) -> String {
1051 + match purpose {
1052 + crate::db::CodePurpose::Discount => {
1053 + match (discount_type, discount_value) {
1054 + (Some(crate::db::DiscountType::Percentage), Some(v)) => format!("{}% off", v),
1055 + (Some(crate::db::DiscountType::Fixed), Some(v)) => {
1056 + format!("${:.2} off", v as f64 / 100.0)
1057 + }
1058 + _ => "Discount".to_string(),
1059 + }
1060 + }
1061 + crate::db::CodePurpose::FreeAccess => "Free access".to_string(),
1062 + crate::db::CodePurpose::FreeTrial => match trial_days {
1063 + Some(days) => format!("{}-day trial", days),
1064 + None => "Free trial".to_string(),
1065 + },
1066 + }
1067 + }
1068 +
1069 + #[test]
1070 + fn promo_percentage_discount() {
1071 + assert_eq!(
1072 + promo_description(
1073 + crate::db::CodePurpose::Discount,
1074 + Some(crate::db::DiscountType::Percentage),
1075 + Some(50),
1076 + None,
1077 + ),
1078 + "50% off"
1079 + );
1080 + }
1081 +
1082 + #[test]
1083 + fn promo_fixed_discount() {
1084 + assert_eq!(
1085 + promo_description(
1086 + crate::db::CodePurpose::Discount,
1087 + Some(crate::db::DiscountType::Fixed),
1088 + Some(1000),
1089 + None,
1090 + ),
1091 + "$10.00 off"
1092 + );
1093 + }
1094 +
1095 + #[test]
1096 + fn promo_trial_period() {
1097 + assert_eq!(
1098 + promo_description(
1099 + crate::db::CodePurpose::FreeTrial,
1100 + None,
1101 + None,
1102 + Some(14),
1103 + ),
1104 + "14-day trial"
1105 + );
1106 + }
1107 +
1108 + #[test]
1109 + fn promo_trial_no_days_fallback() {
1110 + assert_eq!(
1111 + promo_description(
1112 + crate::db::CodePurpose::FreeTrial,
1113 + None,
1114 + None,
1115 + None,
1116 + ),
1117 + "Free trial"
1118 + );
1119 + }
1120 +
1121 + #[test]
1122 + fn promo_free_access() {
1123 + assert_eq!(
1124 + promo_description(
1125 + crate::db::CodePurpose::FreeAccess,
1126 + None,
1127 + None,
1128 + None,
1129 + ),
1130 + "Free access"
1131 + );
1132 + }
1133 +
1134 + #[test]
1135 + fn promo_discount_missing_type_and_value_fallback() {
1136 + // Discount purpose but no discount_type or value falls back to generic label
1137 + assert_eq!(
1138 + promo_description(
1139 + crate::db::CodePurpose::Discount,
1140 + None,
1141 + None,
1142 + None,
1143 + ),
1144 + "Discount"
1145 + );
1146 + }
908 1147 }