Skip to main content

max / makenotwork

68.2 KB · 2246 lines History Blame Raw
1 //! Strongly-typed domain enums that replace stringly-typed database columns.
2 //!
3 //! Each enum uses manual sqlx `Type`/`Encode`/`Decode` impls (via `String`)
4 //! so it works with both VARCHAR and TEXT columns. Plus `Serialize`/`Deserialize`
5 //! for JSON and form parsing.
6
7 use serde::{Deserialize, Serialize};
8
9 /// Generate `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` impls
10 /// for a simple enum ↔ string mapping. The sqlx impls delegate to `String`
11 /// so the enum is compatible with any text-like column (TEXT, VARCHAR, etc.).
12 macro_rules! impl_str_enum {
13 ($enum_name:ident { $($variant:ident => $str:literal),+ $(,)? }) => {
14 impl std::fmt::Display for $enum_name {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 let s = match self {
17 $( Self::$variant => $str, )+
18 };
19 f.write_str(s)
20 }
21 }
22
23 impl std::str::FromStr for $enum_name {
24 type Err = String;
25
26 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
27 match s {
28 $( $str => Ok(Self::$variant), )+
29 other => Err(format!("invalid {}: {other}", stringify!($enum_name))),
30 }
31 }
32 }
33
34 // sqlx Type: delegate to String so it's compatible with TEXT/VARCHAR.
35 impl sqlx::Type<sqlx::Postgres> for $enum_name {
36 fn type_info() -> sqlx::postgres::PgTypeInfo {
37 <String as sqlx::Type<sqlx::Postgres>>::type_info()
38 }
39
40 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
41 <String as sqlx::Type<sqlx::Postgres>>::compatible(ty)
42 }
43 }
44
45 // sqlx Encode: write the Display string.
46 impl sqlx::Encode<'_, sqlx::Postgres> for $enum_name {
47 fn encode_by_ref(
48 &self,
49 buf: &mut sqlx::postgres::PgArgumentBuffer,
50 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
51 <String as sqlx::Encode<'_, sqlx::Postgres>>::encode(self.to_string(), buf)
52 }
53 }
54
55 // sqlx Decode: parse the string value via FromStr.
56 impl sqlx::Decode<'_, sqlx::Postgres> for $enum_name {
57 fn decode(
58 value: sqlx::postgres::PgValueRef<'_>,
59 ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
60 let s = <String as sqlx::Decode<'_, sqlx::Postgres>>::decode(value)?;
61 Ok(s.parse::<Self>()?)
62 }
63 }
64
65 // Allow comparison with string slices (useful in Askama templates).
66 impl PartialEq<&str> for $enum_name {
67 fn eq(&self, other: &&str) -> bool {
68 let s: &str = match self {
69 $( Self::$variant => $str, )+
70 };
71 s == *other
72 }
73 }
74
75 impl PartialEq<str> for $enum_name {
76 fn eq(&self, other: &str) -> bool {
77 let s: &str = match self {
78 $( Self::$variant => $str, )+
79 };
80 s == other
81 }
82 }
83
84 impl $enum_name {
85 /// Every wire/DB string this enum maps to, the single source of
86 /// truth for the variant set. For each enum *registered* in the
87 /// enum-drift integration test (`tests/workflows/enum_drift.rs`),
88 /// this set is asserted equal to the Postgres `CHECK (... IN (...))`
89 /// list on its backing column, so a variant added here without
90 /// widening the DB constraint (or vice versa) fails at test time
91 /// rather than at the first read of a poisoned row. Coverage is that
92 /// registry, not every enum automatically: add a `(enum, table,
93 /// column)` row there when a new CHECK-constrained column lands.
94 pub const VARIANTS: &'static [&'static str] = &[$($str),+];
95 }
96 };
97 }
98
99 // --- Discount codes ---
100
101 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102 #[serde(rename_all = "lowercase")]
103 pub enum DiscountType {
104 Percentage,
105 Fixed,
106 }
107
108 impl_str_enum!(DiscountType {
109 Percentage => "percentage",
110 Fixed => "fixed",
111 });
112
113 // --- Promo codes ---
114
115 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116 #[serde(rename_all = "snake_case")]
117 pub enum CodePurpose {
118 Discount,
119 FreeAccess,
120 FreeTrial,
121 }
122
123 impl_str_enum!(CodePurpose {
124 Discount => "discount",
125 FreeAccess => "free_access",
126 FreeTrial => "free_trial",
127 });
128
129 // --- Waitlist ---
130
131 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132 #[serde(rename_all = "lowercase")]
133 pub enum WaitlistStatus {
134 Pending,
135 Approved,
136 Spam,
137 }
138
139 impl_str_enum!(WaitlistStatus {
140 Pending => "pending",
141 Approved => "approved",
142 Spam => "spam",
143 });
144
145 impl WaitlistStatus {
146 /// Badge vocabulary (charter: `docs/design-system.md`).
147 pub fn badge_status(self) -> crate::types::BadgeStatus {
148 use crate::types::BadgeStatus;
149 match self {
150 Self::Approved => BadgeStatus::Live,
151 Self::Pending => BadgeStatus::Pending,
152 Self::Spam => BadgeStatus::Failed,
153 }
154 }
155 }
156
157 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158 pub enum SelectionMethod {
159 #[serde(rename = "hand_picked")]
160 HandPicked,
161 #[serde(rename = "lottery")]
162 Lottery,
163 #[serde(rename = "invited")]
164 Invited,
165 }
166
167 impl_str_enum!(SelectionMethod {
168 HandPicked => "hand_picked",
169 Lottery => "lottery",
170 Invited => "invited",
171 });
172
173 // --- Transactions ---
174
175 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176 #[serde(rename_all = "lowercase")]
177 pub enum TransactionStatus {
178 Pending,
179 Completed,
180 /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to
181 /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards
182 /// against double-submit on shared-cart PaymentIntents.
183 Refunding,
184 Refunded,
185 /// Present in the DB `CHECK` since the initial schema but never written by
186 /// the app (stale pending transactions are deleted, not failed). Kept as a
187 /// variant so the enum can decode any legacy/manual `'failed'` row instead of
188 /// fail-closed-poisoning the whole query, and so the enum-drift test's
189 /// variant set matches the column constraint.
190 Failed,
191 }
192
193 impl_str_enum!(TransactionStatus {
194 Pending => "pending",
195 Completed => "completed",
196 Refunding => "refunding",
197 Refunded => "refunded",
198 Failed => "failed",
199 });
200
201 impl TransactionStatus {
202 /// Badge vocabulary (charter: `docs/design-system.md`). A refund is over
203 /// and needs nobody, so it is neutral rather than red.
204 pub fn badge_status(self) -> crate::types::BadgeStatus {
205 use crate::types::BadgeStatus;
206 match self {
207 Self::Completed => BadgeStatus::Live,
208 Self::Pending | Self::Refunding => BadgeStatus::Pending,
209 Self::Failed => BadgeStatus::Failed,
210 Self::Refunded => BadgeStatus::Ended,
211 }
212 }
213 }
214
215 // --- Follows ---
216
217 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218 #[serde(rename_all = "lowercase")]
219 pub enum FollowTargetType {
220 User,
221 Project,
222 Tag,
223 }
224
225 impl_str_enum!(FollowTargetType {
226 User => "user",
227 Project => "project",
228 Tag => "tag",
229 });
230
231 // --- Subscriptions ---
232
233 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234 pub enum SubscriptionStatus {
235 #[serde(rename = "active")]
236 Active,
237 #[serde(rename = "trialing")]
238 Trialing,
239 #[serde(rename = "incomplete")]
240 Incomplete,
241 #[serde(rename = "incomplete_expired")]
242 IncompleteExpired,
243 #[serde(rename = "past_due")]
244 PastDue,
245 #[serde(rename = "canceled")]
246 Canceled,
247 #[serde(rename = "unpaid")]
248 Unpaid,
249 }
250
251 impl_str_enum!(SubscriptionStatus {
252 Active => "active",
253 Trialing => "trialing",
254 Incomplete => "incomplete",
255 IncompleteExpired => "incomplete_expired",
256 PastDue => "past_due",
257 Canceled => "canceled",
258 Unpaid => "unpaid",
259 });
260
261 impl SubscriptionStatus {
262 /// Badge vocabulary (charter: `docs/design-system.md`). A trial is live
263 /// because the subscriber has access; a cancellation is over and needs
264 /// nobody, so it is neutral rather than red.
265 pub fn badge_status(self) -> crate::types::BadgeStatus {
266 use crate::types::BadgeStatus;
267 match self {
268 Self::Active | Self::Trialing => BadgeStatus::Live,
269 Self::Incomplete => BadgeStatus::Pending,
270 Self::IncompleteExpired | Self::PastDue | Self::Unpaid => BadgeStatus::Failed,
271 Self::Canceled => BadgeStatus::Ended,
272 }
273 }
274 }
275
276 // --- SyncKit developer billing ---
277
278 /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
279 /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the
280 /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the
281 /// CHECK set.
282 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283 pub enum SyncBillingStatus {
284 #[serde(rename = "draft")]
285 Draft,
286 #[serde(rename = "active")]
287 Active,
288 #[serde(rename = "suspended_unpaid")]
289 SuspendedUnpaid,
290 #[serde(rename = "canceled")]
291 Canceled,
292 }
293
294 impl_str_enum!(SyncBillingStatus {
295 Draft => "draft",
296 Active => "active",
297 SuspendedUnpaid => "suspended_unpaid",
298 Canceled => "canceled",
299 });
300
301 /// How a SyncKit developer app's storage billing is enforced (the
302 /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')`
303 /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to
304 /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an
305 /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical
306 /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live.
307 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308 pub enum SyncEnforcementMode {
309 #[serde(rename = "per_key")]
310 PerKey,
311 #[serde(rename = "bulk")]
312 Bulk,
313 }
314
315 impl_str_enum!(SyncEnforcementMode {
316 PerKey => "per_key",
317 Bulk => "bulk",
318 });
319
320 // --- Git repository visibility ---
321
322 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323 #[serde(rename_all = "lowercase")]
324 pub enum Visibility {
325 Public,
326 Unlisted,
327 Private,
328 }
329
330 impl_str_enum!(Visibility {
331 Public => "public",
332 Unlisted => "unlisted",
333 Private => "private",
334 });
335
336 // --- Git repository kind ---
337
338 /// What a repository is for.
339 ///
340 /// `Source` is every repository a creator makes. `Annotations` is the one
341 /// per-account repository holding nothing but `refs/notes/*`, which is private
342 /// permanently.
343 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344 #[serde(rename_all = "lowercase")]
345 pub enum GitRepoKind {
346 Source,
347 Annotations,
348 }
349
350 impl_str_enum!(GitRepoKind {
351 Source => "source",
352 Annotations => "annotations",
353 });
354
355 // --- Project member roles ---
356
357 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358 #[serde(rename_all = "lowercase")]
359 pub enum ProjectRole {
360 Owner,
361 Member,
362 }
363
364 impl_str_enum!(ProjectRole {
365 Owner => "owner",
366 Member => "member",
367 });
368
369 // --- SyncKit ---
370
371 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372 pub enum SyncOperation {
373 #[serde(rename = "INSERT")]
374 Insert,
375 #[serde(rename = "UPDATE")]
376 Update,
377 #[serde(rename = "DELETE")]
378 Delete,
379 }
380
381 impl_str_enum!(SyncOperation {
382 Insert => "INSERT",
383 Update => "UPDATE",
384 Delete => "DELETE",
385 });
386
387 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388 #[serde(rename_all = "lowercase")]
389 pub enum SyncPlatform {
390 Macos,
391 Ios,
392 Android,
393 Windows,
394 Linux,
395 Web,
396 }
397
398 impl_str_enum!(SyncPlatform {
399 Macos => "macos",
400 Ios => "ios",
401 Android => "android",
402 Windows => "windows",
403 Linux => "linux",
404 Web => "web",
405 });
406
407 // --- File scanning ---
408
409 /// Status of an uploaded file in the scan pipeline.
410 ///
411 /// `Pending`, accepted, waiting in `scan_jobs` queue for a worker.
412 /// `Scanning`, worker has claimed the job and is running the pipeline.
413 /// `Clean`, pipeline completed, no Fail verdicts, no fail-closed Errors.
414 /// `HeldForReview`, pipeline completed with a fail-closed Error, OR the
415 /// uploader is untrusted (every untrusted upload routes to admin review).
416 /// `Quarantined`, pipeline returned a Fail verdict on at least one layer.
417 /// `Error`, pipeline itself crashed (worker exception, S3 fetch failed, etc.).
418 ///
419 /// Transitions are driven by `crate::scanning::final_status` and applied by
420 /// `crate::scanning::worker`; `Pending` is the only entry state.
421 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
422 #[serde(rename_all = "snake_case")]
423 pub enum FileScanStatus {
424 Pending,
425 Scanning,
426 Clean,
427 Quarantined,
428 HeldForReview,
429 Error,
430 }
431
432 impl_str_enum!(FileScanStatus {
433 Pending => "pending",
434 Scanning => "scanning",
435 Clean => "clean",
436 Quarantined => "quarantined",
437 HeldForReview => "held_for_review",
438 Error => "error",
439 });
440
441 // --- Content Insertions ---
442
443 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444 #[serde(rename_all = "snake_case")]
445 pub enum InsertionPosition {
446 PreRoll,
447 MidRoll,
448 PostRoll,
449 }
450
451 impl_str_enum!(InsertionPosition {
452 PreRoll => "pre_roll",
453 MidRoll => "mid_roll",
454 PostRoll => "post_roll",
455 });
456
457 // --- Appeals ---
458
459 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
460 #[serde(rename_all = "lowercase")]
461 pub enum AppealDecision {
462 Approved,
463 Denied,
464 }
465
466 impl_str_enum!(AppealDecision {
467 Approved => "approved",
468 Denied => "denied",
469 });
470
471 // --- Discover sorting ---
472
473 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
474 #[serde(rename_all = "snake_case")]
475 pub enum DiscoverSort {
476 Newest,
477 MostSold,
478 PriceAsc,
479 PriceDesc,
480 }
481
482 impl_str_enum!(DiscoverSort {
483 Newest => "newest",
484 MostSold => "most_sold",
485 PriceAsc => "price_asc",
486 PriceDesc => "price_desc",
487 });
488
489 // --- Items ---
490
491 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
492 #[serde(rename_all = "lowercase")]
493 pub enum ItemType {
494 Audio,
495 Text,
496 Video,
497 Image,
498 Plugin,
499 Preset,
500 Sample,
501 Course,
502 Template,
503 Digital,
504 Bundle,
505 }
506
507 impl_str_enum!(ItemType {
508 Audio => "audio",
509 Text => "text",
510 Video => "video",
511 Image => "image",
512 Plugin => "plugin",
513 Preset => "preset",
514 Sample => "sample",
515 Course => "course",
516 Template => "template",
517 Digital => "digital",
518 Bundle => "bundle",
519 });
520
521 impl ItemType {
522 /// Short human-readable label for display (replaces `helpers::get_item_type_label`).
523 pub fn label(&self) -> &'static str {
524 match self {
525 Self::Audio => "Audio",
526 Self::Text => "Text",
527 Self::Video => "Video",
528 Self::Image => "Image",
529 Self::Plugin => "Plugin",
530 Self::Preset => "Preset",
531 Self::Sample => "Sample",
532 Self::Course => "Course",
533 Self::Template => "Template",
534 Self::Digital => "Digital",
535 Self::Bundle => "Bundle",
536 }
537 }
538
539 /// Which wizard content-input group this type belongs to.
540 ///
541 /// Determines what the content step looks like:
542 /// - `"text"` → Markdown editor
543 /// - `"audio"` → Audio file upload
544 /// - `"video"` → Video file upload
545 /// - `"bundle"` → Item picker for bundle contents
546 /// - `"file"` → Generic file upload
547 pub fn wizard_group(&self) -> &'static str {
548 match self {
549 Self::Text => "text",
550 Self::Audio => "audio",
551 Self::Video => "video",
552 Self::Bundle => "bundle",
553 _ => "file",
554 }
555 }
556 }
557
558 // --- Git Issues ---
559
560 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
561 #[serde(rename_all = "lowercase")]
562 pub enum IssueStatus {
563 Open,
564 Closed,
565 }
566
567 impl_str_enum!(IssueStatus {
568 Open => "open",
569 Closed => "closed",
570 });
571
572 // --- Reports ---
573
574 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
575 #[serde(rename_all = "lowercase")]
576 pub enum ReportTargetType {
577 Project,
578 Item,
579 }
580
581 impl_str_enum!(ReportTargetType {
582 Project => "project",
583 Item => "item",
584 });
585
586 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
587 #[serde(rename_all = "lowercase")]
588 pub enum ReportType {
589 Mislabeled,
590 Spam,
591 Abuse,
592 Infringement,
593 Other,
594 }
595
596 impl_str_enum!(ReportType {
597 Mislabeled => "mislabeled",
598 Spam => "spam",
599 Abuse => "abuse",
600 Infringement => "infringement",
601 Other => "other",
602 });
603
604 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
605 #[serde(rename_all = "lowercase")]
606 pub enum ReportStatus {
607 Open,
608 Resolved,
609 Dismissed,
610 }
611
612 impl_str_enum!(ReportStatus {
613 Open => "open",
614 Resolved => "resolved",
615 Dismissed => "dismissed",
616 });
617
618 // --- Creator Tiers ---
619
620 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
621 #[serde(rename_all = "snake_case")]
622 pub enum CreatorTier {
623 Basic,
624 SmallFiles,
625 BigFiles,
626 Everything,
627 }
628
629 impl_str_enum!(CreatorTier {
630 Basic => "basic",
631 SmallFiles => "small_files",
632 BigFiles => "big_files",
633 Everything => "everything",
634 });
635
636 impl CreatorTier {
637 /// Human-readable label for display.
638 pub fn label(&self) -> &'static str {
639 match self {
640 Self::Basic => "Basic",
641 Self::SmallFiles => "Small Files",
642 Self::BigFiles => "Big Files",
643 Self::Everything => "Everything",
644 }
645 }
646
647 /// Monthly standard price in cents. Reads from the process-global
648 /// `TierPrices` installed at startup from `assumptions.toml`. See
649 /// `crate::tier_prices` for the OnceLock and test-setup helper.
650 pub fn price_cents(&self) -> i32 {
651 crate::tier_prices::TierPrices::global().price_cents_for(*self)
652 }
653
654 /// Maximum per-file upload size in bytes. Reads from the global
655 /// `TierPrices` (see `price_cents`).
656 pub fn max_file_bytes(&self) -> i64 {
657 crate::tier_prices::TierPrices::global().max_file_bytes_for(*self)
658 }
659
660 /// Maximum total storage in bytes. Reads from the global `TierPrices`
661 /// (see `price_cents`).
662 pub fn max_storage_bytes(&self) -> i64 {
663 crate::tier_prices::TierPrices::global().max_storage_bytes_for(*self)
664 }
665
666 /// Whether this tier allows non-cover file uploads (audio, downloads, insertions).
667 /// Basic is text-only; covers are always allowed regardless of tier.
668 pub fn allows_file_uploads(&self) -> bool {
669 !matches!(self, Self::Basic)
670 }
671
672 /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`.
673 ///
674 /// Implementers gate features on these strings rather than tier names so the
675 /// tier lineup can change without breaking callers. Only ship strings backed by
676 /// live behavior; new capabilities are added when they actually launch.
677 pub fn features(&self) -> &'static [&'static str] {
678 match self {
679 Self::Basic => &[],
680 Self::SmallFiles => &["file_uploads"],
681 Self::BigFiles => &["file_uploads", "large_files"],
682 Self::Everything => &["file_uploads", "large_files"],
683 }
684 }
685 }
686
687 // --- AI Tiers ---
688
689 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
690 #[serde(rename_all = "snake_case")]
691 pub enum AiTier {
692 Handmade,
693 Assisted,
694 Generated,
695 }
696
697 impl_str_enum!(AiTier {
698 Handmade => "handmade",
699 Assisted => "assisted",
700 Generated => "generated",
701 });
702
703 impl AiTier {
704 pub fn label(&self) -> &'static str {
705 match self {
706 Self::Handmade => "Handmade",
707 Self::Assisted => "Assisted",
708 Self::Generated => "Generated",
709 }
710 }
711
712 /// The badge modifier for this tier. A disclosure level is not lifecycle,
713 /// so it keeps its own names rather than joining the status set, but the
714 /// class still comes from here rather than from the serialized value.
715 pub fn css_class(&self) -> &'static str {
716 match self {
717 Self::Handmade => "ai-tier-handmade",
718 Self::Assisted => "ai-tier-assisted",
719 Self::Generated => "ai-tier-generated",
720 }
721 }
722 }
723
724 /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
725 /// Use This". Distinct from `AiTier` because this is a *filter*, not a
726 /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
727 /// `None` on `DiscoverFilters.ai_tier` means "Everything", no
728 /// restriction.
729 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
730 pub enum AiTierFilter {
731 HandmadeOnly,
732 HumanLed,
733 }
734
735 impl_str_enum!(AiTierFilter {
736 HandmadeOnly => "handmade_only",
737 HumanLed => "human_led",
738 });
739
740 impl AiTierFilter {
741 pub fn label(&self) -> &'static str {
742 match self {
743 Self::HandmadeOnly => "Handmade only",
744 Self::HumanLed => "Human-led",
745 }
746 }
747 }
748
749 // --- Project Features ---
750
751 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
752 #[serde(rename_all = "snake_case")]
753 pub enum ProjectFeature {
754 Audio,
755 Downloads,
756 Text,
757 Blog,
758 Subscriptions,
759 LicenseKeys,
760 SourceCode,
761 CloudSync,
762 }
763
764 impl_str_enum!(ProjectFeature {
765 Audio => "audio",
766 Downloads => "downloads",
767 Text => "text",
768 Blog => "blog",
769 Subscriptions => "subscriptions",
770 LicenseKeys => "license_keys",
771 SourceCode => "source_code",
772 CloudSync => "cloud_sync",
773 });
774
775 impl ProjectFeature {
776 /// Human-readable label for display.
777 pub fn label(&self) -> &'static str {
778 match self {
779 Self::Audio => "Audio",
780 Self::Downloads => "Downloads",
781 Self::Text => "Text",
782 Self::Blog => "Blog",
783 Self::Subscriptions => "Subscriptions",
784 Self::LicenseKeys => "License Keys",
785 Self::SourceCode => "Source Code",
786 Self::CloudSync => "Cloud Sync",
787 }
788 }
789
790 /// One-line description of what this feature enables.
791 pub fn description(&self) -> &'static str {
792 match self {
793 Self::Audio => "Upload and stream audio files. Player with chapters.",
794 Self::Downloads => "Host file downloads with versioned releases.",
795 Self::Text => "Write and publish text content with markdown.",
796 Self::Blog => "Project blog with RSS feed.",
797 Self::Subscriptions => "Monthly subscriber tiers.",
798 Self::LicenseKeys => "Software license management with activation API.",
799 Self::SourceCode => "Git repository with source browser.",
800 Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.",
801 }
802 }
803
804 /// All features as (value, label, description) tuples for form rendering.
805 pub fn all() -> &'static [(&'static str, &'static str, &'static str)] {
806 &[
807 (
808 "audio",
809 "Audio",
810 "Upload and stream audio files. Player with chapters.",
811 ),
812 (
813 "downloads",
814 "Downloads",
815 "Host file downloads with versioned releases.",
816 ),
817 (
818 "text",
819 "Text",
820 "Write and publish text content with markdown.",
821 ),
822 ("blog", "Blog", "Project blog with RSS feed."),
823 (
824 "subscriptions",
825 "Subscriptions",
826 "Monthly subscriber tiers.",
827 ),
828 (
829 "license_keys",
830 "License Keys",
831 "Software license management with activation API.",
832 ),
833 (
834 "source_code",
835 "Source Code",
836 "Git repository with source browser.",
837 ),
838 (
839 "cloud_sync",
840 "Cloud Sync",
841 "E2E encrypted cloud sync for desktop and mobile apps.",
842 ),
843 ]
844 }
845
846 /// Derive the best-fit project type from a set of features.
847 pub fn derive_project_type(features: &[String]) -> ProjectType {
848 if features.iter().any(|f| f == "audio") {
849 return ProjectType::Music;
850 }
851 if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") {
852 return ProjectType::Blog;
853 }
854 if features.iter().any(|f| f == "downloads") {
855 return ProjectType::Software;
856 }
857 ProjectType::General
858 }
859
860 /// Which item types a feature unlocks.
861 pub fn allowed_item_types(&self) -> &'static [ItemType] {
862 match self {
863 Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset],
864 Self::Downloads => &[
865 ItemType::Digital,
866 ItemType::Plugin,
867 ItemType::Template,
868 ItemType::Course,
869 ItemType::Image,
870 ItemType::Video,
871 ],
872 Self::Text => &[ItemType::Text],
873 // Non-content features don't gate item types
874 Self::Blog
875 | Self::Subscriptions
876 | Self::LicenseKeys
877 | Self::SourceCode
878 | Self::CloudSync => &[],
879 }
880 }
881
882 /// Compute the set of item types allowed by a project's feature list.
883 /// If no content features are enabled, all types are allowed (permissive default).
884 pub fn allowed_item_type_cards(
885 features: &[String],
886 ) -> Vec<(&'static str, &'static str, &'static str)> {
887 let allowed: std::collections::HashSet<ItemType> = features
888 .iter()
889 .filter_map(|f| f.parse::<ProjectFeature>().ok())
890 .flat_map(|f| f.allowed_item_types().iter().copied())
891 .collect();
892
893 // If no content features enabled, show all types (backwards compat)
894 if allowed.is_empty() {
895 return Self::all_item_type_cards().to_vec();
896 }
897
898 Self::all_item_type_cards()
899 .iter()
900 .filter(|(value, _, _)| {
901 value
902 .parse::<ItemType>()
903 .is_ok_and(|t| t == ItemType::Bundle || allowed.contains(&t))
904 })
905 .copied()
906 .collect()
907 }
908
909 /// All item type cards: (value, label, description) tuples for form rendering.
910 pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] {
911 &[
912 ("audio", "Audio", "Podcast, music, sound effects"),
913 ("text", "Text", "Articles, posts, essays, guides"),
914 ("digital", "Digital Download", "Files, archives, documents"),
915 ("video", "Video", "Tutorials, films, recordings"),
916 ("course", "Course", "Multi-part lessons, curricula"),
917 ("plugin", "Plugin", "Software extensions, add-ons"),
918 ("sample", "Sample Pack", "Audio samples, loops, one-shots"),
919 ("preset", "Preset Pack", "Synth presets, effect chains"),
920 ("template", "Template", "Design templates, starter kits"),
921 ("image", "Image", "Photos, artwork, graphics"),
922 ("bundle", "Bundle", "Collection of other items"),
923 ]
924 }
925
926 /// Item type cards filtered to one per distinct wizard behavior group.
927 ///
928 /// The wizard only needs a type selector when the allowed types produce
929 /// different content-step UIs (text editor vs audio upload vs file upload).
930 /// Returns one card per group, using the first allowed type as the value.
931 /// If all types share one group, returns a single card (caller should skip
932 /// the type step entirely).
933 pub fn wizard_type_cards(
934 features: &[String],
935 ) -> Vec<(&'static str, &'static str, &'static str)> {
936 let allowed = Self::allowed_item_type_cards(features);
937 let mut seen_groups = std::collections::HashSet::new();
938 let mut cards = Vec::new();
939
940 for (value, _, _) in &allowed {
941 let Ok(item_type) = value.parse::<ItemType>() else {
942 continue;
943 };
944 let group = item_type.wizard_group();
945 if seen_groups.insert(group) {
946 let (label, desc) = match group {
947 "text" => ("Text", "Write in the editor"),
948 "audio" => ("Audio", "Upload audio files"),
949 "video" => ("Video", "Upload video files"),
950 "bundle" => ("Bundle", "Collection of other items"),
951 _ => ("File", "Upload any file"),
952 };
953 cards.push((*value, label, desc));
954 }
955 }
956
957 cards
958 }
959 }
960
961 // --- Projects ---
962
963 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
964 #[serde(rename_all = "lowercase")]
965 pub enum ProjectType {
966 Blog,
967 Book,
968 Podcast,
969 Course,
970 Music,
971 Software,
972 Art,
973 Writing,
974 #[default]
975 General,
976 }
977
978 impl_str_enum!(ProjectType {
979 Blog => "blog",
980 Book => "book",
981 Podcast => "podcast",
982 Course => "course",
983 Music => "music",
984 Software => "software",
985 Art => "art",
986 Writing => "writing",
987 General => "general",
988 });
989
990 impl ProjectType {
991 /// Human-readable label for display.
992 pub fn label(&self) -> &'static str {
993 match self {
994 Self::Blog => "Blog",
995 Self::Book => "Book",
996 Self::Podcast => "Podcast",
997 Self::Course => "Course",
998 Self::Music => "Music",
999 Self::Software => "Software",
1000 Self::Art => "Art",
1001 Self::Writing => "Writing",
1002 Self::General => "General",
1003 }
1004 }
1005
1006 /// All valid project types as (value, label) pairs for form rendering.
1007 pub fn all() -> &'static [(&'static str, &'static str)] {
1008 &[
1009 ("blog", "Blog"),
1010 ("book", "Book"),
1011 ("podcast", "Podcast"),
1012 ("course", "Course"),
1013 ("music", "Music"),
1014 ("software", "Software"),
1015 ("art", "Art"),
1016 ("writing", "Writing"),
1017 ("general", "General"),
1018 ]
1019 }
1020 }
1021
1022 // --- Build Pipeline ---
1023
1024 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1025 #[serde(rename_all = "snake_case")]
1026 pub enum BuildStatus {
1027 Pending,
1028 Running,
1029 Succeeded,
1030 Failed,
1031 Cancelled,
1032 }
1033
1034 impl_str_enum!(BuildStatus {
1035 Pending => "pending",
1036 Running => "running",
1037 Succeeded => "succeeded",
1038 Failed => "failed",
1039 Cancelled => "cancelled",
1040 });
1041
1042 // --- Project Pricing ---
1043
1044 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1045 #[serde(rename_all = "snake_case")]
1046 pub enum PricingKind {
1047 #[default]
1048 Free,
1049 BuyOnce,
1050 Pwyw,
1051 Subscription,
1052 }
1053
1054 impl_str_enum!(PricingKind {
1055 Free => "free",
1056 BuyOnce => "buy_once",
1057 Pwyw => "pwyw",
1058 Subscription => "subscription",
1059 });
1060
1061 // --- Mailing Lists ---
1062
1063 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1064 #[serde(rename_all = "lowercase")]
1065 pub enum MailingListType {
1066 Content,
1067 Devlog,
1068 Patches,
1069 }
1070
1071 impl_str_enum!(MailingListType {
1072 Content => "content",
1073 Devlog => "devlog",
1074 Patches => "patches",
1075 });
1076
1077 // --- Import System ---
1078
1079 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1080 #[serde(rename_all = "snake_case")]
1081 pub enum ImportSource {
1082 GenericCsv,
1083 Substack,
1084 Ghost,
1085 Gumroad,
1086 Bandcamp,
1087 LemonSqueezy,
1088 Patreon,
1089 }
1090
1091 impl_str_enum!(ImportSource {
1092 GenericCsv => "generic_csv",
1093 Substack => "substack",
1094 Ghost => "ghost",
1095 Gumroad => "gumroad",
1096 Bandcamp => "bandcamp",
1097 LemonSqueezy => "lemon_squeezy",
1098 Patreon => "patreon",
1099 });
1100
1101 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1102 #[serde(rename_all = "lowercase")]
1103 pub enum ImportJobStatus {
1104 Pending,
1105 Processing,
1106 Completed,
1107 Failed,
1108 }
1109
1110 impl_str_enum!(ImportJobStatus {
1111 Pending => "pending",
1112 Processing => "processing",
1113 Completed => "completed",
1114 Failed => "failed",
1115 });
1116
1117 impl ImportJobStatus {
1118 /// Badge vocabulary (charter: `docs/design-system.md`).
1119 pub fn badge_status(self) -> crate::types::BadgeStatus {
1120 use crate::types::BadgeStatus;
1121 match self {
1122 Self::Completed => BadgeStatus::Live,
1123 Self::Pending | Self::Processing => BadgeStatus::Pending,
1124 Self::Failed => BadgeStatus::Failed,
1125 }
1126 }
1127 }
1128
1129 // Moderation action types
1130
1131 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1132 #[serde(rename_all = "snake_case")]
1133 pub enum ModerationActionType {
1134 Warning,
1135 Suspension,
1136 Termination,
1137 ContentRemoval,
1138 }
1139
1140 impl_str_enum!(ModerationActionType {
1141 Warning => "warning",
1142 Suspension => "suspension",
1143 Termination => "termination",
1144 ContentRemoval => "content_removal",
1145 });
1146
1147 // Checkout types (Stripe metadata)
1148
1149 /// Discriminator for checkout session types stored in Stripe metadata.
1150 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1151 #[serde(rename_all = "snake_case")]
1152 pub enum CheckoutType {
1153 Guest,
1154 Subscription,
1155 Tip,
1156 FanPlus,
1157 CreatorTier,
1158 Cart,
1159 SynckitAppSub,
1160 }
1161
1162 impl_str_enum!(CheckoutType {
1163 Guest => "guest",
1164 Subscription => "subscription",
1165 Tip => "tip",
1166 FanPlus => "fan_plus",
1167 CreatorTier => "creator_tier",
1168 Cart => "cart",
1169 SynckitAppSub => "synckit_app_sub",
1170 });
1171
1172 impl ModerationActionType {
1173 pub fn label(&self) -> &'static str {
1174 match self {
1175 Self::Warning => "Warning",
1176 Self::Suspension => "Suspension",
1177 Self::Termination => "Termination",
1178 Self::ContentRemoval => "Content Removal",
1179 }
1180 }
1181 }
1182
1183 // --- Mailing lists (wiki: mnw-mailing-lists) ---
1184
1185 /// The legacy per-project list types map onto the unified kinds one-for-one.
1186 /// Kept as a conversion rather than merging the two enums, because
1187 /// `MailingListType` is pinned by a CHECK on the old table and will be dropped
1188 /// with it rather than grown.
1189 impl From<MailingListType> for ListKind {
1190 fn from(t: MailingListType) -> Self {
1191 match t {
1192 MailingListType::Content => Self::Content,
1193 MailingListType::Devlog => Self::Devlog,
1194 MailingListType::Patches => Self::Patches,
1195 }
1196 }
1197 }
1198
1199 /// What a list is attached to. `Platform` lists have no `scope_id`; every other
1200 /// scope requires one, and the database enforces the pairing.
1201 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1202 pub enum ListScope {
1203 Platform,
1204 Project,
1205 Repo,
1206 Creator,
1207 }
1208
1209 impl_str_enum!(ListScope {
1210 Platform => "platform",
1211 Project => "project",
1212 Repo => "repo",
1213 Creator => "creator",
1214 });
1215
1216 /// What the list carries. Kinds are deliberately coarse: the unsubscribe page
1217 /// shows one row per list, and a subscriber who has to reason about fifteen
1218 /// near-identical kinds will unsubscribe from all of them.
1219 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220 pub enum ListKind {
1221 Content,
1222 Devlog,
1223 Patches,
1224 Releases,
1225 Issues,
1226 Announce,
1227 Marketing,
1228 // Account notification preferences, each mirroring a users.notify_* column.
1229 // Releases and Issues above double as these at platform scope; the rest are
1230 // their own kinds.
1231 Sale,
1232 Follower,
1233 Login,
1234 Status,
1235 Tip,
1236 // Someone redeemed one of your invite codes. Added by migration 195, when
1237 // that notice stopped being mail you could not turn off.
1238 Invite,
1239 }
1240
1241 impl_str_enum!(ListKind {
1242 Content => "content",
1243 Devlog => "devlog",
1244 Patches => "patches",
1245 Releases => "releases",
1246 Issues => "issues",
1247 Announce => "announce",
1248 Marketing => "marketing",
1249 Sale => "sale",
1250 Follower => "follower",
1251 Login => "login",
1252 Status => "status",
1253 Tip => "tip",
1254 Invite => "invite",
1255 });
1256
1257 impl ListKind {
1258 /// Every kind. Exists so a test can assert the enum against the
1259 /// `lists_kind_check` constraint: a kind lives in two places, and adding it
1260 /// to only one fails at INSERT on a deployed database rather than at
1261 /// compile time here.
1262 pub const ALL: &'static [ListKind] = &[
1263 ListKind::Content,
1264 ListKind::Devlog,
1265 ListKind::Patches,
1266 ListKind::Releases,
1267 ListKind::Issues,
1268 ListKind::Announce,
1269 ListKind::Marketing,
1270 ListKind::Sale,
1271 ListKind::Follower,
1272 ListKind::Login,
1273 ListKind::Status,
1274 ListKind::Tip,
1275 ListKind::Invite,
1276 ];
1277 }
1278
1279 /// What is waiting to be acknowledged.
1280 ///
1281 /// Closed and asserted against the `pending_acknowledgements.kind` CHECK
1282 /// constraint by a test in `db::acknowledgements`: a variant added here without
1283 /// a migration compiles, passes every unit test, and then fails at INSERT on a
1284 /// deployed database.
1285 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286 pub enum AckKind {
1287 /// The creator's Stripe account changed settlement currency, so every price
1288 /// they have already set is now a number denominated in different money.
1289 SettlementCurrencyChanged,
1290 }
1291
1292 impl_str_enum!(AckKind {
1293 SettlementCurrencyChanged => "settlement_currency_changed",
1294 });
1295
1296 impl AckKind {
1297 pub const ALL: &'static [AckKind] = &[AckKind::SettlementCurrencyChanged];
1298
1299 /// Subject line and page heading, one source for both.
1300 pub fn title(self) -> &'static str {
1301 match self {
1302 Self::SettlementCurrencyChanged => "Your prices are now in a different currency",
1303 }
1304 }
1305 }
1306
1307 /// Where a subscription stands.
1308 ///
1309 /// `Imported` is its own state on purpose. Everything the step-2 backfill
1310 /// carried over predates any consent record: calling it `Confirmed` would
1311 /// manufacture evidence we do not have, and calling it `Pending` would assert a
1312 /// double-opt-in is in flight when none is. The state says exactly what is
1313 /// known and no more.
1314 ///
1315 /// It is provenance, not a quarantine. An imported subscriber may be mailed:
1316 /// sendable, marketing included, and no re-confirmation pass is coming. See
1317 /// `db::lists::SENDABLE_STATES` for the reasoning. The
1318 /// state stays distinct because "we carried this over" is worth being able to
1319 /// say years later, not because anything gates on it.
1320 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1321 pub enum SubscriptionState {
1322 Pending,
1323 Confirmed,
1324 Imported,
1325 Unsubscribed,
1326 Bounced,
1327 }
1328
1329 impl_str_enum!(SubscriptionState {
1330 Pending => "pending",
1331 Confirmed => "confirmed",
1332 Imported => "imported",
1333 Unsubscribed => "unsubscribed",
1334 Bounced => "bounced",
1335 });
1336
1337 /// How the subscription was created, which the table this replaces could not
1338 /// say.
1339 ///
1340 /// Recorded for a re-confirmation pass that was then decided against (GoingsOn
1341 /// 04a882b4). It stays because "where did this address come from" is the first
1342 /// question asked of a complaint, and answering it is worth one column whether
1343 /// or not anything ever filters on it.
1344 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1345 pub enum SubscriptionSource {
1346 LandingForm,
1347 ProjectPage,
1348 Checkout,
1349 Import,
1350 Admin,
1351 Api,
1352 }
1353
1354 impl_str_enum!(SubscriptionSource {
1355 LandingForm => "landing_form",
1356 ProjectPage => "project_page",
1357 Checkout => "checkout",
1358 Import => "import",
1359 Admin => "admin",
1360 Api => "api",
1361 });
1362
1363 /// An entry in a subscription's consent history. The table is append-only: an
1364 /// opt-out adds a row rather than editing the opt-in that came before, and a
1365 /// database trigger rejects `UPDATE` so that stays true.
1366 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1367 pub enum ConsentEvent {
1368 OptIn,
1369 Confirm,
1370 OptOut,
1371 Bounce,
1372 Complaint,
1373 AdminRemoval,
1374 Import,
1375 }
1376
1377 impl_str_enum!(ConsentEvent {
1378 OptIn => "opt_in",
1379 Confirm => "confirm",
1380 OptOut => "opt_out",
1381 Bounce => "bounce",
1382 Complaint => "complaint",
1383 AdminRemoval => "admin_removal",
1384 Import => "import",
1385 });
1386
1387 #[cfg(test)]
1388 mod tests {
1389 use super::*;
1390
1391 #[test]
1392 fn discount_type_round_trip() {
1393 assert_eq!(DiscountType::Percentage.to_string(), "percentage");
1394 assert_eq!(
1395 "fixed".parse::<DiscountType>().unwrap(),
1396 DiscountType::Fixed
1397 );
1398 assert!("bogus".parse::<DiscountType>().is_err());
1399 }
1400
1401 #[test]
1402 fn waitlist_status_round_trip() {
1403 assert_eq!(WaitlistStatus::Pending.to_string(), "pending");
1404 assert_eq!(
1405 "approved".parse::<WaitlistStatus>().unwrap(),
1406 WaitlistStatus::Approved
1407 );
1408 }
1409
1410 #[test]
1411 fn selection_method_round_trip() {
1412 assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked");
1413 assert_eq!(
1414 "lottery".parse::<SelectionMethod>().unwrap(),
1415 SelectionMethod::Lottery
1416 );
1417 assert_eq!(SelectionMethod::Invited.to_string(), "invited");
1418 assert_eq!(
1419 "invited".parse::<SelectionMethod>().unwrap(),
1420 SelectionMethod::Invited
1421 );
1422 }
1423
1424 #[test]
1425 fn transaction_status_round_trip() {
1426 assert_eq!(TransactionStatus::Completed.to_string(), "completed");
1427 assert_eq!(
1428 "refunded".parse::<TransactionStatus>().unwrap(),
1429 TransactionStatus::Refunded
1430 );
1431 }
1432
1433 #[test]
1434 fn follow_target_type_round_trip() {
1435 assert_eq!(FollowTargetType::User.to_string(), "user");
1436 assert_eq!(
1437 "tag".parse::<FollowTargetType>().unwrap(),
1438 FollowTargetType::Tag
1439 );
1440 }
1441
1442 #[test]
1443 fn subscription_status_round_trip() {
1444 assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due");
1445 assert_eq!(
1446 "canceled".parse::<SubscriptionStatus>().unwrap(),
1447 SubscriptionStatus::Canceled
1448 );
1449 assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing");
1450 assert_eq!(
1451 "trialing".parse::<SubscriptionStatus>().unwrap(),
1452 SubscriptionStatus::Trialing
1453 );
1454 assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete");
1455 assert_eq!(
1456 "incomplete".parse::<SubscriptionStatus>().unwrap(),
1457 SubscriptionStatus::Incomplete
1458 );
1459 assert_eq!(
1460 SubscriptionStatus::IncompleteExpired.to_string(),
1461 "incomplete_expired"
1462 );
1463 assert_eq!(
1464 "incomplete_expired".parse::<SubscriptionStatus>().unwrap(),
1465 SubscriptionStatus::IncompleteExpired
1466 );
1467 }
1468
1469 #[test]
1470 fn sync_operation_round_trip() {
1471 assert_eq!(SyncOperation::Insert.to_string(), "INSERT");
1472 assert_eq!(
1473 "DELETE".parse::<SyncOperation>().unwrap(),
1474 SyncOperation::Delete
1475 );
1476 }
1477
1478 #[test]
1479 fn sync_platform_round_trip() {
1480 assert_eq!(SyncPlatform::Macos.to_string(), "macos");
1481 assert_eq!("web".parse::<SyncPlatform>().unwrap(), SyncPlatform::Web);
1482 }
1483
1484 #[test]
1485 fn item_type_round_trip() {
1486 assert_eq!(ItemType::Audio.to_string(), "audio");
1487 assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin);
1488 assert_eq!(ItemType::Bundle.to_string(), "bundle");
1489 assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle);
1490 }
1491
1492 #[test]
1493 fn insertion_position_round_trip() {
1494 assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll");
1495 assert_eq!(
1496 "mid_roll".parse::<InsertionPosition>().unwrap(),
1497 InsertionPosition::MidRoll
1498 );
1499 assert_eq!(
1500 "post_roll".parse::<InsertionPosition>().unwrap(),
1501 InsertionPosition::PostRoll
1502 );
1503 assert!("invalid".parse::<InsertionPosition>().is_err());
1504 }
1505
1506 #[test]
1507 fn item_type_label() {
1508 assert_eq!(ItemType::Audio.label(), "Audio");
1509 assert_eq!(ItemType::Plugin.label(), "Plugin");
1510 assert_eq!(ItemType::Template.label(), "Template");
1511 }
1512
1513 #[test]
1514 fn appeal_decision_round_trip() {
1515 assert_eq!(AppealDecision::Approved.to_string(), "approved");
1516 assert_eq!(
1517 "denied".parse::<AppealDecision>().unwrap(),
1518 AppealDecision::Denied
1519 );
1520 assert!("bogus".parse::<AppealDecision>().is_err());
1521 }
1522
1523 #[test]
1524 fn discover_sort_round_trip() {
1525 assert_eq!(DiscoverSort::Newest.to_string(), "newest");
1526 assert_eq!(
1527 "most_sold".parse::<DiscoverSort>().unwrap(),
1528 DiscoverSort::MostSold
1529 );
1530 assert_eq!(
1531 "price_asc".parse::<DiscoverSort>().unwrap(),
1532 DiscoverSort::PriceAsc
1533 );
1534 assert_eq!(
1535 "price_desc".parse::<DiscoverSort>().unwrap(),
1536 DiscoverSort::PriceDesc
1537 );
1538 assert!("invalid".parse::<DiscoverSort>().is_err());
1539 }
1540
1541 #[test]
1542 fn file_scan_status_round_trip() {
1543 assert_eq!(FileScanStatus::Clean.to_string(), "clean");
1544 assert_eq!(FileScanStatus::Pending.to_string(), "pending");
1545 assert_eq!(FileScanStatus::Scanning.to_string(), "scanning");
1546 assert_eq!(
1547 "pending".parse::<FileScanStatus>().unwrap(),
1548 FileScanStatus::Pending
1549 );
1550 assert_eq!(
1551 "scanning".parse::<FileScanStatus>().unwrap(),
1552 FileScanStatus::Scanning
1553 );
1554 assert_eq!(
1555 "held_for_review".parse::<FileScanStatus>().unwrap(),
1556 FileScanStatus::HeldForReview
1557 );
1558 assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review");
1559 assert_eq!(
1560 "quarantined".parse::<FileScanStatus>().unwrap(),
1561 FileScanStatus::Quarantined
1562 );
1563 assert!("bogus".parse::<FileScanStatus>().is_err());
1564 }
1565
1566 #[test]
1567 fn code_purpose_round_trip() {
1568 assert_eq!(CodePurpose::Discount.to_string(), "discount");
1569 assert_eq!(
1570 "free_access".parse::<CodePurpose>().unwrap(),
1571 CodePurpose::FreeAccess
1572 );
1573 assert_eq!(
1574 "free_trial".parse::<CodePurpose>().unwrap(),
1575 CodePurpose::FreeTrial
1576 );
1577 assert!("bogus".parse::<CodePurpose>().is_err());
1578 }
1579
1580 #[test]
1581 fn issue_status_round_trip() {
1582 assert_eq!(IssueStatus::Open.to_string(), "open");
1583 assert_eq!(
1584 "closed".parse::<IssueStatus>().unwrap(),
1585 IssueStatus::Closed
1586 );
1587 assert!("bogus".parse::<IssueStatus>().is_err());
1588 }
1589
1590 #[test]
1591 fn report_target_type_round_trip() {
1592 assert_eq!(ReportTargetType::Project.to_string(), "project");
1593 assert_eq!(
1594 "item".parse::<ReportTargetType>().unwrap(),
1595 ReportTargetType::Item
1596 );
1597 assert!("bogus".parse::<ReportTargetType>().is_err());
1598 }
1599
1600 #[test]
1601 fn report_type_round_trip() {
1602 assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled");
1603 assert_eq!("spam".parse::<ReportType>().unwrap(), ReportType::Spam);
1604 assert_eq!("abuse".parse::<ReportType>().unwrap(), ReportType::Abuse);
1605 assert_eq!(
1606 "infringement".parse::<ReportType>().unwrap(),
1607 ReportType::Infringement
1608 );
1609 assert_eq!("other".parse::<ReportType>().unwrap(), ReportType::Other);
1610 assert!("bogus".parse::<ReportType>().is_err());
1611 }
1612
1613 #[test]
1614 fn report_status_round_trip() {
1615 assert_eq!(ReportStatus::Open.to_string(), "open");
1616 assert_eq!(
1617 "resolved".parse::<ReportStatus>().unwrap(),
1618 ReportStatus::Resolved
1619 );
1620 assert_eq!(
1621 "dismissed".parse::<ReportStatus>().unwrap(),
1622 ReportStatus::Dismissed
1623 );
1624 assert!("bogus".parse::<ReportStatus>().is_err());
1625 }
1626
1627 #[test]
1628 fn creator_tier_round_trip() {
1629 assert_eq!(CreatorTier::Basic.to_string(), "basic");
1630 assert_eq!(
1631 "small_files".parse::<CreatorTier>().unwrap(),
1632 CreatorTier::SmallFiles
1633 );
1634 assert_eq!(
1635 "big_files".parse::<CreatorTier>().unwrap(),
1636 CreatorTier::BigFiles
1637 );
1638 assert_eq!(
1639 "everything".parse::<CreatorTier>().unwrap(),
1640 CreatorTier::Everything
1641 );
1642 assert!("bogus".parse::<CreatorTier>().is_err());
1643 }
1644
1645 #[test]
1646 fn creator_tier_label_and_price() {
1647 crate::tier_prices::TierPrices::install_test_default();
1648 assert_eq!(CreatorTier::Basic.label(), "Basic");
1649 assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
1650 // Prices come from assumptions.toml. Assert invariants, founder = std/2,
1651 // monotone across tiers, not literal cents, so a future toml edit doesn't
1652 // break this test.
1653 let tiers = [
1654 CreatorTier::Basic,
1655 CreatorTier::SmallFiles,
1656 CreatorTier::BigFiles,
1657 CreatorTier::Everything,
1658 ];
1659 for pair in tiers.windows(2) {
1660 assert!(
1661 pair[0].price_cents() < pair[1].price_cents(),
1662 "{:?} price_cents ({}) should be < {:?} ({})",
1663 pair[0],
1664 pair[0].price_cents(),
1665 pair[1],
1666 pair[1].price_cents(),
1667 );
1668 }
1669 assert!(CreatorTier::Basic.price_cents() > 0);
1670 }
1671
1672 #[test]
1673 fn creator_tier_file_limits() {
1674 crate::tier_prices::TierPrices::install_test_default();
1675 // File caps monotone across tiers; Basic ≤ SmallFiles ≤ BigFiles == Everything.
1676 let tiers = [
1677 CreatorTier::Basic,
1678 CreatorTier::SmallFiles,
1679 CreatorTier::BigFiles,
1680 CreatorTier::Everything,
1681 ];
1682 for pair in tiers.windows(2) {
1683 assert!(
1684 pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
1685 "{:?} max_file_bytes ({}) should be <= {:?} ({})",
1686 pair[0],
1687 pair[0].max_file_bytes(),
1688 pair[1],
1689 pair[1].max_file_bytes(),
1690 );
1691 }
1692 assert!(CreatorTier::Basic.max_file_bytes() > 0);
1693 }
1694
1695 #[test]
1696 fn creator_tier_storage_limits() {
1697 crate::tier_prices::TierPrices::install_test_default();
1698 let tiers = [
1699 CreatorTier::Basic,
1700 CreatorTier::SmallFiles,
1701 CreatorTier::BigFiles,
1702 CreatorTier::Everything,
1703 ];
1704 for pair in tiers.windows(2) {
1705 assert!(
1706 pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
1707 "{:?} max_storage_bytes ({}) should be <= {:?} ({})",
1708 pair[0],
1709 pair[0].max_storage_bytes(),
1710 pair[1],
1711 pair[1].max_storage_bytes(),
1712 );
1713 }
1714 assert!(CreatorTier::Basic.max_storage_bytes() > 0);
1715 // Every tier's per-file cap must fit in its total storage cap or uploads
1716 // are impossible. Catches an accidental toml edit that shrinks a total
1717 // below the per-file limit.
1718 for &tier in &tiers {
1719 assert!(
1720 tier.max_file_bytes() <= tier.max_storage_bytes(),
1721 "{tier:?}: file cap ({}) exceeds storage cap ({})",
1722 tier.max_file_bytes(),
1723 tier.max_storage_bytes(),
1724 );
1725 }
1726 }
1727
1728 #[test]
1729 fn creator_tier_allows_file_uploads() {
1730 assert!(!CreatorTier::Basic.allows_file_uploads());
1731 assert!(CreatorTier::SmallFiles.allows_file_uploads());
1732 assert!(CreatorTier::BigFiles.allows_file_uploads());
1733 assert!(CreatorTier::Everything.allows_file_uploads());
1734 }
1735
1736 #[test]
1737 fn creator_tier_features_track_live_capabilities() {
1738 assert!(CreatorTier::Basic.features().is_empty());
1739 assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]);
1740 assert_eq!(
1741 CreatorTier::BigFiles.features(),
1742 &["file_uploads", "large_files"]
1743 );
1744 assert_eq!(
1745 CreatorTier::Everything.features(),
1746 &["file_uploads", "large_files"]
1747 );
1748 }
1749
1750 #[test]
1751 fn project_feature_round_trip() {
1752 assert_eq!(ProjectFeature::Audio.to_string(), "audio");
1753 assert_eq!(
1754 "downloads".parse::<ProjectFeature>().unwrap(),
1755 ProjectFeature::Downloads
1756 );
1757 assert_eq!(
1758 "license_keys".parse::<ProjectFeature>().unwrap(),
1759 ProjectFeature::LicenseKeys
1760 );
1761 assert_eq!(
1762 "source_code".parse::<ProjectFeature>().unwrap(),
1763 ProjectFeature::SourceCode
1764 );
1765 assert!("bogus".parse::<ProjectFeature>().is_err());
1766 }
1767
1768 #[test]
1769 fn project_feature_label_and_description() {
1770 assert_eq!(ProjectFeature::Audio.label(), "Audio");
1771 assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys");
1772 assert!(!ProjectFeature::Audio.description().is_empty());
1773 }
1774
1775 #[test]
1776 fn project_feature_all() {
1777 let all = ProjectFeature::all();
1778 assert_eq!(all.len(), 8);
1779 assert_eq!(all[0].0, "audio");
1780 assert_eq!(all[7].0, "cloud_sync");
1781 }
1782
1783 #[test]
1784 fn project_feature_allowed_item_types_audio() {
1785 let types = ProjectFeature::Audio.allowed_item_types();
1786 assert!(types.contains(&ItemType::Audio));
1787 assert!(types.contains(&ItemType::Sample));
1788 assert!(types.contains(&ItemType::Preset));
1789 assert!(!types.contains(&ItemType::Text));
1790 }
1791
1792 #[test]
1793 fn project_feature_allowed_item_types_downloads() {
1794 let types = ProjectFeature::Downloads.allowed_item_types();
1795 assert!(types.contains(&ItemType::Digital));
1796 assert!(types.contains(&ItemType::Plugin));
1797 assert!(types.contains(&ItemType::Video));
1798 assert!(!types.contains(&ItemType::Audio));
1799 }
1800
1801 #[test]
1802 fn project_feature_allowed_item_types_text() {
1803 let types = ProjectFeature::Text.allowed_item_types();
1804 assert!(types.contains(&ItemType::Text));
1805 assert_eq!(types.len(), 1);
1806 }
1807
1808 #[test]
1809 fn project_feature_allowed_item_types_non_content() {
1810 assert!(ProjectFeature::Blog.allowed_item_types().is_empty());
1811 assert!(
1812 ProjectFeature::Subscriptions
1813 .allowed_item_types()
1814 .is_empty()
1815 );
1816 assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty());
1817 assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty());
1818 assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty());
1819 }
1820
1821 #[test]
1822 fn project_feature_allowed_cards_filtered() {
1823 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]);
1824 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1825 assert!(values.contains(&"audio"));
1826 assert!(values.contains(&"sample"));
1827 assert!(values.contains(&"preset"));
1828 assert!(values.contains(&"bundle")); // Bundle always included
1829 assert!(!values.contains(&"text"));
1830 assert!(!values.contains(&"digital"));
1831 }
1832
1833 #[test]
1834 fn project_feature_allowed_cards_combined() {
1835 let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]);
1836 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1837 assert!(values.contains(&"audio"));
1838 assert!(values.contains(&"text"));
1839 assert!(values.contains(&"bundle")); // Bundle always included
1840 assert!(!values.contains(&"digital"));
1841 }
1842
1843 #[test]
1844 fn project_feature_allowed_cards_empty_features_shows_all() {
1845 let cards = ProjectFeature::allowed_item_type_cards(&[]);
1846 assert_eq!(cards.len(), 11); // 10 content types + bundle
1847 }
1848
1849 #[test]
1850 fn project_feature_allowed_cards_non_content_features_shows_all() {
1851 let cards =
1852 ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]);
1853 // Blog and subscriptions don't gate item types, so all should be shown
1854 assert_eq!(cards.len(), 11); // 10 content types + bundle
1855 }
1856
1857 #[test]
1858 fn project_feature_derive_type() {
1859 assert_eq!(
1860 ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]),
1861 ProjectType::Music,
1862 );
1863 assert_eq!(
1864 ProjectFeature::derive_project_type(&["text".into()]),
1865 ProjectType::Blog,
1866 );
1867 assert_eq!(
1868 ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]),
1869 ProjectType::Software,
1870 );
1871 assert_eq!(
1872 ProjectFeature::derive_project_type(&["subscriptions".into()]),
1873 ProjectType::General,
1874 );
1875 }
1876
1877 #[test]
1878 fn project_type_round_trip() {
1879 assert_eq!(ProjectType::Blog.to_string(), "blog");
1880 assert_eq!(
1881 "software".parse::<ProjectType>().unwrap(),
1882 ProjectType::Software
1883 );
1884 assert_eq!(
1885 "general".parse::<ProjectType>().unwrap(),
1886 ProjectType::General
1887 );
1888 assert_eq!(ProjectType::default(), ProjectType::General);
1889 assert!("bogus".parse::<ProjectType>().is_err());
1890 }
1891
1892 #[test]
1893 fn project_type_label() {
1894 assert_eq!(ProjectType::Blog.label(), "Blog");
1895 assert_eq!(ProjectType::Software.label(), "Software");
1896 assert_eq!(ProjectType::General.label(), "General");
1897 }
1898
1899 #[test]
1900 fn project_type_all() {
1901 let all = ProjectType::all();
1902 assert_eq!(all.len(), 9);
1903 assert_eq!(all[0], ("blog", "Blog"));
1904 assert_eq!(all[8], ("general", "General"));
1905 }
1906
1907 #[test]
1908 fn build_status_round_trip() {
1909 assert_eq!(BuildStatus::Pending.to_string(), "pending");
1910 assert_eq!(
1911 "running".parse::<BuildStatus>().unwrap(),
1912 BuildStatus::Running
1913 );
1914 assert_eq!(
1915 "succeeded".parse::<BuildStatus>().unwrap(),
1916 BuildStatus::Succeeded
1917 );
1918 assert_eq!(
1919 "failed".parse::<BuildStatus>().unwrap(),
1920 BuildStatus::Failed
1921 );
1922 assert_eq!(
1923 "cancelled".parse::<BuildStatus>().unwrap(),
1924 BuildStatus::Cancelled
1925 );
1926 assert!("bogus".parse::<BuildStatus>().is_err());
1927 }
1928
1929 #[test]
1930 fn serde_json_round_trip() {
1931 let dt = DiscountType::Percentage;
1932 let json = serde_json::to_string(&dt).unwrap();
1933 assert_eq!(json, "\"percentage\"");
1934 let back: DiscountType = serde_json::from_str(&json).unwrap();
1935 assert_eq!(back, dt);
1936 }
1937
1938 #[test]
1939 fn pricing_kind_round_trip() {
1940 assert_eq!(PricingKind::Free.to_string(), "free");
1941 assert_eq!(
1942 "buy_once".parse::<PricingKind>().unwrap(),
1943 PricingKind::BuyOnce
1944 );
1945 assert_eq!("pwyw".parse::<PricingKind>().unwrap(), PricingKind::Pwyw);
1946 assert_eq!(
1947 "subscription".parse::<PricingKind>().unwrap(),
1948 PricingKind::Subscription
1949 );
1950 assert_eq!(PricingKind::default(), PricingKind::Free);
1951 assert!("bogus".parse::<PricingKind>().is_err());
1952 }
1953
1954 #[test]
1955 fn mailing_list_type_round_trip() {
1956 assert_eq!(MailingListType::Content.to_string(), "content");
1957 assert_eq!(
1958 "devlog".parse::<MailingListType>().unwrap(),
1959 MailingListType::Devlog
1960 );
1961 assert_eq!(
1962 "patches".parse::<MailingListType>().unwrap(),
1963 MailingListType::Patches
1964 );
1965 assert!("bogus".parse::<MailingListType>().is_err());
1966 }
1967
1968 #[test]
1969 fn serde_json_subscription_status() {
1970 let s = SubscriptionStatus::PastDue;
1971 let json = serde_json::to_string(&s).unwrap();
1972 assert_eq!(json, "\"past_due\"");
1973 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1974 assert_eq!(back, s);
1975
1976 let t = SubscriptionStatus::Trialing;
1977 let json = serde_json::to_string(&t).unwrap();
1978 assert_eq!(json, "\"trialing\"");
1979 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1980 assert_eq!(back, t);
1981 }
1982
1983 // --- ItemType::wizard_group ---
1984
1985 #[test]
1986 fn wizard_group_text() {
1987 assert_eq!(ItemType::Text.wizard_group(), "text");
1988 }
1989
1990 #[test]
1991 fn wizard_group_audio() {
1992 assert_eq!(ItemType::Audio.wizard_group(), "audio");
1993 }
1994
1995 #[test]
1996 fn wizard_group_video() {
1997 assert_eq!(ItemType::Video.wizard_group(), "video");
1998 }
1999
2000 #[test]
2001 fn wizard_group_file_types() {
2002 for t in [
2003 ItemType::Digital,
2004 ItemType::Course,
2005 ItemType::Plugin,
2006 ItemType::Sample,
2007 ItemType::Preset,
2008 ItemType::Template,
2009 ItemType::Image,
2010 ] {
2011 assert_eq!(t.wizard_group(), "file", "{t:?} should be in file group");
2012 }
2013 }
2014
2015 #[test]
2016 fn wizard_group_bundle() {
2017 assert_eq!(ItemType::Bundle.wizard_group(), "bundle");
2018 }
2019
2020 // --- ProjectFeature::wizard_type_cards ---
2021
2022 #[test]
2023 fn wizard_cards_text_only_two_groups() {
2024 // Text + bundle (bundle always included)
2025 let cards = ProjectFeature::wizard_type_cards(&["text".into()]);
2026 assert_eq!(cards.len(), 2);
2027 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
2028 assert!(groups.contains(&"text"));
2029 assert!(groups.contains(&"bundle"));
2030 }
2031
2032 #[test]
2033 fn wizard_cards_downloads_three_groups() {
2034 // Download types split into "file" + "video" groups + bundle → 3 cards
2035 let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
2036 assert_eq!(cards.len(), 3);
2037 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
2038 assert!(groups.contains(&"digital")); // first type in file group
2039 assert!(groups.contains(&"video")); // video group
2040 assert!(groups.contains(&"bundle"));
2041 }
2042
2043 #[test]
2044 fn wizard_cards_audio_feature_three_groups() {
2045 // Audio feature allows audio (audio group) + sample, preset (file group) + bundle
2046 let cards = ProjectFeature::wizard_type_cards(&["audio".into()]);
2047 assert_eq!(cards.len(), 3);
2048 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
2049 assert!(groups.contains(&"audio"));
2050 assert!(groups.contains(&"sample")); // first file-group type
2051 assert!(groups.contains(&"bundle"));
2052 }
2053
2054 #[test]
2055 fn wizard_cards_text_and_audio_four_groups() {
2056 let cards = ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]);
2057 assert_eq!(cards.len(), 4); // text, audio, file, bundle
2058 }
2059
2060 #[test]
2061 fn wizard_cards_empty_features_all_five_groups() {
2062 // No content features → all types → 5 wizard groups (text, audio, video, file, bundle)
2063 let cards = ProjectFeature::wizard_type_cards(&[]);
2064 assert_eq!(cards.len(), 5);
2065 }
2066
2067 #[test]
2068 fn ai_tier_round_trip() {
2069 assert_eq!(AiTier::Handmade.to_string(), "handmade");
2070 assert_eq!("assisted".parse::<AiTier>().unwrap(), AiTier::Assisted);
2071 assert_eq!("generated".parse::<AiTier>().unwrap(), AiTier::Generated);
2072 assert!("bogus".parse::<AiTier>().is_err());
2073 }
2074
2075 #[test]
2076 fn ai_tier_label() {
2077 assert_eq!(AiTier::Handmade.label(), "Handmade");
2078 assert_eq!(AiTier::Assisted.label(), "Assisted");
2079 assert_eq!(AiTier::Generated.label(), "Generated");
2080 }
2081
2082 #[test]
2083 fn import_source_round_trip() {
2084 assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv");
2085 assert_eq!(
2086 "substack".parse::<ImportSource>().unwrap(),
2087 ImportSource::Substack
2088 );
2089 assert_eq!(
2090 "ghost".parse::<ImportSource>().unwrap(),
2091 ImportSource::Ghost
2092 );
2093 assert_eq!(
2094 "gumroad".parse::<ImportSource>().unwrap(),
2095 ImportSource::Gumroad
2096 );
2097 assert_eq!(
2098 "bandcamp".parse::<ImportSource>().unwrap(),
2099 ImportSource::Bandcamp
2100 );
2101 assert_eq!(
2102 "lemon_squeezy".parse::<ImportSource>().unwrap(),
2103 ImportSource::LemonSqueezy
2104 );
2105 assert_eq!(
2106 "patreon".parse::<ImportSource>().unwrap(),
2107 ImportSource::Patreon
2108 );
2109 assert!("bogus".parse::<ImportSource>().is_err());
2110 }
2111
2112 #[test]
2113 fn import_job_status_round_trip() {
2114 assert_eq!(ImportJobStatus::Pending.to_string(), "pending");
2115 assert_eq!(
2116 "processing".parse::<ImportJobStatus>().unwrap(),
2117 ImportJobStatus::Processing
2118 );
2119 assert_eq!(
2120 "completed".parse::<ImportJobStatus>().unwrap(),
2121 ImportJobStatus::Completed
2122 );
2123 assert_eq!(
2124 "failed".parse::<ImportJobStatus>().unwrap(),
2125 ImportJobStatus::Failed
2126 );
2127 assert!("bogus".parse::<ImportJobStatus>().is_err());
2128 }
2129
2130 #[test]
2131 fn checkout_type_round_trip() {
2132 assert_eq!(CheckoutType::Guest.to_string(), "guest");
2133 assert_eq!(CheckoutType::Subscription.to_string(), "subscription");
2134 assert_eq!(CheckoutType::Tip.to_string(), "tip");
2135 assert_eq!(CheckoutType::FanPlus.to_string(), "fan_plus");
2136 assert_eq!(CheckoutType::CreatorTier.to_string(), "creator_tier");
2137 assert_eq!(
2138 "guest".parse::<CheckoutType>().unwrap(),
2139 CheckoutType::Guest
2140 );
2141 assert_eq!(
2142 "fan_plus".parse::<CheckoutType>().unwrap(),
2143 CheckoutType::FanPlus
2144 );
2145 assert!("bogus".parse::<CheckoutType>().is_err());
2146 }
2147
2148 #[test]
2149 fn moderation_action_type_round_trip() {
2150 assert_eq!(ModerationActionType::Warning.to_string(), "warning");
2151 assert_eq!(ModerationActionType::Suspension.to_string(), "suspension");
2152 assert_eq!(ModerationActionType::Termination.to_string(), "termination");
2153 assert_eq!(
2154 ModerationActionType::ContentRemoval.to_string(),
2155 "content_removal"
2156 );
2157 assert_eq!(
2158 "warning".parse::<ModerationActionType>().unwrap(),
2159 ModerationActionType::Warning
2160 );
2161 assert_eq!(
2162 "content_removal".parse::<ModerationActionType>().unwrap(),
2163 ModerationActionType::ContentRemoval
2164 );
2165 assert!("bogus".parse::<ModerationActionType>().is_err());
2166 }
2167
2168 /// Every `impl_str_enum!` enum that also derives serde must agree on its wire
2169 /// string across BOTH representations: the macro's Display/FromStr/sqlx form
2170 /// (`VARIANTS`) and serde's JSON form. These are defined in two places (the
2171 /// macro literal and the `#[serde(rename_all = ...)]` attribute), so they can
2172 /// silently diverge, a variant travels via Display today and via serde
2173 /// tomorrow, and the string disagrees with what is already in Postgres. This
2174 /// test drives every serde-deriving enum through its `VARIANTS` (which round
2175 /// through `FromStr`) and asserts serde emits the identical string, in both
2176 /// directions. Add every serde-deriving enum here.
2177 macro_rules! assert_serde_matches_wire {
2178 ($($enum:ty),+ $(,)?) => {{
2179 $(
2180 for wire in <$enum>::VARIANTS {
2181 let value: $enum = wire.parse().unwrap_or_else(|e| {
2182 panic!("{}: VARIANTS string {wire:?} does not parse: {e}", stringify!($enum))
2183 });
2184 let json = serde_json::to_string(&value).expect("serialize");
2185 assert_eq!(
2186 json,
2187 format!("\"{wire}\""),
2188 "{}: serde emits {json} but the DB/Display wire string is {wire:?} \
2189 (add or fix `#[serde(rename_all = ...)]`)",
2190 stringify!($enum)
2191 );
2192 let back: $enum = serde_json::from_str(&json).unwrap_or_else(|e| {
2193 panic!("{}: serde cannot round-trip {json}: {e}", stringify!($enum))
2194 });
2195 assert_eq!(
2196 back.to_string(),
2197 *wire,
2198 "{}: serde deserialize of {json} disagrees with Display",
2199 stringify!($enum)
2200 );
2201 }
2202 )+
2203 }};
2204 }
2205
2206 #[test]
2207 fn serde_repr_matches_db_wire_repr_for_every_enum() {
2208 assert_serde_matches_wire!(
2209 DiscountType,
2210 CodePurpose,
2211 WaitlistStatus,
2212 SelectionMethod,
2213 TransactionStatus,
2214 FollowTargetType,
2215 SubscriptionStatus,
2216 SyncBillingStatus,
2217 SyncEnforcementMode,
2218 Visibility,
2219 GitRepoKind,
2220 ProjectRole,
2221 SyncOperation,
2222 SyncPlatform,
2223 FileScanStatus,
2224 InsertionPosition,
2225 AppealDecision,
2226 DiscoverSort,
2227 ItemType,
2228 IssueStatus,
2229 ReportTargetType,
2230 ReportType,
2231 ReportStatus,
2232 CreatorTier,
2233 AiTier,
2234 ProjectFeature,
2235 ProjectType,
2236 BuildStatus,
2237 PricingKind,
2238 MailingListType,
2239 ImportSource,
2240 ImportJobStatus,
2241 ModerationActionType,
2242 CheckoutType,
2243 );
2244 }
2245 }
2246