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:
+249
-87
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user