max / makenotwork
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 | } |