Add comprehensive TDD infrastructure with 45 tests

- Add lib crate exposing modules for integration testing
- Add dev-dependencies: tokio-test 0.4, tempfile
- Refactor parse_csv_fields() as pure function for unit testing
- Add field validation (minimum 16 fields required)
- Fix repository last_insert_id using SELECT LAST_INSERT_ID()
- Add 10 lib tests for CSV parsing and date formatting
- Add 10 config tests for environment configuration
- Add 7 import tests for CSV file parsing
- Add 6 models tests for database structs
- Add 12 repository tests for CRUD operations
This commit is contained in:
2026-04-02 11:13:41 +02:00
parent 429d5d774f
commit e2123e4619
11 changed files with 1648 additions and 87 deletions
+249 -87
View File
@@ -6,6 +6,111 @@ use std::collections::HashMap;
use std::fs::File;
use std::path::Path;
/// Represents a parsed transaction from CSV fields.
///
/// AI AGENT NOTE: This is an intermediate struct for CSV parsing.
/// It mirrors the CSV column structure and is converted to NewTransaction
/// for database insertion.
#[derive(Debug, Clone)]
pub struct CsvTransaction {
pub date: NaiveDateTime,
pub batch_number: String,
pub amount: f64,
pub volume: f64,
pub price: f64,
pub quality: i32,
pub quality_name: String,
pub card_number: String,
pub customer_number: String,
pub station: String,
pub terminal: String,
pub pump: String,
pub receipt: String,
pub card_report_group_number: String,
pub control_number: String,
}
/// Parses a CSV record from string slices (pure function for testing).
///
/// AI AGENT NOTE: This function contains the core business logic for parsing
/// a single CSV row. It can be tested without file I/O.
///
/// CSV Column Mapping (0-indexed):
/// 0: Date (multiple formats supported)
/// 1: Batch number
/// 2: Amount
/// 3: Volume
/// 4: Price
/// 5: Quality code
/// 6: Quality name
/// 7: Card number
/// 8: Card type (ignored - redundant)
/// 9: Customer number
/// 10: Station
/// 11: Terminal
/// 12: Pump
/// 13: Receipt
/// 14: Card report group number
/// 15: Control number
///
/// Returns None if amount <= 0 (excludes authorizations/cancellations).
pub fn parse_csv_fields(fields: &[&str]) -> anyhow::Result<Option<CsvTransaction>> {
// Validate minimum required fields (date, batch, amount, volume, price, quality, quality_name, card_number, customer_number at index 9, station at 10, terminal at 11, pump at 12, receipt at 13, card_report_group at 14, control at 15)
if fields.len() < 16 {
anyhow::bail!("Expected at least 16 fields, got {}", fields.len());
}
let date_str = fields.get(0).copied().unwrap_or("");
let date = parse_date(date_str)?;
let amount_str = fields.get(2).copied().unwrap_or("0");
let amount: f64 = amount_str.parse().unwrap_or(0.0);
// Skip zero/negative amounts (authorizations, cancellations)
if amount <= 0.0 {
return Ok(None);
}
let customer_number = fields.get(9).copied().unwrap_or("").to_string();
Ok(Some(CsvTransaction {
date,
batch_number: fields.get(1).copied().unwrap_or("").to_string(),
amount,
volume: fields.get(3).copied().unwrap_or("0").parse().unwrap_or(0.0),
price: fields.get(4).copied().unwrap_or("0").parse().unwrap_or(0.0),
quality: fields.get(5).copied().unwrap_or("0").parse().unwrap_or(0),
quality_name: fields.get(6).copied().unwrap_or("").to_string(),
card_number: fields.get(7).copied().unwrap_or("").to_string(),
customer_number,
station: fields.get(10).copied().unwrap_or("").to_string(),
terminal: fields.get(11).copied().unwrap_or("").to_string(),
pump: fields.get(12).copied().unwrap_or("").to_string(),
receipt: fields.get(13).copied().unwrap_or("").to_string(),
card_report_group_number: fields.get(14).copied().unwrap_or("").to_string(),
control_number: fields.get(15).copied().unwrap_or("").to_string(),
}))
}
/// Parses a date string, supporting multiple formats.
///
/// AI AGENT NOTE: Source data may use different date formats:
/// - ISO format: "2026-02-01 06:40:14"
/// - US format: "02/01/2026 06:40:14 AM"
fn parse_date(date_str: &str) -> anyhow::Result<NaiveDateTime> {
NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(date_str, "%m/%d/%Y %I:%M:%S %p"))
.map_err(|e| anyhow::anyhow!("Failed to parse date '{}': {}", date_str, e))
}
/// Checks if a card number is anonymized (contains asterisks).
///
/// AI AGENT NOTE: Anonymized cards have masked digits like "554477******9952".
/// These cards are NOT stored in the cards table - only in transactions.
pub fn is_anonymized_card(card_number: &str) -> bool {
card_number.contains('*')
}
/// Imports transactions from a CSV file into the database.
///
/// AI AGENT NOTE: This is the main data import function. It handles:
@@ -33,30 +138,21 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
.from_reader(file);
let mut transactions = Vec::new();
// Tracks unique customers with their card_report_group
// Key: customer_number, Value: card_report_group
let mut seen_customers: HashMap<String, u8> = HashMap::new();
// Tracks unique known cards and their customer
// Key: card_number, Value: customer_number
// AI AGENT NOTE: Only full card numbers are stored here, not anonymized ones
let mut seen_cards: HashMap<String, String> = HashMap::new();
for result in rdr.records() {
let record = result?;
if let Some(tx) = parse_record(&record)? {
// Only track customers/cards for transactions with known customer_number
// AI AGENT NOTE: Anonymized cards (no customer) don't get cards table entries
if !tx.customer_number.is_empty() {
let card_report_group: u8 = tx.card_report_group_number.parse().unwrap_or(0);
if !seen_customers.contains_key(&tx.customer_number) {
seen_customers.insert(tx.customer_number.clone(), card_report_group);
}
// Only store known cards (full card numbers, not anonymized)
if !seen_cards.contains_key(&tx.card_number) {
seen_cards.insert(tx.card_number.clone(), tx.customer_number.clone());
}
}
// ALL transactions are stored, including anonymized ones
transactions.push(tx);
}
}
@@ -65,7 +161,6 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
println!("Unique customers: {}", seen_customers.len());
println!("Unique known cards: {}", seen_cards.len());
// Phase 1: Import customers
println!("\nImporting customers...");
let mut customer_ids: HashMap<String, u32> = HashMap::new();
for (customer_number, card_report_group) in &seen_customers {
@@ -78,8 +173,6 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
println!(" Customer {} -> id {}", customer_number, id);
}
// Phase 2: Import cards (only known cards)
// AI AGENT NOTE: This links cards to customers. Anonymized cards are NOT inserted.
println!("\nImporting cards...");
let mut card_ids: HashMap<String, u32> = HashMap::new();
for (card_number, customer_number) in &seen_cards {
@@ -94,16 +187,12 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
}
}
// Phase 3: Import transactions
// AI AGENT NOTE: All transactions are imported, but only those with known customers
// have a customer_id. Anonymized transactions have customer_id = NULL.
println!("\nImporting transactions...");
let batch_size = 500;
let mut total_inserted = 0u64;
let mut batch: Vec<NewTransaction> = Vec::with_capacity(batch_size);
for tx in transactions {
// customer_id is None if customer_number was empty (anonymized transaction)
let customer_id = customer_ids.get(&tx.customer_number).copied();
let new_tx = NewTransaction {
@@ -114,13 +203,13 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
price: tx.price,
quality_code: tx.quality,
quality_name: tx.quality_name,
card_number: tx.card_number, // Always stored, even for anonymized cards
card_number: tx.card_number,
station: tx.station,
terminal: tx.terminal,
pump: tx.pump,
receipt: tx.receipt,
control_number: if tx.control_number.is_empty() { None } else { Some(tx.control_number) },
customer_id, // NULL for anonymized transactions
customer_id,
};
batch.push(new_tx);
@@ -133,7 +222,6 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
}
}
// Insert remaining batch
if !batch.is_empty() {
let inserted = repo.insert_transactions_batch(&batch).await?;
total_inserted += inserted;
@@ -145,29 +233,6 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
Ok(())
}
/// Represents a parsed transaction from CSV.
///
/// AI AGENT NOTE: This is an intermediate struct for CSV parsing.
/// It mirrors the CSV column structure and is converted to NewTransaction
/// for database insertion.
struct CsvTransaction {
date: NaiveDateTime,
batch_number: String,
amount: f64,
volume: f64,
price: f64,
quality: i32,
quality_name: String,
card_number: String,
customer_number: String,
station: String,
terminal: String,
pump: String,
receipt: String,
card_report_group_number: String,
control_number: String,
}
fn get_field(record: &csv::StringRecord, index: usize) -> &str {
record.get(index).unwrap_or("")
}
@@ -177,55 +242,152 @@ fn get_field(record: &csv::StringRecord, index: usize) -> &str {
/// AI AGENT NOTE: Returns None if:
/// - amount <= 0 (excludes authorizations/cancellations)
/// - date parsing fails
///
/// CSV Column Mapping (0-indexed):
/// 0: Date (multiple formats supported)
/// 1: Batch number
/// 2: Amount
/// 3: Volume
/// 4: Price
/// 5: Quality code
/// 6: Quality name
/// 7: Card number
/// 8: Card type (ignored - redundant)
/// 9: Customer number
/// 10: Station
/// 11: Terminal
/// 12: Pump
/// 13: Receipt
/// 14: Card report group number
/// 15: Control number
fn parse_record(record: &csv::StringRecord) -> anyhow::Result<Option<CsvTransaction>> {
let date_str = get_field(record, 0);
// Try multiple date formats since source data may vary
let date = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(date_str, "%m/%d/%Y %I:%M:%S %p"))
.map_err(|e| anyhow::anyhow!("Failed to parse date '{}': {}", date_str, e))?;
let fields: Vec<&str> = (0..16)
.map(|i| get_field(record, i))
.collect();
parse_csv_fields(&fields)
}
let amount: f64 = get_field(record, 2).parse().unwrap_or(0.0);
#[cfg(test)]
mod tests {
use super::*;
// Skip zero/negative amounts (authorizations, cancellations)
if amount <= 0.0 {
return Ok(None);
#[test]
fn parse_valid_record_with_known_customer() {
let fields = [
"2026-02-01 10:15:16", "409", "559.26", "35.85", "15.60",
"1001", "95 Oktan", "7825017523017000642", "type",
"1861", "97254", "1", "2", "000910", "1", ""
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_some());
let tx = result.unwrap();
assert_eq!(tx.batch_number, "409");
assert_eq!(tx.amount, 559.26);
assert_eq!(tx.volume, 35.85);
assert_eq!(tx.quality, 1001);
assert_eq!(tx.quality_name, "95 Oktan");
assert_eq!(tx.card_number, "7825017523017000642");
assert_eq!(tx.customer_number, "1861");
}
let customer_number = get_field(record, 9).to_string();
#[test]
fn parse_record_with_empty_customer_number() {
let fields = [
"2026-02-01 06:40:14", "409", "267.23", "17.13", "15.60",
"1001", "95 Oktan", "554477******9952", "type",
"", "97254", "1", "2", "000898", "4", "756969"
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_some());
let tx = result.unwrap();
assert_eq!(tx.customer_number, "");
assert_eq!(tx.card_number, "554477******9952");
assert!(is_anonymized_card(&tx.card_number));
}
Ok(Some(CsvTransaction {
date,
batch_number: get_field(record, 1).to_string(),
amount,
volume: get_field(record, 3).parse().unwrap_or(0.0),
price: get_field(record, 4).parse().unwrap_or(0.0),
quality: get_field(record, 5).parse().unwrap_or(0),
quality_name: get_field(record, 6).to_string(),
card_number: get_field(record, 7).to_string(),
customer_number,
station: get_field(record, 10).to_string(),
terminal: get_field(record, 11).to_string(),
pump: get_field(record, 12).to_string(),
receipt: get_field(record, 13).to_string(),
card_report_group_number: get_field(record, 14).to_string(),
control_number: get_field(record, 15).to_string(),
}))
#[test]
fn parse_zero_amount_returns_none() {
let fields = [
"2026-02-01 06:40:14", "409", "0.00", "0.00", "15.60",
"1001", "95 Oktan", "554477******9952", "type",
"", "97254", "1", "2", "000898", "4", "756969"
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_none());
}
#[test]
fn parse_negative_amount_returns_none() {
let fields = [
"2026-02-01 06:40:14", "409", "-50.00", "-3.00", "15.60",
"1001", "95 Oktan", "7825017523017000642", "type",
"1861", "97254", "1", "2", "000898", "1", ""
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_none());
}
#[test]
fn parse_us_date_format() {
let fields = [
"02/01/2026 10:15:16 AM", "409", "559.26", "35.85", "15.60",
"1001", "95 Oktan", "7825017523017000642", "type",
"1861", "97254", "1", "2", "000910", "1", ""
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_some());
let tx = result.unwrap();
assert_eq!(tx.date.format("%Y-%m-%d").to_string(), "2026-02-01");
}
#[test]
fn parse_diesel_product() {
let fields = [
"2026-02-01 10:05:16", "409", "543.22", "31.40", "17.30",
"4", "Diesel", "673706*********0155", "type",
"", "97254", "1", "2", "000909", "4", "D00824"
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_some());
let tx = result.unwrap();
assert_eq!(tx.quality_name, "Diesel");
assert_eq!(tx.quality, 4);
assert_eq!(tx.control_number, "D00824");
}
#[test]
fn parse_missing_fields_defaults_to_empty() {
let fields: [&str; 16] = [
"2026-02-01 10:15:16", "409", "559.26", "", "",
"", "", "", "", "", "", "", "", "", "", ""
];
let result = parse_csv_fields(&fields).unwrap();
assert!(result.is_some());
let tx = result.unwrap();
assert_eq!(tx.volume, 0.0);
assert_eq!(tx.price, 0.0);
assert_eq!(tx.quality, 0);
assert_eq!(tx.quality_name, "");
}
#[test]
fn parse_too_few_fields_returns_none() {
let fields: [&str; 4] = ["2026-02-01 10:15:16", "409", "559.26", "35.85"];
let result = parse_csv_fields(&fields);
assert!(result.is_err()); // Date parsing succeeds but other fields missing
}
#[test]
fn is_anonymized_card_detects_asterisks() {
assert!(is_anonymized_card("554477******9952"));
assert!(is_anonymized_card("673706*********0155"));
assert!(!is_anonymized_card("7825017523017000642"));
}
#[test]
fn card_report_group_parsed_correctly() {
let fields = [
"2026-02-01 10:15:16", "409", "559.26", "35.85", "15.60",
"1001", "95 Oktan", "7825017523017000642", "type",
"1861", "97254", "1", "2", "000910", "1", ""
];
let tx = parse_csv_fields(&fields).unwrap().unwrap();
assert_eq!(tx.card_report_group_number, "1");
}
}
+155
View File
@@ -270,3 +270,158 @@ pub struct CustomerSummary {
pub total_amount: BigDecimal,
pub total_volume: BigDecimal,
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::Row;
/// Helper to create a test repository with a transaction.
/// Returns the repository and the transaction - rollback when done.
pub async fn with_test_tx<F, T>(test_fn: F) -> anyhow::Result<T>
where
F: FnOnce(&Repository, &mut sqlx::Transaction<'_, sqlx::MySql>) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<T>>>>,
{
let pool = crate::db::create_pool(&std::env::var("DATABASE_URL").unwrap_or_else(|_| {
let config = crate::config::Config::load(crate::config::Env::Test).unwrap();
config.database.connection_url()
})).await?;
let mut tx = pool.begin().await?;
let repo = Repository::new(pool);
let result = test_fn(&repo, &mut tx).await;
tx.rollback().await?;
result
}
/// Inserts a customer using a transaction (for testing).
pub async fn insert_customer_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
customer: &NewCustomer,
) -> anyhow::Result<u32> {
sqlx::query(
"INSERT INTO customers (customer_number, card_report_group) VALUES (?, ?)",
)
.bind(&customer.customer_number)
.bind(customer.card_report_group)
.execute(&mut **tx)
.await?;
let row = sqlx::query("SELECT LAST_INSERT_ID() as id")
.fetch_one(&mut **tx)
.await?;
Ok(row.get("id"))
}
/// Finds a customer by ID using a transaction (for testing).
pub async fn find_customer_by_id_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
id: u32,
) -> anyhow::Result<Option<Customer>> {
let result = sqlx::query_as::<_, Customer>(
"SELECT id, customer_number, card_report_group, created_at, updated_at
FROM customers WHERE id = ?",
)
.bind(id)
.fetch_optional(&mut **tx)
.await?;
Ok(result)
}
/// Inserts a card using a transaction (for testing).
pub async fn insert_card_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
card: &NewCard,
) -> anyhow::Result<u32> {
sqlx::query(
"INSERT INTO cards (card_number, customer_id) VALUES (?, ?)",
)
.bind(&card.card_number)
.bind(card.customer_id)
.execute(&mut **tx)
.await?;
let row = sqlx::query("SELECT LAST_INSERT_ID() as id")
.fetch_one(&mut **tx)
.await?;
Ok(row.get("id"))
}
/// Finds a card by card_number using a transaction (for testing).
pub async fn find_card_by_number_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
card_number: &str,
) -> anyhow::Result<Option<Card>> {
let result = sqlx::query_as::<_, Card>(
"SELECT id, card_number, customer_id, created_at, updated_at
FROM cards WHERE card_number = ?",
)
.bind(card_number)
.fetch_optional(&mut **tx)
.await?;
Ok(result)
}
/// Inserts a single transaction using a transaction (for testing).
pub async fn insert_transaction_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
transaction: &NewTransaction,
) -> anyhow::Result<u64> {
sqlx::query(&format!(
"INSERT INTO transactions (transaction_date, batch_number, amount, volume, price, quality_code, quality_name, card_number, station, terminal, pump, receipt, control_number, customer_id) VALUES ('{}', '{}', {}, {}, {}, {}, '{}', '{}', '{}', '{}', '{}', '{}', {}, {})",
transaction.transaction_date.format("%Y-%m-%d %H:%M:%S"),
transaction.batch_number,
transaction.amount,
transaction.volume,
transaction.price,
transaction.quality_code,
transaction.quality_name.replace("'", "''"),
transaction.card_number.replace("'", "''"),
transaction.station,
transaction.terminal,
transaction.pump,
transaction.receipt,
transaction.control_number.as_ref().map(|s| format!("'{}'", s.replace("'", "''"))).unwrap_or_else(|| "NULL".to_string()),
transaction.customer_id.map(|id| id.to_string()).unwrap_or_else(|| "NULL".to_string()),
))
.execute(&mut **tx)
.await?;
let row = sqlx::query("SELECT LAST_INSERT_ID() as id")
.fetch_one(&mut **tx)
.await?;
Ok(row.get::<u64, _>("id"))
}
/// Counts transactions for a customer using a transaction (for testing).
pub async fn count_customer_transactions_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
customer_id: u32,
) -> anyhow::Result<i64> {
let row = sqlx::query(
"SELECT COUNT(*) as count FROM transactions WHERE customer_id = ?",
)
.bind(customer_id)
.fetch_one(&mut **tx)
.await?;
Ok(row.get("count"))
}
/// Gets transaction count using a transaction (for testing).
pub async fn count_transactions_tx(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
) -> anyhow::Result<i64> {
let row = sqlx::query("SELECT COUNT(*) as count FROM transactions")
.fetch_one(&mut **tx)
.await?;
Ok(row.get("count"))
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Library crate for invoice-generator.
//!
//! AI AGENT NOTE: This library exposes the core modules for testing purposes.
//! The binary crate (main.rs) uses this library.
pub mod commands;
pub mod config;
pub mod db;
pub mod invoice_generator;