Skip to main content

max / makenotwork

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