use crate::{
common::{sanitize_url, validate_crockford_id},
limits::VALIDATION_LIMITS,
traits::{HasIdPath, TimestampId, Validatable},
types::PubkyId,
APP_PATH, PUBLIC_PATH,
};
use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
use url::Url;
const RESERVED_CONTENT_DELETED: &str = "[DELETED]";
#[cfg(target_arch = "wasm32")]
use crate::traits::Json;
#[cfg(target_arch = "wasm32")]
use tsify_next::Tsify;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum PubkyAppPostKind {
#[default]
Short,
Long,
Image,
Video,
Link,
File,
Collection,
#[serde(other)]
Unknown,
}
impl fmt::Display for PubkyAppPostKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let string_repr = serde_json::to_value(self)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
write!(f, "{}", string_repr)
}
}
impl FromStr for PubkyAppPostKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"short" => Ok(PubkyAppPostKind::Short),
"long" => Ok(PubkyAppPostKind::Long),
"image" => Ok(PubkyAppPostKind::Image),
"video" => Ok(PubkyAppPostKind::Video),
"link" => Ok(PubkyAppPostKind::Link),
"file" => Ok(PubkyAppPostKind::File),
"collection" => Ok(PubkyAppPostKind::Collection),
_ => Err(format!("Invalid content kind: {}", s)),
}
}
}
impl PubkyAppPostKind {
pub fn is_known(&self) -> bool {
!matches!(self, PubkyAppPostKind::Unknown)
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppPostEmbed {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub kind: PubkyAppPostKind, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub uri: String, }
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppPostEmbed {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
pub fn new(uri: String, kind: PubkyAppPostKind) -> Self {
PubkyAppPostEmbed { uri, kind }
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn kind(&self) -> String {
match self.kind {
PubkyAppPostKind::Short => "Short".to_string(),
PubkyAppPostKind::Long => "Long".to_string(),
PubkyAppPostKind::Image => "Image".to_string(),
PubkyAppPostKind::Video => "Video".to_string(),
PubkyAppPostKind::Link => "Link".to_string(),
PubkyAppPostKind::File => "File".to_string(),
PubkyAppPostKind::Collection => "Collection".to_string(),
PubkyAppPostKind::Unknown => "Unknown".to_string(),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn uri(&self) -> String {
self.uri.clone()
}
}
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(target_arch = "wasm32", derive(Tsify))]
#[serde(rename_all = "snake_case")]
pub struct PubkyAppCollectionContent {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub items: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppPost {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub content: String,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub kind: PubkyAppPostKind,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub parent: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub embed: Option<PubkyAppPostEmbed>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub attachments: Option<Vec<String>>,
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppPost {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn content(&self) -> String {
self.content.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn kind(&self) -> String {
match self.kind {
PubkyAppPostKind::Short => "Short".to_string(),
PubkyAppPostKind::Long => "Long".to_string(),
PubkyAppPostKind::Image => "Image".to_string(),
PubkyAppPostKind::Video => "Video".to_string(),
PubkyAppPostKind::Link => "Link".to_string(),
PubkyAppPostKind::File => "File".to_string(),
PubkyAppPostKind::Collection => "Collection".to_string(),
PubkyAppPostKind::Unknown => "Unknown".to_string(),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn parent(&self) -> Option<String> {
self.parent.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn embed(&self) -> Option<PubkyAppPostEmbed> {
self.embed.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn attachments(&self) -> Option<Vec<String>> {
self.attachments.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppPost {}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppPost {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
pub fn new(
content: String,
kind: PubkyAppPostKind,
parent: Option<String>,
embed: Option<PubkyAppPostEmbed>,
attachments: Option<Vec<String>>,
) -> Self {
let post = PubkyAppPost {
content,
kind,
parent,
embed,
attachments,
};
post.sanitize()
}
}
impl TimestampId for PubkyAppPost {}
impl HasIdPath for PubkyAppPost {
const PATH_SEGMENT: &'static str = "posts/";
fn create_path(id: &str) -> String {
[PUBLIC_PATH, APP_PATH, Self::PATH_SEGMENT, id].concat()
}
}
impl Validatable for PubkyAppPost {
fn sanitize(self) -> Self {
let content = self.content.trim().to_string();
let parent = self.parent.map(|uri_str| sanitize_url(&uri_str));
let embed = self.embed.map(|e| PubkyAppPostEmbed {
kind: e.kind,
uri: sanitize_url(&e.uri),
});
let attachments = self.attachments.map(|attachments_vec| {
attachments_vec
.into_iter()
.map(|url_str| sanitize_url(&url_str))
.collect()
});
PubkyAppPost {
content,
kind: self.kind,
parent,
embed,
attachments,
}
}
fn validate(&self, id: Option<&str>) -> Result<(), String> {
if let Some(id) = id {
self.validate_id(id)?;
}
if self.content.trim().is_empty() && self.embed.is_none() && self.attachments.is_none() {
return Err(
"Validation Error: Post must have content, an embed, or attachments".into(),
);
}
if self.content == RESERVED_CONTENT_DELETED {
return Err(
"Validation Error: Content cannot be the reserved keyword '[DELETED]'".into(),
);
}
if !self.kind.is_known() {
return Err("Validation Error: post kind is unknown".into());
}
if let Some(ref embed) = self.embed {
if !embed.kind.is_known() {
return Err("Validation Error: embed kind is unknown".into());
}
}
if matches!(self.kind, PubkyAppPostKind::Collection) {
if self.parent.is_some() || self.embed.is_some() {
return Err(
"Validation Error: Collection posts cannot have parent or embed".into(),
);
}
if matches!(&self.attachments, Some(a) if !a.is_empty()) {
return Err(
"Validation Error: Collection posts must not use post.attachments — items belong in the content envelope"
.into(),
);
}
if self.content.chars().count() > VALIDATION_LIMITS.collection_content_max_length {
return Err(format!(
"Validation Error: Collection content exceeds max length {}",
VALIDATION_LIMITS.collection_content_max_length
));
}
let envelope: PubkyAppCollectionContent =
serde_json::from_str(&self.content).map_err(|e| {
format!(
"Validation Error: Collection content must be a valid JSON envelope: {}",
e
)
})?;
if envelope.name.trim().is_empty() {
return Err(
"Validation Error: Collection name must contain non-whitespace characters"
.into(),
);
}
let name_chars = envelope.name.chars().count();
let name_min = VALIDATION_LIMITS.collection_name_min_length;
let name_max = VALIDATION_LIMITS.collection_name_max_length;
if !(name_min..=name_max).contains(&name_chars) {
return Err(format!(
"Validation Error: Collection name must be {}..={} characters",
name_min, name_max
));
}
if let Some(desc) = &envelope.description {
if desc.chars().count() > VALIDATION_LIMITS.collection_description_max_length {
return Err(format!(
"Validation Error: Collection description exceeds {} characters",
VALIDATION_LIMITS.collection_description_max_length
));
}
}
if let Some(cover) = &envelope.cover_image {
if cover.chars().count() > VALIDATION_LIMITS.post_attachment_url_max_length {
return Err(format!(
"Validation Error: Collection cover_image URL exceeds {} characters",
VALIDATION_LIMITS.post_attachment_url_max_length
));
}
let parsed = Url::parse(cover).map_err(|_| {
"Validation Error: Collection cover_image must be a valid URL".to_string()
})?;
if !VALIDATION_LIMITS
.post_allowed_attachment_protocols
.contains(&parsed.scheme())
{
let allowed = VALIDATION_LIMITS
.post_allowed_attachment_protocols
.iter()
.map(|p| format!("{p}://"))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"Validation Error: Collection cover_image must use one of the allowed protocols: {allowed}"
));
}
}
if envelope.items.len() > VALIDATION_LIMITS.collection_items_max_count {
return Err(format!(
"Validation Error: Collection cannot have more than {} items",
VALIDATION_LIMITS.collection_items_max_count
));
}
for (index, uri) in envelope.items.iter().enumerate() {
validate_collection_item_uri(uri).map_err(|e| {
format!("Validation Error: Collection item at index {index}: {e}")
})?;
}
return Ok(());
}
let (max_length, kind_name) = match self.kind {
PubkyAppPostKind::Short => (VALIDATION_LIMITS.post_short_content_max_length, "Short"),
PubkyAppPostKind::Long => (VALIDATION_LIMITS.post_long_content_max_length, "Long"),
PubkyAppPostKind::Image
| PubkyAppPostKind::Video
| PubkyAppPostKind::Link
| PubkyAppPostKind::File => (
VALIDATION_LIMITS.post_short_content_max_length,
"Image/Video/Link/File",
),
PubkyAppPostKind::Collection | PubkyAppPostKind::Unknown => {
unreachable!("guarded by early-return above")
}
};
if self.content.chars().count() > max_length {
return Err(format!(
"Validation Error: Post content exceeds maximum length for {} kind (max: {} characters)",
kind_name, max_length
));
}
if let Some(ref parent_uri) = self.parent {
Url::parse(parent_uri).map_err(|_| {
format!(
"Validation Error: Invalid parent URI format: {}",
parent_uri
)
})?;
}
if let Some(ref embed) = self.embed {
Url::parse(&embed.uri).map_err(|_| {
format!("Validation Error: Invalid embed URI format: {}", embed.uri)
})?;
}
if let Some(attachments) = &self.attachments {
if attachments.len() > VALIDATION_LIMITS.post_attachments_max_count {
return Err(format!(
"Validation Error: Too many attachments (max: {})",
VALIDATION_LIMITS.post_attachments_max_count
));
}
for (index, url) in attachments.iter().enumerate() {
if url.trim().is_empty() {
return Err(format!(
"Validation Error: Attachment URL at index {} cannot be empty",
index
));
}
if url.chars().count() > VALIDATION_LIMITS.post_attachment_url_max_length {
return Err(format!(
"Validation Error: Attachment URL at index {} exceeds maximum length (max: {} characters)",
index, VALIDATION_LIMITS.post_attachment_url_max_length
));
}
let parsed_url = Url::parse(url).map_err(|_| {
format!(
"Validation Error: Invalid attachment URL format at index {}",
index
)
})?;
if !VALIDATION_LIMITS
.post_allowed_attachment_protocols
.contains(&parsed_url.scheme())
{
let allowed_protocols = VALIDATION_LIMITS
.post_allowed_attachment_protocols
.iter()
.map(|p| format!("{}://", p))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"Validation Error: Attachment URL at index {} must use one of the allowed protocols: {}",
index, allowed_protocols
));
}
}
}
Ok(())
}
}
fn validate_collection_item_uri(uri: &str) -> Result<(), String> {
const PREFIX: &str = "pubky://";
const MIDDLE: &str = "/pub/pubky.app/posts/";
let rest = uri
.strip_prefix(PREFIX)
.ok_or_else(|| format!("must start with pubky://: {uri}"))?;
let (host, post_id) = rest
.split_once(MIDDLE)
.ok_or_else(|| format!("must be a canonical post URI: {uri}"))?;
PubkyId::try_from(host).map_err(|e| format!("invalid pubky-id in host: {e}"))?;
validate_crockford_id(post_id).map_err(|e| format!("invalid post id: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_PUBKY_ID: &str = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo";
use crate::{traits::Validatable, APP_PATH, PUBLIC_PATH};
#[test]
fn test_create_id() {
let post = PubkyAppPost::new(
"Hello World!".to_string(),
PubkyAppPostKind::Short,
None,
None,
None,
);
let post_id = post.create_id();
println!("Generated Post ID: {}", post_id);
assert_eq!(post_id.len(), 13);
}
#[test]
fn test_new() {
let content = "This is a test post".to_string();
let kind = PubkyAppPostKind::Short;
let post = PubkyAppPost::new(content.clone(), kind.clone(), None, None, None);
assert_eq!(post.content, content);
assert_eq!(post.kind, kind);
assert!(post.parent.is_none());
assert!(post.embed.is_none());
assert!(post.attachments.is_none());
}
#[test]
fn test_create_path() {
let post = PubkyAppPost::new(
"Test post".to_string(),
PubkyAppPostKind::Short,
None,
None,
None,
);
let post_id = post.create_id();
let path = PubkyAppPost::create_path(&post_id);
let prefix = format!("{}{}posts/", PUBLIC_PATH, APP_PATH);
assert!(path.starts_with(&prefix));
let expected_path_len = prefix.len() + post_id.len();
assert_eq!(path.len(), expected_path_len);
}
#[test]
fn test_sanitize() {
let content = " This is a test post with extra whitespace ".to_string();
let post = PubkyAppPost::new(
content.clone(),
PubkyAppPostKind::Short,
Some(" pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/posts/0034A0X7NJ52G ".to_string()),
Some(PubkyAppPostEmbed {
kind: PubkyAppPostKind::Link,
uri: " pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7Q3D80 ".to_string(),
}),
Some(vec![
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7NJ52G".to_string(),
" pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7Q3D80 ".to_string(), ]),
);
let sanitized_post = post.sanitize();
assert_eq!(sanitized_post.content, content.trim());
assert!(sanitized_post.parent.is_some());
let parent = sanitized_post.parent.unwrap();
assert!(!parent.starts_with(" "));
assert!(!parent.ends_with(" "));
assert!(parent.starts_with("pubky://"));
assert!(sanitized_post.embed.is_some());
let embed = sanitized_post.embed.unwrap();
assert!(!embed.uri.starts_with(" "));
assert!(!embed.uri.ends_with(" "));
assert!(embed.uri.starts_with("pubky://"));
assert!(sanitized_post.attachments.is_some());
let attachments = sanitized_post.attachments.unwrap();
assert_eq!(attachments.len(), 2);
assert!(attachments[0].starts_with("pubky://"));
assert!(attachments[1].starts_with("pubky://"));
assert!(!attachments[1].starts_with(" pubky://"));
assert!(!attachments[1].ends_with(" "));
}
#[test]
fn test_sanitize_trims_parent_and_embed() {
let valid_parent_uri = " pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/posts/0034A0X7NJ52G ".to_string();
let valid_embed_uri = " pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7Q3D80 ".to_string();
let post = PubkyAppPost::new(
"Test content".to_string(),
PubkyAppPostKind::Short,
Some(valid_parent_uri.clone()),
Some(PubkyAppPostEmbed {
kind: PubkyAppPostKind::Link,
uri: valid_embed_uri.clone(),
}),
None,
);
let sanitized_post = post.sanitize();
assert!(sanitized_post.parent.is_some());
let parent = sanitized_post.parent.unwrap();
assert!(!parent.starts_with(" "));
assert!(!parent.ends_with(" "));
assert!(parent.starts_with("pubky://"));
assert!(sanitized_post.embed.is_some());
let embed = sanitized_post.embed.unwrap();
assert!(!embed.uri.starts_with(" "));
assert!(!embed.uri.ends_with(" "));
assert!(embed.uri.starts_with("pubky://"));
}
#[test]
fn test_validate() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Short,
None,
None,
None,
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_ok());
}
#[test]
fn test_validate_invalid_id() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Short,
None,
None,
None,
);
let invalid_id = "INVALIDID12345";
let result = post.validate(Some(invalid_id));
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_parent_uri() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Short,
Some("invalid uri".to_string()),
None,
None,
);
let id = post.create_id();
let sanitized = post.sanitize();
let result = sanitized.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid parent URI format"));
}
#[test]
fn test_validate_invalid_embed_uri() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Short,
None,
Some(PubkyAppPostEmbed {
kind: PubkyAppPostKind::Link,
uri: "invalid uri".to_string(),
}),
None,
);
let id = post.create_id();
let sanitized = post.sanitize();
let result = sanitized.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid embed URI format"));
}
#[test]
fn test_validate_invalid_attachment_uri() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(vec![
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7NJ52G".to_string(),
"invalid uri".to_string(),
]),
);
let id = post.create_id();
let sanitized = post.sanitize();
let result = sanitized.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Invalid attachment URL format"));
}
#[test]
fn test_try_from_valid() {
let post_json = r#"
{
"content": "Hello World!",
"kind": "short",
"parent": null,
"embed": null,
"attachments": null
}
"#;
let id = PubkyAppPost::new(
"Hello World!".to_string(),
PubkyAppPostKind::Short,
None,
None,
None,
)
.create_id();
let blob = post_json.as_bytes();
let post = <PubkyAppPost as Validatable>::try_from(blob, &id).unwrap();
assert_eq!(post.content, "Hello World!");
}
#[test]
fn test_validate_reserved_keyword() {
let post = PubkyAppPost::new(
"[DELETED]".to_string(),
PubkyAppPostKind::Short,
None,
None,
None,
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("reserved keyword"));
}
#[test]
fn test_try_from_invalid_content() {
let content = "[DELETED]".to_string();
let post_json = format!(
r#"{{
"content": "{}",
"kind": "short",
"parent": null,
"embed": null,
"attachments": null
}}"#,
content
);
let id = PubkyAppPost::new(content.clone(), PubkyAppPostKind::Short, None, None, None)
.create_id();
let blob = post_json.as_bytes();
let result = <PubkyAppPost as Validatable>::try_from(blob, &id);
assert!(result.is_err());
assert!(result.unwrap_err().contains("reserved keyword"));
}
#[test]
fn test_validate_attachments_valid_protocols() {
let protocols = vec![
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7NJ52G".to_string(),
"https://example.com/file.png".to_string(),
"http://example.com/file.jpg".to_string(),
];
assert!(
protocols.len() <= VALIDATION_LIMITS.post_attachments_max_count,
"Test uses more than post_attachments_max_count"
);
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(protocols),
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_ok());
}
#[test]
fn test_validate_attachments_all_allowed_protocols() {
let allowed_protocols = vec![
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7NJ52G",
"http://example.com/file.jpg",
"https://example.com/file.png",
];
for protocol_url in allowed_protocols {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(vec![protocol_url.to_string()]),
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_ok(), "Should accept protocol: {}", protocol_url);
}
}
#[test]
fn test_validate_attachments_too_many() {
let mut attachments = Vec::new();
for i in 0..VALIDATION_LIMITS.post_attachments_max_count + 1 {
attachments.push(format!(
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/{}",
i
));
}
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(attachments),
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Too many attachments"));
}
#[test]
fn test_validate_attachments_invalid_protocol() {
let invalid_protocols = vec!["ftp://example.com/file", "file:///path/to/file"];
for invalid_url in invalid_protocols {
let post = PubkyAppPost {
content: "Valid content".to_string(),
kind: PubkyAppPostKind::Image,
parent: None,
embed: None,
attachments: Some(vec![invalid_url.to_string()]),
};
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err(), "Should reject protocol: {}", invalid_url);
assert!(result.unwrap_err().contains("protocol"));
}
}
#[test]
fn test_validate_attachments_invalid_url_format() {
let post = PubkyAppPost {
content: "Valid content".to_string(),
kind: PubkyAppPostKind::Image,
parent: None,
embed: None,
attachments: Some(vec!["not a valid url".to_string()]),
};
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Invalid attachment URL format"));
}
#[test]
fn test_validate_attachments_url_too_long() {
let long_file_id = "a".repeat(150); let long_url = format!(
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/{}",
long_file_id
);
assert!(
long_url.chars().count() > VALIDATION_LIMITS.post_attachment_url_max_length,
"URL length {} should exceed {}",
long_url.chars().count(),
VALIDATION_LIMITS.post_attachment_url_max_length
);
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(vec![long_url]),
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("exceeds maximum length"));
}
#[test]
fn test_validate_attachments_empty_url() {
let post = PubkyAppPost {
content: "Valid content".to_string(),
kind: PubkyAppPostKind::Image,
parent: None,
embed: None,
attachments: Some(vec![" ".to_string()]), };
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[test]
fn test_sanitize_attachments_preserves_all() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(vec![
"pubky://6mfxozzqmb36rc9rgy3rykoyfghfao74n8igt5tf1boehproahoy/pub/pubky.app/files/0034A0X7NJ52G".to_string(),
"https://example.com/file.jpg".to_string(),
" invalid url ".to_string(), ]),
);
let id = post.create_id();
let sanitized = post.sanitize();
assert!(sanitized.attachments.is_some());
let attachments = sanitized.attachments.as_ref().unwrap();
assert_eq!(attachments.len(), 3); assert!(attachments[0].starts_with("pubky://"));
assert!(attachments[1].starts_with("https://"));
assert_eq!(attachments[2], "invalid url");
let result = sanitized.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Invalid attachment URL format"));
}
#[test]
fn test_sanitize_attachments_with_all_invalid_preserved() {
let post = PubkyAppPost::new(
"Valid content".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(vec!["invalid url".to_string(), "not a url".to_string()]),
);
let id = post.create_id();
let sanitized = post.sanitize();
assert!(sanitized.attachments.is_some()); let attachments = sanitized.attachments.as_ref().unwrap();
assert_eq!(attachments.len(), 2);
let result = sanitized.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Invalid attachment URL format"));
}
#[test]
fn test_validate_empty_post_rejected() {
let post = PubkyAppPost::new("".to_string(), PubkyAppPostKind::Short, None, None, None);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("must have content, an embed, or attachments"));
}
#[test]
fn test_validate_empty_content_with_embed_accepted() {
let post = PubkyAppPost::new(
"".to_string(),
PubkyAppPostKind::Short,
None,
Some(PubkyAppPostEmbed {
kind: PubkyAppPostKind::Short,
uri: "pubky://user123/pub/pubky.app/posts/0033SSE3B1FQ0".to_string(),
}),
None,
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(
result.is_ok(),
"Post with embed but no content should be valid"
);
}
#[test]
fn test_validate_empty_content_with_attachments_accepted() {
let post = PubkyAppPost::new(
"".to_string(),
PubkyAppPostKind::Image,
None,
None,
Some(vec![
"pubky://user123/pub/pubky.app/files/0034A0X7NJ52G".to_string()
]),
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(
result.is_ok(),
"Post with attachments but no content should be valid"
);
}
#[test]
fn test_postkind_deserializes_unknown_kind_as_unknown() {
let post_json = r#"
{
"content": "Hello",
"kind": "totally-new-kind",
"parent": null,
"embed": null,
"attachments": null
}
"#;
let post: PubkyAppPost = serde_json::from_str(post_json).unwrap();
assert_eq!(post.kind, PubkyAppPostKind::Unknown);
}
#[test]
fn test_postkind_existing_variants_unchanged_after_unknown_added() {
for (s, expected) in [
("short", PubkyAppPostKind::Short),
("long", PubkyAppPostKind::Long),
("image", PubkyAppPostKind::Image),
("video", PubkyAppPostKind::Video),
("link", PubkyAppPostKind::Link),
("file", PubkyAppPostKind::File),
] {
let json = format!(
r#"{{"content":"x","kind":"{}","parent":null,"embed":null,"attachments":null}}"#,
s
);
let post: PubkyAppPost = serde_json::from_str(&json).unwrap();
assert_eq!(post.kind, expected, "kind={} did not round-trip", s);
let re = serde_json::to_value(&post.kind).unwrap();
assert_eq!(re.as_str(), Some(s));
}
}
#[test]
fn test_postkind_unknown_rejected_by_validator() {
let post = PubkyAppPost {
content: "x".to_string(),
kind: PubkyAppPostKind::Unknown,
parent: None,
embed: None,
attachments: None,
};
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(
result.unwrap_err().to_lowercase().contains("unknown"),
"validator should mention 'unknown' in the error"
);
}
#[test]
fn test_postkind_unknown_displays_as_lowercase() {
assert_eq!(PubkyAppPostKind::Unknown.to_string(), "unknown");
}
#[test]
fn test_postkind_fromstr_rejects_unknown_strings() {
assert!(PubkyAppPostKind::from_str("foobar").is_err());
assert!(PubkyAppPostKind::from_str("totally-new-kind").is_err());
}
#[test]
fn test_is_known_returns_true_for_all_recognized_variants() {
use PubkyAppPostKind::*;
for k in [Short, Long, Image, Video, Link, File, Collection] {
assert!(k.is_known(), "{k:?} should be known");
}
}
#[test]
fn test_is_known_returns_false_for_unknown() {
assert!(!PubkyAppPostKind::Unknown.is_known());
}
#[test]
fn test_post_deserializes_embed_with_unknown_kind_as_unknown() {
let post_json = r#"
{
"content": "x",
"kind": "short",
"parent": null,
"embed": {"kind": "totally-new-embed-kind", "uri": "pubky://x/pub/pubky.app/posts/01"},
"attachments": null
}
"#;
let post: PubkyAppPost = serde_json::from_str(post_json).unwrap();
assert_eq!(post.embed.unwrap().kind, PubkyAppPostKind::Unknown);
}
#[test]
fn test_postkind_unknown_embed_kind_rejected_by_validator() {
let post = PubkyAppPost {
content: "x".to_string(),
kind: PubkyAppPostKind::Short,
parent: None,
embed: Some(PubkyAppPostEmbed {
kind: PubkyAppPostKind::Unknown,
uri: "pubky://x/pub/pubky.app/posts/01".to_string(),
}),
attachments: None,
};
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
let err = result.unwrap_err().to_lowercase();
assert!(
err.contains("embed") && err.contains("unknown"),
"validator should mention 'embed' and 'unknown' in the error, got: {}",
err
);
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn test_postkind_unknown_wasm_getter() {
let post = PubkyAppPost {
content: "x".to_string(),
kind: PubkyAppPostKind::Unknown,
parent: None,
embed: None,
attachments: None,
};
assert_eq!(post.kind(), "Unknown");
}
fn collection_envelope_json(name: &str, description: Option<&str>, items: &[String]) -> String {
serde_json::to_string(&PubkyAppCollectionContent {
name: name.to_string(),
description: description.map(|d| d.to_string()),
items: items.to_vec(),
cover_image: None,
})
.unwrap()
}
fn make_collection_post(
name: &str,
description: Option<&str>,
items: Option<Vec<String>>,
) -> PubkyAppPost {
let items = items.unwrap_or_default();
PubkyAppPost::new(
collection_envelope_json(name, description, &items),
PubkyAppPostKind::Collection,
None,
None,
None,
)
}
fn make_collection_post_with_cover(cover_image: Option<&str>) -> PubkyAppPost {
let envelope = PubkyAppCollectionContent {
name: "X".to_string(),
description: None,
items: vec![],
cover_image: cover_image.map(|s| s.to_string()),
};
let content = serde_json::to_string(&envelope).expect("envelope serialization");
PubkyAppPost::new(content, PubkyAppPostKind::Collection, None, None, None)
}
#[test]
fn test_collection_post_roundtrip_valid() {
let post = make_collection_post(
"AI papers",
Some("Best stuff"),
Some(vec![
format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A"),
format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52B"),
format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52C"),
]),
);
let id = post.create_id();
let blob = serde_json::to_vec(&post).unwrap();
let parsed = <PubkyAppPost as Validatable>::try_from(&blob, &id).unwrap();
assert_eq!(parsed.kind, PubkyAppPostKind::Collection);
assert!(parsed.attachments.is_none());
let envelope: PubkyAppCollectionContent = serde_json::from_str(&parsed.content).unwrap();
assert_eq!(envelope.items.len(), 3);
}
#[test]
fn test_collection_post_rejects_malformed_envelope() {
let post = PubkyAppPost::new(
"this is not JSON".to_string(),
PubkyAppPostKind::Collection,
None,
None,
None,
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("JSON envelope"),
"expected JSON envelope error, got: {}",
err
);
}
#[test]
fn test_collection_post_rejects_empty_name() {
let post = make_collection_post("", None, None);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("name"));
}
#[test]
fn test_collection_post_rejects_oversized_name() {
let oversized = "a".repeat(99) + "🚀🚀";
assert_eq!(oversized.chars().count(), 101);
let post = make_collection_post(&oversized, None, None);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("name"));
}
#[test]
fn test_collection_post_accepts_max_name() {
let exactly_100 = "a".repeat(100);
assert_eq!(exactly_100.chars().count(), 100);
let post = make_collection_post(&exactly_100, None, None);
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_rejects_whitespace_only_name() {
let post = make_collection_post(" ", None, None);
let id = post.create_id();
let err = post
.validate(Some(&id))
.expect_err("whitespace-only name must fail validation");
assert!(
err.contains("whitespace"),
"error should mention whitespace, got: {err}"
);
}
#[test]
fn test_collection_post_counts_whitespace_in_name_length() {
let padded = format!(" {} ", "a".repeat(99));
assert_eq!(padded.chars().count(), 101);
let post = make_collection_post(&padded, None, None);
let id = post.create_id();
let err = post
.validate(Some(&id))
.expect_err("101-char padded name must fail max length");
assert!(
err.contains("1..=100"),
"error should report the length range, got: {err}"
);
}
#[test]
fn test_collection_post_accepts_cover_image_pubky_uri() {
let cover = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/files/0034A0X7NJ52A");
let post = make_collection_post_with_cover(Some(&cover));
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_accepts_cover_image_https() {
let post = make_collection_post_with_cover(Some("https://example.com/cover.png"));
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_rejects_cover_image_invalid_url() {
let post = make_collection_post_with_cover(Some("not a url"));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(
err.contains("cover_image must be a valid URL"),
"got: {err}"
);
}
#[test]
fn test_collection_post_rejects_cover_image_disallowed_protocol() {
let post = make_collection_post_with_cover(Some("ftp://example.com/cover.png"));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(
err.contains("cover_image must use one of the allowed protocols"),
"got: {err}"
);
}
#[test]
fn test_collection_post_rejects_cover_image_too_long() {
let too_long = format!("https://example.com/{}", "a".repeat(200));
let post = make_collection_post_with_cover(Some(&too_long));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("cover_image URL exceeds"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_oversized_description() {
let too_long = "a".repeat(501);
let post = make_collection_post("X", Some(&too_long), None);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("description"));
}
#[test]
fn test_collection_post_accepts_empty_description() {
let post = make_collection_post("X", Some(""), None);
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_accepts_max_description() {
let exactly_500 = "a".repeat(500);
assert_eq!(exactly_500.chars().count(), 500);
let post = make_collection_post("X", Some(&exactly_500), None);
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_rejects_missing_name() {
let envelope = r#"{ "description": "no name here" }"#.to_string();
let post = PubkyAppPost::new(envelope, PubkyAppPostKind::Collection, None, None, None);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("name") || err.to_lowercase().contains("missing"),
"expected name-required error, got: {err}"
);
}
#[test]
fn test_collection_post_rejects_parent() {
let post = PubkyAppPost::new(
collection_envelope_json("X", None, &[]),
PubkyAppPostKind::Collection,
Some("pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string()),
None,
None,
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("parent or embed"),
"expected parent-or-embed error, got: {}",
err
);
}
#[test]
fn test_collection_post_rejects_embed() {
let post = PubkyAppPost::new(
collection_envelope_json("X", None, &[]),
PubkyAppPostKind::Collection,
None,
Some(PubkyAppPostEmbed {
kind: PubkyAppPostKind::Short,
uri: "pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string(),
}),
None,
);
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("parent or embed"));
}
#[test]
fn test_collection_post_accepts_100_items() {
let items: Vec<String> = (0..100)
.map(|i| format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/{:013}", i))
.collect();
let post = make_collection_post("Big list", None, Some(items));
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_rejects_101_items() {
let items: Vec<String> = (0..101)
.map(|i| format!("pubky://userA/pub/pubky.app/posts/{:013}", i))
.collect();
let post = make_collection_post("Too big", None, Some(items));
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("100 items"));
}
#[test]
fn test_postkind_collection_display_lowercase() {
assert_eq!(PubkyAppPostKind::Collection.to_string(), "collection");
}
#[test]
fn test_postkind_fromstr_collection() {
assert_eq!(
PubkyAppPostKind::from_str("collection").unwrap(),
PubkyAppPostKind::Collection
);
}
#[test]
fn test_collection_post_accepts_zero_items() {
let post = make_collection_post("Drafts", None, None);
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_envelope_tolerates_extra_fields() {
let envelope_json = r#"{"name":"X","_forward_compat_canary":"future-only"}"#;
let post = PubkyAppPost::new(
envelope_json.to_string(),
PubkyAppPostKind::Collection,
None,
None,
None,
);
let id = post.create_id();
assert!(
post.validate(Some(&id)).is_ok(),
"unknown envelope fields must be tolerated"
);
}
#[test]
fn test_collection_post_rejects_non_post_uri() {
let post =
make_collection_post("X", None, Some(vec!["ftp://example.com/file".to_string()]));
let id = post.create_id();
let result = post.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Collection item"));
}
#[test]
fn test_collection_post_rejects_post_uri_with_invalid_post_id() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/abc-def-ghi-j");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid post id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_post_uri_with_extra_path_segment() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A/extra");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid post id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_post_uri_with_query_string() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A?foo=bar");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid post id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_post_uri_with_fragment() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A#frag");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid post id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_post_uri_with_trailing_slash() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A/");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid post id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_post_uri_with_empty_post_id() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid post id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_userinfo_padding_bypass() {
let uri = format!(
"pubky://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A"
);
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid pubky-id"), "got: {err}");
}
#[test]
fn test_collection_post_rejects_dot_dot_path_bypass() {
let uri = format!("pubky://{TEST_PUBKY_ID}/aa/bb/../../pub/pubky.app/posts/0034A0X7NJ52A");
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
let err = post.validate(Some(&id)).unwrap_err();
assert!(err.contains("invalid pubky-id"), "got: {err}");
}
#[test]
fn test_collection_post_accepts_canonical_max_length_uri() {
let uri = format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/0034A0X7NJ52A");
assert_eq!(uri.chars().count(), 94);
let post = make_collection_post("X", None, Some(vec![uri]));
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_collection_post_rejects_non_empty_attachments() {
let post = PubkyAppPost::new(
collection_envelope_json("X", None, &[]),
PubkyAppPostKind::Collection,
None,
None,
Some(vec![
"pubky://userA/pub/pubky.app/posts/0034A0X7NJ52A".to_string()
]),
);
let id = post.create_id();
let err = post
.validate(Some(&id))
.expect_err("Collection with non-empty post.attachments must be rejected");
assert!(
err.contains("post.attachments"),
"expected anti-misuse error, got: {err}"
);
}
#[test]
fn test_collection_post_accepts_missing_items_field() {
let envelope_json = r#"{"name":"X"}"#;
let post = PubkyAppPost::new(
envelope_json.to_string(),
PubkyAppPostKind::Collection,
None,
None,
None,
);
let id = post.create_id();
assert!(
post.validate(Some(&id)).is_ok(),
"missing `items` field must deserialize as empty list via serde(default)"
);
}
#[test]
fn test_collection_post_envelope_at_max_size() {
let items: Vec<String> = (0..VALIDATION_LIMITS.collection_items_max_count)
.map(|i| format!("pubky://{TEST_PUBKY_ID}/pub/pubky.app/posts/{:013}", i))
.collect();
let max_name = "a".repeat(VALIDATION_LIMITS.collection_name_max_length);
let max_desc = "b".repeat(VALIDATION_LIMITS.collection_description_max_length);
let post = make_collection_post(&max_name, Some(&max_desc), Some(items));
assert!(
post.content.chars().count() < VALIDATION_LIMITS.collection_content_max_length,
"envelope at max field sizes must fit under collection_content_max_length"
);
let id = post.create_id();
assert!(post.validate(Some(&id)).is_ok());
}
#[test]
fn test_existing_post_kinds_unchanged_with_collection() {
for s in ["short", "long", "image", "video", "link", "file"] {
let json = format!(
r#"{{"content":"x","kind":"{}","parent":null,"embed":null,"attachments":null}}"#,
s
);
let post: PubkyAppPost = serde_json::from_str(&json).unwrap();
let re = serde_json::to_value(&post.kind).unwrap();
assert_eq!(re.as_str(), Some(s), "kind={} did not round-trip", s);
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn test_postkind_collection_wasm_getter() {
let post = PubkyAppPost {
content: collection_envelope_json("X", None, &[]),
kind: PubkyAppPostKind::Collection,
parent: None,
embed: None,
attachments: None,
};
assert_eq!(post.kind(), "Collection");
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn test_create_collection_post_wasm_builder() {
use crate::PubkySpecsBuilder;
let pubky_id = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo".to_string();
let builder = PubkySpecsBuilder::new(pubky_id).expect("Failed to construct builder");
let result = builder
.create_collection_post(
"My favorites".to_string(),
Some("Best things".to_string()),
Some(vec![
"pubky://operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0034A0X7NJ52A".to_string(),
]),
Some("https://example.com/cover.png".to_string()),
)
.expect("createCollectionPost should succeed");
let post = result.post();
assert_eq!(post.kind, PubkyAppPostKind::Collection);
assert!(post.attachments.is_none());
let envelope: PubkyAppCollectionContent = serde_json::from_str(&post.content)
.expect("Collection content must deserialize as PubkyAppCollectionContent");
assert_eq!(envelope.name, "My favorites");
assert_eq!(envelope.description.as_deref(), Some("Best things"));
assert_eq!(envelope.items.len(), 1);
assert_eq!(
envelope.cover_image.as_deref(),
Some("https://example.com/cover.png")
);
}
}