1use std::collections::HashSet;
2use std::sync::LazyLock;
3
4use regex::Regex;
5
6use crate::constants::{
7 MAX_ALT_TEXT_LENGTH, MAX_CAROUSEL_ITEMS, MAX_LINKS, MAX_POLL_OPTION_LENGTH,
8 MAX_POSTS_PER_REQUEST, MAX_TEXT_ATTACHMENT_LENGTH, MAX_TEXT_ENTITIES, MAX_TEXT_LENGTH,
9 MAX_TOPIC_TAG_LENGTH, MIN_CAROUSEL_ITEMS, MIN_SEARCH_TIMESTAMP,
10};
11use crate::error::new_validation_error;
12use crate::types::{
13 GifAttachment, PaginationOptions, PendingRepliesOptions, PollAttachment, PostsOptions,
14 RepliesOptions, SearchOptions, TextAttachment, TextEntity,
15};
16
17static URL_REGEX: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"https?://[^\s)<>\]\}]+").unwrap());
20
21pub fn validate_limit(limit: Option<usize>) -> crate::Result<()> {
25 if let Some(limit) = limit {
26 if limit > MAX_POSTS_PER_REQUEST {
27 return Err(new_validation_error(
28 400,
29 &format!("limit {limit} exceeds maximum of {MAX_POSTS_PER_REQUEST}"),
30 "limit too large",
31 "limit",
32 ));
33 }
34 }
35 Ok(())
36}
37
38pub fn validate_text_length(text: &str, field_name: &str) -> crate::Result<()> {
44 let count = text_length_with_emoji_bytes(text);
45 if count > MAX_TEXT_LENGTH {
46 return Err(new_validation_error(
47 400,
48 &format!(
49 "{field_name} exceeds maximum length of {MAX_TEXT_LENGTH} characters (got {count})"
50 ),
51 "text too long",
52 field_name,
53 ));
54 }
55 Ok(())
56}
57
58fn text_length_with_emoji_bytes(text: &str) -> usize {
61 text.chars()
62 .map(|c| {
63 if c.len_utf8() > 1 {
64 c.len_utf8()
67 } else {
68 1
69 }
70 })
71 .sum()
72}
73
74pub fn validate_link_count(text: &str, link_attachment_url: &str) -> crate::Result<()> {
77 let mut unique: HashSet<&str> = HashSet::new();
78
79 for m in URL_REGEX.find_iter(text) {
80 unique.insert(m.as_str());
81 }
82
83 if !link_attachment_url.is_empty() {
84 unique.insert(link_attachment_url);
85 }
86
87 if unique.len() > MAX_LINKS {
88 return Err(new_validation_error(
89 400,
90 &format!(
91 "post contains {} unique links, maximum allowed is {MAX_LINKS}",
92 unique.len()
93 ),
94 "too many links",
95 "text",
96 ));
97 }
98 Ok(())
99}
100
101pub fn validate_text_attachment(attachment: &TextAttachment) -> crate::Result<()> {
103 let char_count = attachment.plaintext.chars().count();
104 if char_count > MAX_TEXT_ATTACHMENT_LENGTH {
105 return Err(new_validation_error(
106 400,
107 &format!(
108 "text attachment plaintext exceeds maximum length of {MAX_TEXT_ATTACHMENT_LENGTH} characters (got {char_count})"
109 ),
110 "text attachment too long",
111 "text_attachment.plaintext",
112 ));
113 }
114
115 if let Some(ref styles) = attachment.text_with_styling_info {
116 for (i, info) in styles.iter().enumerate() {
117 let end = info.offset.saturating_add(info.length);
118 if end > char_count {
119 return Err(new_validation_error(
120 400,
121 &format!(
122 "text_with_styling_info[{i}] range ({offset}..{end}) exceeds plaintext length ({char_count})",
123 offset = info.offset,
124 ),
125 "styling range out of bounds",
126 "text_attachment.text_with_styling_info",
127 ));
128 }
129 }
130 }
131
132 Ok(())
133}
134
135pub fn validate_text_entities(
138 entities: &[TextEntity],
139 text_char_count: usize,
140) -> crate::Result<()> {
141 if entities.len() > MAX_TEXT_ENTITIES {
142 return Err(new_validation_error(
143 400,
144 &format!(
145 "too many text entities: got {}, maximum is {MAX_TEXT_ENTITIES}",
146 entities.len()
147 ),
148 "too many text entities",
149 "text_entities",
150 ));
151 }
152
153 for (i, entity) in entities.iter().enumerate() {
154 if entity.entity_type != "SPOILER" {
155 return Err(new_validation_error(
156 400,
157 &format!(
158 "text_entities[{i}] has unsupported entity_type '{}', only 'SPOILER' is allowed",
159 entity.entity_type,
160 ),
161 "invalid entity type",
162 "text_entities",
163 ));
164 }
165
166 if entity.length == 0 {
167 return Err(new_validation_error(
168 400,
169 &format!("text_entities[{i}] has zero length"),
170 "invalid entity length",
171 "text_entities",
172 ));
173 }
174
175 let end = entity.offset.saturating_add(entity.length);
176 if end > text_char_count {
177 return Err(new_validation_error(
178 400,
179 &format!(
180 "text_entities[{i}] range ({}..{end}) exceeds text length ({text_char_count})",
181 entity.offset,
182 ),
183 "entity range out of bounds",
184 "text_entities",
185 ));
186 }
187 }
188
189 Ok(())
190}
191
192pub fn validate_media_url(url: &str, media_type: &str) -> crate::Result<()> {
194 if url.is_empty() {
195 return Err(new_validation_error(
196 400,
197 &format!("{media_type} URL is required"),
198 "empty media url",
199 &format!("{media_type}_url"),
200 ));
201 }
202
203 if !url.starts_with("http://") && !url.starts_with("https://") {
204 return Err(new_validation_error(
205 400,
206 &format!("{media_type} URL must start with http:// or https://"),
207 "invalid media url scheme",
208 &format!("{media_type}_url"),
209 ));
210 }
211
212 Ok(())
213}
214
215pub fn validate_topic_tag(tag: &str) -> crate::Result<()> {
217 let len = tag.chars().count();
218 if len == 0 {
219 return Err(new_validation_error(
220 400,
221 "topic tag must not be empty",
222 "empty topic tag",
223 "topic_tag",
224 ));
225 }
226 if len > MAX_TOPIC_TAG_LENGTH {
227 return Err(new_validation_error(
228 400,
229 &format!(
230 "topic tag exceeds maximum length of {MAX_TOPIC_TAG_LENGTH} characters (got {len})"
231 ),
232 "topic tag too long",
233 "topic_tag",
234 ));
235 }
236 if tag.contains('.') {
237 return Err(new_validation_error(
238 400,
239 "topic tag must not contain periods",
240 "invalid character in topic tag",
241 "topic_tag",
242 ));
243 }
244 if tag.contains('&') {
245 return Err(new_validation_error(
246 400,
247 "topic tag must not contain ampersands",
248 "invalid character in topic tag",
249 "topic_tag",
250 ));
251 }
252 Ok(())
253}
254
255pub fn validate_country_codes(codes: &[String]) -> crate::Result<()> {
257 for (i, code) in codes.iter().enumerate() {
258 if code.chars().count() != 2 {
259 return Err(new_validation_error(
260 400,
261 &format!("allowlisted_country_codes[{i}] '{code}' must be exactly 2 characters"),
262 "invalid country code length",
263 "allowlisted_country_codes",
264 ));
265 }
266 if !code.chars().all(|c| c.is_ascii_alphabetic()) {
267 return Err(new_validation_error(
268 400,
269 &format!(
270 "allowlisted_country_codes[{i}] '{code}' must contain only alphabetic characters"
271 ),
272 "invalid country code characters",
273 "allowlisted_country_codes",
274 ));
275 }
276 }
277 Ok(())
278}
279
280pub fn validate_carousel_children(count: usize) -> crate::Result<()> {
283 if count < MIN_CAROUSEL_ITEMS {
284 return Err(new_validation_error(
285 400,
286 &format!("carousel requires at least {MIN_CAROUSEL_ITEMS} items, got {count}"),
287 "too few carousel items",
288 "children",
289 ));
290 }
291 if count > MAX_CAROUSEL_ITEMS {
292 return Err(new_validation_error(
293 400,
294 &format!("carousel allows at most {MAX_CAROUSEL_ITEMS} items, got {count}"),
295 "too many carousel items",
296 "children",
297 ));
298 }
299 Ok(())
300}
301
302pub fn validate_pagination_options(opts: &PaginationOptions) -> crate::Result<()> {
305 if let Some(limit) = opts.limit {
306 if limit > MAX_POSTS_PER_REQUEST {
307 return Err(new_validation_error(
308 400,
309 &format!("limit {limit} exceeds maximum of {MAX_POSTS_PER_REQUEST}"),
310 "limit too large",
311 "limit",
312 ));
313 }
314 }
315 if opts.before.is_some() && opts.after.is_some() {
316 return Err(new_validation_error(
317 400,
318 "before and after cursors cannot both be specified",
319 "conflicting cursors",
320 "before",
321 ));
322 }
323 Ok(())
324}
325
326pub fn validate_replies_options(opts: &RepliesOptions) -> crate::Result<()> {
328 if let Some(limit) = opts.limit {
329 if limit > MAX_POSTS_PER_REQUEST {
330 return Err(new_validation_error(
331 400,
332 &format!("limit {limit} exceeds maximum of {MAX_POSTS_PER_REQUEST}"),
333 "limit too large",
334 "limit",
335 ));
336 }
337 }
338 if opts.before.is_some() && opts.after.is_some() {
339 return Err(new_validation_error(
340 400,
341 "before and after cursors cannot both be specified",
342 "conflicting cursors",
343 "before",
344 ));
345 }
346 Ok(())
347}
348
349pub fn validate_pending_replies_options(opts: &PendingRepliesOptions) -> crate::Result<()> {
351 if let Some(limit) = opts.limit {
352 if limit > MAX_POSTS_PER_REQUEST {
353 return Err(new_validation_error(
354 400,
355 &format!("limit {limit} exceeds maximum of {MAX_POSTS_PER_REQUEST}"),
356 "limit too large",
357 "limit",
358 ));
359 }
360 }
361 if opts.before.is_some() && opts.after.is_some() {
362 return Err(new_validation_error(
363 400,
364 "before and after cursors cannot both be specified",
365 "conflicting cursors",
366 "before",
367 ));
368 }
369 Ok(())
370}
371
372pub fn validate_search_options(opts: &SearchOptions) -> crate::Result<()> {
374 if let Some(limit) = opts.limit {
375 if limit > MAX_POSTS_PER_REQUEST {
376 return Err(new_validation_error(
377 400,
378 &format!("limit {limit} exceeds maximum of {MAX_POSTS_PER_REQUEST}"),
379 "limit too large",
380 "limit",
381 ));
382 }
383 }
384
385 if opts.before.is_some() && opts.after.is_some() {
386 return Err(new_validation_error(
387 400,
388 "before and after cursors cannot both be specified",
389 "conflicting cursors",
390 "before",
391 ));
392 }
393
394 if let Some(since) = opts.since {
395 if since < MIN_SEARCH_TIMESTAMP {
396 return Err(new_validation_error(
397 400,
398 &format!(
399 "since timestamp {since} is before the minimum allowed ({MIN_SEARCH_TIMESTAMP})"
400 ),
401 "since timestamp too early",
402 "since",
403 ));
404 }
405 }
406
407 if let (Some(since), Some(until)) = (opts.since, opts.until) {
408 if since > until {
409 return Err(new_validation_error(
410 400,
411 &format!("since ({since}) must be <= until ({until})"),
412 "since after until",
413 "since",
414 ));
415 }
416 }
417
418 Ok(())
419}
420
421pub fn validate_alt_text(alt_text: &str) -> crate::Result<()> {
423 if alt_text.is_empty() {
424 return Ok(());
425 }
426 let count = alt_text.chars().count();
427 if count > MAX_ALT_TEXT_LENGTH {
428 return Err(new_validation_error(
429 400,
430 &format!(
431 "alt text exceeds maximum length of {MAX_ALT_TEXT_LENGTH} characters (got {count})"
432 ),
433 "alt text too long",
434 "alt_text",
435 ));
436 }
437 Ok(())
438}
439
440pub fn validate_poll_attachment(poll: &PollAttachment) -> crate::Result<()> {
442 if poll.option_a.trim().is_empty() {
443 return Err(new_validation_error(
444 400,
445 "poll option_a must not be empty or whitespace",
446 "empty poll option",
447 "poll_attachment.option_a",
448 ));
449 }
450 if poll.option_b.trim().is_empty() {
451 return Err(new_validation_error(
452 400,
453 "poll option_b must not be empty or whitespace",
454 "empty poll option",
455 "poll_attachment.option_b",
456 ));
457 }
458
459 if poll.option_d.is_some() && poll.option_c.is_none() {
461 return Err(new_validation_error(
462 400,
463 "poll option_d requires option_c to be set",
464 "option_d without option_c",
465 "poll_attachment.option_d",
466 ));
467 }
468
469 if poll.option_a.chars().count() > MAX_POLL_OPTION_LENGTH {
471 return Err(new_validation_error(
472 400,
473 &format!("poll option_a exceeds maximum length of {MAX_POLL_OPTION_LENGTH} characters"),
474 "poll option too long",
475 "poll_attachment.option_a",
476 ));
477 }
478 if poll.option_b.chars().count() > MAX_POLL_OPTION_LENGTH {
479 return Err(new_validation_error(
480 400,
481 &format!("poll option_b exceeds maximum length of {MAX_POLL_OPTION_LENGTH} characters"),
482 "poll option too long",
483 "poll_attachment.option_b",
484 ));
485 }
486 if let Some(ref c) = poll.option_c {
487 if c.chars().count() > MAX_POLL_OPTION_LENGTH {
488 return Err(new_validation_error(
489 400,
490 &format!(
491 "poll option_c exceeds maximum length of {MAX_POLL_OPTION_LENGTH} characters"
492 ),
493 "poll option too long",
494 "poll_attachment.option_c",
495 ));
496 }
497 }
498 if let Some(ref d) = poll.option_d {
499 if d.chars().count() > MAX_POLL_OPTION_LENGTH {
500 return Err(new_validation_error(
501 400,
502 &format!(
503 "poll option_d exceeds maximum length of {MAX_POLL_OPTION_LENGTH} characters"
504 ),
505 "poll option too long",
506 "poll_attachment.option_d",
507 ));
508 }
509 }
510
511 Ok(())
512}
513
514pub fn validate_posts_options(opts: &PostsOptions) -> crate::Result<()> {
516 if let Some(limit) = opts.limit {
517 if limit > MAX_POSTS_PER_REQUEST {
518 return Err(new_validation_error(
519 400,
520 &format!("limit {limit} exceeds maximum of {MAX_POSTS_PER_REQUEST}"),
521 "limit too large",
522 "limit",
523 ));
524 }
525 }
526
527 if opts.before.is_some() && opts.after.is_some() {
528 return Err(new_validation_error(
529 400,
530 "before and after cursors cannot both be specified",
531 "conflicting cursors",
532 "before",
533 ));
534 }
535
536 if let (Some(since), Some(until)) = (opts.since, opts.until) {
537 if since > until {
538 return Err(new_validation_error(
539 400,
540 &format!("since ({since}) must be <= until ({until})"),
541 "since after until",
542 "since",
543 ));
544 }
545 }
546
547 Ok(())
548}
549
550pub fn validate_gif_attachment(attachment: &GifAttachment) -> crate::Result<()> {
553 if attachment.gif_id.is_empty() {
554 return Err(new_validation_error(
555 400,
556 "gif_id is required",
557 "empty gif_id",
558 "gif_attachment.gif_id",
559 ));
560 }
561 Ok(())
564}
565
566#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::types::{GifProvider, PostsOptions, TextStylingInfo};
574
575 #[test]
578 fn text_length_ok() {
579 assert!(validate_text_length("hello", "text").is_ok());
580 }
581
582 #[test]
583 fn text_length_exact_limit() {
584 let s: String = "a".repeat(MAX_TEXT_LENGTH);
585 assert!(validate_text_length(&s, "text").is_ok());
586 }
587
588 #[test]
589 fn text_length_exceeds() {
590 let s: String = "a".repeat(MAX_TEXT_LENGTH + 1);
591 let err = validate_text_length(&s, "text").unwrap_err();
592 assert!(err.is_validation());
593 }
594
595 #[test]
596 fn text_length_unicode_emoji_counted_as_bytes() {
597 let s: String = "\u{1F600}".repeat(125);
600 assert!(validate_text_length(&s, "text").is_ok());
601
602 let s2: String = "\u{1F600}".repeat(126);
604 assert!(validate_text_length(&s2, "text").is_err());
605 }
606
607 #[test]
610 fn link_count_ok() {
611 assert!(validate_link_count("check https://a.com", "").is_ok());
612 }
613
614 #[test]
615 fn link_count_with_attachment() {
616 let text = "https://a.com https://b.com https://c.com https://d.com";
617 assert!(validate_link_count(text, "https://e.com").is_ok());
618 }
619
620 #[test]
621 fn link_count_exceeds() {
622 let text = "https://a.com https://b.com https://c.com https://d.com https://e.com";
623 let err = validate_link_count(text, "https://f.com").unwrap_err();
624 assert!(err.is_validation());
625 }
626
627 #[test]
628 fn link_count_deduplicates() {
629 let text = "https://a.com https://a.com https://a.com";
630 assert!(validate_link_count(text, "https://a.com").is_ok());
631 }
632
633 #[test]
636 fn text_attachment_ok() {
637 let att = TextAttachment {
638 plaintext: "hello world".into(),
639 link_attachment_url: None,
640 text_with_styling_info: None,
641 };
642 assert!(validate_text_attachment(&att).is_ok());
643 }
644
645 #[test]
646 fn text_attachment_too_long() {
647 let att = TextAttachment {
648 plaintext: "x".repeat(MAX_TEXT_ATTACHMENT_LENGTH + 1),
649 link_attachment_url: None,
650 text_with_styling_info: None,
651 };
652 assert!(validate_text_attachment(&att).is_err());
653 }
654
655 #[test]
656 fn text_attachment_styling_in_range() {
657 let att = TextAttachment {
658 plaintext: "hello".into(),
659 link_attachment_url: None,
660 text_with_styling_info: Some(vec![TextStylingInfo {
661 offset: 0,
662 length: 5,
663 styling_info: vec!["BOLD".into()],
664 }]),
665 };
666 assert!(validate_text_attachment(&att).is_ok());
667 }
668
669 #[test]
670 fn text_attachment_styling_out_of_range() {
671 let att = TextAttachment {
672 plaintext: "hello".into(),
673 link_attachment_url: None,
674 text_with_styling_info: Some(vec![TextStylingInfo {
675 offset: 3,
676 length: 5,
677 styling_info: vec!["BOLD".into()],
678 }]),
679 };
680 assert!(validate_text_attachment(&att).is_err());
681 }
682
683 #[test]
686 fn text_entities_ok() {
687 let entities = vec![TextEntity {
688 entity_type: "SPOILER".into(),
689 offset: 0,
690 length: 5,
691 }];
692 assert!(validate_text_entities(&entities, 100).is_ok());
693 }
694
695 #[test]
696 fn text_entities_empty_ok() {
697 assert!(validate_text_entities(&[], 0).is_ok());
698 }
699
700 #[test]
701 fn text_entities_too_many() {
702 let entities: Vec<TextEntity> = (0..MAX_TEXT_ENTITIES + 1)
703 .map(|i| TextEntity {
704 entity_type: "SPOILER".into(),
705 offset: i,
706 length: 1,
707 })
708 .collect();
709 assert!(validate_text_entities(&entities, 100).is_err());
710 }
711
712 #[test]
713 fn text_entities_wrong_type() {
714 let entities = vec![TextEntity {
715 entity_type: "LINK".into(),
716 offset: 0,
717 length: 5,
718 }];
719 assert!(validate_text_entities(&entities, 100).is_err());
720 }
721
722 #[test]
723 fn text_entities_zero_length() {
724 let entities = vec![TextEntity {
725 entity_type: "SPOILER".into(),
726 offset: 0,
727 length: 0,
728 }];
729 assert!(validate_text_entities(&entities, 100).is_err());
730 }
731
732 #[test]
733 fn text_entities_out_of_bounds() {
734 let entities = vec![TextEntity {
735 entity_type: "SPOILER".into(),
736 offset: 8,
737 length: 5,
738 }];
739 assert!(validate_text_entities(&entities, 10).is_err());
740 }
741
742 #[test]
745 fn media_url_ok_https() {
746 assert!(validate_media_url("https://example.com/img.jpg", "image").is_ok());
747 }
748
749 #[test]
750 fn media_url_ok_http() {
751 assert!(validate_media_url("http://example.com/img.jpg", "image").is_ok());
752 }
753
754 #[test]
755 fn media_url_empty() {
756 assert!(validate_media_url("", "image").is_err());
757 }
758
759 #[test]
760 fn media_url_bad_scheme() {
761 assert!(validate_media_url("ftp://example.com/img.jpg", "image").is_err());
762 }
763
764 #[test]
767 fn topic_tag_ok() {
768 assert!(validate_topic_tag("rustlang").is_ok());
769 }
770
771 #[test]
772 fn topic_tag_period() {
773 assert!(validate_topic_tag("rust.lang").is_err());
774 }
775
776 #[test]
777 fn topic_tag_ampersand() {
778 assert!(validate_topic_tag("rust&go").is_err());
779 }
780
781 #[test]
782 fn topic_tag_empty() {
783 assert!(validate_topic_tag("").is_err());
784 }
785
786 #[test]
787 fn topic_tag_exact_max_length() {
788 let tag = "a".repeat(MAX_TOPIC_TAG_LENGTH);
789 assert!(validate_topic_tag(&tag).is_ok());
790 }
791
792 #[test]
793 fn topic_tag_exceeds_max_length() {
794 let tag = "a".repeat(MAX_TOPIC_TAG_LENGTH + 1);
795 assert!(validate_topic_tag(&tag).is_err());
796 }
797
798 #[test]
801 fn country_codes_ok() {
802 let codes = vec!["US".into(), "GB".into(), "DE".into()];
803 assert!(validate_country_codes(&codes).is_ok());
804 }
805
806 #[test]
807 fn country_codes_wrong_length() {
808 let codes = vec!["USA".into()];
809 assert!(validate_country_codes(&codes).is_err());
810 }
811
812 #[test]
813 fn country_codes_non_alpha() {
814 let codes = vec!["U1".into()];
815 assert!(validate_country_codes(&codes).is_err());
816 }
817
818 #[test]
819 fn country_codes_empty_list() {
820 assert!(validate_country_codes(&[]).is_ok());
821 }
822
823 #[test]
826 fn carousel_ok() {
827 assert!(validate_carousel_children(5).is_ok());
828 }
829
830 #[test]
831 fn carousel_min_boundary() {
832 assert!(validate_carousel_children(MIN_CAROUSEL_ITEMS).is_ok());
833 }
834
835 #[test]
836 fn carousel_max_boundary() {
837 assert!(validate_carousel_children(MAX_CAROUSEL_ITEMS).is_ok());
838 }
839
840 #[test]
841 fn carousel_too_few() {
842 assert!(validate_carousel_children(1).is_err());
843 }
844
845 #[test]
846 fn carousel_too_many() {
847 assert!(validate_carousel_children(MAX_CAROUSEL_ITEMS + 1).is_err());
848 }
849
850 #[test]
853 fn pagination_ok() {
854 let opts = PaginationOptions {
855 limit: Some(50),
856 ..Default::default()
857 };
858 assert!(validate_pagination_options(&opts).is_ok());
859 }
860
861 #[test]
862 fn pagination_no_limit() {
863 let opts = PaginationOptions::default();
864 assert!(validate_pagination_options(&opts).is_ok());
865 }
866
867 #[test]
868 fn pagination_exceeds() {
869 let opts = PaginationOptions {
870 limit: Some(MAX_POSTS_PER_REQUEST + 1),
871 ..Default::default()
872 };
873 assert!(validate_pagination_options(&opts).is_err());
874 }
875
876 #[test]
879 fn search_ok() {
880 let opts = SearchOptions {
881 limit: Some(25),
882 since: Some(MIN_SEARCH_TIMESTAMP + 100),
883 ..Default::default()
884 };
885 assert!(validate_search_options(&opts).is_ok());
886 }
887
888 #[test]
889 fn search_limit_exceeds() {
890 let opts = SearchOptions {
891 limit: Some(MAX_POSTS_PER_REQUEST + 1),
892 ..Default::default()
893 };
894 assert!(validate_search_options(&opts).is_err());
895 }
896
897 #[test]
898 fn search_since_too_early() {
899 let opts = SearchOptions {
900 since: Some(MIN_SEARCH_TIMESTAMP - 1),
901 ..Default::default()
902 };
903 assert!(validate_search_options(&opts).is_err());
904 }
905
906 #[test]
907 fn search_since_exact_boundary() {
908 let opts = SearchOptions {
909 since: Some(MIN_SEARCH_TIMESTAMP),
910 ..Default::default()
911 };
912 assert!(validate_search_options(&opts).is_ok());
913 }
914
915 #[test]
916 fn search_defaults_ok() {
917 let opts = SearchOptions::default();
918 assert!(validate_search_options(&opts).is_ok());
919 }
920
921 #[test]
924 fn gif_attachment_ok() {
925 let att = GifAttachment {
926 gif_id: "abc123".into(),
927 provider: GifProvider::Giphy,
928 };
929 assert!(validate_gif_attachment(&att).is_ok());
930 }
931
932 #[test]
933 fn gif_attachment_tenor_ok() {
934 let att = GifAttachment {
935 gif_id: "xyz".into(),
936 provider: GifProvider::Tenor,
937 };
938 assert!(validate_gif_attachment(&att).is_ok());
939 }
940
941 #[test]
942 fn gif_attachment_empty_id() {
943 let att = GifAttachment {
944 gif_id: "".into(),
945 provider: GifProvider::Giphy,
946 };
947 assert!(validate_gif_attachment(&att).is_err());
948 }
949
950 #[test]
953 fn alt_text_ok() {
954 assert!(validate_alt_text("A nice photo").is_ok());
955 }
956
957 #[test]
958 fn alt_text_empty_ok() {
959 assert!(validate_alt_text("").is_ok());
960 }
961
962 #[test]
963 fn alt_text_exact_limit() {
964 let s: String = "a".repeat(MAX_ALT_TEXT_LENGTH);
965 assert!(validate_alt_text(&s).is_ok());
966 }
967
968 #[test]
969 fn alt_text_exceeds() {
970 let s: String = "a".repeat(MAX_ALT_TEXT_LENGTH + 1);
971 assert!(validate_alt_text(&s).is_err());
972 }
973
974 #[test]
977 fn poll_ok_two_options() {
978 let poll = PollAttachment {
979 option_a: "Yes".into(),
980 option_b: "No".into(),
981 option_c: None,
982 option_d: None,
983 };
984 assert!(validate_poll_attachment(&poll).is_ok());
985 }
986
987 #[test]
988 fn poll_ok_four_options() {
989 let poll = PollAttachment {
990 option_a: "A".into(),
991 option_b: "B".into(),
992 option_c: Some("C".into()),
993 option_d: Some("D".into()),
994 };
995 assert!(validate_poll_attachment(&poll).is_ok());
996 }
997
998 #[test]
999 fn poll_empty_option_a() {
1000 let poll = PollAttachment {
1001 option_a: "".into(),
1002 option_b: "No".into(),
1003 option_c: None,
1004 option_d: None,
1005 };
1006 assert!(validate_poll_attachment(&poll).is_err());
1007 }
1008
1009 #[test]
1010 fn poll_whitespace_option_b() {
1011 let poll = PollAttachment {
1012 option_a: "Yes".into(),
1013 option_b: " ".into(),
1014 option_c: None,
1015 option_d: None,
1016 };
1017 assert!(validate_poll_attachment(&poll).is_err());
1018 }
1019
1020 #[test]
1021 fn poll_d_without_c() {
1022 let poll = PollAttachment {
1023 option_a: "Yes".into(),
1024 option_b: "No".into(),
1025 option_c: None,
1026 option_d: Some("D".into()),
1027 };
1028 assert!(validate_poll_attachment(&poll).is_err());
1029 }
1030
1031 #[test]
1032 fn poll_option_too_long() {
1033 let poll = PollAttachment {
1034 option_a: "a".repeat(MAX_POLL_OPTION_LENGTH + 1),
1035 option_b: "No".into(),
1036 option_c: None,
1037 option_d: None,
1038 };
1039 assert!(validate_poll_attachment(&poll).is_err());
1040 }
1041
1042 #[test]
1045 fn search_since_after_until() {
1046 let opts = SearchOptions {
1047 since: Some(MIN_SEARCH_TIMESTAMP + 200),
1048 until: Some(MIN_SEARCH_TIMESTAMP + 100),
1049 ..Default::default()
1050 };
1051 assert!(validate_search_options(&opts).is_err());
1052 }
1053
1054 #[test]
1055 fn search_since_equals_until() {
1056 let opts = SearchOptions {
1057 since: Some(MIN_SEARCH_TIMESTAMP + 100),
1058 until: Some(MIN_SEARCH_TIMESTAMP + 100),
1059 ..Default::default()
1060 };
1061 assert!(validate_search_options(&opts).is_ok());
1062 }
1063
1064 #[test]
1067 fn posts_options_ok() {
1068 let opts = PostsOptions {
1069 limit: Some(50),
1070 ..Default::default()
1071 };
1072 assert!(validate_posts_options(&opts).is_ok());
1073 }
1074
1075 #[test]
1076 fn posts_options_limit_exceeds() {
1077 let opts = PostsOptions {
1078 limit: Some(MAX_POSTS_PER_REQUEST + 1),
1079 ..Default::default()
1080 };
1081 assert!(validate_posts_options(&opts).is_err());
1082 }
1083
1084 #[test]
1085 fn posts_options_since_after_until() {
1086 let opts = PostsOptions {
1087 since: Some(2000),
1088 until: Some(1000),
1089 ..Default::default()
1090 };
1091 assert!(validate_posts_options(&opts).is_err());
1092 }
1093
1094 #[test]
1095 fn posts_options_since_equals_until() {
1096 let opts = PostsOptions {
1097 since: Some(1000),
1098 until: Some(1000),
1099 ..Default::default()
1100 };
1101 assert!(validate_posts_options(&opts).is_ok());
1102 }
1103
1104 #[test]
1107 fn pagination_before_and_after_rejected() {
1108 let opts = PaginationOptions {
1109 before: Some("abc".into()),
1110 after: Some("def".into()),
1111 ..Default::default()
1112 };
1113 assert!(validate_pagination_options(&opts).is_err());
1114 }
1115
1116 #[test]
1117 fn posts_before_and_after_rejected() {
1118 let opts = PostsOptions {
1119 before: Some("abc".into()),
1120 after: Some("def".into()),
1121 ..Default::default()
1122 };
1123 assert!(validate_posts_options(&opts).is_err());
1124 }
1125
1126 #[test]
1127 fn search_before_and_after_rejected() {
1128 let opts = SearchOptions {
1129 before: Some("abc".into()),
1130 after: Some("def".into()),
1131 ..Default::default()
1132 };
1133 assert!(validate_search_options(&opts).is_err());
1134 }
1135
1136 #[test]
1139 fn text_length_ascii_only() {
1140 assert_eq!(text_length_with_emoji_bytes("hello"), 5);
1141 }
1142
1143 #[test]
1144 fn text_length_emoji_counts_as_bytes() {
1145 assert_eq!(text_length_with_emoji_bytes("\u{1F600}"), 4);
1147 }
1148
1149 #[test]
1150 fn text_length_mixed() {
1151 assert_eq!(text_length_with_emoji_bytes("hi\u{1F600}"), 6);
1153 }
1154}