Skip to main content

max / makenotwork

27.8 KB · 840 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 {MAX_IMPORT_ROWS} 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 // Reject local parts starting with a spreadsheet formula
59 // trigger (=, +, -, @). Such addresses aren't valid anyway,
60 // and rejecting them keeps the stored email, which is what a
61 // later CSV export re-emits, from carrying a live formula
62 // (the email column is used for sending, so it can't be
63 // quote-prefixed like the other sanitized fields).
64 && !parts[0].starts_with(['=', '+', '-', '@'])
65 && parts[1].contains('.')
66 && !parts[1].starts_with('.')
67 && !parts[1].ends_with('.')
68 });
69
70 // Extract name
71 let name = mapping
72 .name
73 .and_then(|i| record.get(i))
74 .map(sanitize_field)
75 .filter(|s| !s.is_empty());
76
77 // Extract amount (always in cents)
78 let amount_cents = mapping
79 .amount
80 .and_then(|i| record.get(i))
81 .and_then(parse_amount_cents);
82
83 // Extract date
84 let date = mapping
85 .date
86 .and_then(|i| record.get(i))
87 .and_then(parse_flexible_date);
88
89 // Extract item title
90 let item_title = mapping
91 .item_title
92 .and_then(|i| record.get(i))
93 .map(sanitize_field)
94 .filter(|s| !s.is_empty());
95
96 // Extract tier
97 let tier_name = mapping
98 .tier
99 .and_then(|i| record.get(i))
100 .map(sanitize_field)
101 .filter(|s| !s.is_empty());
102
103 // Extract status
104 let status = mapping
105 .status
106 .and_then(|i| record.get(i))
107 .map(|s| sanitize_field(s).to_lowercase())
108 .filter(|s| !s.is_empty());
109
110 // Build subscriber if we have email
111 if let Some(ref email) = email {
112 payload.subscribers.push(ImportSubscriber {
113 email: email.clone(),
114 name: name.clone(),
115 tier_name,
116 status: status.clone(),
117 joined_at: date,
118 lifetime_amount_cents: amount_cents,
119 stripe_customer_id: None,
120 });
121 }
122
123 // Build transaction if we have amount and email
124 if let (Some(cents), Some(email)) = (amount_cents, &email) {
125 payload.transactions.push(ImportTransaction {
126 buyer_email: email.clone(),
127 buyer_name: name,
128 item_title,
129 amount_cents: cents,
130 currency: "USD".into(),
131 date: date.unwrap_or_else(Utc::now),
132 status,
133 });
134 }
135 }
136
137 if payload.subscribers.is_empty() && payload.transactions.is_empty() {
138 return Err(AppError::validation(
139 "No valid rows found. Check your column mapping and CSV format.",
140 ));
141 }
142
143 if !errors.is_empty() {
144 tracing::warn!(error_count = errors.len(), "CSV parse had row-level errors");
145 }
146
147 Ok(payload)
148 }
149
150 /// Parse a currency string into cents.
151 ///
152 /// All values are interpreted as cents. A decimal point is treated as
153 /// dollars.cents (e.g. "12.50" → 1250). Whole numbers are cents as-is
154 /// (e.g. "500" → 500). Currency symbols and commas are stripped.
155 fn parse_amount_cents(s: &str) -> Option<i64> {
156 let cleaned: String = s
157 .trim()
158 .replace(['$', '', '£', '¥'], "")
159 .replace(',', "")
160 .trim()
161 .to_string();
162
163 if cleaned.is_empty() {
164 return None;
165 }
166
167 // Decimal point means dollars.cents
168 if let Some((dollars_str, cents_str)) = cleaned.split_once('.') {
169 let dollars_str = dollars_str.trim();
170 // A leading '-' carries the sign even when the whole-dollar part is zero:
171 // "-0.50" is -50 cents, not +50. `"-0".parse::<i64>()` yields 0 and
172 // silently drops the sign, so read negativity from the string itself.
173 let negative = dollars_str.starts_with('-');
174 let dollars: i64 = dollars_str.parse().ok()?;
175 // Pad or truncate to exactly 2 decimal places
176 let cents_str = if cents_str.len() >= 2 {
177 &cents_str[..2]
178 } else {
179 cents_str
180 };
181 let cents: i64 = if cents_str.len() == 1 {
182 cents_str.parse::<i64>().ok()? * 10
183 } else {
184 cents_str.parse().ok()?
185 };
186 let magnitude = dollars.abs() * 100 + cents;
187 return Some(if negative { -magnitude } else { magnitude });
188 }
189
190 // Whole number, already in cents
191 cleaned.parse().ok()
192 }
193
194 /// Parse dates flexibly. Supports:
195 /// - ISO 8601: "2024-01-15T10:30:00Z", "2024-01-15"
196 /// - US format: "01/15/2024", "1/15/2024"
197 /// - EU format: "15.01.2024", "15/01/2024" (day > 12 disambiguates)
198 /// - Epoch seconds: "1705312200"
199 fn parse_flexible_date(s: &str) -> Option<DateTime<Utc>> {
200 let s = s.trim();
201 if s.is_empty() {
202 return None;
203 }
204
205 // ISO 8601 with time
206 if let Ok(dt) = s.parse::<DateTime<Utc>>() {
207 return Some(dt);
208 }
209
210 // ISO 8601 datetime without timezone
211 if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
212 return Some(ndt.and_utc());
213 }
214
215 // ISO 8601 date only
216 if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
217 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
218 }
219
220 // US format: MM/DD/YYYY
221 if let Ok(nd) = NaiveDate::parse_from_str(s, "%m/%d/%Y") {
222 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
223 }
224
225 // EU format with dots: DD.MM.YYYY
226 if let Ok(nd) = NaiveDate::parse_from_str(s, "%d.%m.%Y") {
227 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
228 }
229
230 // EU format with slashes: DD/MM/YYYY (try if day > 12)
231 if let Some((a, rest)) = s.split_once('/')
232 && let Some((b, c)) = rest.split_once('/')
233 && let (Ok(first), Ok(second), Ok(year)) =
234 (a.parse::<u32>(), b.parse::<u32>(), c.parse::<i32>())
235 && first > 12
236 && second <= 12
237 && let Some(nd) = NaiveDate::from_ymd_opt(year, second, first)
238 {
239 return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
240 }
241
242 // Epoch seconds
243 if let Ok(ts) = s.parse::<i64>() {
244 return DateTime::from_timestamp(ts, 0);
245 }
246
247 None
248 }
249
250 /// Sanitize a CSV field value: trim whitespace, strip formula-triggering chars.
251 fn sanitize_field(s: &str) -> String {
252 let trimmed = s.trim();
253 if trimmed.starts_with(['=', '+', '-', '@']) {
254 format!("'{trimmed}")
255 } else {
256 trimmed.to_string()
257 }
258 }
259
260 #[cfg(test)]
261 mod tests {
262 use super::*;
263 use std::fmt::Write as _;
264
265 #[test]
266 fn parse_amount_decimal_as_dollars_cents() {
267 assert_eq!(parse_amount_cents("$12.50"), Some(1250));
268 assert_eq!(parse_amount_cents("12.50"), Some(1250));
269 assert_eq!(parse_amount_cents("€10.00"), Some(1000));
270 assert_eq!(parse_amount_cents("£5.5"), Some(550));
271 assert_eq!(parse_amount_cents("$1,234.56"), Some(123_456));
272 }
273
274 #[test]
275 fn parse_amount_whole_numbers_are_cents() {
276 assert_eq!(parse_amount_cents("500"), Some(500));
277 assert_eq!(parse_amount_cents("15000"), Some(15000));
278 assert_eq!(parse_amount_cents("0"), Some(0));
279 assert_eq!(parse_amount_cents("10"), Some(10));
280 }
281
282 #[test]
283 fn parse_amount_empty() {
284 assert_eq!(parse_amount_cents(""), None);
285 assert_eq!(parse_amount_cents(" "), None);
286 assert_eq!(parse_amount_cents("abc"), None);
287 }
288
289 #[test]
290 fn parse_date_iso8601() {
291 let dt = parse_flexible_date("2024-01-15").unwrap();
292 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
293 }
294
295 #[test]
296 fn parse_date_iso8601_with_time() {
297 let dt = parse_flexible_date("2024-01-15T10:30:00Z").unwrap();
298 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
299 }
300
301 #[test]
302 fn parse_date_us_format() {
303 let dt = parse_flexible_date("01/15/2024").unwrap();
304 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
305 }
306
307 #[test]
308 fn parse_date_eu_format_dots() {
309 let dt = parse_flexible_date("15.01.2024").unwrap();
310 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
311 }
312
313 #[test]
314 fn parse_date_eu_format_slashes_day_over_12() {
315 let dt = parse_flexible_date("25/01/2024").unwrap();
316 assert_eq!(dt.date_naive().to_string(), "2024-01-25");
317 }
318
319 #[test]
320 fn parse_date_epoch() {
321 let dt = parse_flexible_date("1705312200").unwrap();
322 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
323 }
324
325 #[test]
326 fn parse_date_empty() {
327 assert!(parse_flexible_date("").is_none());
328 assert!(parse_flexible_date(" ").is_none());
329 assert!(parse_flexible_date("not-a-date").is_none());
330 }
331
332 #[test]
333 fn parse_csv_basic_subscribers() {
334 let csv = b"email,name\nalice@test.com,Alice\nbob@test.com,Bob\n";
335 let mapping = ColumnMapping {
336 email: Some(0),
337 name: Some(1),
338 ..Default::default()
339 };
340 let payload = parse_csv(csv, &mapping).unwrap();
341 assert_eq!(payload.subscribers.len(), 2);
342 assert_eq!(payload.subscribers[0].email, "alice@test.com");
343 assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice"));
344 assert_eq!(payload.subscribers[1].email, "bob@test.com");
345 }
346
347 #[test]
348 fn parse_csv_with_bom() {
349 let mut csv = vec![0xEF, 0xBB, 0xBF]; // UTF-8 BOM
350 csv.extend_from_slice(b"email\nalice@test.com\n");
351 let mapping = ColumnMapping {
352 email: Some(0),
353 ..Default::default()
354 };
355 let payload = parse_csv(&csv, &mapping).unwrap();
356 assert_eq!(payload.subscribers.len(), 1);
357 }
358
359 #[test]
360 fn parse_csv_transactions() {
361 let csv = b"email,amount,date\nbuyer@test.com,$25.00,2024-01-15\n";
362 let mapping = ColumnMapping {
363 email: Some(0),
364 amount: Some(1),
365 date: Some(2),
366 ..Default::default()
367 };
368 let payload = parse_csv(csv, &mapping).unwrap();
369 assert_eq!(payload.subscribers.len(), 1);
370 assert_eq!(payload.transactions.len(), 1);
371 assert_eq!(payload.transactions[0].amount_cents, 2500);
372 }
373
374 #[test]
375 fn parse_csv_empty_returns_error() {
376 let csv = b"email,name\n";
377 let mapping = ColumnMapping {
378 email: Some(0),
379 name: Some(1),
380 ..Default::default()
381 };
382 assert!(parse_csv(csv, &mapping).is_err());
383 }
384
385 #[test]
386 fn parse_csv_skips_invalid_emails() {
387 let csv = b"email\nnot-an-email\nalice@test.com\n";
388 let mapping = ColumnMapping {
389 email: Some(0),
390 ..Default::default()
391 };
392 let payload = parse_csv(csv, &mapping).unwrap();
393 assert_eq!(payload.subscribers.len(), 1);
394 assert_eq!(payload.subscribers[0].email, "alice@test.com");
395 }
396
397 #[test]
398 fn parse_csv_mixed_currencies() {
399 let csv = b"email,amount\na@b.com,$10.00\nc@d.com,EUR20.50\ne@f.com,500\n";
400 let mapping = ColumnMapping {
401 email: Some(0),
402 amount: Some(1),
403 ..Default::default()
404 };
405 let payload = parse_csv(csv, &mapping).unwrap();
406 assert_eq!(payload.transactions.len(), 2); // EUR20.50 won't parse (no € symbol)
407 assert_eq!(payload.transactions[0].amount_cents, 1000);
408 }
409
410 #[test]
411 fn sanitize_field_strips_formula_chars() {
412 assert_eq!(sanitize_field("=SUM(A1)"), "'=SUM(A1)");
413 assert_eq!(sanitize_field("+cmd"), "'+cmd");
414 assert_eq!(sanitize_field("normal"), "normal");
415 }
416
417 // ── Edge cases: empty CSV / headers only ──
418
419 #[test]
420 fn parse_csv_completely_empty() {
421 let csv = b"";
422 let mapping = ColumnMapping {
423 email: Some(0),
424 ..Default::default()
425 };
426 assert!(parse_csv(csv, &mapping).is_err());
427 }
428
429 #[test]
430 fn parse_csv_only_headers_no_data_rows() {
431 let csv = b"email,name,amount\n";
432 let mapping = ColumnMapping {
433 email: Some(0),
434 name: Some(1),
435 amount: Some(2),
436 ..Default::default()
437 };
438 assert!(parse_csv(csv, &mapping).is_err());
439 }
440
441 #[test]
442 fn parse_csv_special_characters_in_fields() {
443 let csv = b"email,name\nalice@test.com,\"O'Brien, Jr.\"\nbob@test.com,\"Name with \"\"quotes\"\"\"\n";
444 let mapping = ColumnMapping {
445 email: Some(0),
446 name: Some(1),
447 ..Default::default()
448 };
449 let payload = parse_csv(csv, &mapping).unwrap();
450 assert_eq!(payload.subscribers.len(), 2);
451 assert_eq!(payload.subscribers[0].name.as_deref(), Some("O'Brien, Jr."));
452 }
453
454 // ── Column mapping edge cases ──
455
456 #[test]
457 fn parse_csv_mapped_column_index_beyond_row_length() {
458 let csv = b"email\nalice@test.com\n";
459 let mapping = ColumnMapping {
460 email: Some(0),
461 name: Some(5), // column 5 does not exist
462 ..Default::default()
463 };
464 let payload = parse_csv(csv, &mapping).unwrap();
465 assert_eq!(payload.subscribers.len(), 1);
466 assert!(payload.subscribers[0].name.is_none());
467 }
468
469 #[test]
470 fn parse_csv_extra_unmapped_columns_ignored() {
471 let csv = b"email,name,phone,address,zip\nalice@test.com,Alice,555-1234,123 Main,90210\n";
472 let mapping = ColumnMapping {
473 email: Some(0),
474 name: Some(1),
475 ..Default::default()
476 };
477 let payload = parse_csv(csv, &mapping).unwrap();
478 assert_eq!(payload.subscribers.len(), 1);
479 assert_eq!(payload.subscribers[0].email, "alice@test.com");
480 assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice"));
481 }
482
483 #[test]
484 fn parse_csv_no_email_mapping_no_subscribers() {
485 let csv = b"name,amount\nAlice,$10.00\n";
486 let mapping = ColumnMapping {
487 name: Some(0),
488 amount: Some(1),
489 ..Default::default()
490 };
491 // No email mapped means no subscribers or transactions
492 assert!(parse_csv(csv, &mapping).is_err());
493 }
494
495 // ── Price parsing edge cases ──
496
497 #[test]
498 fn parse_amount_dollar_sign_with_cents() {
499 assert_eq!(parse_amount_cents("$10.99"), Some(1099));
500 }
501
502 #[test]
503 fn parse_amount_free_or_zero() {
504 assert_eq!(parse_amount_cents("Free"), None);
505 assert_eq!(parse_amount_cents("$0.00"), Some(0));
506 assert_eq!(parse_amount_cents("0"), Some(0));
507 }
508
509 #[test]
510 fn parse_amount_yen_symbol() {
511 // ¥1500 whole number = 1500 cents
512 assert_eq!(parse_amount_cents("¥1500"), Some(1500));
513 }
514
515 #[test]
516 fn parse_amount_pound_with_pence() {
517 assert_eq!(parse_amount_cents("£9.99"), Some(999));
518 }
519
520 #[test]
521 fn parse_amount_trailing_whitespace() {
522 assert_eq!(parse_amount_cents(" $12.50 "), Some(1250));
523 }
524
525 #[test]
526 fn parse_amount_commas_in_thousands() {
527 assert_eq!(parse_amount_cents("$1,000.00"), Some(100_000));
528 assert_eq!(parse_amount_cents("1,234,567.89"), Some(123_456_789));
529 }
530
531 #[test]
532 fn parse_amount_single_decimal_digit() {
533 assert_eq!(parse_amount_cents("5.5"), Some(550));
534 }
535
536 #[test]
537 fn parse_amount_three_decimal_digits_truncates() {
538 assert_eq!(parse_amount_cents("12.999"), Some(1299));
539 }
540
541 #[test]
542 fn parse_amount_negative_parses_as_negative_cents() {
543 assert_eq!(parse_amount_cents("-10.00"), Some(-1000));
544 assert_eq!(parse_amount_cents("-"), None);
545 // Sub-dollar negatives: the whole-dollar part is zero, so the sign must
546 // come from the string, not from `dollars < 0` ("-0" parses to 0).
547 assert_eq!(parse_amount_cents("-0.50"), Some(-50));
548 assert_eq!(parse_amount_cents("-$0.99"), Some(-99));
549 assert_eq!(parse_amount_cents("-0.5"), Some(-50));
550 assert_eq!(parse_amount_cents("-12.50"), Some(-1250));
551 }
552
553 // ── Date parsing edge cases ──
554
555 #[test]
556 fn parse_date_iso8601_datetime_no_timezone() {
557 let dt = parse_flexible_date("2024-06-15T14:30:00").unwrap();
558 assert_eq!(dt.date_naive().to_string(), "2024-06-15");
559 }
560
561 #[test]
562 fn parse_date_whitespace_padded() {
563 let dt = parse_flexible_date(" 2024-01-15 ").unwrap();
564 assert_eq!(dt.date_naive().to_string(), "2024-01-15");
565 }
566
567 #[test]
568 fn parse_date_garbage_returns_none() {
569 assert!(parse_flexible_date("yesterday").is_none());
570 assert!(parse_flexible_date("2024/13/01").is_none()); // month 13
571 assert!(parse_flexible_date("foo bar baz").is_none());
572 }
573
574 #[test]
575 fn parse_date_ambiguous_slash_format_defaults_to_us() {
576 // 01/05/2024 - day <= 12, so it parses as US (Jan 5)
577 let dt = parse_flexible_date("01/05/2024").unwrap();
578 assert_eq!(dt.date_naive().to_string(), "2024-01-05");
579 }
580
581 #[test]
582 fn parse_date_epoch_zero() {
583 let dt = parse_flexible_date("0").unwrap();
584 assert_eq!(dt.date_naive().to_string(), "1970-01-01");
585 }
586
587 // ── Unicode in fields ──
588
589 #[test]
590 fn parse_csv_unicode_names_and_titles() {
591 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";
592 let mapping = ColumnMapping {
593 email: Some(0),
594 name: Some(1),
595 item_title: Some(2),
596 ..Default::default()
597 };
598 let payload = parse_csv(csv.as_bytes(), &mapping).unwrap();
599 assert_eq!(payload.subscribers.len(), 2);
600 assert_eq!(
601 payload.subscribers[0].name.as_deref(),
602 Some("Alícia Müller")
603 );
604 assert_eq!(
605 payload.subscribers[1].name.as_deref(),
606 Some("\u{1F600} Bob")
607 );
608 }
609
610 #[test]
611 fn parse_csv_cjk_characters() {
612 let csv = "email,name\ntest@example.com,田中太郎\n";
613 let mapping = ColumnMapping {
614 email: Some(0),
615 name: Some(1),
616 ..Default::default()
617 };
618 let payload = parse_csv(csv.as_bytes(), &mapping).unwrap();
619 assert_eq!(payload.subscribers[0].name.as_deref(), Some("田中太郎"));
620 }
621
622 // ── Sanitization edge cases ──
623
624 #[test]
625 fn sanitize_field_all_formula_prefixes() {
626 assert_eq!(sanitize_field("@import"), "'@import");
627 assert_eq!(sanitize_field("-command"), "'-command");
628 // Tab and CR are whitespace, so trim() strips them before starts_with
629 // Only non-whitespace formula chars are caught after trimming
630 assert_eq!(sanitize_field("\tcmd"), "cmd");
631 }
632
633 #[test]
634 fn sanitize_field_trims_whitespace() {
635 assert_eq!(sanitize_field(" hello "), "hello");
636 assert_eq!(sanitize_field("\n\t data \n"), "data");
637 }
638
639 // ── Full CSV integration ──
640
641 #[test]
642 fn parse_csv_all_columns_mapped() {
643 let csv = b"email,name,amount,date,item,tier,status\n\
644 alice@test.com,Alice,$25.00,2024-01-15,My Album,gold,active\n";
645 let mapping = ColumnMapping {
646 email: Some(0),
647 name: Some(1),
648 amount: Some(2),
649 date: Some(3),
650 item_title: Some(4),
651 tier: Some(5),
652 status: Some(6),
653 };
654 let payload = parse_csv(csv, &mapping).unwrap();
655 assert_eq!(payload.subscribers.len(), 1);
656 assert_eq!(payload.transactions.len(), 1);
657
658 let sub = &payload.subscribers[0];
659 assert_eq!(sub.email, "alice@test.com");
660 assert_eq!(sub.name.as_deref(), Some("Alice"));
661 assert_eq!(sub.tier_name.as_deref(), Some("gold"));
662 assert_eq!(sub.status.as_deref(), Some("active"));
663 assert_eq!(sub.lifetime_amount_cents, Some(2500));
664
665 let txn = &payload.transactions[0];
666 assert_eq!(txn.buyer_email, "alice@test.com");
667 assert_eq!(txn.amount_cents, 2500);
668 assert_eq!(txn.item_title.as_deref(), Some("My Album"));
669 assert_eq!(txn.currency, "USD");
670 }
671
672 #[test]
673 fn parse_csv_email_lowercased() {
674 let csv = b"email\nALICE@TEST.COM\nBob@Example.Com\n";
675 let mapping = ColumnMapping {
676 email: Some(0),
677 ..Default::default()
678 };
679 let payload = parse_csv(csv, &mapping).unwrap();
680 assert_eq!(payload.subscribers[0].email, "alice@test.com");
681 assert_eq!(payload.subscribers[1].email, "bob@example.com");
682 }
683
684 #[test]
685 fn parse_csv_whitespace_only_name_treated_as_empty() {
686 let csv = b"email,name\nalice@test.com, \n";
687 let mapping = ColumnMapping {
688 email: Some(0),
689 name: Some(1),
690 ..Default::default()
691 };
692 let payload = parse_csv(csv, &mapping).unwrap();
693 assert!(payload.subscribers[0].name.is_none());
694 }
695
696 #[test]
697 fn parse_csv_transaction_requires_email() {
698 // Amount present but no email -> no transaction
699 let csv = b"amount\n$10.00\n";
700 let mapping = ColumnMapping {
701 amount: Some(0),
702 ..Default::default()
703 };
704 assert!(parse_csv(csv, &mapping).is_err());
705 }
706
707 #[test]
708 fn parse_csv_status_lowercased() {
709 let csv = b"email,status\nalice@test.com,ACTIVE\n";
710 let mapping = ColumnMapping {
711 email: Some(0),
712 status: Some(1),
713 ..Default::default()
714 };
715 let payload = parse_csv(csv, &mapping).unwrap();
716 assert_eq!(payload.subscribers[0].status.as_deref(), Some("active"));
717 }
718
719 #[test]
720 fn parse_csv_flexible_row_lengths() {
721 // Rows with different column counts (flexible mode)
722 let csv = b"email,name,extra\nalice@test.com,Alice\nbob@test.com,Bob,stuff,bonus\n";
723 let mapping = ColumnMapping {
724 email: Some(0),
725 name: Some(1),
726 ..Default::default()
727 };
728 let payload = parse_csv(csv, &mapping).unwrap();
729 assert_eq!(payload.subscribers.len(), 2);
730 }
731
732 // ── Adversarial: row-error recovery, malformed input, boundaries ──
733
734 #[test]
735 fn parse_csv_malformed_row_skipped_good_rows_survive() {
736 // Second data row has an unterminated quote → a row-level parse error.
737 // parse_csv must record the error, skip that row, and still keep the
738 // surrounding valid rows rather than aborting the whole import.
739 let csv = b"email\nalice@test.com\nbob@\"broken\ncarol@test.com\n";
740 let mapping = ColumnMapping {
741 email: Some(0),
742 ..Default::default()
743 };
744 let payload = parse_csv(csv, &mapping).unwrap();
745 let emails: Vec<&str> = payload
746 .subscribers
747 .iter()
748 .map(|s| s.email.as_str())
749 .collect();
750 assert!(
751 emails.contains(&"alice@test.com"),
752 "good row before the bad one kept"
753 );
754 assert!(
755 emails.contains(&"carol@test.com"),
756 "good row after the bad one kept"
757 );
758 }
759
760 #[test]
761 fn parse_csv_crlf_line_endings() {
762 // Windows-exported CSVs use \r\n; trim(Trim::All) + the csv reader must
763 // handle them without leaking a trailing \r into the values.
764 let csv = b"email,name\r\nalice@test.com,Alice\r\nbob@test.com,Bob\r\n";
765 let mapping = ColumnMapping {
766 email: Some(0),
767 name: Some(1),
768 ..Default::default()
769 };
770 let payload = parse_csv(csv, &mapping).unwrap();
771 assert_eq!(payload.subscribers.len(), 2);
772 assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice"));
773 assert_eq!(payload.subscribers[1].email, "bob@test.com");
774 }
775
776 #[test]
777 fn email_domain_dot_rules_enforced() {
778 // Rows that must be rejected by the inline email filter, plus one valid
779 // plus-addressed email that must survive.
780 let csv = b"email\nno-dot@localhost\nleading@.com\ntrailing@dot.\n@nolocal.com\nvalid+tag@sub.example.com\n";
781 let mapping = ColumnMapping {
782 email: Some(0),
783 ..Default::default()
784 };
785 let payload = parse_csv(csv, &mapping).unwrap();
786 assert_eq!(
787 payload.subscribers.len(),
788 1,
789 "only the well-formed email survives"
790 );
791 assert_eq!(payload.subscribers[0].email, "valid+tag@sub.example.com");
792 }
793
794 #[test]
795 fn parse_amount_negative_whole_number_is_negative_cents() {
796 assert_eq!(parse_amount_cents("-500"), Some(-500));
797 assert_eq!(parse_amount_cents("-$1,000"), Some(-1000));
798 }
799
800 #[test]
801 fn parse_amount_overflow_returns_none() {
802 // Beyond i64 range → parse fails → None, never a panic or wrapped value.
803 assert_eq!(parse_amount_cents("99999999999999999999999"), None);
804 assert_eq!(parse_amount_cents("999999999999999999999.99"), None);
805 }
806
807 #[test]
808 fn parse_csv_duplicate_emails_all_retained() {
809 // Deduplication is a downstream concern; the parser must faithfully emit
810 // every valid row so the importer can decide how to merge.
811 let csv = b"email\ndup@test.com\ndup@test.com\n";
812 let mapping = ColumnMapping {
813 email: Some(0),
814 ..Default::default()
815 };
816 let payload = parse_csv(csv, &mapping).unwrap();
817 assert_eq!(payload.subscribers.len(), 2);
818 }
819
820 #[test]
821 fn parse_csv_enforces_row_cap() {
822 // MAX_IMPORT_ROWS is 100_000; a CSV with more data rows must cap at the
823 // limit rather than ingesting an unbounded upload.
824 let mut csv = String::from("email\n");
825 for i in 0..100_050 {
826 writeln!(csv, "user{i}@test.com").unwrap();
827 }
828 let mapping = ColumnMapping {
829 email: Some(0),
830 ..Default::default()
831 };
832 let payload = parse_csv(csv.as_bytes(), &mapping).unwrap();
833 assert_eq!(
834 payload.subscribers.len(),
835 100_000,
836 "capped at MAX_IMPORT_ROWS"
837 );
838 }
839 }
840