//! Strongly-typed domain enums that replace stringly-typed database columns. //! //! Each enum uses manual sqlx `Type`/`Encode`/`Decode` impls (via `String`) //! so it works with both VARCHAR and TEXT columns. Plus `Serialize`/`Deserialize` //! for JSON and form parsing. use serde::{Deserialize, Serialize}; /// Generate `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` impls /// for a simple enum ↔ string mapping. The sqlx impls delegate to `String` /// so the enum is compatible with any text-like column (TEXT, VARCHAR, etc.). macro_rules! impl_str_enum { ($enum_name:ident { $($variant:ident => $str:literal),+ $(,)? }) => { impl std::fmt::Display for $enum_name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { $( Self::$variant => $str, )+ }; f.write_str(s) } } impl std::str::FromStr for $enum_name { type Err = String; fn from_str(s: &str) -> std::result::Result { match s { $( $str => Ok(Self::$variant), )+ other => Err(format!("invalid {}: {other}", stringify!($enum_name))), } } } // sqlx Type: delegate to String so it's compatible with TEXT/VARCHAR. impl sqlx::Type for $enum_name { fn type_info() -> sqlx::postgres::PgTypeInfo { >::type_info() } fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { >::compatible(ty) } } // sqlx Encode: write the Display string. impl sqlx::Encode<'_, sqlx::Postgres> for $enum_name { fn encode_by_ref( &self, buf: &mut sqlx::postgres::PgArgumentBuffer, ) -> Result> { >::encode(self.to_string(), buf) } } // sqlx Decode: parse the string value via FromStr. impl sqlx::Decode<'_, sqlx::Postgres> for $enum_name { fn decode( value: sqlx::postgres::PgValueRef<'_>, ) -> std::result::Result> { let s = >::decode(value)?; Ok(s.parse::()?) } } // Allow comparison with string slices (useful in Askama templates). impl PartialEq<&str> for $enum_name { fn eq(&self, other: &&str) -> bool { let s: &str = match self { $( Self::$variant => $str, )+ }; s == *other } } impl PartialEq for $enum_name { fn eq(&self, other: &str) -> bool { let s: &str = match self { $( Self::$variant => $str, )+ }; s == other } } }; } // ── Discount codes ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DiscountType { Percentage, Fixed, } impl_str_enum!(DiscountType { Percentage => "percentage", Fixed => "fixed", }); // ── Promo codes ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CodePurpose { Discount, FreeAccess, FreeTrial, } impl_str_enum!(CodePurpose { Discount => "discount", FreeAccess => "free_access", FreeTrial => "free_trial", }); // ── Waitlist ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum WaitlistStatus { Pending, Approved, Spam, } impl_str_enum!(WaitlistStatus { Pending => "pending", Approved => "approved", Spam => "spam", }); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SelectionMethod { #[serde(rename = "hand_picked")] HandPicked, #[serde(rename = "lottery")] Lottery, #[serde(rename = "invited")] Invited, } impl_str_enum!(SelectionMethod { HandPicked => "hand_picked", Lottery => "lottery", Invited => "invited", }); // ── Transactions ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TransactionStatus { Pending, Completed, Refunded, } impl_str_enum!(TransactionStatus { Pending => "pending", Completed => "completed", Refunded => "refunded", }); // ── Follows ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum FollowTargetType { User, Project, Tag, } impl_str_enum!(FollowTargetType { User => "user", Project => "project", Tag => "tag", }); // ── Subscriptions ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SubscriptionStatus { #[serde(rename = "active")] Active, #[serde(rename = "trialing")] Trialing, #[serde(rename = "incomplete")] Incomplete, #[serde(rename = "incomplete_expired")] IncompleteExpired, #[serde(rename = "past_due")] PastDue, #[serde(rename = "canceled")] Canceled, #[serde(rename = "unpaid")] Unpaid, } impl_str_enum!(SubscriptionStatus { Active => "active", Trialing => "trialing", Incomplete => "incomplete", IncompleteExpired => "incomplete_expired", PastDue => "past_due", Canceled => "canceled", Unpaid => "unpaid", }); // ── Git repository visibility ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Visibility { Public, Unlisted, Private, } impl_str_enum!(Visibility { Public => "public", Unlisted => "unlisted", Private => "private", }); // ── Project member roles ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProjectRole { Owner, Member, } impl_str_enum!(ProjectRole { Owner => "owner", Member => "member", }); // ── SyncKit ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SyncOperation { #[serde(rename = "INSERT")] Insert, #[serde(rename = "UPDATE")] Update, #[serde(rename = "DELETE")] Delete, } impl_str_enum!(SyncOperation { Insert => "INSERT", Update => "UPDATE", Delete => "DELETE", }); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SyncPlatform { Macos, Ios, Android, Windows, Linux, Web, } impl_str_enum!(SyncPlatform { Macos => "macos", Ios => "ios", Android => "android", Windows => "windows", Linux => "linux", Web => "web", }); // ── File scanning ── /// Status of an uploaded file in the scan pipeline. /// /// `Pending` — accepted, waiting in `scan_jobs` queue for a worker. /// `Scanning` — worker has claimed the job and is running the pipeline. /// `Clean` — pipeline completed, no Fail verdicts, no fail-closed Errors. /// `HeldForReview` — pipeline completed with a fail-closed Error, OR the /// uploader is untrusted (every untrusted upload routes to admin review). /// `Quarantined` — pipeline returned a Fail verdict on at least one layer. /// `Error` — pipeline itself crashed (worker exception, S3 fetch failed, etc.). /// /// State machine in `docs/scan-pipeline-audit.md`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FileScanStatus { Pending, Scanning, Clean, Quarantined, HeldForReview, Error, } impl_str_enum!(FileScanStatus { Pending => "pending", Scanning => "scanning", Clean => "clean", Quarantined => "quarantined", HeldForReview => "held_for_review", Error => "error", }); // ── Content Insertions ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InsertionPosition { PreRoll, MidRoll, PostRoll, } impl_str_enum!(InsertionPosition { PreRoll => "pre_roll", MidRoll => "mid_roll", PostRoll => "post_roll", }); // ── Appeals ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AppealDecision { Approved, Denied, } impl_str_enum!(AppealDecision { Approved => "approved", Denied => "denied", }); // ── Discover sorting ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DiscoverSort { Newest, MostSold, PriceAsc, PriceDesc, } impl_str_enum!(DiscoverSort { Newest => "newest", MostSold => "most_sold", PriceAsc => "price_asc", PriceDesc => "price_desc", }); // ── Items ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ItemType { Audio, Text, Video, Image, Plugin, Preset, Sample, Course, Template, Digital, Bundle, } impl_str_enum!(ItemType { Audio => "audio", Text => "text", Video => "video", Image => "image", Plugin => "plugin", Preset => "preset", Sample => "sample", Course => "course", Template => "template", Digital => "digital", Bundle => "bundle", }); impl ItemType { /// Short human-readable label for display (replaces `helpers::get_item_type_label`). pub fn label(&self) -> &'static str { match self { Self::Audio => "Audio", Self::Text => "Text", Self::Video => "Video", Self::Image => "Image", Self::Plugin => "Plugin", Self::Preset => "Preset", Self::Sample => "Sample", Self::Course => "Course", Self::Template => "Template", Self::Digital => "Digital", Self::Bundle => "Bundle", } } /// Which wizard content-input group this type belongs to. /// /// Determines what the content step looks like: /// - `"text"` → Markdown editor /// - `"audio"` → Audio file upload /// - `"video"` → Video file upload /// - `"bundle"` → Item picker for bundle contents /// - `"file"` → Generic file upload pub fn wizard_group(&self) -> &'static str { match self { Self::Text => "text", Self::Audio => "audio", Self::Video => "video", Self::Bundle => "bundle", _ => "file", } } } // ── Git Issues ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum IssueStatus { Open, Closed, } impl_str_enum!(IssueStatus { Open => "open", Closed => "closed", }); // ── Reports ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReportTargetType { Project, Item, } impl_str_enum!(ReportTargetType { Project => "project", Item => "item", }); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReportType { Mislabeled, Spam, Abuse, Infringement, Other, } impl_str_enum!(ReportType { Mislabeled => "mislabeled", Spam => "spam", Abuse => "abuse", Infringement => "infringement", Other => "other", }); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReportStatus { Open, Resolved, Dismissed, } impl_str_enum!(ReportStatus { Open => "open", Resolved => "resolved", Dismissed => "dismissed", }); // ── Creator Tiers ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CreatorTier { Basic, SmallFiles, BigFiles, Everything, } impl_str_enum!(CreatorTier { Basic => "basic", SmallFiles => "small_files", BigFiles => "big_files", Everything => "everything", }); impl CreatorTier { /// Human-readable label for display. pub fn label(&self) -> &'static str { match self { Self::Basic => "Basic", Self::SmallFiles => "Small Files", Self::BigFiles => "Big Files", Self::Everything => "Everything", } } /// Monthly price in cents. pub fn price_cents(&self) -> i32 { match self { Self::Basic => 1600, Self::SmallFiles => 2400, Self::BigFiles => 3600, Self::Everything => 6000, } } /// Maximum per-file upload size in bytes. pub fn max_file_bytes(&self) -> i64 { match self { Self::Basic => 10 * 1024 * 1024, // 10 MB Self::SmallFiles => 500 * 1024 * 1024, // 500 MB Self::BigFiles => 20 * 1024 * 1024 * 1024, // 20 GB Self::Everything => 20 * 1024 * 1024 * 1024, // 20 GB } } /// Maximum total storage in bytes. pub fn max_storage_bytes(&self) -> i64 { match self { Self::Basic => 50 * 1024 * 1024 * 1024, // 50 GB Self::SmallFiles => 250 * 1024 * 1024 * 1024, // 250 GB Self::BigFiles => 500 * 1024 * 1024 * 1024, // 500 GB Self::Everything => 500 * 1024 * 1024 * 1024, // 500 GB } } /// Whether this tier allows non-cover file uploads (audio, downloads, insertions). /// Basic is text-only; covers are always allowed regardless of tier. pub fn allows_file_uploads(&self) -> bool { !matches!(self, Self::Basic) } /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`. /// /// Implementers gate features on these strings rather than tier names so the /// tier lineup can change without breaking callers. Only ship strings backed by /// live behavior; new capabilities are added when they actually launch. pub fn features(&self) -> &'static [&'static str] { match self { Self::Basic => &[], Self::SmallFiles => &["file_uploads"], Self::BigFiles => &["file_uploads", "large_files"], Self::Everything => &["file_uploads", "large_files"], } } } // ── AI Tiers ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AiTier { Handmade, Assisted, Generated, } impl_str_enum!(AiTier { Handmade => "handmade", Assisted => "assisted", Generated => "generated", }); impl AiTier { pub fn label(&self) -> &'static str { match self { Self::Handmade => "Handmade", Self::Assisted => "Assisted", Self::Generated => "Generated", } } } /// Discover-page filter shape per `about/generative-ai.md` § "How Fans /// Use This". Distinct from `AiTier` because this is a *filter*, not a /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers. /// `None` on `DiscoverFilters.ai_tier` means "Everything" — no /// restriction. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AiTierFilter { HandmadeOnly, HumanLed, } impl_str_enum!(AiTierFilter { HandmadeOnly => "handmade_only", HumanLed => "human_led", }); impl AiTierFilter { pub fn label(&self) -> &'static str { match self { Self::HandmadeOnly => "Handmade only", Self::HumanLed => "Human-led", } } } // ── Project Features ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProjectFeature { Audio, Downloads, Text, Blog, Subscriptions, LicenseKeys, SourceCode, CloudSync, } impl_str_enum!(ProjectFeature { Audio => "audio", Downloads => "downloads", Text => "text", Blog => "blog", Subscriptions => "subscriptions", LicenseKeys => "license_keys", SourceCode => "source_code", CloudSync => "cloud_sync", }); impl ProjectFeature { /// Human-readable label for display. pub fn label(&self) -> &'static str { match self { Self::Audio => "Audio", Self::Downloads => "Downloads", Self::Text => "Text", Self::Blog => "Blog", Self::Subscriptions => "Subscriptions", Self::LicenseKeys => "License Keys", Self::SourceCode => "Source Code", Self::CloudSync => "Cloud Sync", } } /// One-line description of what this feature enables. pub fn description(&self) -> &'static str { match self { Self::Audio => "Upload and stream audio files. Player with chapters.", Self::Downloads => "Host file downloads with versioned releases.", Self::Text => "Write and publish text content with markdown.", Self::Blog => "Project blog with RSS feed.", Self::Subscriptions => "Monthly subscriber tiers.", Self::LicenseKeys => "Software license management with activation API.", Self::SourceCode => "Git repository with source browser.", Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.", } } /// All features as (value, label, description) tuples for form rendering. pub fn all() -> &'static [(&'static str, &'static str, &'static str)] { &[ ("audio", "Audio", "Upload and stream audio files. Player with chapters."), ("downloads", "Downloads", "Host file downloads with versioned releases."), ("text", "Text", "Write and publish text content with markdown."), ("blog", "Blog", "Project blog with RSS feed."), ("subscriptions", "Subscriptions", "Monthly subscriber tiers."), ("license_keys", "License Keys", "Software license management with activation API."), ("source_code", "Source Code", "Git repository with source browser."), ("cloud_sync", "Cloud Sync", "E2E encrypted cloud sync for desktop and mobile apps."), ] } /// Derive the best-fit project type from a set of features. pub fn derive_project_type(features: &[String]) -> ProjectType { if features.iter().any(|f| f == "audio") { return ProjectType::Music; } if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") { return ProjectType::Blog; } if features.iter().any(|f| f == "downloads") { return ProjectType::Software; } ProjectType::General } /// Which item types a feature unlocks. pub fn allowed_item_types(&self) -> &'static [ItemType] { match self { Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset], Self::Downloads => &[ ItemType::Digital, ItemType::Plugin, ItemType::Template, ItemType::Course, ItemType::Image, ItemType::Video, ], Self::Text => &[ItemType::Text], // Non-content features don't gate item types Self::Blog | Self::Subscriptions | Self::LicenseKeys | Self::SourceCode | Self::CloudSync => &[], } } /// Compute the set of item types allowed by a project's feature list. /// If no content features are enabled, all types are allowed (permissive default). pub fn allowed_item_type_cards( features: &[String], ) -> Vec<(&'static str, &'static str, &'static str)> { let allowed: std::collections::HashSet = features .iter() .filter_map(|f| f.parse::().ok()) .flat_map(|f| f.allowed_item_types().iter().copied()) .collect(); // If no content features enabled, show all types (backwards compat) if allowed.is_empty() { return Self::all_item_type_cards().to_vec(); } Self::all_item_type_cards() .iter() .filter(|(value, _, _)| { value .parse::() .map(|t| t == ItemType::Bundle || allowed.contains(&t)) .unwrap_or(false) }) .copied() .collect() } /// All item type cards: (value, label, description) tuples for form rendering. pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] { &[ ("audio", "Audio", "Podcast, music, sound effects"), ("text", "Text", "Articles, posts, essays, guides"), ("digital", "Digital Download", "Files, archives, documents"), ("video", "Video", "Tutorials, films, recordings"), ("course", "Course", "Multi-part lessons, curricula"), ("plugin", "Plugin", "Software extensions, add-ons"), ("sample", "Sample Pack", "Audio samples, loops, one-shots"), ("preset", "Preset Pack", "Synth presets, effect chains"), ("template", "Template", "Design templates, starter kits"), ("image", "Image", "Photos, artwork, graphics"), ("bundle", "Bundle", "Collection of other items"), ] } /// Item type cards filtered to one per distinct wizard behavior group. /// /// The wizard only needs a type selector when the allowed types produce /// different content-step UIs (text editor vs audio upload vs file upload). /// Returns one card per group, using the first allowed type as the value. /// If all types share one group, returns a single card (caller should skip /// the type step entirely). pub fn wizard_type_cards( features: &[String], ) -> Vec<(&'static str, &'static str, &'static str)> { let allowed = Self::allowed_item_type_cards(features); let mut seen_groups = std::collections::HashSet::new(); let mut cards = Vec::new(); for (value, _, _) in &allowed { let Ok(item_type) = value.parse::() else { continue; }; let group = item_type.wizard_group(); if seen_groups.insert(group) { let (label, desc) = match group { "text" => ("Text", "Write in the editor"), "audio" => ("Audio", "Upload audio files"), "video" => ("Video", "Upload video files"), "bundle" => ("Bundle", "Collection of other items"), _ => ("File", "Upload any file"), }; cards.push((*value, label, desc)); } } cards } } // ── Projects ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProjectType { Blog, Book, Podcast, Course, Music, Software, Art, Writing, #[default] General, } impl_str_enum!(ProjectType { Blog => "blog", Book => "book", Podcast => "podcast", Course => "course", Music => "music", Software => "software", Art => "art", Writing => "writing", General => "general", }); impl ProjectType { /// Human-readable label for display. pub fn label(&self) -> &'static str { match self { Self::Blog => "Blog", Self::Book => "Book", Self::Podcast => "Podcast", Self::Course => "Course", Self::Music => "Music", Self::Software => "Software", Self::Art => "Art", Self::Writing => "Writing", Self::General => "General", } } /// All valid project types as (value, label) pairs for form rendering. pub fn all() -> &'static [(&'static str, &'static str)] { &[ ("blog", "Blog"), ("book", "Book"), ("podcast", "Podcast"), ("course", "Course"), ("music", "Music"), ("software", "Software"), ("art", "Art"), ("writing", "Writing"), ("general", "General"), ] } } // ── Build Pipeline ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BuildStatus { Pending, Running, Succeeded, Failed, Cancelled, } impl_str_enum!(BuildStatus { Pending => "pending", Running => "running", Succeeded => "succeeded", Failed => "failed", Cancelled => "cancelled", }); // ── Project Pricing ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PricingKind { #[default] Free, BuyOnce, Pwyw, Subscription, } impl_str_enum!(PricingKind { Free => "free", BuyOnce => "buy_once", Pwyw => "pwyw", Subscription => "subscription", }); // ── Mailing Lists ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MailingListType { Content, Devlog, Patches, } impl_str_enum!(MailingListType { Content => "content", Devlog => "devlog", Patches => "patches", }); // ── Import System ── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ImportSource { GenericCsv, Substack, Ghost, Gumroad, Bandcamp, LemonSqueezy, Patreon, } impl_str_enum!(ImportSource { GenericCsv => "generic_csv", Substack => "substack", Ghost => "ghost", Gumroad => "gumroad", Bandcamp => "bandcamp", LemonSqueezy => "lemon_squeezy", Patreon => "patreon", }); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ImportJobStatus { Pending, Processing, Completed, Failed, } impl_str_enum!(ImportJobStatus { Pending => "pending", Processing => "processing", Completed => "completed", Failed => "failed", }); // -- Moderation action types -------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ModerationActionType { Warning, Suspension, Termination, ContentRemoval, } impl_str_enum!(ModerationActionType { Warning => "warning", Suspension => "suspension", Termination => "termination", ContentRemoval => "content_removal", }); // -- Checkout types (Stripe metadata) ----------------------------------------- /// Discriminator for checkout session types stored in Stripe metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CheckoutType { Guest, Subscription, Tip, FanPlus, CreatorTier, Cart, SynckitAppSub, } impl_str_enum!(CheckoutType { Guest => "guest", Subscription => "subscription", Tip => "tip", FanPlus => "fan_plus", CreatorTier => "creator_tier", Cart => "cart", SynckitAppSub => "synckit_app_sub", }); impl ModerationActionType { pub fn label(&self) -> &'static str { match self { Self::Warning => "Warning", Self::Suspension => "Suspension", Self::Termination => "Termination", Self::ContentRemoval => "Content Removal", } } } #[cfg(test)] mod tests { use super::*; #[test] fn discount_type_round_trip() { assert_eq!(DiscountType::Percentage.to_string(), "percentage"); assert_eq!("fixed".parse::().unwrap(), DiscountType::Fixed); assert!("bogus".parse::().is_err()); } #[test] fn waitlist_status_round_trip() { assert_eq!(WaitlistStatus::Pending.to_string(), "pending"); assert_eq!("approved".parse::().unwrap(), WaitlistStatus::Approved); } #[test] fn selection_method_round_trip() { assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked"); assert_eq!("lottery".parse::().unwrap(), SelectionMethod::Lottery); assert_eq!(SelectionMethod::Invited.to_string(), "invited"); assert_eq!("invited".parse::().unwrap(), SelectionMethod::Invited); } #[test] fn transaction_status_round_trip() { assert_eq!(TransactionStatus::Completed.to_string(), "completed"); assert_eq!("refunded".parse::().unwrap(), TransactionStatus::Refunded); } #[test] fn follow_target_type_round_trip() { assert_eq!(FollowTargetType::User.to_string(), "user"); assert_eq!("tag".parse::().unwrap(), FollowTargetType::Tag); } #[test] fn subscription_status_round_trip() { assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due"); assert_eq!("canceled".parse::().unwrap(), SubscriptionStatus::Canceled); assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing"); assert_eq!("trialing".parse::().unwrap(), SubscriptionStatus::Trialing); assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete"); assert_eq!("incomplete".parse::().unwrap(), SubscriptionStatus::Incomplete); assert_eq!(SubscriptionStatus::IncompleteExpired.to_string(), "incomplete_expired"); assert_eq!("incomplete_expired".parse::().unwrap(), SubscriptionStatus::IncompleteExpired); } #[test] fn sync_operation_round_trip() { assert_eq!(SyncOperation::Insert.to_string(), "INSERT"); assert_eq!("DELETE".parse::().unwrap(), SyncOperation::Delete); } #[test] fn sync_platform_round_trip() { assert_eq!(SyncPlatform::Macos.to_string(), "macos"); assert_eq!("web".parse::().unwrap(), SyncPlatform::Web); } #[test] fn item_type_round_trip() { assert_eq!(ItemType::Audio.to_string(), "audio"); assert_eq!("plugin".parse::().unwrap(), ItemType::Plugin); assert_eq!(ItemType::Bundle.to_string(), "bundle"); assert_eq!("bundle".parse::().unwrap(), ItemType::Bundle); } #[test] fn insertion_position_round_trip() { assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll"); assert_eq!("mid_roll".parse::().unwrap(), InsertionPosition::MidRoll); assert_eq!("post_roll".parse::().unwrap(), InsertionPosition::PostRoll); assert!("invalid".parse::().is_err()); } #[test] fn item_type_label() { assert_eq!(ItemType::Audio.label(), "Audio"); assert_eq!(ItemType::Plugin.label(), "Plugin"); assert_eq!(ItemType::Template.label(), "Template"); } #[test] fn appeal_decision_round_trip() { assert_eq!(AppealDecision::Approved.to_string(), "approved"); assert_eq!("denied".parse::().unwrap(), AppealDecision::Denied); assert!("bogus".parse::().is_err()); } #[test] fn discover_sort_round_trip() { assert_eq!(DiscoverSort::Newest.to_string(), "newest"); assert_eq!("most_sold".parse::().unwrap(), DiscoverSort::MostSold); assert_eq!("price_asc".parse::().unwrap(), DiscoverSort::PriceAsc); assert_eq!("price_desc".parse::().unwrap(), DiscoverSort::PriceDesc); assert!("invalid".parse::().is_err()); } #[test] fn file_scan_status_round_trip() { assert_eq!(FileScanStatus::Clean.to_string(), "clean"); assert_eq!(FileScanStatus::Pending.to_string(), "pending"); assert_eq!(FileScanStatus::Scanning.to_string(), "scanning"); assert_eq!("pending".parse::().unwrap(), FileScanStatus::Pending); assert_eq!("scanning".parse::().unwrap(), FileScanStatus::Scanning); assert_eq!("held_for_review".parse::().unwrap(), FileScanStatus::HeldForReview); assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review"); assert_eq!("quarantined".parse::().unwrap(), FileScanStatus::Quarantined); assert!("bogus".parse::().is_err()); } #[test] fn code_purpose_round_trip() { assert_eq!(CodePurpose::Discount.to_string(), "discount"); assert_eq!("free_access".parse::().unwrap(), CodePurpose::FreeAccess); assert_eq!("free_trial".parse::().unwrap(), CodePurpose::FreeTrial); assert!("bogus".parse::().is_err()); } #[test] fn issue_status_round_trip() { assert_eq!(IssueStatus::Open.to_string(), "open"); assert_eq!("closed".parse::().unwrap(), IssueStatus::Closed); assert!("bogus".parse::().is_err()); } #[test] fn report_target_type_round_trip() { assert_eq!(ReportTargetType::Project.to_string(), "project"); assert_eq!("item".parse::().unwrap(), ReportTargetType::Item); assert!("bogus".parse::().is_err()); } #[test] fn report_type_round_trip() { assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled"); assert_eq!("spam".parse::().unwrap(), ReportType::Spam); assert_eq!("abuse".parse::().unwrap(), ReportType::Abuse); assert_eq!("infringement".parse::().unwrap(), ReportType::Infringement); assert_eq!("other".parse::().unwrap(), ReportType::Other); assert!("bogus".parse::().is_err()); } #[test] fn report_status_round_trip() { assert_eq!(ReportStatus::Open.to_string(), "open"); assert_eq!("resolved".parse::().unwrap(), ReportStatus::Resolved); assert_eq!("dismissed".parse::().unwrap(), ReportStatus::Dismissed); assert!("bogus".parse::().is_err()); } #[test] fn creator_tier_round_trip() { assert_eq!(CreatorTier::Basic.to_string(), "basic"); assert_eq!("small_files".parse::().unwrap(), CreatorTier::SmallFiles); assert_eq!("big_files".parse::().unwrap(), CreatorTier::BigFiles); assert_eq!("everything".parse::().unwrap(), CreatorTier::Everything); assert!("bogus".parse::().is_err()); } #[test] fn creator_tier_label_and_price() { assert_eq!(CreatorTier::Basic.label(), "Basic"); assert_eq!(CreatorTier::SmallFiles.label(), "Small Files"); assert_eq!(CreatorTier::Basic.price_cents(), 1600); assert_eq!(CreatorTier::Everything.price_cents(), 6000); } #[test] fn creator_tier_file_limits() { assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024); assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024); assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024); assert_eq!(CreatorTier::Everything.max_file_bytes(), 20 * 1024 * 1024 * 1024); } #[test] fn creator_tier_storage_limits() { assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024); assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024); assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024); assert_eq!(CreatorTier::Everything.max_storage_bytes(), 500 * 1024 * 1024 * 1024); } #[test] fn creator_tier_allows_file_uploads() { assert!(!CreatorTier::Basic.allows_file_uploads()); assert!(CreatorTier::SmallFiles.allows_file_uploads()); assert!(CreatorTier::BigFiles.allows_file_uploads()); assert!(CreatorTier::Everything.allows_file_uploads()); } #[test] fn creator_tier_features_track_live_capabilities() { assert!(CreatorTier::Basic.features().is_empty()); assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]); assert_eq!(CreatorTier::BigFiles.features(), &["file_uploads", "large_files"]); assert_eq!(CreatorTier::Everything.features(), &["file_uploads", "large_files"]); } #[test] fn project_feature_round_trip() { assert_eq!(ProjectFeature::Audio.to_string(), "audio"); assert_eq!("downloads".parse::().unwrap(), ProjectFeature::Downloads); assert_eq!("license_keys".parse::().unwrap(), ProjectFeature::LicenseKeys); assert_eq!("source_code".parse::().unwrap(), ProjectFeature::SourceCode); assert!("bogus".parse::().is_err()); } #[test] fn project_feature_label_and_description() { assert_eq!(ProjectFeature::Audio.label(), "Audio"); assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys"); assert!(!ProjectFeature::Audio.description().is_empty()); } #[test] fn project_feature_all() { let all = ProjectFeature::all(); assert_eq!(all.len(), 8); assert_eq!(all[0].0, "audio"); assert_eq!(all[7].0, "cloud_sync"); } #[test] fn project_feature_allowed_item_types_audio() { let types = ProjectFeature::Audio.allowed_item_types(); assert!(types.contains(&ItemType::Audio)); assert!(types.contains(&ItemType::Sample)); assert!(types.contains(&ItemType::Preset)); assert!(!types.contains(&ItemType::Text)); } #[test] fn project_feature_allowed_item_types_downloads() { let types = ProjectFeature::Downloads.allowed_item_types(); assert!(types.contains(&ItemType::Digital)); assert!(types.contains(&ItemType::Plugin)); assert!(types.contains(&ItemType::Video)); assert!(!types.contains(&ItemType::Audio)); } #[test] fn project_feature_allowed_item_types_text() { let types = ProjectFeature::Text.allowed_item_types(); assert!(types.contains(&ItemType::Text)); assert_eq!(types.len(), 1); } #[test] fn project_feature_allowed_item_types_non_content() { assert!(ProjectFeature::Blog.allowed_item_types().is_empty()); assert!(ProjectFeature::Subscriptions.allowed_item_types().is_empty()); assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty()); assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty()); assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty()); } #[test] fn project_feature_allowed_cards_filtered() { let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]); let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); assert!(values.contains(&"audio")); assert!(values.contains(&"sample")); assert!(values.contains(&"preset")); assert!(values.contains(&"bundle")); // Bundle always included assert!(!values.contains(&"text")); assert!(!values.contains(&"digital")); } #[test] fn project_feature_allowed_cards_combined() { let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]); let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); assert!(values.contains(&"audio")); assert!(values.contains(&"text")); assert!(values.contains(&"bundle")); // Bundle always included assert!(!values.contains(&"digital")); } #[test] fn project_feature_allowed_cards_empty_features_shows_all() { let cards = ProjectFeature::allowed_item_type_cards(&[]); assert_eq!(cards.len(), 11); // 10 content types + bundle } #[test] fn project_feature_allowed_cards_non_content_features_shows_all() { let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]); // Blog and subscriptions don't gate item types, so all should be shown assert_eq!(cards.len(), 11); // 10 content types + bundle } #[test] fn project_feature_derive_type() { assert_eq!( ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]), ProjectType::Music, ); assert_eq!( ProjectFeature::derive_project_type(&["text".into()]), ProjectType::Blog, ); assert_eq!( ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]), ProjectType::Software, ); assert_eq!( ProjectFeature::derive_project_type(&["subscriptions".into()]), ProjectType::General, ); } #[test] fn project_type_round_trip() { assert_eq!(ProjectType::Blog.to_string(), "blog"); assert_eq!("software".parse::().unwrap(), ProjectType::Software); assert_eq!("general".parse::().unwrap(), ProjectType::General); assert_eq!(ProjectType::default(), ProjectType::General); assert!("bogus".parse::().is_err()); } #[test] fn project_type_label() { assert_eq!(ProjectType::Blog.label(), "Blog"); assert_eq!(ProjectType::Software.label(), "Software"); assert_eq!(ProjectType::General.label(), "General"); } #[test] fn project_type_all() { let all = ProjectType::all(); assert_eq!(all.len(), 9); assert_eq!(all[0], ("blog", "Blog")); assert_eq!(all[8], ("general", "General")); } #[test] fn build_status_round_trip() { assert_eq!(BuildStatus::Pending.to_string(), "pending"); assert_eq!("running".parse::().unwrap(), BuildStatus::Running); assert_eq!("succeeded".parse::().unwrap(), BuildStatus::Succeeded); assert_eq!("failed".parse::().unwrap(), BuildStatus::Failed); assert_eq!("cancelled".parse::().unwrap(), BuildStatus::Cancelled); assert!("bogus".parse::().is_err()); } #[test] fn serde_json_round_trip() { let dt = DiscountType::Percentage; let json = serde_json::to_string(&dt).unwrap(); assert_eq!(json, "\"percentage\""); let back: DiscountType = serde_json::from_str(&json).unwrap(); assert_eq!(back, dt); } #[test] fn pricing_kind_round_trip() { assert_eq!(PricingKind::Free.to_string(), "free"); assert_eq!("buy_once".parse::().unwrap(), PricingKind::BuyOnce); assert_eq!("pwyw".parse::().unwrap(), PricingKind::Pwyw); assert_eq!("subscription".parse::().unwrap(), PricingKind::Subscription); assert_eq!(PricingKind::default(), PricingKind::Free); assert!("bogus".parse::().is_err()); } #[test] fn mailing_list_type_round_trip() { assert_eq!(MailingListType::Content.to_string(), "content"); assert_eq!("devlog".parse::().unwrap(), MailingListType::Devlog); assert_eq!("patches".parse::().unwrap(), MailingListType::Patches); assert!("bogus".parse::().is_err()); } #[test] fn serde_json_subscription_status() { let s = SubscriptionStatus::PastDue; let json = serde_json::to_string(&s).unwrap(); assert_eq!(json, "\"past_due\""); let back: SubscriptionStatus = serde_json::from_str(&json).unwrap(); assert_eq!(back, s); let t = SubscriptionStatus::Trialing; let json = serde_json::to_string(&t).unwrap(); assert_eq!(json, "\"trialing\""); let back: SubscriptionStatus = serde_json::from_str(&json).unwrap(); assert_eq!(back, t); } // ── ItemType::wizard_group ── #[test] fn wizard_group_text() { assert_eq!(ItemType::Text.wizard_group(), "text"); } #[test] fn wizard_group_audio() { assert_eq!(ItemType::Audio.wizard_group(), "audio"); } #[test] fn wizard_group_video() { assert_eq!(ItemType::Video.wizard_group(), "video"); } #[test] fn wizard_group_file_types() { for t in [ ItemType::Digital, ItemType::Course, ItemType::Plugin, ItemType::Sample, ItemType::Preset, ItemType::Template, ItemType::Image, ] { assert_eq!(t.wizard_group(), "file", "{t:?} should be in file group"); } } #[test] fn wizard_group_bundle() { assert_eq!(ItemType::Bundle.wizard_group(), "bundle"); } // ── ProjectFeature::wizard_type_cards ── #[test] fn wizard_cards_text_only_two_groups() { // Text + bundle (bundle always included) let cards = ProjectFeature::wizard_type_cards(&["text".into()]); assert_eq!(cards.len(), 2); let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); assert!(groups.contains(&"text")); assert!(groups.contains(&"bundle")); } #[test] fn wizard_cards_downloads_three_groups() { // Download types split into "file" + "video" groups + bundle → 3 cards let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]); assert_eq!(cards.len(), 3); let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); assert!(groups.contains(&"digital")); // first type in file group assert!(groups.contains(&"video")); // video group assert!(groups.contains(&"bundle")); } #[test] fn wizard_cards_audio_feature_three_groups() { // Audio feature allows audio (audio group) + sample, preset (file group) + bundle let cards = ProjectFeature::wizard_type_cards(&["audio".into()]); assert_eq!(cards.len(), 3); let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect(); assert!(groups.contains(&"audio")); assert!(groups.contains(&"sample")); // first file-group type assert!(groups.contains(&"bundle")); } #[test] fn wizard_cards_text_and_audio_four_groups() { let cards = ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]); assert_eq!(cards.len(), 4); // text, audio, file, bundle } #[test] fn wizard_cards_empty_features_all_five_groups() { // No content features → all types → 5 wizard groups (text, audio, video, file, bundle) let cards = ProjectFeature::wizard_type_cards(&[]); assert_eq!(cards.len(), 5); } #[test] fn ai_tier_round_trip() { assert_eq!(AiTier::Handmade.to_string(), "handmade"); assert_eq!("assisted".parse::().unwrap(), AiTier::Assisted); assert_eq!("generated".parse::().unwrap(), AiTier::Generated); assert!("bogus".parse::().is_err()); } #[test] fn ai_tier_label() { assert_eq!(AiTier::Handmade.label(), "Handmade"); assert_eq!(AiTier::Assisted.label(), "Assisted"); assert_eq!(AiTier::Generated.label(), "Generated"); } #[test] fn import_source_round_trip() { assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv"); assert_eq!("substack".parse::().unwrap(), ImportSource::Substack); assert_eq!("ghost".parse::().unwrap(), ImportSource::Ghost); assert_eq!("gumroad".parse::().unwrap(), ImportSource::Gumroad); assert_eq!("bandcamp".parse::().unwrap(), ImportSource::Bandcamp); assert_eq!("lemon_squeezy".parse::().unwrap(), ImportSource::LemonSqueezy); assert_eq!("patreon".parse::().unwrap(), ImportSource::Patreon); assert!("bogus".parse::().is_err()); } #[test] fn import_job_status_round_trip() { assert_eq!(ImportJobStatus::Pending.to_string(), "pending"); assert_eq!("processing".parse::().unwrap(), ImportJobStatus::Processing); assert_eq!("completed".parse::().unwrap(), ImportJobStatus::Completed); assert_eq!("failed".parse::().unwrap(), ImportJobStatus::Failed); assert!("bogus".parse::().is_err()); } #[test] fn checkout_type_round_trip() { assert_eq!(CheckoutType::Guest.to_string(), "guest"); assert_eq!(CheckoutType::Subscription.to_string(), "subscription"); assert_eq!(CheckoutType::Tip.to_string(), "tip"); assert_eq!(CheckoutType::FanPlus.to_string(), "fan_plus"); assert_eq!(CheckoutType::CreatorTier.to_string(), "creator_tier"); assert_eq!("guest".parse::().unwrap(), CheckoutType::Guest); assert_eq!("fan_plus".parse::().unwrap(), CheckoutType::FanPlus); assert!("bogus".parse::().is_err()); } #[test] fn moderation_action_type_round_trip() { assert_eq!(ModerationActionType::Warning.to_string(), "warning"); assert_eq!(ModerationActionType::Suspension.to_string(), "suspension"); assert_eq!(ModerationActionType::Termination.to_string(), "termination"); assert_eq!(ModerationActionType::ContentRemoval.to_string(), "content_removal"); assert_eq!("warning".parse::().unwrap(), ModerationActionType::Warning); assert_eq!("content_removal".parse::().unwrap(), ModerationActionType::ContentRemoval); assert!("bogus".parse::().is_err()); } }