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#[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#[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 pub name: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub description: Option<String>,
73 #[serde(default)]
77 pub items: Vec<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub cover_image: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub layout: Option<PubkyAppCollectionLayout>,
86}
87
88pub(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 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
177fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}