//! Tests for [`super`]. 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, GitRepoKind, ProjectRole, SyncOperation, SyncPlatform, FileScanStatus, InsertionPosition, AppealDecision, DiscoverSort, ItemType, IssueStatus, ReportTargetType, ReportType, ReportStatus, CreatorTier, AiTier, ProjectFeature, ProjectType, BuildStatus, PricingKind, MailingListType, ImportSource, ImportJobStatus, ModerationActionType, CheckoutType, ); }