Skip to main content

app_store_server_library/models/
helper_validation_utils.rs

1use std::fmt;
2
3use uuid::Uuid;
4
5/// Validation errors for Advanced Commerce API
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ValidationError {
8    InvalidCurrencyLength(usize),
9    InvalidCurrencyFormat(String),
10    EmptyTaxCode,
11    EmptyTransactionId,
12    EmptyTargetProductId,
13    UuidTooLong(usize),
14    NegativePrice(i64),
15    DescriptionTooLong(usize),
16    DisplayNameTooLong(usize),
17    SkuTooLong(usize),
18    InvalidPeriodCount(i32),
19    EmptyItems,
20}
21
22impl fmt::Display for ValidationError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            ValidationError::InvalidCurrencyLength(len) => {
26                write!(
27                    f,
28                    "Currency must be a 3-letter ISO 4217 code, got {} characters",
29                    len
30                )
31            }
32            ValidationError::InvalidCurrencyFormat(currency) => {
33                write!(
34                    f,
35                    "Currency must contain only uppercase letters: {}",
36                    currency
37                )
38            }
39            ValidationError::EmptyTaxCode => write!(f, "Tax code cannot be empty"),
40            ValidationError::EmptyTransactionId => write!(f, "Transaction ID cannot be empty"),
41            ValidationError::EmptyTargetProductId => write!(f, "Target Product ID cannot be empty"),
42            ValidationError::UuidTooLong(len) => {
43                write!(
44                    f,
45                    "UUID string representation cannot exceed {} characters, got {}",
46                    MAXIMUM_REQUEST_REFERENCE_ID_LENGTH, len
47                )
48            }
49            ValidationError::NegativePrice(price) => {
50                write!(f, "Price cannot be negative: {}", price)
51            }
52            ValidationError::DescriptionTooLong(len) => {
53                write!(
54                    f,
55                    "Description length ({}) exceeds maximum allowed ({})",
56                    len, MAXIMUM_DESCRIPTION_LENGTH
57                )
58            }
59            ValidationError::DisplayNameTooLong(len) => {
60                write!(
61                    f,
62                    "Display name length ({}) exceeds maximum allowed ({})",
63                    len, MAXIMUM_DISPLAY_NAME_LENGTH
64                )
65            }
66            ValidationError::SkuTooLong(len) => {
67                write!(
68                    f,
69                    "SKU length ({}) exceeds maximum allowed ({})",
70                    len, MAXIMUM_SKU_LENGTH
71                )
72            }
73            ValidationError::InvalidPeriodCount(period_count) => {
74                write!(
75                    f,
76                    "AdvancedCommercePeriod count must be between 1 and {} inclusive, got {}",
77                    MAXIMUM_PERIOD_COUNT, period_count
78                )
79            }
80            ValidationError::EmptyItems => write!(f, "Items list cannot be empty"),
81        }
82    }
83}
84
85impl std::error::Error for ValidationError {}
86
87/// Validation constants
88pub const CURRENCY_CODE_LENGTH: usize = 3;
89pub const MAXIMUM_STOREFRONT_LENGTH: usize = 10;
90pub const MAXIMUM_REQUEST_REFERENCE_ID_LENGTH: usize = 36;
91pub const MAXIMUM_DESCRIPTION_LENGTH: usize = 45;
92pub const MAXIMUM_DISPLAY_NAME_LENGTH: usize = 30;
93const MAXIMUM_SKU_LENGTH: usize = 128;
94/// The maximum number of billing periods a commitment may span.
95pub const MAXIMUM_PERIOD_COUNT: i32 = 12;
96
97/// Validates currency code according to ISO 4217 standard.
98///
99/// # Arguments
100/// * `currency` - The currency code to validate
101///
102/// # Returns
103/// * `Ok(String)` - The validated currency code
104/// * `Err(ValidationError)` - If validation fails
105pub fn validate_currency(currency: &str) -> Result<String, ValidationError> {
106    if currency.len() != CURRENCY_CODE_LENGTH {
107        return Err(ValidationError::InvalidCurrencyLength(currency.len()));
108    }
109
110    if !currency
111        .chars()
112        .all(|c| c.is_ascii_uppercase())
113    {
114        return Err(ValidationError::InvalidCurrencyFormat(currency.to_string()));
115    }
116
117    Ok(currency.to_string())
118}
119
120/// Validates tax code is not empty.
121///
122/// # Arguments
123/// * `tax_code` - The tax code to validate
124///
125/// # Returns
126/// * `Ok(String)` - The validated tax code
127/// * `Err(ValidationError)` - If validation fails
128pub fn validate_tax_code(tax_code: &str) -> Result<String, ValidationError> {
129    if tax_code.trim().is_empty() {
130        return Err(ValidationError::EmptyTaxCode);
131    }
132    Ok(tax_code.to_string())
133}
134
135/// Validates transaction ID is not empty.
136///
137/// # Arguments
138/// * `transaction_id` - The transaction ID to validate
139///
140/// # Returns
141/// * `Ok(String)` - The validated transaction ID
142/// * `Err(ValidationError)` - If validation fails
143pub fn validate_transaction_id(transaction_id: &str) -> Result<String, ValidationError> {
144    if transaction_id.trim().is_empty() {
145        return Err(ValidationError::EmptyTransactionId);
146    }
147    Ok(transaction_id.to_string())
148}
149
150/// Validates target product ID is not empty.
151///
152/// # Arguments
153/// * `target_product_id` - The target product ID to validate
154///
155/// # Returns
156/// * `Ok(String)` - The validated target product ID
157/// * `Err(ValidationError)` - If validation fails
158pub fn validate_target_product_id(target_product_id: &str) -> Result<String, ValidationError> {
159    if target_product_id.trim().is_empty() {
160        return Err(ValidationError::EmptyTargetProductId);
161    }
162    Ok(target_product_id.to_string())
163}
164
165/// Validates UUID string representation doesn't exceed maximum length.
166///
167/// # Arguments
168/// * `uuid` - The UUID to validate
169///
170/// # Returns
171/// * `Ok(Uuid)` - The validated UUID
172/// * `Err(ValidationError)` - If validation fails
173pub fn validate_uuid(uuid: &Uuid) -> Result<Uuid, ValidationError> {
174    let uuid_string = uuid.to_string();
175    if uuid_string.len() > MAXIMUM_REQUEST_REFERENCE_ID_LENGTH {
176        return Err(ValidationError::UuidTooLong(uuid_string.len()));
177    }
178    Ok(*uuid)
179}
180
181/// Validates price is non-negative.
182///
183/// # Arguments
184/// * `price` - The price to validate
185///
186/// # Returns
187/// * `Ok(i64)` - The validated price
188/// * `Err(ValidationError)` - If validation fails
189pub fn validate_price(price: i64) -> Result<i64, ValidationError> {
190    if price < 0 {
191        return Err(ValidationError::NegativePrice(price));
192    }
193    Ok(price)
194}
195
196/// Validates description does not exceed maximum length.
197///
198/// # Arguments
199/// * `description` - The description to validate
200///
201/// # Returns
202/// * `Ok(String)` - The validated description
203/// * `Err(ValidationError)` - If validation fails
204pub fn validate_description(description: &str) -> Result<String, ValidationError> {
205    let length = description.chars().count();
206    if length > MAXIMUM_DESCRIPTION_LENGTH {
207        return Err(ValidationError::DescriptionTooLong(length));
208    }
209    Ok(description.to_string())
210}
211
212/// Validates display name does not exceed maximum length.
213///
214/// # Arguments
215/// * `display_name` - The display name to validate
216///
217/// # Returns
218/// * `Ok(String)` - The validated display name
219/// * `Err(ValidationError)` - If validation fails
220pub fn validate_display_name(display_name: &str) -> Result<String, ValidationError> {
221    let length = display_name.chars().count();
222    if length > MAXIMUM_DISPLAY_NAME_LENGTH {
223        return Err(ValidationError::DisplayNameTooLong(length));
224    }
225    Ok(display_name.to_string())
226}
227
228/// Validates SKU does not exceed maximum length.
229///
230/// # Arguments
231/// * `sku` - The SKU to validate
232///
233/// # Returns
234/// * `Ok(String)` - The validated SKU
235/// * `Err(ValidationError)` - If validation fails
236pub fn validate_sku(sku: &str) -> Result<String, ValidationError> {
237    let length = sku.chars().count();
238    if length > MAXIMUM_SKU_LENGTH {
239        return Err(ValidationError::SkuTooLong(length));
240    }
241    Ok(sku.to_string())
242}
243
244/// Validates a period count, which must be between 1 and 12 inclusive.
245///
246/// # Arguments
247/// * `period_count` - The period count to validate
248///
249/// # Returns
250/// * `Ok(i32)` - The validated period count
251/// * `Err(ValidationError)` - If validation fails
252pub fn validate_period_count(period_count: i32) -> Result<i32, ValidationError> {
253    if !(1..=MAXIMUM_PERIOD_COUNT).contains(&period_count) {
254        return Err(ValidationError::InvalidPeriodCount(period_count));
255    }
256    Ok(period_count)
257}
258
259/// Validates that a list of items is not empty.
260///
261/// # Arguments
262/// * `items` - The list of items to validate
263///
264/// # Returns
265/// * `Ok(Vec<T>)` - The validated list of items
266/// * `Err(ValidationError)` - If the list is empty
267pub fn validate_items<T>(items: Vec<T>) -> Result<Vec<T>, ValidationError> {
268    if items.is_empty() {
269        return Err(ValidationError::EmptyItems);
270    }
271    Ok(items)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_validate_currency_valid() {
280        assert_eq!(validate_currency("USD").unwrap(), "USD");
281        assert_eq!(validate_currency("EUR").unwrap(), "EUR");
282        assert_eq!(validate_currency("GBP").unwrap(), "GBP");
283    }
284
285    #[test]
286    fn test_validate_currency_invalid_length() {
287        assert!(matches!(
288            validate_currency("US"),
289            Err(ValidationError::InvalidCurrencyLength(2))
290        ));
291        assert!(matches!(
292            validate_currency("USDD"),
293            Err(ValidationError::InvalidCurrencyLength(4))
294        ));
295    }
296
297    #[test]
298    fn test_validate_currency_invalid_format() {
299        assert!(matches!(
300            validate_currency("usd"),
301            Err(ValidationError::InvalidCurrencyFormat(_))
302        ));
303        assert!(matches!(
304            validate_currency("US1"),
305            Err(ValidationError::InvalidCurrencyFormat(_))
306        ));
307    }
308
309    #[test]
310    fn test_validate_price_valid() {
311        assert_eq!(validate_price(0).unwrap(), 0);
312        assert_eq!(validate_price(100).unwrap(), 100);
313        assert_eq!(validate_price(999999).unwrap(), 999999);
314    }
315
316    #[test]
317    fn test_validate_price_invalid() {
318        assert!(matches!(
319            validate_price(-1),
320            Err(ValidationError::NegativePrice(-1))
321        ));
322        assert!(matches!(
323            validate_price(-100),
324            Err(ValidationError::NegativePrice(-100))
325        ));
326    }
327
328    #[test]
329    fn test_validate_empty_strings() {
330        assert!(matches!(
331            validate_tax_code(""),
332            Err(ValidationError::EmptyTaxCode)
333        ));
334        assert!(matches!(
335            validate_tax_code("  "),
336            Err(ValidationError::EmptyTaxCode)
337        ));
338        assert!(validate_tax_code("ABC123").is_ok());
339    }
340
341    #[test]
342    fn test_validate_lengths() {
343        let long_description = "a".repeat(46);
344        assert!(matches!(
345            validate_description(&long_description),
346            Err(ValidationError::DescriptionTooLong(46))
347        ));
348
349        let ok_description = "a".repeat(45);
350        assert!(validate_description(&ok_description).is_ok());
351
352        let long_display_name = "a".repeat(31);
353        assert!(matches!(
354            validate_display_name(&long_display_name),
355            Err(ValidationError::DisplayNameTooLong(31))
356        ));
357
358        let ok_display_name = "a".repeat(30);
359        assert!(validate_display_name(&ok_display_name).is_ok());
360
361        let long_sku = "a".repeat(129);
362        assert!(matches!(
363            validate_sku(&long_sku),
364            Err(ValidationError::SkuTooLong(129))
365        ));
366
367        let ok_sku = "a".repeat(128);
368        assert!(validate_sku(&ok_sku).is_ok());
369    }
370
371    #[test]
372    fn test_validate_lengths_counts_characters_not_bytes() {
373        // Multi-byte characters must count as one each, matching Swift's String.count.
374        // "é" is 2 bytes, "日" is 3 bytes: a byte-based check would reject all of these.
375        let description = "é".repeat(MAXIMUM_DESCRIPTION_LENGTH);
376        assert_eq!(description.len(), MAXIMUM_DESCRIPTION_LENGTH * 2);
377        assert!(validate_description(&description).is_ok());
378
379        let display_name = "日".repeat(MAXIMUM_DISPLAY_NAME_LENGTH);
380        assert_eq!(display_name.len(), MAXIMUM_DISPLAY_NAME_LENGTH * 3);
381        assert!(validate_display_name(&display_name).is_ok());
382
383        let sku = "é".repeat(MAXIMUM_SKU_LENGTH);
384        assert!(validate_sku(&sku).is_ok());
385
386        // One character past the limit still fails, and reports the character count.
387        let long_description = "é".repeat(MAXIMUM_DESCRIPTION_LENGTH + 1);
388        assert!(matches!(
389            validate_description(&long_description),
390            Err(ValidationError::DescriptionTooLong(46))
391        ));
392
393        let long_display_name = "日".repeat(MAXIMUM_DISPLAY_NAME_LENGTH + 1);
394        assert!(matches!(
395            validate_display_name(&long_display_name),
396            Err(ValidationError::DisplayNameTooLong(31))
397        ));
398
399        let long_sku = "é".repeat(MAXIMUM_SKU_LENGTH + 1);
400        assert!(matches!(
401            validate_sku(&long_sku),
402            Err(ValidationError::SkuTooLong(129))
403        ));
404    }
405
406    #[test]
407    fn test_validate_uuid() {
408        let uuid = Uuid::new_v4();
409        assert_eq!(validate_uuid(&uuid).unwrap(), uuid);
410    }
411
412    #[test]
413    fn test_validate_period_count_accepts_boundaries() {
414        assert_eq!(validate_period_count(1).unwrap(), 1);
415        assert_eq!(
416            validate_period_count(MAXIMUM_PERIOD_COUNT).unwrap(),
417            MAXIMUM_PERIOD_COUNT
418        );
419        assert_eq!(validate_period_count(6).unwrap(), 6);
420    }
421
422    #[test]
423    fn test_validate_period_count_rejects_out_of_range() {
424        assert!(matches!(
425            validate_period_count(0),
426            Err(ValidationError::InvalidPeriodCount(0))
427        ));
428        assert!(matches!(
429            validate_period_count(13),
430            Err(ValidationError::InvalidPeriodCount(13))
431        ));
432        assert!(matches!(
433            validate_period_count(-1),
434            Err(ValidationError::InvalidPeriodCount(-1))
435        ));
436    }
437
438    #[test]
439    fn test_validate_items() {
440        let valid_list = vec!["item1"];
441        assert_eq!(validate_items(valid_list.clone()).unwrap(), valid_list);
442
443        let empty_list: Vec<&str> = vec![];
444        assert!(matches!(
445            validate_items(empty_list),
446            Err(ValidationError::EmptyItems)
447        ));
448    }
449}