Skip to main content

max / makenotwork

Finish the hygiene pass: pricing and payments take their siblings The last two of the 54-file wave 0 inventory, both of the interleaved shape that made them invisible until witchbroom@3674d6d. pricing.rs declared `mod tests;` and then kept a second test module inline above its production code. That block joins pricing/tests.rs under its own banner; the sibling already opens with `use super::*`, so the block's copy of that line goes rather than nesting. 93 test attributes before, 93 after. payments/mod.rs's `test_provider` is a fixture, not a test module: fan_ops.rs builds a ScriptedProvider too. It moves to payments/test_provider.rs and stays `pub(crate)`, so `crate::payments::test_provider::ScriptedProvider` still resolves at the one call site outside it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-05 13:39 UTC
Signed with PGP, not checked
Commit: 53612422b24706dce9fbc0d25e94ad43fa4b85a7
Parent: 067984e
4 files changed, +483 insertions, -481 deletions
@@ -66,114 +66,6 @@
66 66 Ok(cents_f as i32)
67 67 }
68 68
69 - #[cfg(test)]
70 - mod parse_dollars_tests {
71 - use super::*;
72 -
73 - #[test]
74 - fn empty_or_missing_is_zero() {
75 - assert_eq!(parse_dollars_to_cents("Price", None).unwrap(), 0);
76 - assert_eq!(parse_dollars_to_cents("Price", Some("")).unwrap(), 0);
77 - assert_eq!(parse_dollars_to_cents("Price", Some(" ")).unwrap(), 0);
78 - }
79 -
80 - #[test]
81 - fn rounds_to_nearest_cent() {
82 - assert_eq!(parse_dollars_to_cents("Price", Some("9.99")).unwrap(), 999);
83 - assert_eq!(parse_dollars_to_cents("Price", Some("1.234")).unwrap(), 123);
84 - assert_eq!(parse_dollars_to_cents("Price", Some("1.236")).unwrap(), 124);
85 - }
86 -
87 - #[test]
88 - fn rejects_nan() {
89 - assert!(parse_dollars_to_cents("Price", Some("NaN")).is_err());
90 - assert!(parse_dollars_to_cents("Price", Some("nan")).is_err());
91 - }
92 -
93 - #[test]
94 - fn rejects_infinity() {
95 - assert!(parse_dollars_to_cents("Price", Some("inf")).is_err());
96 - assert!(parse_dollars_to_cents("Price", Some("Infinity")).is_err());
97 - }
98 -
99 - #[test]
100 - fn rejects_negative() {
101 - assert!(parse_dollars_to_cents("Price", Some("-1")).is_err());
102 - assert!(parse_dollars_to_cents("Price", Some("-0.01")).is_err());
103 - }
104 -
105 - #[test]
106 - fn rejects_overflow() {
107 - assert!(parse_dollars_to_cents("Price", Some("100000000000")).is_err());
108 - assert!(parse_dollars_to_cents("Price", Some("1e20")).is_err());
109 - }
110 -
111 - #[test]
112 - fn rejects_garbage() {
113 - assert!(parse_dollars_to_cents("Price", Some("abc")).is_err());
114 - assert!(parse_dollars_to_cents("Price", Some("free")).is_err());
115 - }
116 -
117 - #[test]
118 - fn strips_clipboard_decoration() {
119 - // Clipboard pastes from invoices / price lists shouldn't 422.
120 - assert_eq!(parse_dollars_to_cents("Price", Some("$5")).unwrap(), 500);
121 - assert_eq!(
122 - parse_dollars_to_cents("Price", Some("1,000")).unwrap(),
123 - 100_000
124 - );
125 - assert_eq!(
126 - parse_dollars_to_cents("Price", Some("$ 1,250.00")).unwrap(),
127 - 125_000
128 - );
129 - assert_eq!(
130 - parse_dollars_to_cents("Price", Some(" $9.99 ")).unwrap(),
131 - 999
132 - );
133 - // Decoration-only input still parses as garbage (no digits → fails)
134 - assert!(parse_dollars_to_cents("Price", Some("$$")).is_err());
135 - }
136 -
137 - // `validate_dollars_f64` is the JSON-handler entry point and had no test of
138 - // its own: every case above reached it only through the string parser, so
139 - // mutation testing found all twelve of its mutants alive (Phase 0 run,
140 - // 2026-08-16). The boundary cases are the point — an off-by-one on the
141 - // overflow guard is the difference between a $21,474,836.47 price and a
142 - // wrapped negative one.
143 -
144 - #[test]
145 - fn validate_f64_rejects_non_finite() {
146 - assert!(validate_dollars_f64("Price", f64::NAN).is_err());
147 - assert!(validate_dollars_f64("Price", f64::INFINITY).is_err());
148 - assert!(validate_dollars_f64("Price", f64::NEG_INFINITY).is_err());
149 - }
150 -
151 - #[test]
152 - fn validate_f64_rejects_negative_but_accepts_zero() {
153 - assert!(validate_dollars_f64("Price", -0.01).is_err());
154 - assert!(validate_dollars_f64("Price", -1.0).is_err());
155 - assert_eq!(validate_dollars_f64("Price", 0.0).unwrap(), 0);
156 - }
157 -
158 - #[test]
159 - fn validate_f64_multiplies_by_a_hundred_and_rounds() {
160 - assert_eq!(validate_dollars_f64("Price", 9.99).unwrap(), 999);
161 - assert_eq!(validate_dollars_f64("Price", 1.234).unwrap(), 123);
162 - assert_eq!(validate_dollars_f64("Price", 1.236).unwrap(), 124);
163 - assert_eq!(validate_dollars_f64("Price", 2.0).unwrap(), 200);
164 - }
165 -
166 - #[test]
167 - fn validate_f64_overflow_guard_is_inclusive_at_i32_max_cents() {
168 - // Exactly `i32::MAX` cents is the largest representable price and must
169 - // be accepted; one cent more must not be.
170 - let at_max = f64::from(i32::MAX) / 100.0;
171 - assert_eq!(validate_dollars_f64("Price", at_max).unwrap(), i32::MAX);
172 - assert!(validate_dollars_f64("Price", at_max + 0.01).is_err());
173 - assert!(validate_dollars_f64("Price", 1e20).is_err());
174 - }
175 - }
176 -
177 69 /// Pre-fetched access state for a user viewing a priced resource.
178 70 ///
179 71 /// Routes populate this from DB lookups, then pass it to `PricingModel::can_access()`.
@@ -545,379 +545,7 @@
545 545 }
546 546
547 547 #[cfg(test)]
548 - pub(crate) mod test_provider {
549 - //! A crate-visible [`PaymentProvider`] double for lib tests.
550 - //!
551 - //! The integration suite already has `MockPaymentProvider`
552 - //! (`tests/harness/stripe.rs`), which is richer: it captures checkout
553 - //! sessions and signs webhooks. It lives in a separate test binary, so a
554 - //! `--lib` test cannot reach it, and this is deliberately the smaller
555 - //! thing. It answers the subscription-lifecycle calls and panics on
556 - //! everything else, which is enough to test the code that fans those out
557 - //! without a database, a router or a Stripe key.
558 - //!
559 - //! Implement a method here when a lib test needs it. Growing this toward
560 - //! the harness's copy would give the crate two mocks to keep in agreement,
561 - //! which is the imitation-oracle failure wiki `testing-posture` describes.
562 -
563 - use std::collections::HashSet;
564 - use std::sync::Mutex;
565 -
566 - use super::*;
567 -
568 - /// Records every subscription op it is asked for, and fails the ones whose
569 - /// subscription id was listed as failing.
570 - #[derive(Default)]
571 - pub(crate) struct ScriptedProvider {
572 - failing: HashSet<String>,
573 - calls: Mutex<Vec<(&'static str, String)>>,
574 - }
575 -
576 - impl ScriptedProvider {
577 - /// Every call succeeds.
578 - pub(crate) fn healthy() -> Self {
579 - Self::default()
580 - }
581 -
582 - /// Every call succeeds except those naming one of `sub_ids`.
583 - pub(crate) fn failing(sub_ids: impl IntoIterator<Item = &'static str>) -> Self {
584 - Self {
585 - failing: sub_ids.into_iter().map(str::to_owned).collect(),
586 - calls: Mutex::new(Vec::new()),
587 - }
588 - }
589 -
590 - /// `(op, subscription id)` in the order they were applied.
591 - pub(crate) fn calls(&self) -> Vec<(&'static str, String)> {
592 - self.calls
593 - .lock()
594 - .expect("no test panics while holding this")
595 - .clone()
596 - }
597 -
598 - fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> {
599 - self.calls
600 - .lock()
601 - .expect("no test panics while holding this")
602 - .push((op, sub_id.to_string()));
603 - if self.failing.contains(sub_id) {
604 - return Err(crate::error::AppError::BadRequest(format!(
605 - "scripted failure for {sub_id}"
606 - )));
607 - }
608 - Ok(())
609 - }
610 - }
611 -
612 - /// The methods no lib test drives yet. A call is a bug in the test, not a
613 - /// condition to handle, so it panics rather than returning an error the
614 - /// code under test would quietly count as a Stripe failure.
615 - macro_rules! unused {
616 - ($($name:ident),+ $(,)?) => {
617 - $(
618 - #[allow(unused_variables)]
619 - fn $name(&self) -> ! {
620 - unimplemented!(
621 - "ScriptedProvider::{} is not implemented; add it if a lib test needs it",
622 - stringify!($name)
623 - )
624 - }
625 - )+
626 - };
627 - }
628 -
629 - impl ScriptedProvider {
630 - unused!(
631 - create_checkout_session,
632 - create_guest_checkout_session,
633 - create_subscription_checkout_session,
634 - create_tip_checkout_session,
635 - create_fan_plus_checkout_session,
636 - create_creator_tier_checkout_session,
637 - create_synckit_app_sub_checkout_session,
638 - create_cart_checkout_session,
639 - create_connect_account,
640 - create_account_link,
641 - fetch_account,
642 - create_subscription_product_and_price,
643 - get_balance,
644 - cancel_platform_subscription,
645 - set_platform_cancel_at_period_end,
646 - create_billing_portal_session,
647 - create_refund_for_transaction,
648 - create_platform_credit_transfer,
649 - create_platform_credit_reversal,
650 - verify_webhook,
651 - verify_webhook_v2,
652 - normalize_webhook,
653 - create_synckit_customer,
654 - create_synckit_subscription,
655 - update_synckit_subscription_price,
656 - update_synckit_app_sub_price,
657 - cancel_synckit_subscription,
658 - create_synckit_billing_portal,
659 - );
660 - }
661 -
662 - #[async_trait::async_trait]
663 - impl PaymentProvider for ScriptedProvider {
664 - // ── what the fan-out drives ──
665 -
666 - async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
667 - self.record("pause", sub)
668 - }
669 -
670 - async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
671 - self.record("resume", sub)
672 - }
673 -
674 - async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
675 - self.record("cancel", sub)
676 - }
677 -
678 - async fn set_cancel_at_period_end(
679 - &self,
680 - sub: &str,
681 - _account: &str,
682 - cancel: bool,
683 - ) -> crate::error::Result<()> {
684 - self.record(
685 - if cancel {
686 - "set_cancel_at_period_end"
687 - } else {
688 - "clear_cancel_at_period_end"
689 - },
690 - sub,
691 - )
692 - }
693 -
694 - // ── everything else ──
695 -
696 - async fn create_checkout_session(
697 - &self,
698 - _params: &CheckoutParams<'_>,
699 - ) -> crate::error::Result<CheckoutResult> {
700 - ScriptedProvider::create_checkout_session(self)
701 - }
702 - async fn create_guest_checkout_session(
703 - &self,
704 - _params: &GuestCheckoutParams<'_>,
705 - ) -> crate::error::Result<CheckoutResult> {
706 - ScriptedProvider::create_guest_checkout_session(self)
707 - }
708 - async fn create_subscription_checkout_session(
709 - &self,
710 - _params: &SubscriptionCheckoutParams<'_>,
711 - ) -> crate::error::Result<CheckoutResult> {
712 - ScriptedProvider::create_subscription_checkout_session(self)
713 - }
714 - async fn create_tip_checkout_session(
715 - &self,
716 - _params: &TipCheckoutParams<'_>,
717 - ) -> crate::error::Result<CheckoutResult> {
718 - ScriptedProvider::create_tip_checkout_session(self)
719 - }
720 - async fn create_fan_plus_checkout_session(
721 - &self,
722 - _price_id: &str,
723 - _user_id: crate::db::UserId,
724 - _success_url: &str,
725 - _cancel_url: &str,
726 - ) -> crate::error::Result<CheckoutResult> {
727 - ScriptedProvider::create_fan_plus_checkout_session(self)
728 - }
729 - async fn create_creator_tier_checkout_session(
730 - &self,
731 - _price_id: &str,
732 - _user_id: crate::db::UserId,
733 - _tier: &str,
734 - _success_url: &str,
735 - _cancel_url: &str,
736 - _trial_days: Option<i32>,
737 - ) -> crate::error::Result<CheckoutResult> {
738 - ScriptedProvider::create_creator_tier_checkout_session(self)
739 - }
740 - async fn create_synckit_app_sub_checkout_session(
741 - &self,
742 - _params: &SynckitAppSubCheckoutParams<'_>,
743 - ) -> crate::error::Result<CheckoutResult> {
744 - ScriptedProvider::create_synckit_app_sub_checkout_session(self)
745 - }
746 - async fn create_cart_checkout_session(
747 - &self,
748 - _params: &CartCheckoutParams<'_>,
749 - ) -> crate::error::Result<CheckoutResult> {
750 - ScriptedProvider::create_cart_checkout_session(self)
751 - }
752 - async fn create_connect_account(
753 - &self,
754 - _email: &str,
755 - ) -> crate::error::Result<ProviderAccountId> {
756 - ScriptedProvider::create_connect_account(self)
757 - }
758 - async fn get_balance(
759 - &self,
760 - _account_id: &str,
761 - _currency: crate::currency::SettlementCurrency,
762 - ) -> crate::error::Result<BalanceSummary> {
763 - ScriptedProvider::get_balance(self)
764 - }
765 - async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> {
766 - ScriptedProvider::cancel_platform_subscription(self)
767 - }
768 - async fn set_platform_cancel_at_period_end(
769 - &self,
770 - _sub: &str,
771 - _cancel: bool,
772 - ) -> crate::error::Result<()> {
773 - ScriptedProvider::set_platform_cancel_at_period_end(self)
774 - }
775 - fn verify_webhook(
776 - &self,
777 - _payload: &str,
778 - _signature: &str,
779 - ) -> crate::error::Result<UntypedEvent> {
780 - ScriptedProvider::verify_webhook(self)
781 - }
782 - fn verify_webhook_v2(
783 - &self,
784 - _payload: &str,
785 - _signature: &str,
786 - ) -> crate::error::Result<serde_json::Value> {
787 - ScriptedProvider::verify_webhook_v2(self)
788 - }
789 - fn normalize_webhook(&self, _event: UntypedEvent) -> crate::error::Result<MnwEvent> {
790 - ScriptedProvider::normalize_webhook(self)
791 - }
792 - async fn update_synckit_subscription_price(
793 - &self,
794 - _subscription_id: &str,
795 - _new_price_cents: i64,
796 - _app_name: &str,
797 - ) -> crate::error::Result<()> {
798 - ScriptedProvider::update_synckit_subscription_price(self)
799 - }
800 - async fn update_synckit_app_sub_price(
801 - &self,
802 - _subscription_id: &str,
803 - _new_price_cents: i64,
804 - _interval: SyncBillingInterval,
805 - _product_name: &str,
806 - ) -> crate::error::Result<()> {
807 - ScriptedProvider::update_synckit_app_sub_price(self)
808 - }
809 - async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
810 - ScriptedProvider::cancel_synckit_subscription(self)
811 - }
812 - }
813 -
814 - // ── The capability extensions, every one of them a panic: no lib test
815 - // drives an extension yet, and `ScriptedProvider` implements them so the
816 - // double stays wirable wherever a full provider is expected.
817 -
818 - #[async_trait::async_trait]
819 - impl HostedPortal for ScriptedProvider {
820 - async fn create_billing_portal_session(
821 - &self,
822 - _customer_id: &str,
823 - _return_url: &str,
824 - ) -> crate::error::Result<String> {
825 - ScriptedProvider::create_billing_portal_session(self)
826 - }
827 - async fn create_synckit_billing_portal(
828 - &self,
829 - _customer_id: &str,
830 - _return_url: &str,
831 - ) -> crate::error::Result<String> {
832 - ScriptedProvider::create_synckit_billing_portal(self)
833 - }
834 - }
835 -
836 - #[async_trait::async_trait]
837 - impl ConnectOnboarding for ScriptedProvider {
838 - async fn create_account_link(
839 - &self,
840 - _account_id: &str,
841 - _return_url: &str,
842 - _refresh_url: &str,
843 - ) -> crate::error::Result<String> {
844 - ScriptedProvider::create_account_link(self)
845 - }
846 - async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
847 - ScriptedProvider::fetch_account(self)
848 - }
849 - }
850 -
851 - #[async_trait::async_trait]
852 - impl Catalogue for ScriptedProvider {
853 - async fn create_subscription_product_and_price(
854 - &self,
855 - _connected_account_id: &str,
856 - _tier_name: &str,
857 - _tier_description: Option<&str>,
858 - _price_cents: i64,
859 - _currency: crate::currency::SettlementCurrency,
860 - ) -> crate::error::Result<(String, String)> {
861 - ScriptedProvider::create_subscription_product_and_price(self)
862 - }
863 - }
864 -
865 - #[async_trait::async_trait]
866 - impl Refundable for ScriptedProvider {
867 - async fn create_refund_for_transaction(
868 - &self,
869 - _payment_intent_id: &str,
870 - _connected_account_id: &str,
871 - _amount_cents: i64,
872 - _transaction_id: crate::db::TransactionId,
873 - ) -> crate::error::Result<()> {
874 - ScriptedProvider::create_refund_for_transaction(self)
875 - }
876 - }
877 -
878 - #[async_trait::async_trait]
879 - impl PlatformTransfers for ScriptedProvider {
880 - async fn create_platform_credit_transfer(
881 - &self,
882 - _connected_account_id: &str,
883 - _amount_cents: i64,
884 - _transaction_id: crate::db::TransactionId,
885 - _currency: crate::currency::SettlementCurrency,
886 - ) -> crate::error::Result<String> {
887 - ScriptedProvider::create_platform_credit_transfer(self)
888 - }
889 - async fn create_platform_credit_reversal(
890 - &self,
891 - _transfer_id: &str,
892 - _amount_cents: i64,
893 - _transaction_id: crate::db::TransactionId,
894 - ) -> crate::error::Result<()> {
895 - ScriptedProvider::create_platform_credit_reversal(self)
896 - }
897 - }
898 -
899 - #[async_trait::async_trait]
900 - impl CustodialCustomers for ScriptedProvider {
901 - async fn create_synckit_customer(
902 - &self,
903 - _developer_user_id: crate::db::UserId,
904 - _app_id: crate::db::SyncAppId,
905 - _email: &str,
906 - _app_name: &str,
907 - ) -> crate::error::Result<String> {
908 - ScriptedProvider::create_synckit_customer(self)
909 - }
910 - async fn create_synckit_subscription(
911 - &self,
912 - _customer_id: &str,
913 - _app_id: crate::db::SyncAppId,
914 - _app_name: &str,
915 - _price_cents: i64,
916 - ) -> crate::error::Result<SynckitSubResult> {
917 - ScriptedProvider::create_synckit_subscription(self)
918 - }
919 - }
920 - }
548 + pub(crate) mod test_provider;
921 549
922 550 #[async_trait::async_trait]
923 551 impl PaymentProvider for StripeClient {
@@ -847,3 +847,108 @@
847 847 proptest::prop_assert!(SubscriptionPricing.validate_amount(amount, SettlementCurrency::Usd).is_err());
848 848 }
849 849 }
850 +
851 + // ── parse_dollars_to_cents ──
852 +
853 + #[test]
854 + fn empty_or_missing_is_zero() {
855 + assert_eq!(parse_dollars_to_cents("Price", None).unwrap(), 0);
856 + assert_eq!(parse_dollars_to_cents("Price", Some("")).unwrap(), 0);
857 + assert_eq!(parse_dollars_to_cents("Price", Some(" ")).unwrap(), 0);
858 + }
859 +
860 + #[test]
861 + fn rounds_to_nearest_cent() {
862 + assert_eq!(parse_dollars_to_cents("Price", Some("9.99")).unwrap(), 999);
863 + assert_eq!(parse_dollars_to_cents("Price", Some("1.234")).unwrap(), 123);
864 + assert_eq!(parse_dollars_to_cents("Price", Some("1.236")).unwrap(), 124);
865 + }
866 +
867 + #[test]
868 + fn rejects_nan() {
869 + assert!(parse_dollars_to_cents("Price", Some("NaN")).is_err());
870 + assert!(parse_dollars_to_cents("Price", Some("nan")).is_err());
871 + }
872 +
873 + #[test]
874 + fn rejects_infinity() {
875 + assert!(parse_dollars_to_cents("Price", Some("inf")).is_err());
876 + assert!(parse_dollars_to_cents("Price", Some("Infinity")).is_err());
877 + }
878 +
879 + #[test]
880 + fn rejects_negative() {
881 + assert!(parse_dollars_to_cents("Price", Some("-1")).is_err());
882 + assert!(parse_dollars_to_cents("Price", Some("-0.01")).is_err());
883 + }
884 +
885 + #[test]
886 + fn rejects_overflow() {
887 + assert!(parse_dollars_to_cents("Price", Some("100000000000")).is_err());
888 + assert!(parse_dollars_to_cents("Price", Some("1e20")).is_err());
889 + }
890 +
891 + #[test]
892 + fn rejects_garbage() {
893 + assert!(parse_dollars_to_cents("Price", Some("abc")).is_err());
894 + assert!(parse_dollars_to_cents("Price", Some("free")).is_err());
895 + }
896 +
897 + #[test]
898 + fn strips_clipboard_decoration() {
899 + // Clipboard pastes from invoices / price lists shouldn't 422.
900 + assert_eq!(parse_dollars_to_cents("Price", Some("$5")).unwrap(), 500);
901 + assert_eq!(
902 + parse_dollars_to_cents("Price", Some("1,000")).unwrap(),
903 + 100_000
904 + );
905 + assert_eq!(
906 + parse_dollars_to_cents("Price", Some("$ 1,250.00")).unwrap(),
907 + 125_000
908 + );
909 + assert_eq!(
910 + parse_dollars_to_cents("Price", Some(" $9.99 ")).unwrap(),
911 + 999
912 + );
913 + // Decoration-only input still parses as garbage (no digits → fails)
914 + assert!(parse_dollars_to_cents("Price", Some("$$")).is_err());
915 + }
916 +
917 + // `validate_dollars_f64` is the JSON-handler entry point and had no test of
918 + // its own: every case above reached it only through the string parser, so
919 + // mutation testing found all twelve of its mutants alive (Phase 0 run,
920 + // 2026-08-16). The boundary cases are the point — an off-by-one on the
921 + // overflow guard is the difference between a $21,474,836.47 price and a
922 + // wrapped negative one.
923 +
924 + #[test]
925 + fn validate_f64_rejects_non_finite() {
926 + assert!(validate_dollars_f64("Price", f64::NAN).is_err());
927 + assert!(validate_dollars_f64("Price", f64::INFINITY).is_err());
928 + assert!(validate_dollars_f64("Price", f64::NEG_INFINITY).is_err());
929 + }
930 +
931 + #[test]
932 + fn validate_f64_rejects_negative_but_accepts_zero() {
933 + assert!(validate_dollars_f64("Price", -0.01).is_err());
934 + assert!(validate_dollars_f64("Price", -1.0).is_err());
935 + assert_eq!(validate_dollars_f64("Price", 0.0).unwrap(), 0);
936 + }
937 +
938 + #[test]
939 + fn validate_f64_multiplies_by_a_hundred_and_rounds() {
940 + assert_eq!(validate_dollars_f64("Price", 9.99).unwrap(), 999);
941 + assert_eq!(validate_dollars_f64("Price", 1.234).unwrap(), 123);
942 + assert_eq!(validate_dollars_f64("Price", 1.236).unwrap(), 124);
943 + assert_eq!(validate_dollars_f64("Price", 2.0).unwrap(), 200);
944 + }
945 +
946 + #[test]
947 + fn validate_f64_overflow_guard_is_inclusive_at_i32_max_cents() {
948 + // Exactly `i32::MAX` cents is the largest representable price and must
949 + // be accepted; one cent more must not be.
950 + let at_max = f64::from(i32::MAX) / 100.0;
951 + assert_eq!(validate_dollars_f64("Price", at_max).unwrap(), i32::MAX);
952 + assert!(validate_dollars_f64("Price", at_max + 0.01).is_err());
953 + assert!(validate_dollars_f64("Price", 1e20).is_err());
954 + }
@@ -1,0 +1,377 @@
1 + //! Scripted payment providers, for tests that need one that behaves on cue.
2 + //!
3 + //! `pub(crate)` rather than private: `payments/fan_ops.rs` builds a
4 + //! `ScriptedProvider` too, and a fixture two modules share is a module, not a
5 + //! copy in each.
6 +
7 + //! A crate-visible [`PaymentProvider`] double for lib tests.
8 + //!
9 + //! The integration suite already has `MockPaymentProvider`
10 + //! (`tests/harness/stripe.rs`), which is richer: it captures checkout
11 + //! sessions and signs webhooks. It lives in a separate test binary, so a
12 + //! `--lib` test cannot reach it, and this is deliberately the smaller
13 + //! thing. It answers the subscription-lifecycle calls and panics on
14 + //! everything else, which is enough to test the code that fans those out
15 + //! without a database, a router or a Stripe key.
16 + //!
17 + //! Implement a method here when a lib test needs it. Growing this toward
18 + //! the harness's copy would give the crate two mocks to keep in agreement,
19 + //! which is the imitation-oracle failure wiki `testing-posture` describes.
20 +
21 + use std::collections::HashSet;
22 + use std::sync::Mutex;
23 +
24 + use super::*;
25 +
26 + /// Records every subscription op it is asked for, and fails the ones whose
27 + /// subscription id was listed as failing.
28 + #[derive(Default)]
29 + pub(crate) struct ScriptedProvider {
30 + failing: HashSet<String>,
31 + calls: Mutex<Vec<(&'static str, String)>>,
32 + }
33 +
34 + impl ScriptedProvider {
35 + /// Every call succeeds.
36 + pub(crate) fn healthy() -> Self {
37 + Self::default()
38 + }
39 +
40 + /// Every call succeeds except those naming one of `sub_ids`.
41 + pub(crate) fn failing(sub_ids: impl IntoIterator<Item = &'static str>) -> Self {
42 + Self {
43 + failing: sub_ids.into_iter().map(str::to_owned).collect(),
44 + calls: Mutex::new(Vec::new()),
45 + }
46 + }
47 +
48 + /// `(op, subscription id)` in the order they were applied.
49 + pub(crate) fn calls(&self) -> Vec<(&'static str, String)> {
50 + self.calls
51 + .lock()
52 + .expect("no test panics while holding this")
53 + .clone()
54 + }
55 +
56 + fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> {
57 + self.calls
58 + .lock()
59 + .expect("no test panics while holding this")
60 + .push((op, sub_id.to_string()));
61 + if self.failing.contains(sub_id) {
62 + return Err(crate::error::AppError::BadRequest(format!(
63 + "scripted failure for {sub_id}"
64 + )));
65 + }
66 + Ok(())
67 + }
68 + }
69 +
70 + /// The methods no lib test drives yet. A call is a bug in the test, not a
71 + /// condition to handle, so it panics rather than returning an error the
72 + /// code under test would quietly count as a Stripe failure.
73 + macro_rules! unused {
74 + ($($name:ident),+ $(,)?) => {
75 + $(
76 + #[allow(unused_variables)]
77 + fn $name(&self) -> ! {
78 + unimplemented!(
79 + "ScriptedProvider::{} is not implemented; add it if a lib test needs it",
80 + stringify!($name)
81 + )
82 + }
83 + )+
84 + };
85 + }
86 +
87 + impl ScriptedProvider {
88 + unused!(
89 + create_checkout_session,
90 + create_guest_checkout_session,
91 + create_subscription_checkout_session,
92 + create_tip_checkout_session,
93 + create_fan_plus_checkout_session,
94 + create_creator_tier_checkout_session,
95 + create_synckit_app_sub_checkout_session,
96 + create_cart_checkout_session,
97 + create_connect_account,
98 + create_account_link,
99 + fetch_account,
100 + create_subscription_product_and_price,
101 + get_balance,
102 + cancel_platform_subscription,
103 + set_platform_cancel_at_period_end,
104 + create_billing_portal_session,
105 + create_refund_for_transaction,
106 + create_platform_credit_transfer,
107 + create_platform_credit_reversal,
108 + verify_webhook,
109 + verify_webhook_v2,
110 + normalize_webhook,
111 + create_synckit_customer,
112 + create_synckit_subscription,
113 + update_synckit_subscription_price,
114 + update_synckit_app_sub_price,
115 + cancel_synckit_subscription,
116 + create_synckit_billing_portal,
117 + );
118 + }
119 +
120 + #[async_trait::async_trait]
121 + impl PaymentProvider for ScriptedProvider {
122 + // ── what the fan-out drives ──
123 +
124 + async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
125 + self.record("pause", sub)
126 + }
127 +
128 + async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
129 + self.record("resume", sub)
130 + }
131 +
132 + async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
133 + self.record("cancel", sub)
134 + }
135 +
136 + async fn set_cancel_at_period_end(
137 + &self,
138 + sub: &str,
139 + _account: &str,
140 + cancel: bool,
141 + ) -> crate::error::Result<()> {
142 + self.record(
143 + if cancel {
144 + "set_cancel_at_period_end"
145 + } else {
146 + "clear_cancel_at_period_end"
147 + },
148 + sub,
149 + )
150 + }
151 +
152 + // ── everything else ──
153 +
154 + async fn create_checkout_session(
155 + &self,
156 + _params: &CheckoutParams<'_>,
157 + ) -> crate::error::Result<CheckoutResult> {
158 + ScriptedProvider::create_checkout_session(self)
159 + }
160 + async fn create_guest_checkout_session(
161 + &self,
162 + _params: &GuestCheckoutParams<'_>,
163 + ) -> crate::error::Result<CheckoutResult> {
164 + ScriptedProvider::create_guest_checkout_session(self)
165 + }
166 + async fn create_subscription_checkout_session(
167 + &self,
168 + _params: &SubscriptionCheckoutParams<'_>,
169 + ) -> crate::error::Result<CheckoutResult> {
170 + ScriptedProvider::create_subscription_checkout_session(self)
171 + }
172 + async fn create_tip_checkout_session(
173 + &self,
174 + _params: &TipCheckoutParams<'_>,
175 + ) -> crate::error::Result<CheckoutResult> {
176 + ScriptedProvider::create_tip_checkout_session(self)
177 + }
178 + async fn create_fan_plus_checkout_session(
179 + &self,
180 + _price_id: &str,
181 + _user_id: crate::db::UserId,
182 + _success_url: &str,
183 + _cancel_url: &str,
184 + ) -> crate::error::Result<CheckoutResult> {
185 + ScriptedProvider::create_fan_plus_checkout_session(self)
186 + }
187 + async fn create_creator_tier_checkout_session(
188 + &self,
189 + _price_id: &str,
190 + _user_id: crate::db::UserId,
191 + _tier: &str,
192 + _success_url: &str,
193 + _cancel_url: &str,
194 + _trial_days: Option<i32>,
195 + ) -> crate::error::Result<CheckoutResult> {
196 + ScriptedProvider::create_creator_tier_checkout_session(self)
197 + }
198 + async fn create_synckit_app_sub_checkout_session(
199 + &self,
200 + _params: &SynckitAppSubCheckoutParams<'_>,
201 + ) -> crate::error::Result<CheckoutResult> {
202 + ScriptedProvider::create_synckit_app_sub_checkout_session(self)
203 + }
204 + async fn create_cart_checkout_session(
205 + &self,
206 + _params: &CartCheckoutParams<'_>,
207 + ) -> crate::error::Result<CheckoutResult> {
208 + ScriptedProvider::create_cart_checkout_session(self)
209 + }
210 + async fn create_connect_account(
211 + &self,
212 + _email: &str,
213 + ) -> crate::error::Result<ProviderAccountId> {
214 + ScriptedProvider::create_connect_account(self)
215 + }
216 + async fn get_balance(
217 + &self,
218 + _account_id: &str,
219 + _currency: crate::currency::SettlementCurrency,
220 + ) -> crate::error::Result<BalanceSummary> {
221 + ScriptedProvider::get_balance(self)
222 + }
223 + async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> {
224 + ScriptedProvider::cancel_platform_subscription(self)
225 + }
226 + async fn set_platform_cancel_at_period_end(
227 + &self,
228 + _sub: &str,
229 + _cancel: bool,
230 + ) -> crate::error::Result<()> {
231 + ScriptedProvider::set_platform_cancel_at_period_end(self)
232 + }
233 + fn verify_webhook(
234 + &self,
235 + _payload: &str,
236 + _signature: &str,
237 + ) -> crate::error::Result<UntypedEvent> {
238 + ScriptedProvider::verify_webhook(self)
239 + }
240 + fn verify_webhook_v2(
241 + &self,
242 + _payload: &str,
243 + _signature: &str,
244 + ) -> crate::error::Result<serde_json::Value> {
245 + ScriptedProvider::verify_webhook_v2(self)
246 + }
247 + fn normalize_webhook(&self, _event: UntypedEvent) -> crate::error::Result<MnwEvent> {
248 + ScriptedProvider::normalize_webhook(self)
249 + }
250 + async fn update_synckit_subscription_price(
251 + &self,
252 + _subscription_id: &str,
253 + _new_price_cents: i64,
254 + _app_name: &str,
255 + ) -> crate::error::Result<()> {
256 + ScriptedProvider::update_synckit_subscription_price(self)
257 + }
258 + async fn update_synckit_app_sub_price(
259 + &self,
260 + _subscription_id: &str,
261 + _new_price_cents: i64,
262 + _interval: SyncBillingInterval,
263 + _product_name: &str,
264 + ) -> crate::error::Result<()> {
265 + ScriptedProvider::update_synckit_app_sub_price(self)
266 + }
267 + async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
268 + ScriptedProvider::cancel_synckit_subscription(self)
269 + }
270 + }
271 +
272 + // ── The capability extensions, every one of them a panic: no lib test
273 + // drives an extension yet, and `ScriptedProvider` implements them so the
274 + // double stays wirable wherever a full provider is expected.
275 +
276 + #[async_trait::async_trait]
277 + impl HostedPortal for ScriptedProvider {
278 + async fn create_billing_portal_session(
279 + &self,
280 + _customer_id: &str,
281 + _return_url: &str,
282 + ) -> crate::error::Result<String> {
283 + ScriptedProvider::create_billing_portal_session(self)
284 + }
285 + async fn create_synckit_billing_portal(
286 + &self,
287 + _customer_id: &str,
288 + _return_url: &str,
289 + ) -> crate::error::Result<String> {
290 + ScriptedProvider::create_synckit_billing_portal(self)
291 + }
292 + }
293 +
294 + #[async_trait::async_trait]
295 + impl ConnectOnboarding for ScriptedProvider {
296 + async fn create_account_link(
297 + &self,
298 + _account_id: &str,
299 + _return_url: &str,
300 + _refresh_url: &str,
301 + ) -> crate::error::Result<String> {
302 + ScriptedProvider::create_account_link(self)
303 + }
304 + async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
305 + ScriptedProvider::fetch_account(self)
306 + }
307 + }
308 +
309 + #[async_trait::async_trait]
310 + impl Catalogue for ScriptedProvider {
311 + async fn create_subscription_product_and_price(
312 + &self,
313 + _connected_account_id: &str,
314 + _tier_name: &str,
315 + _tier_description: Option<&str>,
316 + _price_cents: i64,
317 + _currency: crate::currency::SettlementCurrency,
318 + ) -> crate::error::Result<(String, String)> {
319 + ScriptedProvider::create_subscription_product_and_price(self)
320 + }
321 + }
322 +
323 + #[async_trait::async_trait]
324 + impl Refundable for ScriptedProvider {
325 + async fn create_refund_for_transaction(
326 + &self,
327 + _payment_intent_id: &str,
328 + _connected_account_id: &str,
329 + _amount_cents: i64,
330 + _transaction_id: crate::db::TransactionId,
331 + ) -> crate::error::Result<()> {
332 + ScriptedProvider::create_refund_for_transaction(self)
333 + }
334 + }
335 +
336 + #[async_trait::async_trait]
337 + impl PlatformTransfers for ScriptedProvider {
338 + async fn create_platform_credit_transfer(
339 + &self,
340 + _connected_account_id: &str,
341 + _amount_cents: i64,
342 + _transaction_id: crate::db::TransactionId,
343 + _currency: crate::currency::SettlementCurrency,
344 + ) -> crate::error::Result<String> {
345 + ScriptedProvider::create_platform_credit_transfer(self)
346 + }
347 + async fn create_platform_credit_reversal(
348 + &self,
349 + _transfer_id: &str,
350 + _amount_cents: i64,
351 + _transaction_id: crate::db::TransactionId,
352 + ) -> crate::error::Result<()> {
353 + ScriptedProvider::create_platform_credit_reversal(self)
354 + }
355 + }
356 +
357 + #[async_trait::async_trait]
358 + impl CustodialCustomers for ScriptedProvider {
359 + async fn create_synckit_customer(
360 + &self,
361 + _developer_user_id: crate::db::UserId,
362 + _app_id: crate::db::SyncAppId,
363 + _email: &str,
364 + _app_name: &str,
365 + ) -> crate::error::Result<String> {
366 + ScriptedProvider::create_synckit_customer(self)
367 + }
368 + async fn create_synckit_subscription(
369 + &self,
370 + _customer_id: &str,
371 + _app_id: crate::db::SyncAppId,
372 + _app_name: &str,
373 + _price_cents: i64,
374 + ) -> crate::error::Result<SynckitSubResult> {
375 + ScriptedProvider::create_synckit_subscription(self)
376 + }
377 + }