//! 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 } } impl $enum_name { /// Every wire/DB string this enum maps to, the single source of /// truth for the variant set. For each enum *registered* in the /// enum-drift integration test (`tests/workflows/enum_drift.rs`), /// this set is asserted equal to the Postgres `CHECK (... IN (...))` /// list on its backing column, so a variant added here without /// widening the DB constraint (or vice versa) fails at test time /// rather than at the first read of a poisoned row. Coverage is that /// registry, not every enum automatically: add a `(enum, table, /// column)` row there when a new CHECK-constrained column lands. pub const VARIANTS: &'static [&'static str] = &[$($str),+]; } }; } // ── 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, /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards /// against double-submit on shared-cart PaymentIntents (Pay-S1, Run 9). Refunding, Refunded, /// Present in the DB `CHECK` since the initial schema but never written by /// the app (stale pending transactions are deleted, not failed). Kept as a /// variant so the enum can decode any legacy/manual `'failed'` row instead of /// fail-closed-poisoning the whole query, and so the enum-drift test's /// variant set matches the column constraint. Failed, } impl_str_enum!(TransactionStatus { Pending => "pending", Completed => "completed", Refunding => "refunding", Refunded => "refunded", Failed => "failed", }); // ── 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", }); // ── SyncKit developer billing ── /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status` /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the /// CHECK set. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SyncBillingStatus { #[serde(rename = "draft")] Draft, #[serde(rename = "active")] Active, #[serde(rename = "suspended_unpaid")] SuspendedUnpaid, #[serde(rename = "canceled")] Canceled, } impl_str_enum!(SyncBillingStatus { Draft => "draft", Active => "active", SuspendedUnpaid => "suspended_unpaid", Canceled => "canceled", }); /// How a SyncKit developer app's storage billing is enforced (the /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')` /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SyncEnforcementMode { #[serde(rename = "per_key")] PerKey, #[serde(rename = "bulk")] Bulk, } impl_str_enum!(SyncEnforcementMode { PerKey => "per_key", Bulk => "bulk", }); // ── 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 standard price in cents. Reads from the process-global /// `TierPrices` installed at startup from `assumptions.toml`. See /// `crate::tier_prices` for the OnceLock and test-setup helper. pub fn price_cents(&self) -> i32 { crate::tier_prices::TierPrices::global().price_cents_for(*self) } /// Maximum per-file upload size in bytes. Reads from the global /// `TierPrices` (see `price_cents`). pub fn max_file_bytes(&self) -> i64 { crate::tier_prices::TierPrices::global().max_file_bytes_for(*self) } /// Maximum total storage in bytes. Reads from the global `TierPrices` /// (see `price_cents`). pub fn max_storage_bytes(&self) -> i64 { crate::tier_prices::TierPrices::global().max_storage_bytes_for(*self) } /// 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::() .is_ok_and(|t| t == ItemType::Bundle || allowed.contains(&t)) }) .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)] #[serde(rename_all = "snake_case")] 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)] #[serde(rename_all = "snake_case")] 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() { crate::tier_prices::TierPrices::install_test_default(); assert_eq!(CreatorTier::Basic.label(), "Basic"); assert_eq!(CreatorTier::SmallFiles.label(), "Small Files"); // Prices come from assumptions.toml. Assert invariants, founder = std/2, // monotone across tiers, not literal cents, so a future toml edit doesn't // break this test. let tiers = [ CreatorTier::Basic, CreatorTier::SmallFiles, CreatorTier::BigFiles, CreatorTier::Everything, ]; for pair in tiers.windows(2) { assert!( pair[0].price_cents() < pair[1].price_cents(), "{:?} price_cents ({}) should be < {:?} ({})", pair[0], pair[0].price_cents(), pair[1], pair[1].price_cents(), ); } assert!(CreatorTier::Basic.price_cents() > 0); } #[test] fn creator_tier_file_limits() { crate::tier_prices::TierPrices::install_test_default(); // File caps monotone across tiers; Basic ≤ SmallFiles ≤ BigFiles == Everything. let tiers = [ CreatorTier::Basic, CreatorTier::SmallFiles, CreatorTier::BigFiles, CreatorTier::Everything, ]; for pair in tiers.windows(2) { assert!( pair[0].max_file_bytes() <= pair[1].max_file_bytes(), "{:?} max_file_bytes ({}) should be <= {:?} ({})", pair[0], pair[0].max_file_bytes(), pair[1], pair[1].max_file_bytes(), ); } assert!(CreatorTier::Basic.max_file_bytes() > 0); } #[test] fn creator_tier_storage_limits() { crate::tier_prices::TierPrices::install_test_default(); let tiers = [ CreatorTier::Basic, CreatorTier::SmallFiles, CreatorTier::BigFiles, CreatorTier::Everything, ]; for pair in tiers.windows(2) { assert!( pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(), "{:?} max_storage_bytes ({}) should be <= {:?} ({})", pair[0], pair[0].max_storage_bytes(), pair[1], pair[1].max_storage_bytes(), ); } assert!(CreatorTier::Basic.max_storage_bytes() > 0); // Every tier's per-file cap must fit in its total storage cap or uploads // are impossible. Catches an accidental toml edit that shrinks a total // below the per-file limit. for &tier in &tiers { assert!( tier.max_file_bytes() <= tier.max_storage_bytes(), "{tier:?}: file cap ({}) exceeds storage cap ({})", tier.max_file_bytes(), tier.max_storage_bytes(), ); } } #[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()); } /// Every `impl_str_enum!` enum that also derives serde must agree on its wire /// string across BOTH representations: the macro's Display/FromStr/sqlx form /// (`VARIANTS`) and serde's JSON form. These are defined in two places (the /// macro literal and the `#[serde(rename_all = ...)]` attribute), so they can /// silently diverge, a variant travels via Display today and via serde /// tomorrow, and the string disagrees with what is already in Postgres. This /// test drives every serde-deriving enum through its `VARIANTS` (which round /// through `FromStr`) and asserts serde emits the identical string, in both /// directions. Add every serde-deriving enum here. macro_rules! assert_serde_matches_wire { ($($enum:ty),+ $(,)?) => {{ $( for wire in <$enum>::VARIANTS { let value: $enum = wire.parse().unwrap_or_else(|e| { panic!("{}: VARIANTS string {wire:?} does not parse: {e}", stringify!($enum)) }); let json = serde_json::to_string(&value).expect("serialize"); assert_eq!( json, format!("\"{wire}\""), "{}: serde emits {json} but the DB/Display wire string is {wire:?} \ (add or fix `#[serde(rename_all = ...)]`)", stringify!($enum) ); let back: $enum = serde_json::from_str(&json).unwrap_or_else(|e| { panic!("{}: serde cannot round-trip {json}: {e}", stringify!($enum)) }); assert_eq!( back.to_string(), *wire, "{}: serde deserialize of {json} disagrees with Display", stringify!($enum) ); } )+ }}; } #[test] fn serde_repr_matches_db_wire_repr_for_every_enum() { assert_serde_matches_wire!( DiscountType, CodePurpose, WaitlistStatus, SelectionMethod, TransactionStatus, FollowTargetType, SubscriptionStatus, SyncBillingStatus, SyncEnforcementMode, Visibility, ProjectRole, SyncOperation, SyncPlatform, FileScanStatus, InsertionPosition, AppealDecision, DiscoverSort, ItemType, IssueStatus, ReportTargetType, ReportType, ReportStatus, CreatorTier, AiTier, ProjectFeature, ProjectType, BuildStatus, PricingKind, MailingListType, ImportSource, ImportJobStatus, ModerationActionType, CheckoutType, ); } }