Skip to main content

max / makenotwork

Kill the pure-logic mutation survivors on the money path Phase 0's run measured `--lib` only, so its 337 survivors are two different things: 159 in functions that take a `PgPool` and cannot be reached from a unit test at all, and the rest in code that is pure and simply had no test. This is the second group, for the files where the gap was a missing assertion rather than a missing harness. pricing: `validate_dollars_f64` had no caller under test, and was a verbatim copy of the tail of `parse_dollars_to_cents`. It is now the one implementation, with the overflow boundary pinned at exactly i32::MAX cents. webhooks: the replay guard's timestamp comparison moves into `check_timestamp_skew`, because a test that reads the clock to reach the tolerance edge can never sit on it. `saturating_sub` drops the two ordering guards that no test could have justified. The narrow Stripe views (current_period, subscription_id, is_renewal, the refund pair, the expandable-id deserializer) get direct tests. checkout: the inline line-item builders restated a currency the constructor already sets, at the cost of a clone; the product name and the adaptive-pricing flag are asserted rather than assumed. tier_prices: `install_test_default` goes through `install_global` instead of writing the OnceLock itself, so the installer production depends on has a caller under test. synckit: the two 10 TiB ceilings are pinned against each other, and the checkout-type predicate for app subscriptions gets its false case. 2199 lib tests to 2224, clippy clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 20:08 UTC
Signed with PGP, not checked
Commit: 3da1107a9cf6a33d6e17a666ac9510e2572ee34b
Parent: c082b0f
7 files changed, +420 insertions, -35 deletions
@@ -41,25 +41,15 @@
41 41 let dollars: f64 = parse_src
42 42 .parse()
43 43 .map_err(|_| AppError::validation(format!("{field} must be a number")))?;
44 - if !dollars.is_finite() {
45 - return Err(AppError::validation(format!(
46 - "{field} must be a finite number"
47 - )));
48 - }
49 - if dollars < 0.0 {
50 - return Err(AppError::validation(format!("{field} cannot be negative")));
51 - }
52 - let cents_f = (dollars * 100.0).round();
53 - if cents_f > i32::MAX as f64 {
54 - return Err(AppError::validation(format!("{field} is too large")));
55 - }
56 - Ok(cents_f as i32)
44 + validate_dollars_f64(field, dollars)
57 45 }
58 46
59 47 /// Validate an already-parsed `f64` dollar amount and convert to `i32` cents.
60 48 ///
61 - /// For JSON API handlers where serde has already deserialized the dollars
62 - /// field. Same NaN/Inf/negative/overflow rejection as [`parse_dollars_to_cents`].
49 + /// The single place NaN, infinities, negatives and `i32`-cent overflow are
50 + /// rejected: [`parse_dollars_to_cents`] parses the string and then hands the
51 + /// `f64` here, so a JSON handler that already has the number gets the same
52 + /// answer as a form post of the same amount.
63 53 pub fn validate_dollars_f64(field: &str, dollars: f64) -> crate::error::Result<i32> {
64 54 if !dollars.is_finite() {
65 55 return Err(AppError::validation(format!(
@@ -143,6 +133,45 @@
143 133 // Decoration-only input still parses as garbage (no digits → fails)
144 134 assert!(parse_dollars_to_cents("Price", Some("$$")).is_err());
145 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 + }
146 175 }
147 176
148 177 /// Pre-fetched access state for a user viewing a priced resource.
@@ -86,6 +86,19 @@
86 86 use super::*;
87 87 use crate::db::SyncEnforcementMode::{Bulk, PerKey};
88 88
89 + // `validate_knobs`' bound tests in `routes/synckit/billing.rs` are written
90 + // against the constant itself, so they hold whatever it says. Pin the value
91 + // once, against the end-user cap it is documented to match: the two ceilings
92 + // drifting apart is the failure that would otherwise be invisible.
93 + #[test]
94 + fn developer_storage_ceiling_matches_the_end_user_one() {
95 + assert_eq!(MAX_STORAGE_GB, 10 * 1024, "10 TiB, in GiB");
96 + assert_eq!(
97 + MAX_STORAGE_GB * 1024 * 1024 * 1024,
98 + crate::payments::synckit_app_pricing::MAX_CAP_BYTES,
99 + );
100 + }
101 +
89 102 #[test]
90 103 fn bulk_mode_pricing() {
91 104 // 100 GB bulk → 100 × 3 = 300 cents.
@@ -179,15 +179,11 @@
179 179 // Path is relative to the crate root at test time.
180 180 let a = Assumptions::load("docs/business/assumptions.toml")
181 181 .expect("test setup: load canonical assumptions.toml");
182 - let tp = TierPrices::from_assumptions(&a);
183 - // Losing the race is the documented outcome rather than a failure:
184 - // concurrent tests install identical values.
185 - if GLOBAL.set(tp).is_err() {
186 - tracing::warn!(
187 - "TierPrices::install_test_default raced a concurrent install; the fixture is \
188 - ignored and the first table stays in force"
189 - );
190 - }
182 + // Through `install_global` rather than around it: losing the race is
183 + // the documented outcome in both paths (concurrent tests install
184 + // identical values), so a second write path here only meant the
185 + // installer the whole process depends on had no caller under test.
186 + TierPrices::from_assumptions(&a).install_global();
191 187 }
192 188 }
193 189
@@ -427,4 +423,71 @@
427 423 assert_eq!(cards[3].key, "everything");
428 424 assert_eq!(cards[1].standard_monthly, p.small_files_std);
429 425 }
426 +
427 + // The per-tier accessors back `CreatorTier::price_cents` and
428 + // `max_file_bytes`, and had no direct test: the tier tests assert
429 + // structural invariants (positive, monotone) that hold just as well if the
430 + // dollars-to-cents conversion or the tier-to-field mapping is wrong.
431 +
432 + #[test]
433 + fn price_cents_for_converts_dollars_to_cents_per_tier() {
434 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
435 + let p = TierPrices::from_assumptions(&a);
436 + for (tier, dollars) in [
437 + (CreatorTier::Basic, p.basic_std),
438 + (CreatorTier::SmallFiles, p.small_files_std),
439 + (CreatorTier::BigFiles, p.big_files_std),
440 + (CreatorTier::Everything, p.everything_std),
441 + ] {
442 + assert_eq!(
443 + p.price_cents_for(tier),
444 + dollars * 100,
445 + "{tier:?} price is the toml's dollars in cents"
446 + );
447 + }
448 + }
449 +
450 + #[test]
451 + fn byte_accessors_read_the_field_belonging_to_the_tier() {
452 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
453 + let p = TierPrices::from_assumptions(&a);
454 + for (tier, per_file, total) in [
455 + (
456 + CreatorTier::Basic,
457 + p.basic_per_file_bytes,
458 + p.basic_total_bytes,
459 + ),
460 + (
461 + CreatorTier::SmallFiles,
462 + p.small_files_per_file_bytes,
463 + p.small_files_total_bytes,
464 + ),
465 + (
466 + CreatorTier::BigFiles,
467 + p.big_files_per_file_bytes,
468 + p.big_files_total_bytes,
469 + ),
470 + (
471 + CreatorTier::Everything,
472 + p.everything_per_file_bytes,
473 + p.everything_total_bytes,
474 + ),
475 + ] {
476 + assert_eq!(p.max_file_bytes_for(tier), per_file, "{tier:?} per-file");
477 + assert_eq!(p.max_storage_bytes_for(tier), total, "{tier:?} total");
478 + }
479 + }
480 +
481 + #[test]
482 + fn the_installed_global_is_the_one_the_accessors_read() {
483 + // `install_test_default` goes through `install_global`, so this also
484 + // pins that the installer actually writes the slot: without it,
485 + // `global()` panics on the message below rather than answering.
486 + TierPrices::install_test_default();
487 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
488 + assert_eq!(
489 + TierPrices::global().price_cents_for(CreatorTier::SmallFiles),
490 + TierPrices::from_assumptions(&a).price_cents_for(CreatorTier::SmallFiles),
491 + );
492 + }
430 493 }
@@ -173,13 +173,12 @@
173 173 amount_cents: i64,
174 174 currency: SettlementCurrency,
175 175 ) -> CreateCheckoutSessionLineItems {
176 - let currency = currency.to_stripe();
177 176 CreateCheckoutSessionLineItems {
178 177 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
179 - currency: currency.clone(),
180 178 product_data: Some(ProductData::new(title.to_string())),
181 179 unit_amount: Some(amount_cents),
182 - ..CreateCheckoutSessionLineItemsPriceData::new(currency)
180 + // `new` takes the currency; restating it here only cost a clone.
181 + ..CreateCheckoutSessionLineItemsPriceData::new(currency.to_stripe())
183 182 }),
184 183 quantity: Some(1),
185 184 ..CreateCheckoutSessionLineItems::new()
@@ -206,7 +205,6 @@
206 205 ) -> CreateCheckoutSessionLineItems {
207 206 CreateCheckoutSessionLineItems {
208 207 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
209 - currency: Currency::USD,
210 208 product_data: Some(ProductData::new(product_name.to_string())),
211 209 unit_amount: Some(amount_cents),
212 210 recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(
@@ -669,6 +667,11 @@
669 667 item.price.is_none(),
670 668 "an inline item must not also reference a Stripe Price"
671 669 );
670 + assert_eq!(
671 + price.product_data.map(|p| p.name).as_deref(),
672 + Some("A Record"),
673 + "without product_data the buyer sees an unnamed line on the Stripe page"
674 + );
672 675 }
673 676
674 677 #[test]
@@ -697,6 +700,27 @@
697 700 "without `recurring` Stripe bills this once instead of every period"
698 701 );
699 702 assert_eq!(item.quantity, Some(1));
703 + assert_eq!(
704 + price.product_data.map(|p| p.name).as_deref(),
705 + Some("SyncKit Pro")
706 + );
707 + }
708 +
709 + // ── adaptive pricing ──
710 +
711 + #[test]
712 + fn adaptive_pricing_states_the_buyers_choice_rather_than_omitting_it() {
713 + // `None` is not a neutral value: it hands the decision to the Connect
714 + // dashboard setting, which is the failure the always-send rule exists
715 + // to prevent.
716 + assert_eq!(
717 + adaptive_pricing(ConversionChoice::AtCheckout).enabled,
718 + Some(true)
719 + );
720 + assert_eq!(
721 + adaptive_pricing(ConversionChoice::ByBuyersBank).enabled,
722 + Some(false)
723 + );
700 724 }
701 725
702 726 // ── automatic tax ──
@@ -611,6 +611,7 @@
611 611 ("subscription", CheckoutType::Subscription),
612 612 ("guest", CheckoutType::Guest),
613 613 ("cart", CheckoutType::Cart),
614 + ("synckit_app_sub", CheckoutType::SynckitAppSub),
614 615 ] {
615 616 let m = meta_of(&[("checkout_type", s)]);
616 617 assert_eq!(get_checkout_type(Some(&m)), Some(expected));
@@ -639,5 +640,27 @@
639 640 assert!(!is_subscription_checkout(meta));
640 641 assert!(!is_guest_checkout(meta));
641 642 assert!(!is_cart_checkout(meta));
643 + assert!(!is_synckit_app_sub_checkout(meta));
644 + }
645 +
646 + // The SynckitAppSub predicate had no test of its own and no false case:
647 + // both of its stub mutants and its comparison survived Phase 0. It decides
648 + // whether a settled session bills an end user for app sync, so answering
649 + // `true` for every session is the expensive direction.
650 + #[test]
651 + fn is_synckit_app_sub_checkout_matches_only_its_own_type() {
652 + let own = meta_of(&[("checkout_type", "synckit_app_sub")]);
653 + assert!(is_synckit_app_sub_checkout(Some(&own)));
654 +
655 + let other = meta_of(&[("checkout_type", "subscription")]);
656 + assert!(!is_synckit_app_sub_checkout(Some(&other)));
657 + assert!(!is_synckit_app_sub_checkout(None));
658 + }
659 +
660 + #[test]
661 + fn is_tip_checkout_false_for_another_type_and_for_no_metadata() {
662 + let other = meta_of(&[("checkout_type", "cart")]);
663 + assert!(!is_tip_checkout(Some(&other)));
664 + assert!(!is_tip_checkout(None));
642 665 }
643 666 }
@@ -136,6 +136,31 @@
136 136 assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err());
137 137 }
138 138
139 + #[test]
140 + fn cap_bounds_are_inclusive_at_both_ends() {
141 + // `gb(20_000)` above is far enough over the ceiling that an off-by-one
142 + // on either bound, or a `MAX_CAP_BYTES` that means something other than
143 + // 10 TiB, still rejects it. These sit on the two edges instead.
144 + assert_eq!(MAX_CAP_BYTES, gb(10 * 1024), "10 TiB, in bytes");
145 + assert_eq!(MIN_CAP_BYTES, gb(10), "10 GiB, in bytes");
146 + assert!(quote_price_cents(MAX_CAP_BYTES, SyncBillingInterval::Monthly).is_ok());
147 + assert!(quote_price_cents(MAX_CAP_BYTES + 1, SyncBillingInterval::Monthly).is_err());
148 + assert!(quote_price_cents(MIN_CAP_BYTES, SyncBillingInterval::Monthly).is_ok());
149 + assert!(quote_price_cents(MIN_CAP_BYTES - 1, SyncBillingInterval::Monthly).is_err());
150 + }
151 +
152 + #[test]
153 + fn interval_as_str_is_the_wire_form_parse_reads_back() {
154 + assert_eq!(SyncBillingInterval::Monthly.as_str(), "monthly");
155 + assert_eq!(SyncBillingInterval::Annual.as_str(), "annual");
156 + for interval in [SyncBillingInterval::Monthly, SyncBillingInterval::Annual] {
157 + assert_eq!(
158 + SyncBillingInterval::parse(interval.as_str()).unwrap(),
159 + interval
160 + );
161 + }
162 + }
163 +
139 164 #[test]
140 165 fn interval_parse_round_trip() {
141 166 assert_eq!(
@@ -481,6 +481,29 @@
481 481 pub object_type: String,
482 482 }
483 483
484 + /// Reject a webhook timestamp further than `tolerance` seconds from now, in
485 + /// either direction, naming which direction it was.
486 + ///
487 + /// Split out of [`verify_signature`] because it is the only part of the replay
488 + /// guard that is a decision rather than a clock read, and a test that has to
489 + /// call `SystemTime::now()` to reach the boundary cannot sit exactly on it.
490 + /// `saturating_sub` rather than a guarded subtraction: an ordering test around
491 + /// a subtraction that already cannot underflow has no observable effect, so it
492 + /// is a branch no test could ever justify.
493 + fn check_timestamp_skew(
494 + ts_secs: u64,
495 + now_secs: u64,
496 + tolerance: u64,
497 + ) -> std::result::Result<(), String> {
498 + if now_secs.saturating_sub(ts_secs) > tolerance {
499 + return Err("timestamp too old".to_string());
500 + }
501 + if ts_secs.saturating_sub(now_secs) > tolerance {
502 + return Err("timestamp too far in the future".to_string());
503 + }
504 + Ok(())
505 + }
506 +
484 507 /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
485 508 ///
486 509 /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
@@ -514,13 +537,11 @@
514 537 .duration_since(std::time::UNIX_EPOCH)
515 538 .map_err(|_| "system clock error")?
516 539 .as_secs();
517 - let tolerance = crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS;
518 - if now_secs > ts_secs && now_secs - ts_secs > tolerance {
519 - return Err("timestamp too old".to_string());
520 - }
521 - if ts_secs > now_secs && ts_secs - now_secs > tolerance {
522 - return Err("timestamp too far in the future".to_string());
523 - }
540 + check_timestamp_skew(
541 + ts_secs,
542 + now_secs,
543 + crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS,
544 + )?;
524 545
525 546 let signed_payload = format!("{timestamp}.{payload}");
526 547 let mut last_err = "signature mismatch".to_string();
@@ -814,4 +835,191 @@
814 835 let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err();
815 836 assert!(err.contains("mismatch"), "got: {err}");
816 837 }
838 +
839 + // --- check_timestamp_skew ---
840 + //
841 + // The tests above sign against the real clock, so they can only land near
842 + // the tolerance edge, never on it. Every mutant of the two comparisons
843 + // survived Phase 0 for that reason. These sit on the boundary exactly.
844 +
845 + const TOL: u64 = 300;
846 + const NOW: u64 = 1_700_000_000;
847 +
848 + #[test]
849 + fn skew_accepts_exactly_at_tolerance_in_both_directions() {
850 + assert!(check_timestamp_skew(NOW - TOL, NOW, TOL).is_ok());
851 + assert!(check_timestamp_skew(NOW + TOL, NOW, TOL).is_ok());
852 + assert!(check_timestamp_skew(NOW, NOW, TOL).is_ok());
853 + }
854 +
855 + #[test]
856 + fn skew_rejects_one_second_past_tolerance_in_both_directions() {
857 + let old = check_timestamp_skew(NOW - TOL - 1, NOW, TOL).unwrap_err();
858 + assert!(old.contains("too old"), "got: {old}");
859 + let future = check_timestamp_skew(NOW + TOL + 1, NOW, TOL).unwrap_err();
860 + assert!(future.contains("future"), "got: {future}");
861 + }
862 +
863 + #[test]
864 + fn skew_reads_the_two_directions_separately() {
865 + // A timestamp ahead of now is not stale, and one behind is not from the
866 + // future: the guard that mixes the two operands passes this only by
867 + // accident of small numbers, so keep the values epoch-sized.
868 + assert!(check_timestamp_skew(NOW + 60, NOW, TOL).is_ok());
869 + assert!(check_timestamp_skew(NOW - 60, NOW, TOL).is_ok());
870 + }
871 +
872 + // --- narrow view accessors ---
873 + //
874 + // Parsed from JSON rather than hand-built: these types exist to read
875 + // Stripe's payload shapes, so the shape is half of what is under test.
876 +
877 + fn subscription(json: serde_json::Value) -> SubscriptionView {
878 + serde_json::from_value(json).expect("subscription view parses")
879 + }
880 +
881 + fn invoice(json: serde_json::Value) -> InvoiceView {
882 + serde_json::from_value(json).expect("invoice view parses")
883 + }
884 +
885 + fn refund(json: serde_json::Value) -> RefundView {
886 + serde_json::from_value(json).expect("refund view parses")
887 + }
888 +
889 + #[test]
890 + fn current_period_reads_the_first_item() {
891 + let sub = subscription(json!({
892 + "id": "sub_1",
893 + "status": "active",
894 + "items": {"data": [
895 + {"current_period_start": 1_700_000_000i64, "current_period_end": 1_702_592_000i64},
896 + {"current_period_start": 1i64, "current_period_end": 2i64},
897 + ]},
898 + }));
899 + assert_eq!(
900 + sub.current_period(),
901 + Some((1_700_000_000, 1_702_592_000)),
902 + "the period comes from items.data[0], not from a later item"
903 + );
904 + }
905 +
906 + #[test]
907 + fn current_period_is_none_without_items() {
908 + let sub = subscription(json!({"id": "sub_2", "status": "active"}));
909 + assert_eq!(sub.current_period(), None);
910 + }
911 +
912 + #[test]
913 + fn subscription_id_prefers_the_legacy_field() {
914 + let inv = invoice(json!({
915 + "subscription": "sub_legacy",
916 + "parent": {"subscription_details": {"subscription": "sub_new"}},
917 + }));
918 + assert_eq!(inv.subscription_id(), Some("sub_legacy"));
919 + }
920 +
921 + #[test]
922 + fn subscription_id_falls_back_to_the_parent_path() {
923 + let inv = invoice(json!({
924 + "parent": {"subscription_details": {"subscription": "sub_new"}},
925 + }));
926 + assert_eq!(inv.subscription_id(), Some("sub_new"));
927 + }
928 +
929 + #[test]
930 + fn subscription_id_is_none_when_neither_path_carries_one() {
931 + assert_eq!(invoice(json!({})).subscription_id(), None);
932 + assert_eq!(invoice(json!({"parent": {}})).subscription_id(), None);
933 + assert_eq!(
934 + invoice(json!({"parent": {"subscription_details": {}}})).subscription_id(),
935 + None
936 + );
937 + }
938 +
939 + #[test]
940 + fn is_renewal_only_for_subscription_cycle() {
941 + assert!(invoice(json!({"billing_reason": "subscription_cycle"})).is_renewal());
942 + assert!(!invoice(json!({"billing_reason": "subscription_create"})).is_renewal());
943 + assert!(!invoice(json!({})).is_renewal());
944 + }
945 +
946 + #[test]
947 + fn expandable_id_reads_a_bare_string_or_an_object() {
948 + assert_eq!(
949 + invoice(json!({"subscription": "sub_bare"})).subscription,
950 + Some("sub_bare".to_string()),
951 + "the bare-id form"
952 + );
953 + assert_eq!(
954 + invoice(json!({"subscription": {"id": "sub_expanded", "object": "subscription"}}))
955 + .subscription,
956 + Some("sub_expanded".to_string()),
957 + "the expanded-object form"
958 + );
959 + }
960 +
961 + #[test]
962 + fn expandable_id_is_none_for_null_or_an_object_without_a_string_id() {
963 + assert_eq!(invoice(json!({"subscription": null})).subscription, None);
964 + assert_eq!(invoice(json!({"subscription": {}})).subscription, None);
965 + assert_eq!(
966 + invoice(json!({"subscription": {"id": 7}})).subscription,
967 + None,
968 + "a numeric id is not an id we can use"
969 + );
970 + assert_eq!(invoice(json!({"subscription": 7})).subscription, None);
971 + }
972 +
973 + #[test]
974 + fn refund_transaction_id_comes_from_metadata() {
975 + let tagged = refund(json!({
976 + "status": "succeeded",
977 + "metadata": {"mnw_transaction_id": "txn_9"},
978 + }));
979 + assert_eq!(tagged.mnw_transaction_id(), Some("txn_9"));
980 +
981 + let other_metadata = refund(json!({"metadata": {"something_else": "x"}}));
982 + assert_eq!(other_metadata.mnw_transaction_id(), None);
983 + assert_eq!(refund(json!({})).mnw_transaction_id(), None);
984 + }
985 +
986 + #[test]
987 + fn refund_is_succeeded_only_for_succeeded() {
988 + assert!(refund(json!({"status": "succeeded"})).is_succeeded());
989 + assert!(!refund(json!({"status": "pending"})).is_succeeded());
990 + assert!(!refund(json!({"status": "failed"})).is_succeeded());
991 + assert!(!refund(json!({})).is_succeeded());
992 + }
993 +
994 + #[test]
995 + fn charge_refund_data_needs_a_payment_intent() {
996 + let with_pi: ChargeView = serde_json::from_value(json!({
997 + "amount": 1000,
998 + "amount_refunded": 1000,
999 + "payment_intent": "pi_1",
1000 + }))
1001 + .unwrap();
1002 + let data = ChargeRefundData::from_view(with_pi).expect("a charge with an intent converts");
1003 + assert_eq!(data.payment_intent_id, "pi_1");
1004 + assert_eq!(data.amount, Cents::new(1000));
1005 + assert_eq!(data.amount_refunded, Cents::new(1000));
1006 +
1007 + let without_pi: ChargeView =
1008 + serde_json::from_value(json!({"amount": 1000, "amount_refunded": 0})).unwrap();
1009 + assert!(ChargeRefundData::from_view(without_pi).is_none());
1010 + }
1011 +
1012 + #[test]
1013 + fn settlement_currency_keeps_only_supported_codes() {
1014 + assert_eq!(
1015 + settlement_currency_of("acct_1", Some("usd")),
1016 + Some(crate::currency::SettlementCurrency::Usd)
1017 + );
1018 + assert_eq!(
1019 + settlement_currency_of("acct_2", Some("xyz")),
1020 + None,
1021 + "an unsupported currency leaves the stored one alone"
1022 + );
1023 + assert_eq!(settlement_currency_of("acct_3", None), None);
1024 + }
817 1025 }