The smokesignal.events web application

bug: content trim needed before facet parsing

+137 -8
+120
src/facets.rs
··· 1139 1139 let html = render_text_with_facets_html(text, Some(&facets), &limits); 1140 1140 assert_eq!(html, "Hello world"); 1141 1141 } 1142 + 1143 + #[test] 1144 + fn test_parse_urls_from_atproto_record_text() { 1145 + // Test parsing URLs from real AT Protocol record description text. 1146 + // This demonstrates the correct byte positions that should be used for facets. 1147 + let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\n✨the daniel✨ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/"; 1148 + 1149 + let url_spans = parse_urls(text); 1150 + 1151 + assert_eq!(url_spans.len(), 2, "Should find 2 URLs"); 1152 + 1153 + // First URL: https://stream.place/psingletary.com 1154 + assert_eq!(url_spans[0].url, "https://stream.place/psingletary.com"); 1155 + assert_eq!(url_spans[0].start, 221); 1156 + assert_eq!(url_spans[0].end, 257); 1157 + 1158 + // Second URL: https://atprotocalls.leaflet.pub/ 1159 + assert_eq!(url_spans[1].url, "https://atprotocalls.leaflet.pub/"); 1160 + assert_eq!(url_spans[1].start, 290); 1161 + assert_eq!(url_spans[1].end, 323); 1162 + 1163 + // Verify the byte slices match the expected text 1164 + let text_bytes = text.as_bytes(); 1165 + assert_eq!( 1166 + std::str::from_utf8(&text_bytes[221..257]).unwrap(), 1167 + "https://stream.place/psingletary.com" 1168 + ); 1169 + assert_eq!( 1170 + std::str::from_utf8(&text_bytes[290..323]).unwrap(), 1171 + "https://atprotocalls.leaflet.pub/" 1172 + ); 1173 + 1174 + // Note: The AT Protocol record had incorrect facet indices: 1175 + // - First link: byteStart=222, byteEnd=258 (should be 221, 257) 1176 + // - Second link: byteStart=291, byteEnd=324 (should be 290, 323) 1177 + // This off-by-one error was in the source data, not our parser. 1178 + } 1179 + 1180 + #[test] 1181 + fn test_render_with_off_by_one_facet_indices() { 1182 + // Regression test for facets with off-by-one byte indices from external AT Protocol data. 1183 + // The facets in this test have byteStart values that are 1 byte too high, causing 1184 + // the first character of the URL to appear outside the link tag. 1185 + // 1186 + // This test documents the current behavior: the renderer faithfully applies facets 1187 + // at the specified byte positions, even if those positions are incorrect. 1188 + // The root cause is incorrect facet generation by the client that created the record. 1189 + let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\n✨the daniel✨ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/"; 1190 + 1191 + // Verify text length - the second facet's byte_end (324) exceeds this 1192 + assert_eq!(text.len(), 323, "Text should be 323 bytes"); 1193 + 1194 + let limits = FacetLimits::default(); 1195 + 1196 + // These facets have incorrect byte indices (off by 1) - this is real data from AT Protocol 1197 + let facets = vec![ 1198 + Facet { 1199 + index: ByteSlice { 1200 + byte_start: 222, // Should be 221 1201 + byte_end: 258, // Should be 257 (but 258 is within bounds) 1202 + }, 1203 + features: vec![FacetFeature::Link(Link { 1204 + uri: "https://stream.place/psingletary.com".to_string(), 1205 + })], 1206 + }, 1207 + Facet { 1208 + index: ByteSlice { 1209 + byte_start: 291, // Should be 290 1210 + byte_end: 324, // Should be 323 - but 324 > text.len() so this facet is SKIPPED 1211 + }, 1212 + features: vec![FacetFeature::Link(Link { 1213 + uri: "https://atprotocalls.leaflet.pub/".to_string(), 1214 + })], 1215 + }, 1216 + ]; 1217 + 1218 + let html = render_text_with_facets_html(text, Some(&facets), &limits); 1219 + 1220 + // Due to off-by-one facet indices, the 'h' from 'https' appears before the link tag 1221 + assert!( 1222 + html.contains(r#"stream out to h<a href="https://stream.place/psingletary.com""#), 1223 + "First link should have 'h' outside due to off-by-one error. Got: {}", 1224 + html 1225 + ); 1226 + 1227 + // The second facet is SKIPPED entirely because byte_end (324) > text.len() (323) 1228 + // This is the bounds check in render_text_with_facets_html preventing out-of-bounds access 1229 + assert!( 1230 + !html.contains(r#"<a href="https://atprotocalls.leaflet.pub/""#), 1231 + "Second link should NOT be rendered because facet is out of bounds. Got: {}", 1232 + html 1233 + ); 1234 + assert!( 1235 + html.contains("https://atprotocalls.leaflet.pub/"), 1236 + "Second URL should appear as plain text. Got: {}", 1237 + html 1238 + ); 1239 + 1240 + // Verify correct byte positions from our parser 1241 + let url_spans = parse_urls(text); 1242 + assert_eq!(url_spans.len(), 2, "Should find 2 URLs"); 1243 + 1244 + // The correct byte positions from our parser 1245 + assert_eq!( 1246 + url_spans[0].start, 221, 1247 + "First URL should start at byte 221, not 222" 1248 + ); 1249 + assert_eq!( 1250 + url_spans[0].end, 257, 1251 + "First URL should end at byte 257, not 258" 1252 + ); 1253 + assert_eq!( 1254 + url_spans[1].start, 290, 1255 + "Second URL should start at byte 290, not 291" 1256 + ); 1257 + assert_eq!( 1258 + url_spans[1].end, 323, 1259 + "Second URL should end at byte 323, not 324" 1260 + ); 1261 + } 1142 1262 }
+6 -3
src/http/handle_create_event.rs
··· 630 630 }) 631 631 .collect(); 632 632 633 + // Trim description first - facets must be parsed from the same text that gets stored 634 + let description = request.description.trim().to_string(); 635 + 633 636 // Parse facets from description 634 637 let facet_limits = crate::facets::FacetLimits { 635 638 mentions_max: web_context.config.facets_mentions_max, ··· 638 641 max: web_context.config.facets_max, 639 642 }; 640 643 641 - let facets = if !request.description.is_empty() { 644 + let facets = if !description.is_empty() { 642 645 crate::facets::parse_facets_from_text( 643 - &request.description, 646 + &description, 644 647 web_context.identity_resolver.as_ref(), 645 648 &facet_limits, 646 649 ) ··· 697 700 // Create the event record 698 701 let the_record = Event { 699 702 name: request.name.trim().to_string(), 700 - description: request.description.trim().to_string(), 703 + description, 701 704 created_at: now, 702 705 starts_at, 703 706 ends_at,
+6 -3
src/http/handle_edit_event.rs
··· 757 757 }) 758 758 .collect(); 759 759 760 + // Trim description first - facets must be parsed from the same text that gets stored 761 + let description = request.description.trim().to_string(); 762 + 760 763 // Parse facets 761 764 let facet_limits = crate::facets::FacetLimits { 762 765 mentions_max: ctx.web_context.config.facets_mentions_max, ··· 765 768 max: ctx.web_context.config.facets_max, 766 769 }; 767 770 768 - let facets = if !request.description.is_empty() { 771 + let facets = if !description.is_empty() { 769 772 crate::facets::parse_facets_from_text( 770 - &request.description, 773 + &description, 771 774 ctx.web_context.identity_resolver.as_ref(), 772 775 &facet_limits, 773 776 ) ··· 827 830 // Create updated record (preserve extra fields from existing event) 828 831 let the_record = LexiconCommunityEvent { 829 832 name: request.name.trim().to_string(), 830 - description: request.description.trim().to_string(), 833 + description, 831 834 created_at: existing_event.created_at, // Preserve original creation time 832 835 starts_at, 833 836 ends_at,
+5 -2
src/http/handle_preview_description.rs
··· 47 47 .into_response()); 48 48 } 49 49 50 + // Trim description to match create/edit behavior - facets must be parsed from the same text 51 + let description = request.description.trim(); 52 + 50 53 // Parse facets from the description 51 54 let limits = FacetLimits::default(); 52 55 let facets = parse_facets_from_text( 53 - &request.description, 56 + description, 54 57 web_context.identity_resolver.as_ref(), 55 58 &limits, 56 59 ) 57 60 .await; 58 61 59 62 // Render HTML with facets 60 - let html = render_text_with_facets_html(&request.description, facets.as_ref(), &limits); 63 + let html = render_text_with_facets_html(description, facets.as_ref(), &limits); 61 64 62 65 // Convert newlines to <br> tags for proper display 63 66 let html_with_breaks = html.replace('\n', "<br>");