Skip to main content

threads_rs/
validation.rs

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
17/// Regex matching HTTP(S) URLs.
18static URL_REGEX: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"https?://[^\s)<>\]\}]+").unwrap());
20
21/// Validate that an optional limit does not exceed `MAX_POSTS_PER_REQUEST`.
22///
23/// Works with any options type — just pass `opts.limit` directly.
24pub 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
38/// Validate that `text` does not exceed `MAX_TEXT_LENGTH` characters.
39///
40/// Per the API docs, non-ASCII characters (emojis, CJK, accented Latin, etc.)
41/// are counted by their UTF-8 byte length rather than as single characters.
42/// ASCII characters count as 1 each.
43pub 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
58/// Count text length where non-ASCII characters are counted by their UTF-8
59/// byte length and ASCII characters count as 1.
60fn text_length_with_emoji_bytes(text: &str) -> usize {
61    text.chars()
62        .map(|c| {
63            if c.len_utf8() > 1 {
64                // Non-ASCII characters (emojis, CJK, accented Latin, etc.)
65                // count as their UTF-8 byte length
66                c.len_utf8()
67            } else {
68                1
69            }
70        })
71        .sum()
72}
73
74/// Validate that the combined unique link count from `text` and
75/// `link_attachment_url` does not exceed `MAX_LINKS`.
76pub 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
101/// Validate a text attachment: plaintext length and styling ranges.
102pub 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
135/// Validate text entities: at most `MAX_TEXT_ENTITIES`, each must be SPOILER
136/// type, and offsets must be non-negative (enforced by `usize`).
137pub 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
192/// Validate a media URL: must be non-empty and start with `http://` or `https://`.
193pub 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
215/// Validate a topic tag: must be 1-50 characters and not contain periods (`.`) or ampersands (`&`).
216pub 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
255/// Validate country codes: each must be exactly 2 alphabetic ASCII characters.
256pub 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
280/// Validate carousel child count is between `MIN_CAROUSEL_ITEMS` and
281/// `MAX_CAROUSEL_ITEMS` inclusive.
282pub 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
302/// Validate pagination options: limit must not exceed `MAX_POSTS_PER_REQUEST`,
303/// and `before` and `after` cannot both be set.
304pub 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
326/// Validate replies options: limit and before/after exclusivity.
327pub 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
349/// Validate pending replies options: limit and before/after exclusivity.
350pub 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
372/// Validate search options: limit, since timestamp, since <= until ordering, and before/after exclusivity.
373pub 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
421/// Validate alt text length.
422pub 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
440/// Validate poll attachment options.
441pub 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    // option_d requires option_c
460    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    // Validate lengths
470    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
514/// Validate posts options: limit, since <= until ordering, and before/after exclusivity.
515pub 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
550/// Validate a GIF attachment: `gif_id` must be non-empty and `provider` must
551/// be a known variant (enforced at the type level by `GifProvider`).
552pub 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    // `provider` is a `GifProvider` enum — only valid variants are representable,
562    // so no runtime check is needed.
563    Ok(())
564}
565
566// ---------------------------------------------------------------------------
567// Tests
568// ---------------------------------------------------------------------------
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::types::{GifProvider, PostsOptions, TextStylingInfo};
574
575    // --- validate_text_length ---
576
577    #[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        // \u{1F600} is 4 UTF-8 bytes, so counts as 4 toward the limit.
598        // 125 emojis = 500 byte-equivalents = exactly at the limit.
599        let s: String = "\u{1F600}".repeat(125);
600        assert!(validate_text_length(&s, "text").is_ok());
601
602        // 126 emojis = 504 byte-equivalents > 500 limit.
603        let s2: String = "\u{1F600}".repeat(126);
604        assert!(validate_text_length(&s2, "text").is_err());
605    }
606
607    // --- validate_link_count ---
608
609    #[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    // --- validate_text_attachment ---
634
635    #[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    // --- validate_text_entities ---
684
685    #[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    // --- validate_media_url ---
743
744    #[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    // --- validate_topic_tag ---
765
766    #[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    // --- validate_country_codes ---
799
800    #[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    // --- validate_carousel_children ---
824
825    #[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    // --- validate_pagination_options ---
851
852    #[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    // --- validate_search_options ---
877
878    #[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    // --- validate_gif_attachment ---
922
923    #[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    // --- validate_alt_text ---
951
952    #[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    // --- validate_poll_attachment ---
975
976    #[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    // --- validate_search_options since <= until ---
1043
1044    #[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    // --- validate_posts_options ---
1065
1066    #[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    // --- before/after mutual exclusivity ---
1105
1106    #[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    // --- text_length_with_emoji_bytes ---
1137
1138    #[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        // Single emoji \u{1F600} is 4 UTF-8 bytes
1146        assert_eq!(text_length_with_emoji_bytes("\u{1F600}"), 4);
1147    }
1148
1149    #[test]
1150    fn text_length_mixed() {
1151        // "hi" (2) + emoji (4) = 6
1152        assert_eq!(text_length_with_emoji_bytes("hi\u{1F600}"), 6);
1153    }
1154}