Skip to main content

pubky_app_specs/models/post/content/
collection.rs

1use crate::{common::validate_crockford_id, limits::VALIDATION_LIMITS, types::PubkyId};
2use serde::{Deserialize, Serialize};
3use std::str::FromStr;
4use url::Url;
5
6#[cfg(target_arch = "wasm32")]
7use tsify_next::Tsify;
8
9#[cfg(feature = "openapi")]
10use utoipa::ToSchema;
11
12use super::super::PubkyAppPost;
13
14/// Creator-chosen default layout for experiencing a collection.
15///
16/// Unrecognized values deserialize as `Unknown` so future layouts never
17/// invalidate the whole post (same policy as `PubkyAppPostKind`).
18#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
19#[cfg_attr(feature = "openapi", derive(ToSchema))]
20#[cfg_attr(target_arch = "wasm32", derive(Tsify))]
21#[serde(rename_all = "snake_case")]
22pub enum PubkyAppCollectionLayout {
23    Grid,
24    List,
25    Visual,
26    #[serde(other)]
27    Unknown,
28}
29
30impl FromStr for PubkyAppCollectionLayout {
31    type Err = String;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        match s {
35            "grid" => Ok(Self::Grid),
36            "list" => Ok(Self::List),
37            "visual" => Ok(Self::Visual),
38            _ => Err(format!("Invalid collection layout: {}", s)),
39        }
40    }
41}
42
43/// Typed JSON envelope stored in `PubkyAppPost::content` when `kind == Collection`.
44///
45/// A collection post curates an ordered list of URIs (via `items`)
46/// under a `name` and optional `description`. The envelope is parsed and validated
47/// by the spec but never re-serialized as a top-level homeserver object.
48///
49/// **Construction**: this struct is deserialized from the post's `content` JSON
50/// envelope during validation and is **not** intended to be constructed by
51/// callers directly. It is re-exported publicly (via `lib.rs`) so SDK consumers
52/// can inspect the envelope shape (OpenAPI schema, type definitions), but the
53/// authoritative way to produce a Collection post is to author a `PubkyAppPost`
54/// with `kind: Collection` and a `content` string that JSON-parses into this
55/// shape.
56///
57/// Forward-compat: `#[serde(deny_unknown_fields)]` is intentionally NOT used so
58/// future minor versions can add fields (e.g. `cover_image`) without breaking
59/// older parsers. New fields must be additive and ignorable.
60#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
61#[cfg_attr(feature = "openapi", derive(ToSchema))]
62#[cfg_attr(target_arch = "wasm32", derive(Tsify))]
63#[serde(rename_all = "snake_case")]
64pub struct PubkyAppCollectionContent {
65    /// Display name of the collection. Length bounded by
66    /// `VALIDATION_LIMITS.collection_name_{min,max}_length` (unicode scalars).
67    /// Whitespace-only names are rejected separately by the validator.
68    pub name: String,
69    /// Optional human-readable description. Length bounded by
70    /// `VALIDATION_LIMITS.collection_description_max_length` (unicode scalars).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub description: Option<String>,
73    /// Ordered list of Post URIs this collection curates. Count bounded by
74    /// `VALIDATION_LIMITS.collection_items_max_count`; each URI must be in
75    /// exact canonical form (see `validate_collection_item_uri`).
76    #[serde(default)]
77    pub items: Vec<String>,
78    /// Optional hero/cover image URL. Length bounded by
79    /// `VALIDATION_LIMITS.post_attachment_url_max_length`; protocol must be in
80    /// `VALIDATION_LIMITS.post_allowed_attachment_protocols`.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub cover_image: Option<String>,
83    /// Creator's preferred default layout.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub layout: Option<PubkyAppCollectionLayout>,
86}
87
88/// Validates a `kind = Collection` post, including its JSON content envelope.
89pub(crate) fn validate_collection_post(post: &PubkyAppPost) -> Result<(), String> {
90    if post.parent.is_some() || post.embed.is_some() {
91        return Err("Validation Error: Collection posts cannot have parent or embed".into());
92    }
93    // Anti-misuse guard: items belong in the envelope, not in `post.attachments`.
94    if post.attachments.is_some() {
95        return Err(
96            "Validation Error: Collection posts must not use post.attachments — items belong in the content envelope"
97                .into(),
98        );
99    }
100    if post.content.chars().count() > VALIDATION_LIMITS.collection_content_max_length {
101        return Err(format!(
102            "Validation Error: Collection content exceeds max length {}",
103            VALIDATION_LIMITS.collection_content_max_length
104        ));
105    }
106    let envelope: PubkyAppCollectionContent = serde_json::from_str(&post.content).map_err(|e| {
107        format!(
108            "Validation Error: Collection content must be a valid JSON envelope: {}",
109            e
110        )
111    })?;
112    validate_collection_envelope(&envelope)
113}
114
115fn validate_collection_envelope(envelope: &PubkyAppCollectionContent) -> Result<(), String> {
116    if envelope.name.trim().is_empty() {
117        return Err(
118            "Validation Error: Collection name must contain non-whitespace characters".into(),
119        );
120    }
121    let name_chars = envelope.name.chars().count();
122    let name_min = VALIDATION_LIMITS.collection_name_min_length;
123    let name_max = VALIDATION_LIMITS.collection_name_max_length;
124    if !(name_min..=name_max).contains(&name_chars) {
125        return Err(format!(
126            "Validation Error: Collection name must be {}..={} characters",
127            name_min, name_max
128        ));
129    }
130    if let Some(desc) = &envelope.description {
131        if desc.chars().count() > VALIDATION_LIMITS.collection_description_max_length {
132            return Err(format!(
133                "Validation Error: Collection description exceeds {} characters",
134                VALIDATION_LIMITS.collection_description_max_length
135            ));
136        }
137    }
138    if let Some(cover) = &envelope.cover_image {
139        if cover.chars().count() > VALIDATION_LIMITS.post_attachment_url_max_length {
140            return Err(format!(
141                "Validation Error: Collection cover_image URL exceeds {} characters",
142                VALIDATION_LIMITS.post_attachment_url_max_length
143            ));
144        }
145        let parsed = Url::parse(cover).map_err(|_| {
146            "Validation Error: Collection cover_image must be a valid URL".to_string()
147        })?;
148        if !VALIDATION_LIMITS
149            .post_allowed_attachment_protocols
150            .iter()
151            .any(|&protocol| parsed.scheme().eq_ignore_ascii_case(protocol))
152        {
153            let allowed = VALIDATION_LIMITS
154                .post_allowed_attachment_protocols
155                .iter()
156                .map(|p| format!("{p}://"))
157                .collect::<Vec<_>>()
158                .join(", ");
159            return Err(format!(
160                "Validation Error: Collection cover_image must use one of the allowed protocols: {allowed}"
161            ));
162        }
163    }
164    if envelope.items.len() > VALIDATION_LIMITS.collection_items_max_count {
165        return Err(format!(
166            "Validation Error: Collection cannot have more than {} items",
167            VALIDATION_LIMITS.collection_items_max_count
168        ));
169    }
170    for (index, uri) in envelope.items.iter().enumerate() {
171        validate_collection_item_uri(uri)
172            .map_err(|e| format!("Validation Error: Collection item at index {index}: {e}"))?;
173    }
174    Ok(())
175}
176
177/// Strict canonical post-URI check for Collection items. Accepts only the
178/// exact form `pubky://<pubky-id>/pub/pubky.app/posts/<post-id>`.
179///
180/// Deliberately avoids `Url::parse`: it silently strips userinfo and collapses
181/// `..` path segments, smuggling non-canonical strings past a parse-and-recheck
182/// approach. Splitting the raw string and delegating to `PubkyId::try_from`
183/// (52-char z-base-32) and `validate_crockford_id` (13-char Crockford) enforces
184/// the canonical 94-char form structurally.
185fn validate_collection_item_uri(uri: &str) -> Result<(), String> {
186    const PREFIX: &str = "pubky://";
187    const MIDDLE: &str = "/pub/pubky.app/posts/";
188    let rest = uri
189        .strip_prefix(PREFIX)
190        .ok_or_else(|| format!("must start with pubky://: {uri}"))?;
191    let (host, post_id) = rest
192        .split_once(MIDDLE)
193        .ok_or_else(|| format!("must be a canonical post URI: {uri}"))?;
194    PubkyId::try_from(host).map_err(|e| format!("invalid pubky-id in host: {e}"))?;
195    validate_crockford_id(post_id).map_err(|e| format!("invalid post id: {e}"))?;
196    Ok(())
197}
198
199#[cfg(test)]
200mod tests {
201    use super::super::super::{PubkyAppPost, PubkyAppPostEmbed, PubkyAppPostKind};
202    use super::*;
203    use crate::traits::{TimestampId, Validatable};
204
205    const TEST_PUBKY_ID: &str = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo";
206
207    fn collection_envelope_json(name: &str, description: Option<&str>, items: &[String]) -> String {
208        serde_json::to_string(&PubkyAppCollectionContent {
209            name: name.to_string(),
210            description: description.map(|d| d.to_string()),
211            items: items.to_vec(),
212            ..Default::default()
213        })
214        .unwrap()
215    }
216
217    fn make_collection_post(
218        name: &str,
219        description: Option<&str>,
220        items: Option<Vec<String>>,
221    ) -> PubkyAppPost {
222        let items = items.unwrap_or_default();
223        PubkyAppPost::new(
224            collection_envelope_json(name, description, &items),
225            PubkyAppPostKind::Collection,
226            None,
227            None,
228            None,
229        )
230    }
231
232    fn make_collection_post_with_cover(cover_image: Option<&str>) -> PubkyAppPost {
233        let envelope = PubkyAppCollectionContent {
234            name: "X".to_string(),
235            cover_image: cover_image.map(|s| s.to_string()),
236            ..Default::default()
237        };
238        let content = serde_json::to_string(&envelope).expect("envelope serialization");
239        PubkyAppPost::new(content, PubkyAppPostKind::Collection, None, None, None)
240    }
241
242    #[test]
243    fn test_collection_post_roundtrip_valid() {
244        let post = make_collection_post(
245            "AI papers",
246            Some("Best stuff"),
247            Some(vec![
248                format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A"),
249                format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52B"),
250                format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52C"),
251            ]),
252        );
253        let id = post.create_id();
254        let blob = serde_json::to_vec(&post).unwrap();
255        let parsed = <PubkyAppPost as Validatable>::try_from(&blob, &id).unwrap();
256        assert_eq!(parsed.kind, PubkyAppPostKind::Collection);
257        assert!(parsed.attachments.is_none());
258        let envelope: PubkyAppCollectionContent = serde_json::from_str(&parsed.content).unwrap();
259        assert_eq!(envelope.items.len(), 3);
260    }
261
262    #[test]
263    fn test_collection_post_rejects_malformed_envelope() {
264        let post = PubkyAppPost::new(
265            "this is not JSON".to_string(),
266            PubkyAppPostKind::Collection,
267            None,
268            None,
269            None,
270        );
271        let id = post.create_id();
272        let result = post.validate(Some(&id));
273        assert!(result.is_err());
274        let err = result.unwrap_err();
275        assert!(
276            err.contains("JSON envelope"),
277            "expected JSON envelope error, got: {}",
278            err
279        );
280    }
281
282    #[test]
283    fn test_collection_post_rejects_empty_name() {
284        let post = make_collection_post("", None, None);
285        let id = post.create_id();
286        let result = post.validate(Some(&id));
287        assert!(result.is_err());
288        assert!(result.unwrap_err().contains("name"));
289    }
290
291    #[test]
292    fn test_collection_post_rejects_oversized_name() {
293        // 101 grapheme-ish chars; mix in emoji to confirm we count by unicode scalars, not bytes.
294        let oversized = "a".repeat(99) + "🚀🚀";
295        assert_eq!(oversized.chars().count(), 101);
296        let post = make_collection_post(&oversized, None, None);
297        let id = post.create_id();
298        let result = post.validate(Some(&id));
299        assert!(result.is_err());
300        assert!(result.unwrap_err().contains("name"));
301    }
302
303    #[test]
304    fn test_collection_post_accepts_max_name() {
305        let exactly_100 = "a".repeat(100);
306        assert_eq!(exactly_100.chars().count(), 100);
307        let post = make_collection_post(&exactly_100, None, None);
308        let id = post.create_id();
309        assert!(post.validate(Some(&id)).is_ok());
310    }
311
312    #[test]
313    fn test_collection_post_rejects_whitespace_only_name() {
314        // Whitespace-only names pass `min_length=1` purely by char count, so
315        // we reject them with a dedicated guard. Without that guard, a name
316        // of `"    "` would be a 4-char valid name with no meaningful content.
317        let post = make_collection_post("    ", None, None);
318        let id = post.create_id();
319        let err = post
320            .validate(Some(&id))
321            .expect_err("whitespace-only name must fail validation");
322        assert!(
323            err.contains("whitespace"),
324            "error should mention whitespace, got: {err}"
325        );
326    }
327
328    #[test]
329    fn test_collection_post_counts_whitespace_in_name_length() {
330        // Regression guard: the validator does NOT trim before counting. A
331        // 99-char name padded with one space on each side is 101 chars and
332        // must fail max=100. With the previous trim-then-count behavior this
333        // would have been 99 chars and passed.
334        let padded = format!(" {} ", "a".repeat(99));
335        assert_eq!(padded.chars().count(), 101);
336        let post = make_collection_post(&padded, None, None);
337        let id = post.create_id();
338        let err = post
339            .validate(Some(&id))
340            .expect_err("101-char padded name must fail max length");
341        assert!(
342            err.contains("1..=100"),
343            "error should report the length range, got: {err}"
344        );
345    }
346
347    #[test]
348    fn test_collection_post_accepts_cover_image_pubky_uri() {
349        let cover = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/files/0034A0X7NJ52A");
350        let post = make_collection_post_with_cover(Some(&cover));
351        let id = post.create_id();
352        assert!(post.validate(Some(&id)).is_ok());
353    }
354
355    #[test]
356    fn test_collection_post_accepts_cover_image_https() {
357        let post = make_collection_post_with_cover(Some("https://example.com/cover.png"));
358        let id = post.create_id();
359        assert!(post.validate(Some(&id)).is_ok());
360    }
361
362    #[test]
363    fn test_collection_post_rejects_cover_image_invalid_url() {
364        let post = make_collection_post_with_cover(Some("not a url"));
365        let id = post.create_id();
366        let err = post.validate(Some(&id)).unwrap_err();
367        assert!(
368            err.contains("cover_image must be a valid URL"),
369            "got: {err}"
370        );
371    }
372
373    #[test]
374    fn test_collection_post_rejects_cover_image_disallowed_protocol() {
375        let post = make_collection_post_with_cover(Some("ftp://example.com/cover.png"));
376        let id = post.create_id();
377        let err = post.validate(Some(&id)).unwrap_err();
378        assert!(
379            err.contains("cover_image must use one of the allowed protocols"),
380            "got: {err}"
381        );
382    }
383
384    #[test]
385    fn test_collection_post_rejects_cover_image_too_long() {
386        // post_attachment_url_max_length is 200; this URL exceeds it.
387        let too_long = format!("https://example.com/{}", "a".repeat(200));
388        let post = make_collection_post_with_cover(Some(&too_long));
389        let id = post.create_id();
390        let err = post.validate(Some(&id)).unwrap_err();
391        assert!(err.contains("cover_image URL exceeds"), "got: {err}");
392    }
393
394    #[test]
395    fn test_collection_post_rejects_oversized_description() {
396        let too_long = "a".repeat(501);
397        let post = make_collection_post("X", Some(&too_long), None);
398        let id = post.create_id();
399        let result = post.validate(Some(&id));
400        assert!(result.is_err());
401        assert!(result.unwrap_err().contains("description"));
402    }
403
404    #[test]
405    fn test_collection_post_accepts_empty_description() {
406        // Explicit empty-string description is valid (the field is optional and
407        // 0..=500 chars allowed).
408        let post = make_collection_post("X", Some(""), None);
409        let id = post.create_id();
410        assert!(post.validate(Some(&id)).is_ok());
411    }
412
413    #[test]
414    fn test_collection_post_accepts_max_description() {
415        let exactly_500 = "a".repeat(500);
416        assert_eq!(exactly_500.chars().count(), 500);
417        let post = make_collection_post("X", Some(&exactly_500), None);
418        let id = post.create_id();
419        assert!(post.validate(Some(&id)).is_ok());
420    }
421
422    #[test]
423    fn test_collection_post_rejects_missing_name() {
424        // Envelope JSON without a `name` field at all (description-only).
425        // Distinct from `test_collection_post_rejects_empty_name`, which sends
426        // an empty string; this sends a missing key entirely.
427        let envelope = r#"{ "description": "no name here" }"#.to_string();
428        let post = PubkyAppPost::new(envelope, PubkyAppPostKind::Collection, None, None, None);
429        let id = post.create_id();
430        let result = post.validate(Some(&id));
431        assert!(result.is_err());
432        let err = result.unwrap_err();
433        assert!(
434            err.contains("name") || err.to_lowercase().contains("missing"),
435            "expected name-required error, got: {err}"
436        );
437    }
438
439    #[test]
440    fn test_collection_post_rejects_parent() {
441        let post = PubkyAppPost::new(
442            collection_envelope_json("X", None, &[]),
443            PubkyAppPostKind::Collection,
444            Some("pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string()),
445            None,
446            None,
447        );
448        let id = post.create_id();
449        let result = post.validate(Some(&id));
450        assert!(result.is_err());
451        let err = result.unwrap_err();
452        assert!(
453            err.contains("parent or embed"),
454            "expected parent-or-embed error, got: {}",
455            err
456        );
457    }
458
459    #[test]
460    fn test_collection_post_rejects_embed() {
461        let post = PubkyAppPost::new(
462            collection_envelope_json("X", None, &[]),
463            PubkyAppPostKind::Collection,
464            None,
465            Some(PubkyAppPostEmbed {
466                kind: PubkyAppPostKind::Short,
467                uri: "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string(),
468            }),
469            None,
470        );
471        let id = post.create_id();
472        let result = post.validate(Some(&id));
473        assert!(result.is_err());
474        assert!(result.unwrap_err().contains("parent or embed"));
475    }
476
477    #[test]
478    fn test_collection_post_accepts_100_items() {
479        let items: Vec<String> = (0..100)
480            .map(|i| format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/{:013}", i))
481            .collect();
482        let post = make_collection_post("Big list", None, Some(items));
483        let id = post.create_id();
484        assert!(post.validate(Some(&id)).is_ok());
485    }
486
487    #[test]
488    fn test_collection_post_rejects_101_items() {
489        let items: Vec<String> = (0..101)
490            .map(|i| format!("pubky://userA/pub/pubky.app/posts/{:013}", i))
491            .collect();
492        let post = make_collection_post("Too big", None, Some(items));
493        let id = post.create_id();
494        let result = post.validate(Some(&id));
495        assert!(result.is_err());
496        assert!(result.unwrap_err().contains("100 items"));
497    }
498    #[test]
499    fn test_collection_post_accepts_zero_items() {
500        // Curators may create a draft and add items later via edits.
501        let post = make_collection_post("Drafts", None, None);
502        let id = post.create_id();
503        assert!(post.validate(Some(&id)).is_ok());
504    }
505
506    #[test]
507    fn test_collection_post_roundtrip_layout() {
508        let envelope_json = r#"{"name":"Photos","layout":"visual"}"#;
509        let post = PubkyAppPost::new(
510            envelope_json.to_string(),
511            PubkyAppPostKind::Collection,
512            None,
513            None,
514            None,
515        );
516        let id = post.create_id();
517        assert!(post.validate(Some(&id)).is_ok());
518        let envelope: PubkyAppCollectionContent = serde_json::from_str(&post.content).unwrap();
519        assert_eq!(envelope.layout, Some(PubkyAppCollectionLayout::Visual));
520    }
521
522    #[test]
523    fn test_collection_post_unknown_layout_tolerated() {
524        // Forward-compat: a layout variant from a future spec version must not
525        // invalidate the whole post; it degrades to Unknown.
526        let envelope_json = r#"{"name":"X","layout":"spiral"}"#;
527        let post = PubkyAppPost::new(
528            envelope_json.to_string(),
529            PubkyAppPostKind::Collection,
530            None,
531            None,
532            None,
533        );
534        let id = post.create_id();
535        assert!(post.validate(Some(&id)).is_ok());
536        let envelope: PubkyAppCollectionContent = serde_json::from_str(&post.content).unwrap();
537        assert_eq!(envelope.layout, Some(PubkyAppCollectionLayout::Unknown));
538    }
539
540    #[test]
541    fn test_collection_envelope_tolerates_extra_fields() {
542        // Forward-compat: the envelope intentionally does NOT use deny_unknown_fields,
543        // so future minor versions can add fields without breaking older parsers.
544        // Use a deliberately-fictional canary field name so this test stays
545        // meaningful even after real fields land.
546        let envelope_json = r#"{"name":"X","_forward_compat_canary":"future-only"}"#;
547        let post = PubkyAppPost::new(
548            envelope_json.to_string(),
549            PubkyAppPostKind::Collection,
550            None,
551            None,
552            None,
553        );
554        let id = post.create_id();
555        assert!(
556            post.validate(Some(&id)).is_ok(),
557            "unknown envelope fields must be tolerated"
558        );
559    }
560
561    #[test]
562    fn test_collection_post_rejects_non_post_uri() {
563        let post =
564            make_collection_post("X", None, Some(vec!["ftp://example.com/file".to_string()]));
565        let id = post.create_id();
566        let result = post.validate(Some(&id));
567        assert!(result.is_err());
568        assert!(result.unwrap_err().contains("Collection item"));
569    }
570
571    #[test]
572    fn test_collection_post_rejects_post_uri_with_invalid_post_id() {
573        // 13 chars but not valid Crockford: contains hyphens which aren't in the alphabet.
574        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/abc-def-ghi-j");
575        let post = make_collection_post("X", None, Some(vec![uri]));
576        let id = post.create_id();
577        let err = post.validate(Some(&id)).unwrap_err();
578        assert!(err.contains("invalid post id"), "got: {err}");
579    }
580
581    #[test]
582    fn test_collection_post_rejects_post_uri_with_extra_path_segment() {
583        // Extra segment lands inside the post-id slot, failing the 13-char
584        // Crockford check.
585        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A/extra");
586        let post = make_collection_post("X", None, Some(vec![uri]));
587        let id = post.create_id();
588        let err = post.validate(Some(&id)).unwrap_err();
589        assert!(err.contains("invalid post id"), "got: {err}");
590    }
591
592    #[test]
593    fn test_collection_post_rejects_post_uri_with_query_string() {
594        // Query string lands inside the post-id slot and fails the Crockford
595        // check (`?` and `=` aren't in the alphabet, and the length is wrong).
596        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A?foo=bar");
597        let post = make_collection_post("X", None, Some(vec![uri]));
598        let id = post.create_id();
599        let err = post.validate(Some(&id)).unwrap_err();
600        assert!(err.contains("invalid post id"), "got: {err}");
601    }
602
603    #[test]
604    fn test_collection_post_rejects_post_uri_with_fragment() {
605        // Same as the query-string case: `#` and the fragment body land in the
606        // post-id slot and fail Crockford.
607        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A#frag");
608        let post = make_collection_post("X", None, Some(vec![uri]));
609        let id = post.create_id();
610        let err = post.validate(Some(&id)).unwrap_err();
611        assert!(err.contains("invalid post id"), "got: {err}");
612    }
613
614    #[test]
615    fn test_collection_post_rejects_post_uri_with_trailing_slash() {
616        // A trailing slash bloats the post-id past 13 chars.
617        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A/");
618        let post = make_collection_post("X", None, Some(vec![uri]));
619        let id = post.create_id();
620        let err = post.validate(Some(&id)).unwrap_err();
621        assert!(err.contains("invalid post id"), "got: {err}");
622    }
623
624    #[test]
625    fn test_collection_post_rejects_post_uri_with_empty_post_id() {
626        // Empty post-id segment fails the 13-char Crockford check.
627        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/");
628        let post = make_collection_post("X", None, Some(vec![uri]));
629        let id = post.create_id();
630        let err = post.validate(Some(&id)).unwrap_err();
631        assert!(err.contains("invalid post id"), "got: {err}");
632    }
633
634    #[test]
635    fn test_collection_post_rejects_userinfo_padding_bypass() {
636        // `Url::parse(...).host_str()` strips userinfo, which would smuggle
637        // arbitrary bytes past a parse-and-recheck approach. The strict
638        // validator keeps the `JUNK@` in the host slot, failing the 52-char
639        // PubkyId length check.
640        let uri = format!(
641            "pubky://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A"
642        );
643        let post = make_collection_post("X", None, Some(vec![uri]));
644        let id = post.create_id();
645        let err = post.validate(Some(&id)).unwrap_err();
646        assert!(err.contains("invalid pubky-id"), "got: {err}");
647    }
648
649    #[test]
650    fn test_collection_post_rejects_dot_dot_path_bypass() {
651        // `Url::parse(...)` collapses `..` segments before path inspection,
652        // which would smuggle a non-canonical raw path past a parse-and-recheck
653        // approach. The strict validator splits the raw string, so the extra
654        // segments land in the host slot and fail PubkyId.
655        let uri = format!("pubky://{TEST_PUBKY_ID}/aa/bb/../../pub/pubky.app/posts/0034A0X7NJ52A");
656        let post = make_collection_post("X", None, Some(vec![uri]));
657        let id = post.create_id();
658        let err = post.validate(Some(&id)).unwrap_err();
659        assert!(err.contains("invalid pubky-id"), "got: {err}");
660    }
661
662    #[test]
663    fn test_collection_post_accepts_canonical_max_length_uri() {
664        // Success-side boundary: the longest valid canonical post URI is
665        // pubky://<52-char-pubky-id>/pub/pubky.app/posts/<13-char-crockford>
666        // which is exactly 94 chars. Validator must accept this.
667        let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A");
668        assert_eq!(uri.chars().count(), 94);
669        let post = make_collection_post("X", None, Some(vec![uri]));
670        let id = post.create_id();
671        assert!(post.validate(Some(&id)).is_ok());
672    }
673
674    #[test]
675    fn test_collection_post_rejects_non_empty_attachments() {
676        let post = PubkyAppPost::new(
677            collection_envelope_json("X", None, &[]),
678            PubkyAppPostKind::Collection,
679            None,
680            None,
681            Some(vec![
682                "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string()
683            ]),
684        );
685        let id = post.create_id();
686        let err = post
687            .validate(Some(&id))
688            .expect_err("Collection with non-empty post.attachments must be rejected");
689        assert!(
690            err.contains("post.attachments"),
691            "expected anti-misuse error, got: {err}"
692        );
693    }
694
695    #[test]
696    fn test_collection_post_rejects_empty_attachments() {
697        let post = PubkyAppPost::new(
698            collection_envelope_json("X", None, &[]),
699            PubkyAppPostKind::Collection,
700            None,
701            None,
702            Some(vec![]),
703        );
704        let id = post.create_id();
705        let err = post
706            .validate(Some(&id))
707            .expect_err("Collection with empty post.attachments must be rejected");
708        assert!(
709            err.contains("post.attachments"),
710            "expected anti-misuse error, got: {err}"
711        );
712    }
713
714    #[test]
715    fn test_collection_post_accepts_missing_items_field() {
716        let envelope_json = r#"{"name":"X"}"#;
717        let post = PubkyAppPost::new(
718            envelope_json.to_string(),
719            PubkyAppPostKind::Collection,
720            None,
721            None,
722            None,
723        );
724        let id = post.create_id();
725        assert!(
726            post.validate(Some(&id)).is_ok(),
727            "missing `items` field must deserialize as empty list via serde(default)"
728        );
729    }
730
731    #[test]
732    fn test_collection_post_envelope_at_max_size() {
733        // 100 distinct valid pubky post URIs (max-count). Each exactly 94 chars.
734        let items: Vec<String> = (0..VALIDATION_LIMITS.collection_items_max_count)
735            .map(|i| format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/{:013}", i))
736            .collect();
737        let max_name = "a".repeat(VALIDATION_LIMITS.collection_name_max_length);
738        let max_desc = "b".repeat(VALIDATION_LIMITS.collection_description_max_length);
739        let post = make_collection_post(&max_name, Some(&max_desc), Some(items));
740        assert!(
741            post.content.chars().count() < VALIDATION_LIMITS.collection_content_max_length,
742            "envelope at max field sizes must fit under collection_content_max_length"
743        );
744        let id = post.create_id();
745        assert!(post.validate(Some(&id)).is_ok());
746    }
747
748    #[cfg(target_arch = "wasm32")]
749    #[wasm_bindgen_test::wasm_bindgen_test]
750    fn test_postkind_collection_wasm_getter() {
751        let post = PubkyAppPost {
752            content: collection_envelope_json("X", None, &[]),
753            kind: PubkyAppPostKind::Collection,
754            parent: None,
755            embed: None,
756            attachments: None,
757            lock: None,
758        };
759        assert_eq!(post.kind(), "Collection");
760    }
761
762    #[cfg(target_arch = "wasm32")]
763    #[wasm_bindgen_test::wasm_bindgen_test]
764    fn test_create_collection_post_wasm_builder() {
765        // End-to-end via the JS-facing builder:
766        //   PubkySpecsBuilder.createCollectionPost(name, description?, items?, cover_image?, layout?)
767        // builds the {name, description} envelope internally, packages it
768        // into a kind=Collection PubkyAppPost, and returns a PostResult
769        // ready to ship to the homeserver. JS callers don't have to
770        // JSON-stringify the envelope themselves.
771        use crate::PubkySpecsBuilder;
772        let pubky_id = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo".to_string();
773        let builder = PubkySpecsBuilder::new(pubky_id).expect("Failed to construct builder");
774        let result = builder
775            .create_collection_post(
776                "My favorites".to_string(),
777                Some("Best things".to_string()),
778                Some(vec![
779                    "pubky://operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0034A0X7NJ52A".to_string(),
780                ]),
781                Some("https://example.com/cover.png".to_string()),
782                Some("list".to_string()),
783            )
784            .expect("createCollectionPost should succeed");
785
786        let post = result.post();
787        assert_eq!(post.kind, PubkyAppPostKind::Collection);
788        assert!(post.attachments.is_none());
789        let envelope: PubkyAppCollectionContent = serde_json::from_str(&post.content)
790            .expect("Collection content must deserialize as PubkyAppCollectionContent");
791        assert_eq!(envelope.name, "My favorites");
792        assert_eq!(envelope.description.as_deref(), Some("Best things"));
793        assert_eq!(envelope.items.len(), 1);
794        assert_eq!(
795            envelope.cover_image.as_deref(),
796            Some("https://example.com/cover.png")
797        );
798        assert_eq!(envelope.layout, Some(PubkyAppCollectionLayout::List));
799    }
800}