Skip to main content

max / makenotwork

22.5 KB · 712 lines History Blame Raw
1 //! Generic CSV → ImportPayload converter.
2 //!
3 //! Parses CSV data with a user-provided column mapping and produces an
4 //! `ImportPayload` containing subscribers and/or transactions depending
5 //! on which columns are mapped.
6
7 use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
8
9 use super::{ColumnMapping, ImportPayload, ImportSubscriber, ImportTransaction};
10 use crate::error::{AppError, Result};
11
12 /// Parse CSV bytes into an `ImportPayload` using the given column mapping.
13 ///
14 /// Rows with an email column produce `ImportSubscriber` entries.
15 /// Rows with an amount column produce `ImportTransaction` entries.
16 /// A single row can produce both if both columns are mapped.
17 #[tracing::instrument(skip_all, name = "import::parse_csv")]
18 pub fn parse_csv(bytes: &[u8], mapping: &ColumnMapping) -> Result<ImportPayload> {
19 // Strip UTF-8 BOM if present
20 let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
21 &bytes[3..]
22 } else {
23 bytes
24 };
25
26 let mut reader = csv::ReaderBuilder::new()
27 .flexible(true)
28 .trim(csv::Trim::All)
29 .from_reader(bytes);
30
31 let mut payload = ImportPayload::default();
32 let mut errors = Vec::new();
33
34 const MAX_IMPORT_ROWS: usize = 100_000;
35
36 for (row_idx, result) in reader.records().enumerate() {
37 if row_idx >= MAX_IMPORT_ROWS {
38 errors.push(format!("Import capped at {} rows", MAX_IMPORT_ROWS));
39 break;
40 }
41 let record = match result {
42 Ok(r) => r,
43 Err(e) => {
44 errors.push(format!("Row {}: parse error: {}", row_idx + 2, e));
45 continue;
46 }
47 };
48
49 // Extract email
50 let email = mapping
51 .email
52 .and_then(|i| record.get(i))
53 .map(|s| s.trim().to_lowercase())
54 .filter(|s| {
55 let parts: Vec<&str> = s.splitn(2, '@').collect();
56 parts.len() == 2
57 && !parts[0].is_empty()
58 && parts[1].contains('.')
59 && !parts[1].starts_with('.')
60 && !parts[1].ends_with('.')
61 });
62
63 // Extract name
64 let name = mapping
65 .name
66 .and_then(|i| record.get(i))
67 .map(sanitize_field)
68 .filter(|s| !s.is_empty());
69
70 // Extract amount (always in cents)
71 let amount_cents = mapping
72 .amount
73 .and_then(|i| record.get(i))
74 .and_then(parse_amount_cents);
75
76 // Extract date
77 let date = mapping
78 .date
79 .and_then(|i| record.get(i))
80 .and_then(parse_flexible_date);
81
82 // Extract item title
83 let item_title = mapping
84 .item_title
85 .and_then(|i| record.get(i))
86 .map(sanitize_field)
87 .filter(|s| !s.is_empty());
88
89 // Extract tier
90 let tier_name = mapping
91 .tier
92 .and_then(|i| record.get(i))
93 .map(sanitize_field)
94 .filter(|s| !s.is_empty());
95
96 // Extract status
97 let status = mapping
98 .status
99 .and_then(|i| record.get(i))
100 .map(|s| sanitize_field(s).to_lowercase())
101 .filter(|s| !s.is_empty());
102
103 // Build subscriber if we have email
104 if let Some(ref email) = email {
105 payload.subscribers.push(ImportSubscriber {
106 email: email.clone(),
107 name: name.clone(),
108 tier_name,
109 status: status.clone(),
110 joined_at: date,
111 lifetime_amount_cents: amount_cents,
112 stripe_customer_id: None,
113 });
114 }
115
116 // Build transaction if we have amount and email
117 if let (Some(cents), Some(email)) = (amount_cents, &email) {
118 payload.transactions.push(ImportTransaction {
119 buyer_email: email.clone(),
120 buyer_name: name,
121 item_title,
122 amount_cents: cents,
123 currency: "USD".into(),
124 date: date.unwrap_or_else(Utc::now),
125 status,
126 });
127 }
128 }
129
130 if payload.subscribers.is_empty() && payload.transactions.is_empty() {
131 return Err(AppError::validation(
132 "No valid rows found. Check your column mapping and CSV format.",
133 ));
134 }
135
136 if !errors.is_empty() {
137 tracing::warn!(error_count = errors.len(), "CSV parse had row-level errors");
138 }
139
140 Ok(payload)
141 }
142
143 /// Parse a currency string into cents.
144 ///
145 /// All values are interpreted as cents. A decimal point is treated as
146 /// dollars.cents (e.g. "12.50" → 1250). Whole numbers are cents as-is
147 /// (e.g. "500" → 500). Currency symbols and commas are stripped.
148 fn parse_amount_cents(s: &str) -> Option<i64> {
149 let cleaned: String = s
150 .trim()
151 .replace(['$', '', '£', '¥'], "")
152 .replace(',', "")
153 .trim()
154 .to_string();
155
156 if cleaned.is_empty() {
157 return None;
158 }
159
160 // Decimal point means dollars.cents
161 if let Some((dollars, cents_str)) = cleaned.split_once('.') {
162 let dollars: i64 = dollars.trim().parse().ok()?;
163 // Pad or truncate to exactly 2 decimal places
164 let cents_str = if cents_str.len() >= 2 {
165 &cents_str[..2]
166 } else {
167 cents_str
168 };
169 let cents: i64 = if cents_str.len() == 1 {
170 cents_str.parse::<i64>().ok()? * 10
171 } else {
172 cents_str.parse().ok()?
173 };
174 return Some(if dollars < 0 { dollars * 100 - cents } else { dollars * 100 + cents });
175 }
176
177 // Whole number — already in cents
178 cleaned.parse().ok()
179 }
180
181 /// Parse dates flexibly. Supports:
182 /// - ISO 8601: "2024-01-15T10:30:00Z", "2024-01-15"
183 /// - US format: "01/15/2024", "1/15/2024"
184 /// - EU format: "15.01.2024", "15/01/2024" (day > 12 disambiguates)
185 /// - Epoch seconds: "1705312200"
186 fn parse_flexible_date(s: &str) -> Option<DateTime<Utc>> {
187 let s = s.trim();
188 if s.is_empty() {
189 return None;
190 }
191
192 // ISO 8601 with time
193 if let Ok(dt) = s.parse::<DateTime<Utc>>() {
194 return Some(dt);
195 }
196
197 // ISO 8601 datetime without timezone
198 if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
199 return Some(ndt.and_utc());
200 }
201
202 // ISO 8601 date only
203 if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
204 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
205 }
206
207 // US format: MM/DD/YYYY
208 if let Ok(nd) = NaiveDate::parse_from_str(s, "%m/%d/%Y") {
209 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
210 }
211
212 // EU format with dots: DD.MM.YYYY
213 if let Ok(nd) = NaiveDate::parse_from_str(s, "%d.%m.%Y") {
214 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
215 }
216
217 // EU format with slashes: DD/MM/YYYY (try if day > 12)
218 if let Some((a, rest)) = s.split_once('/')
219 && let Some((b, c)) = rest.split_once('/')
220 && let (Ok(first), Ok(second), Ok(year)) =
221 (a.parse::<u32>(), b.parse::<u32>(), c.parse::<i32>())
222 && first > 12
223 && second <= 12
224 && let Some(nd) = NaiveDate::from_ymd_opt(year, second, first)
225 {
226 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
227 }
228
229 // Epoch seconds
230 if let Ok(ts) = s.parse::<i64>() {
231 return DateTime::from_timestamp(ts, 0);
232 }
233
234 None
235 }
236
237 /// Sanitize a CSV field value: trim whitespace, strip formula-triggering chars.
238 fn sanitize_field(s: &str) -> String {
239 let trimmed = s.trim();
240 if trimmed.starts_with(['=', '+', '-', '@']) {
241 format!("'{trimmed}")
242 } else {
243 trimmed.to_string()
244 }
245 }
246
247 #[cfg(test)]
248 mod tests {
249 use super::*;
250
251 #[test]
252 fn parse_amount_decimal_as_dollars_cents() {
253 assert_eq!(parse_amount_cents("$12.50"), Some(1250));
254 assert_eq!(parse_amount_cents("12.50"), Some(1250));
255 assert_eq!(parse_amount_cents("€10.00"), Some(1000));
256 assert_eq!(parse_amount_cents("£5.5"), Some(550));
257 assert_eq!(parse_amount_cents("$1,234.56"), Some(123456));
258 }
259
260 #[test]
261 fn parse_amount_whole_numbers_are_cents() {
262 assert_eq!(parse_amount_cents("500"), Some(500));
263 assert_eq!(parse_amount_cents("15000"), Some(15000));
264 assert_eq!(parse_amount_cents("0"), Some(0));
265 assert_eq!(parse_amount_cents("10"), Some(10));
266 }
267
268 #[test]
269 fn parse_amount_empty() {
270 assert_eq!(parse_amount_cents(""), None);
271 assert_eq!(parse_amount_cents(" "), None);
272 assert_eq!(parse_amount_cents("abc"), None);
273 }
274
275 #[test]
276 fn parse_date_iso8601() {
277 let dt = parse_flexible_date("2024-01-15").unwrap();
278 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
279 }
280
281 #[test]
282 fn parse_date_iso8601_with_time() {
283 let dt = parse_flexible_date("2024-01-15T10:30:00Z").unwrap();
284 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
285 }
286
287 #[test]
288 fn parse_date_us_format() {
289 let dt = parse_flexible_date("01/15/2024").unwrap();
290 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
291 }
292
293 #[test]
294 fn parse_date_eu_format_dots() {
295 let dt = parse_flexible_date("15.01.2024").unwrap();
296 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
297 }
298
299 #[test]
300 fn parse_date_eu_format_slashes_day_over_12() {
301 let dt = parse_flexible_date("25/01/2024").unwrap();
302 assert_eq!(dt.date_naive().to_string(), "2024-01-25");
303 }
304
305 #[test]
306 fn parse_date_epoch() {
307 let dt = parse_flexible_date("1705312200").unwrap();
308 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
309 }
310
311 #[test]
312 fn parse_date_empty() {
313 assert!(parse_flexible_date("").is_none());
314 assert!(parse_flexible_date(" ").is_none());
315 assert!(parse_flexible_date("not-a-date").is_none());
316 }
317
318 #[test]
319 fn parse_csv_basic_subscribers() {
320 let csv = b"email,name\nalice@test.com,Alice\nbob@test.com,Bob\n";
321 let mapping = ColumnMapping {
322 email: Some(0),
323 name: Some(1),
324 ..Default::default()
325 };
326 let payload = parse_csv(csv, &mapping).unwrap();
327 assert_eq!(payload.subscribers.len(), 2);
328 assert_eq!(payload.subscribers[0].email, "alice@test.com");
329 assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice"));
330 assert_eq!(payload.subscribers[1].email, "bob@test.com");
331 }
332
333 #[test]
334 fn parse_csv_with_bom() {
335 let mut csv = vec![0xEF, 0xBB, 0xBF]; // UTF-8 BOM
336 csv.extend_from_slice(b"email\nalice@test.com\n");
337 let mapping = ColumnMapping {
338 email: Some(0),
339 ..Default::default()
340 };
341 let payload = parse_csv(&csv, &mapping).unwrap();
342 assert_eq!(payload.subscribers.len(), 1);
343 }
344
345 #[test]
346 fn parse_csv_transactions() {
347 let csv = b"email,amount,date\nbuyer@test.com,$25.00,2024-01-15\n";
348 let mapping = ColumnMapping {
349 email: Some(0),
350 amount: Some(1),
351 date: Some(2),
352 ..Default::default()
353 };
354 let payload = parse_csv(csv, &mapping).unwrap();
355 assert_eq!(payload.subscribers.len(), 1);
356 assert_eq!(payload.transactions.len(), 1);
357 assert_eq!(payload.transactions[0].amount_cents, 2500);
358 }
359
360 #[test]
361 fn parse_csv_empty_returns_error() {
362 let csv = b"email,name\n";
363 let mapping = ColumnMapping {
364 email: Some(0),
365 name: Some(1),
366 ..Default::default()
367 };
368 assert!(parse_csv(csv, &mapping).is_err());
369 }
370
371 #[test]
372 fn parse_csv_skips_invalid_emails() {
373 let csv = b"email\nnot-an-email\nalice@test.com\n";
374 let mapping = ColumnMapping {
375 email: Some(0),
376 ..Default::default()
377 };
378 let payload = parse_csv(csv, &mapping).unwrap();
379 assert_eq!(payload.subscribers.len(), 1);
380 assert_eq!(payload.subscribers[0].email, "alice@test.com");
381 }
382
383 #[test]
384 fn parse_csv_mixed_currencies() {
385 let csv = b"email,amount\na@b.com,$10.00\nc@d.com,EUR20.50\ne@f.com,500\n";
386 let mapping = ColumnMapping {
387 email: Some(0),
388 amount: Some(1),
389 ..Default::default()
390 };
391 let payload = parse_csv(csv, &mapping).unwrap();
392 assert_eq!(payload.transactions.len(), 2); // EUR20.50 won't parse (no € symbol)
393 assert_eq!(payload.transactions[0].amount_cents, 1000);
394 }
395
396 #[test]
397 fn sanitize_field_strips_formula_chars() {
398 assert_eq!(sanitize_field("=SUM(A1)"), "'=SUM(A1)");
399 assert_eq!(sanitize_field("+cmd"), "'+cmd");
400 assert_eq!(sanitize_field("normal"), "normal");
401 }
402
403 // ── Edge cases: empty CSV / headers only ──
404
405 #[test]
406 fn parse_csv_completely_empty() {
407 let csv = b"";
408 let mapping = ColumnMapping {
409 email: Some(0),
410 ..Default::default()
411 };
412 assert!(parse_csv(csv, &mapping).is_err());
413 }
414
415 #[test]
416 fn parse_csv_only_headers_no_data_rows() {
417 let csv = b"email,name,amount\n";
418 let mapping = ColumnMapping {
419 email: Some(0),
420 name: Some(1),
421 amount: Some(2),
422 ..Default::default()
423 };
424 assert!(parse_csv(csv, &mapping).is_err());
425 }
426
427 #[test]
428 fn parse_csv_special_characters_in_fields() {
429 let csv = b"email,name\nalice@test.com,\"O'Brien, Jr.\"\nbob@test.com,\"Name with \"\"quotes\"\"\"\n";
430 let mapping = ColumnMapping {
431 email: Some(0),
432 name: Some(1),
433 ..Default::default()
434 };
435 let payload = parse_csv(csv, &mapping).unwrap();
436 assert_eq!(payload.subscribers.len(), 2);
437 assert_eq!(payload.subscribers[0].name.as_deref(), Some("O'Brien, Jr."));
438 }
439
440 // ── Column mapping edge cases ──
441
442 #[test]
443 fn parse_csv_mapped_column_index_beyond_row_length() {
444 let csv = b"email\nalice@test.com\n";
445 let mapping = ColumnMapping {
446 email: Some(0),
447 name: Some(5), // column 5 does not exist
448 ..Default::default()
449 };
450 let payload = parse_csv(csv, &mapping).unwrap();
451 assert_eq!(payload.subscribers.len(), 1);
452 assert!(payload.subscribers[0].name.is_none());
453 }
454
455 #[test]
456 fn parse_csv_extra_unmapped_columns_ignored() {
457 let csv = b"email,name,phone,address,zip\nalice@test.com,Alice,555-1234,123 Main,90210\n";
458 let mapping = ColumnMapping {
459 email: Some(0),
460 name: Some(1),
461 ..Default::default()
462 };
463 let payload = parse_csv(csv, &mapping).unwrap();
464 assert_eq!(payload.subscribers.len(), 1);
465 assert_eq!(payload.subscribers[0].email, "alice@test.com");
466 assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice"));
467 }
468
469 #[test]
470 fn parse_csv_no_email_mapping_no_subscribers() {
471 let csv = b"name,amount\nAlice,$10.00\n";
472 let mapping = ColumnMapping {
473 name: Some(0),
474 amount: Some(1),
475 ..Default::default()
476 };
477 // No email mapped means no subscribers or transactions
478 assert!(parse_csv(csv, &mapping).is_err());
479 }
480
481 // ── Price parsing edge cases ──
482
483 #[test]
484 fn parse_amount_dollar_sign_with_cents() {
485 assert_eq!(parse_amount_cents("$10.99"), Some(1099));
486 }
487
488 #[test]
489 fn parse_amount_free_or_zero() {
490 assert_eq!(parse_amount_cents("Free"), None);
491 assert_eq!(parse_amount_cents("$0.00"), Some(0));
492 assert_eq!(parse_amount_cents("0"), Some(0));
493 }
494
495 #[test]
496 fn parse_amount_yen_symbol() {
497 // ¥1500 whole number = 1500 cents
498 assert_eq!(parse_amount_cents("¥1500"), Some(1500));
499 }
500
501 #[test]
502 fn parse_amount_pound_with_pence() {
503 assert_eq!(parse_amount_cents("£9.99"), Some(999));
504 }
505
506 #[test]
507 fn parse_amount_trailing_whitespace() {
508 assert_eq!(parse_amount_cents(" $12.50 "), Some(1250));
509 }
510
511 #[test]
512 fn parse_amount_commas_in_thousands() {
513 assert_eq!(parse_amount_cents("$1,000.00"), Some(100000));
514 assert_eq!(parse_amount_cents("1,234,567.89"), Some(123456789));
515 }
516
517 #[test]
518 fn parse_amount_single_decimal_digit() {
519 assert_eq!(parse_amount_cents("5.5"), Some(550));
520 }
521
522 #[test]
523 fn parse_amount_three_decimal_digits_truncates() {
524 assert_eq!(parse_amount_cents("12.999"), Some(1299));
525 }
526
527 #[test]
528 fn parse_amount_negative_parses_as_negative_cents() {
529 assert_eq!(parse_amount_cents("-10.00"), Some(-1000));
530 assert_eq!(parse_amount_cents("-"), None);
531 }
532
533 // ── Date parsing edge cases ──
534
535 #[test]
536 fn parse_date_iso8601_datetime_no_timezone() {
537 let dt = parse_flexible_date("2024-06-15T14:30:00").unwrap();
538 assert_eq!(dt.date_naive().to_string(), "2024-06-15");
539 }
540
541 #[test]
542 fn parse_date_whitespace_padded() {
543 let dt = parse_flexible_date(" 2024-01-15 ").unwrap();
544 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
545 }
546
547 #[test]
548 fn parse_date_garbage_returns_none() {
549 assert!(parse_flexible_date("yesterday").is_none());
550 assert!(parse_flexible_date("2024/13/01").is_none()); // month 13
551 assert!(parse_flexible_date("foo bar baz").is_none());
552 }
553
554 #[test]
555 fn parse_date_ambiguous_slash_format_defaults_to_us() {
556 // 01/05/2024 - day <= 12, so it parses as US (Jan 5)
557 let dt = parse_flexible_date("01/05/2024").unwrap();
558 assert_eq!(dt.date_naive().to_string(), "2024-01-05");
559 }
560
561 #[test]
562 fn parse_date_epoch_zero() {
563 let dt = parse_flexible_date("0").unwrap();
564 assert_eq!(dt.date_naive().to_string(), "1970-01-01");
565 }
566
567 // ── Unicode in fields ──
568
569 #[test]
570 fn parse_csv_unicode_names_and_titles() {
571 let csv = "email,name,item_title\nalice@test.com,Alícia Müller,Über Spëcial Tïtle\nbob@test.com,\u{1F600} Bob,日本語タイトル\n";
572 let mapping = ColumnMapping {
573 email: Some(0),
574 name: Some(1),
575 item_title: Some(2),
576 ..Default::default()
577 };
578 let payload = parse_csv(csv.as_bytes(), &mapping).unwrap();
579 assert_eq!(payload.subscribers.len(), 2);
580 assert_eq!(
581 payload.subscribers[0].name.as_deref(),
582 Some("Alícia Müller")
583 );
584 assert_eq!(
585 payload.subscribers[1].name.as_deref(),
586 Some("\u{1F600} Bob")
587 );
588 }
589
590 #[test]
591 fn parse_csv_cjk_characters() {
592 let csv = "email,name\ntest@example.com,田中太郎\n";
593 let mapping = ColumnMapping {
594 email: Some(0),
595 name: Some(1),
596 ..Default::default()
597 };
598 let payload = parse_csv(csv.as_bytes(), &mapping).unwrap();
599 assert_eq!(payload.subscribers[0].name.as_deref(), Some("田中太郎"));
600 }
601
602 // ── Sanitization edge cases ──
603
604 #[test]
605 fn sanitize_field_all_formula_prefixes() {
606 assert_eq!(sanitize_field("@import"), "'@import");
607 assert_eq!(sanitize_field("-command"), "'-command");
608 // Tab and CR are whitespace, so trim() strips them before starts_with
609 // Only non-whitespace formula chars are caught after trimming
610 assert_eq!(sanitize_field("\tcmd"), "cmd");
611 }
612
613 #[test]
614 fn sanitize_field_trims_whitespace() {
615 assert_eq!(sanitize_field(" hello "), "hello");
616 assert_eq!(sanitize_field("\n\t data \n"), "data");
617 }
618
619 // ── Full CSV integration ──
620
621 #[test]
622 fn parse_csv_all_columns_mapped() {
623 let csv = b"email,name,amount,date,item,tier,status\n\
624 alice@test.com,Alice,$25.00,2024-01-15,My Album,gold,active\n";
625 let mapping = ColumnMapping {
626 email: Some(0),
627 name: Some(1),
628 amount: Some(2),
629 date: Some(3),
630 item_title: Some(4),
631 tier: Some(5),
632 status: Some(6),
633 };
634 let payload = parse_csv(csv, &mapping).unwrap();
635 assert_eq!(payload.subscribers.len(), 1);
636 assert_eq!(payload.transactions.len(), 1);
637
638 let sub = &payload.subscribers[0];
639 assert_eq!(sub.email, "alice@test.com");
640 assert_eq!(sub.name.as_deref(), Some("Alice"));
641 assert_eq!(sub.tier_name.as_deref(), Some("gold"));
642 assert_eq!(sub.status.as_deref(), Some("active"));
643 assert_eq!(sub.lifetime_amount_cents, Some(2500));
644
645 let txn = &payload.transactions[0];
646 assert_eq!(txn.buyer_email, "alice@test.com");
647 assert_eq!(txn.amount_cents, 2500);
648 assert_eq!(txn.item_title.as_deref(), Some("My Album"));
649 assert_eq!(txn.currency, "USD");
650 }
651
652 #[test]
653 fn parse_csv_email_lowercased() {
654 let csv = b"email\nALICE@TEST.COM\nBob@Example.Com\n";
655 let mapping = ColumnMapping {
656 email: Some(0),
657 ..Default::default()
658 };
659 let payload = parse_csv(csv, &mapping).unwrap();
660 assert_eq!(payload.subscribers[0].email, "alice@test.com");
661 assert_eq!(payload.subscribers[1].email, "bob@example.com");
662 }
663
664 #[test]
665 fn parse_csv_whitespace_only_name_treated_as_empty() {
666 let csv = b"email,name\nalice@test.com, \n";
667 let mapping = ColumnMapping {
668 email: Some(0),
669 name: Some(1),
670 ..Default::default()
671 };
672 let payload = parse_csv(csv, &mapping).unwrap();
673 assert!(payload.subscribers[0].name.is_none());
674 }
675
676 #[test]
677 fn parse_csv_transaction_requires_email() {
678 // Amount present but no email -> no transaction
679 let csv = b"amount\n$10.00\n";
680 let mapping = ColumnMapping {
681 amount: Some(0),
682 ..Default::default()
683 };
684 assert!(parse_csv(csv, &mapping).is_err());
685 }
686
687 #[test]
688 fn parse_csv_status_lowercased() {
689 let csv = b"email,status\nalice@test.com,ACTIVE\n";
690 let mapping = ColumnMapping {
691 email: Some(0),
692 status: Some(1),
693 ..Default::default()
694 };
695 let payload = parse_csv(csv, &mapping).unwrap();
696 assert_eq!(payload.subscribers[0].status.as_deref(), Some("active"));
697 }
698
699 #[test]
700 fn parse_csv_flexible_row_lengths() {
701 // Rows with different column counts (flexible mode)
702 let csv = b"email,name,extra\nalice@test.com,Alice\nbob@test.com,Bob,stuff,bonus\n";
703 let mapping = ColumnMapping {
704 email: Some(0),
705 name: Some(1),
706 ..Default::default()
707 };
708 let payload = parse_csv(csv, &mapping).unwrap();
709 assert_eq!(payload.subscribers.len(), 2);
710 }
711 }
712