// Integration tests for nutrition-mcp. // Uses temp SQLite DBs — no network calls. mod common; #[cfg(test)] mod tests { use super::common::TestDb; use nutrition_mcp::api::{OffClient, Product}; use nutrition_mcp::db; use nutrition_mcp::tools; use serde_json::{json, Value}; // ── Database tests ──────────────────────────────────────────── #[tokio::test] async fn test_db_init_creates_all_tables() { let _db = TestDb::new().await; // If init succeeds without panic, all tables were created. // The TestDb constructor calls db::init_database. } #[tokio::test] async fn test_db_seeds_default_goals() { let db = TestDb::new().await; let row = sqlx::query("SELECT goals FROM daily_goal WHERE id = 1") .fetch_one(&db.pool) .await .unwrap(); let goals_str: String = sqlx::Row::get(&row, "goals"); let goals: Value = serde_json::from_str(&goals_str).unwrap(); assert_eq!(goals["energy_kcal"], json!(2500)); assert_eq!(goals["protein"], json!(150)); assert_eq!(goals["fructose"], json!(15)); assert_eq!(goals["alcohol"], json!(0)); assert_eq!(goals["salt"], json!(6)); assert_eq!(goals["potassium"], json!(3500)); assert_eq!(goals["calcium"], json!(700)); assert_eq!(goals["magnesium"], json!(300)); assert_eq!(goals["cholesterol"], json!(300)); } #[test] fn test_tracked_nutrients_count() { assert_eq!(db::TRACKED_NUTRIENTS.len(), 14); } #[test] fn test_tracked_nutrients_has_gout_and_hypertension_markers() { let keys: Vec<&str> = db::TRACKED_NUTRIENTS.iter().map(|(k, _)| *k).collect(); // Gout assert!(keys.contains(&"fructose")); assert!(keys.contains(&"alcohol")); // Hypertension assert!(keys.contains(&"salt")); assert!(keys.contains(&"potassium")); assert!(keys.contains(&"calcium")); assert!(keys.contains(&"magnesium")); assert!(keys.contains(&"cholesterol")); // Core macros assert!(keys.contains(&"energy_kcal")); assert!(keys.contains(&"protein")); assert!(keys.contains(&"carbohydrates")); assert!(keys.contains(&"fat")); assert!(keys.contains(&"fiber")); assert!(keys.contains(&"sugars")); assert!(keys.contains(&"saturated_fat")); } // ── Cache tests (no network) ──────────────────────────────── #[tokio::test] async fn test_cache_products_and_get() { let db = TestDb::new().await; let product = Product { code: "123456789".to_string(), product_name: "Test Blueberries".to_string(), brands: Some("TestBrand".to_string()), nutriments: json!({"energy-kcal_100g": 57.7, "proteins_100g": 0.87}).to_string(), serving_quantity: Some(100.0), serving_size: Some("100g".to_string()), image_url: None, }; OffClient::cache_products(&db.pool, &[product]).await.unwrap(); let cached = OffClient::cache_get(&db.pool, "123456789").await.unwrap(); assert!(cached.is_some()); let p = cached.unwrap(); assert_eq!(p.product_name, "Test Blueberries"); assert_eq!(p.brands, Some("TestBrand".to_string())); } #[tokio::test] async fn test_cache_search_fts5() { let db = TestDb::new().await; let products = vec![ Product { code: "111".to_string(), product_name: "Organic Blueberries".to_string(), brands: None, nutriments: "{}".to_string(), serving_quantity: None, serving_size: None, image_url: None, }, Product { code: "222".to_string(), product_name: "Chicken Breast".to_string(), brands: None, nutriments: "{}".to_string(), serving_quantity: None, serving_size: None, image_url: None, }, ]; OffClient::cache_products(&db.pool, &products).await.unwrap(); let results = OffClient::cache_search(&db.pool, "blueberries", 10).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].product_name, "Organic Blueberries"); let results = OffClient::cache_search(&db.pool, "chicken", 10).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].product_name, "Chicken Breast"); } #[tokio::test] async fn test_cache_products_upsert() { let db = TestDb::new().await; let p1 = Product { code: "999".to_string(), product_name: "Original Name".to_string(), brands: None, nutriments: "{}".to_string(), serving_quantity: None, serving_size: None, image_url: None, }; OffClient::cache_products(&db.pool, &[p1]).await.unwrap(); let p2 = Product { code: "999".to_string(), product_name: "Updated Name".to_string(), brands: Some("NewBrand".to_string()), nutriments: "{}".to_string(), serving_quantity: None, serving_size: None, image_url: None, }; OffClient::cache_products(&db.pool, &[p2]).await.unwrap(); let cached = OffClient::cache_get(&db.pool, "999").await.unwrap().unwrap(); assert_eq!(cached.product_name, "Updated Name"); assert_eq!(cached.brands, Some("NewBrand".to_string())); } // ── Portion scaling tests ──────────────────────────────────── #[test] fn test_scale_nutriments_100g() { let n = json!({"energy-kcal_100g": 200.0, "proteins_100g": 10.0}); let scaled = tools::scale_nutriments(&n, 100.0); assert_eq!(scaled["energy-kcal_100g"], json!(200.0)); assert_eq!(scaled["proteins_100g"], json!(10.0)); } #[test] fn test_scale_nutriments_50g() { let n = json!({"energy-kcal_100g": 200.0, "proteins_100g": 10.0}); let scaled = tools::scale_nutriments(&n, 50.0); assert_eq!(scaled["energy-kcal_100g"], json!(100.0)); assert_eq!(scaled["proteins_100g"], json!(5.0)); } #[test] fn test_scale_nutriments_200g() { let n = json!({"energy-kcal_100g": 200.0, "proteins_100g": 10.0}); let scaled = tools::scale_nutriments(&n, 200.0); assert_eq!(scaled["energy-kcal_100g"], json!(400.0)); assert_eq!(scaled["proteins_100g"], json!(20.0)); } #[test] fn test_scale_nutriments_empty() { let n = json!({}); let scaled = tools::scale_nutriments(&n, 50.0); assert!(scaled.as_object().unwrap().is_empty()); } #[test] fn test_scale_nutriments_preserves_non_numeric() { let n = json!({"energy-kcal_100g": 200.0, "unit": "kcal"}); let scaled = tools::scale_nutriments(&n, 50.0); assert_eq!(scaled["energy-kcal_100g"], json!(100.0)); assert_eq!(scaled["unit"], json!("kcal")); // non-numeric preserved } // ── Sum nutriments tests ───────────────────────────────────── #[test] fn test_sum_nutriments_single_entry() { let entries = vec![json!({"energy-kcal_100g": 250.0, "proteins_100g": 20.0})]; let totals = tools::sum_nutriments(&entries); assert_eq!(totals["energy_kcal"], json!(250.0)); assert_eq!(totals["protein"], json!(20.0)); } #[test] fn test_sum_nutriments_multiple_entries() { let entries = vec![ json!({"energy-kcal_100g": 250.0, "proteins_100g": 20.0}), json!({"energy-kcal_100g": 150.0, "proteins_100g": 10.0}), ]; let totals = tools::sum_nutriments(&entries); assert_eq!(totals["energy_kcal"], json!(400.0)); assert_eq!(totals["protein"], json!(30.0)); } #[test] fn test_sum_nutriments_no_data_for_missing() { // Entry has only energy, not potassium — potassium should be "no data" let entries = vec![json!({"energy-kcal_100g": 250.0})]; let totals = tools::sum_nutriments(&entries); assert_eq!(totals["energy_kcal"], json!(250.0)); assert_eq!(totals["potassium"], json!("no data")); } #[test] fn test_sum_nutriments_empty() { let entries: Vec = vec![]; let totals = tools::sum_nutriments(&entries); // All should be "no data" when no entries assert_eq!(totals["energy_kcal"], json!("no data")); assert_eq!(totals["protein"], json!("no data")); assert_eq!(totals["fructose"], json!("no data")); } #[test] fn test_sum_nutriments_partial_data() { // One entry has potassium, another doesn't — should sum available let entries = vec![ json!({"potassium_100g": 100.0}), json!({"potassium_100g": 50.0, "energy-kcal_100g": 200.0}), ]; let totals = tools::sum_nutriments(&entries); assert_eq!(totals["potassium"], json!(150.0)); assert_eq!(totals["energy_kcal"], json!(200.0)); } // ── Date/meal tests ────────────────────────────────────────── #[test] fn test_today_format() { let today = tools::today(); assert!(regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap().is_match(&today)); } #[test] fn test_detect_meal_returns_valid_value() { let meal = tools::detect_meal(); assert!( meal == "breakfast" || meal == "lunch" || meal == "dinner" || meal == "snack", "detect_meal returned invalid value: {meal}" ); } // ── Integration: entries round trips ───────────────────────── #[tokio::test] async fn test_log_entry_and_daily_summary() { let db = TestDb::new().await; // Insert a product into cache let product = Product { code: "blueberry-test".to_string(), product_name: "Blueberries".to_string(), brands: Some("TestBrand".to_string()), nutriments: json!({ "energy-kcal_100g": 57.7, "proteins_100g": 0.87, "carbohydrates_100g": 10.6, "fat_100g": 0.33, "fiber_100g": 2.4, "sugars_100g": 9.96, "fructose_100g": 4.97, "salt_100g": 0.0025, "saturated-fat_100g": 0.028, "potassium_100g": 0.077, "alcohol_100g": 0, "cholesterol_100g": 0, }) .to_string(), serving_quantity: Some(100.0), serving_size: Some("100g".to_string()), image_url: None, }; OffClient::cache_products(&db.pool, &[product]).await.unwrap(); // Log 50g of blueberries let date = chrono::Local::now().format("%Y-%m-%d").to_string(); let scaled = tools::scale_nutriments( &json!({ "energy-kcal_100g": 57.7, "proteins_100g": 0.87, "fructose_100g": 4.97, "potassium_100g": 0.077, }), 50.0, ); sqlx::query( r#"INSERT INTO entries (date, meal, product_code, food_name, grams, nutriments) VALUES (?, ?, ?, ?, ?, ?)"#, ) .bind(&date) .bind("breakfast") .bind("blueberry-test") .bind("Blueberries") .bind(50.0_f64) .bind(scaled.to_string()) .execute(&db.pool) .await .unwrap(); // Query entries for today let rows = sqlx::query("SELECT food_name, grams, nutriments FROM entries WHERE date = ?") .bind(&date) .fetch_all(&db.pool) .await .unwrap(); assert_eq!(rows.len(), 1); let n_str: String = sqlx::Row::get(&rows[0], "nutriments"); let n: Value = serde_json::from_str(&n_str).unwrap(); // 50g of 57.7 kcal/100g = 28.85 kcal assert!((n["energy-kcal_100g"].as_f64().unwrap() - 28.85).abs() < 0.01); // 50g of 4.97g fructose/100g = 2.485g assert!((n["fructose_100g"].as_f64().unwrap() - 2.485).abs() < 0.001); } #[tokio::test] async fn test_log_custom_food_and_sum() { let db = TestDb::new().await; let date = "2026-01-15"; // Log a custom food: 200g of homemade salad, 150 kcal/100g let nutriments = json!({ "energy-kcal_100g": 150.0, "proteins_100g": 5.0, "carbohydrates_100g": 20.0, "fat_100g": 6.0, }); let scaled = tools::scale_nutriments(&nutriments, 200.0); sqlx::query( r#"INSERT INTO entries (date, meal, food_name, grams, nutriments) VALUES (?, ?, ?, ?, ?)"#, ) .bind(date) .bind("lunch") .bind("Homemade Salad") .bind(200.0_f64) .bind(scaled.to_string()) .execute(&db.pool) .await .unwrap(); // Fetch and sum let rows = sqlx::query("SELECT nutriments FROM entries WHERE date = ?") .bind(date) .fetch_all(&db.pool) .await .unwrap(); let entries_n: Vec = rows .iter() .map(|r| { let s: String = sqlx::Row::get(r, "nutriments"); serde_json::from_str(&s).unwrap() }) .collect(); let totals = tools::sum_nutriments(&entries_n); // 200g of 150 kcal/100g = 300 kcal assert_eq!(totals["energy_kcal"], json!(300.0)); // 200g of 5g protein/100g = 10g assert_eq!(totals["protein"], json!(10.0)); } #[tokio::test] async fn test_delete_entry() { let db = TestDb::new().await; let result = sqlx::query( r#"INSERT INTO entries (date, meal, food_name, grams, nutriments) VALUES ('2026-01-15', 'breakfast', 'Test', 100.0, '{}')"#, ) .execute(&db.pool) .await .unwrap(); let id = result.last_insert_rowid(); sqlx::query("DELETE FROM entries WHERE id = ?") .bind(id) .execute(&db.pool) .await .unwrap(); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(count, 0); } // ── Goals round trip ───────────────────────────────────────── #[tokio::test] async fn test_get_and_update_goals() { let db = TestDb::new().await; // Get default goals let row = sqlx::query("SELECT goals FROM daily_goal WHERE id = 1") .fetch_one(&db.pool) .await .unwrap(); let goals_str: String = sqlx::Row::get(&row, "goals"); let mut goals: Value = serde_json::from_str(&goals_str).unwrap(); assert_eq!(goals["energy_kcal"], json!(2500)); // Update calories goal if let Some(obj) = goals.as_object_mut() { obj.insert("energy_kcal".to_string(), json!(2200)); obj.insert("fructose".to_string(), json!(10)); } sqlx::query("UPDATE daily_goal SET goals = ?, updated_at = datetime('now') WHERE id = 1") .bind(goals.to_string()) .execute(&db.pool) .await .unwrap(); // Verify let row = sqlx::query("SELECT goals FROM daily_goal WHERE id = 1") .fetch_one(&db.pool) .await .unwrap(); let goals_str: String = sqlx::Row::get(&row, "goals"); let goals: Value = serde_json::from_str(&goals_str).unwrap(); assert_eq!(goals["energy_kcal"], json!(2200)); assert_eq!(goals["fructose"], json!(10)); // Other goals preserved assert_eq!(goals["protein"], json!(150)); } // ── Weight round trip ───────────────────────────────────────── #[tokio::test] async fn test_log_weight_and_history() { let db = TestDb::new().await; sqlx::query("INSERT INTO weight_log (date, weight_kg) VALUES ('2026-01-10', 75.5)") .execute(&db.pool) .await .unwrap(); sqlx::query("INSERT INTO weight_log (date, weight_kg) VALUES ('2026-01-11', 75.3)") .execute(&db.pool) .await .unwrap(); sqlx::query("INSERT INTO weight_log (date, weight_kg) VALUES ('2026-01-12', 75.1)") .execute(&db.pool) .await .unwrap(); let rows = sqlx::query("SELECT date, weight_kg FROM weight_log ORDER BY date") .fetch_all(&db.pool) .await .unwrap(); assert_eq!(rows.len(), 3); let w0: f64 = sqlx::Row::get(&rows[0], "weight_kg"); let w2: f64 = sqlx::Row::get(&rows[2], "weight_kg"); assert_eq!(w0, 75.5); assert_eq!(w2, 75.1); } #[tokio::test] async fn test_weight_upsert_same_day() { let db = TestDb::new().await; sqlx::query("INSERT INTO weight_log (date, weight_kg) VALUES ('2026-01-10', 75.5)") .execute(&db.pool) .await .unwrap(); // Same day, different weight — should upsert (in production; here just test insert doesn't fail) sqlx::query( r#"INSERT INTO weight_log (date, weight_kg) VALUES ('2026-01-10', 75.0) ON CONFLICT DO UPDATE SET weight_kg = excluded.weight_kg"#, ) .execute(&db.pool) .await .unwrap(); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM weight_log WHERE date = '2026-01-10'") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(count, 1); let weight: f64 = sqlx::query_scalar("SELECT weight_kg FROM weight_log WHERE date = '2026-01-10'") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(weight, 75.0); } // ── Ingest endpoint (vitals / Apple Health) ───────────────── #[tokio::test] async fn test_db_init_creates_vitals_table() { let db = TestDb::new().await; // The vitals table is created by init_database; a query against it // should succeed and return zero rows on a fresh DB. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM vitals") .fetch_one(&db.pool) .await .expect("vitals table should exist after init"); assert_eq!(count, 0); } #[tokio::test] async fn test_ingest_samples_upserts_vitals() { use nutrition_mcp::ingest::{IngestRequest, IngestSample}; use nutrition_mcp::ingest::ingest_samples; let db = TestDb::new().await; let req = IngestRequest { samples: vec![ IngestSample { sample_type: "weight".into(), timestamp_utc: "2026-08-20T08:00:00Z".into(), value: json!(83.7), source: Some("apple_health".into()), }, IngestSample { sample_type: "blood_pressure".into(), timestamp_utc: "2026-08-20T08:05:00Z".into(), value: json!({"systolic": 132, "diastolic": 87}), source: None, }, ], }; let report = ingest_samples(&db.pool, &req).await; assert_eq!(report.status, "ok"); assert_eq!(report.received, 2); assert_eq!(report.ingested, 2); assert_eq!(report.skipped, 0); assert!(report.errors.is_empty()); // Weight sample must also mirror into weight_log. assert_eq!(report.weight_synced, 1); // Verify both rows landed in vitals. let vitals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM vitals") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(vitals, 2); // Blood pressure stored as JSON text. let bp_row = sqlx::query("SELECT value FROM vitals WHERE type = 'blood_pressure'") .fetch_one(&db.pool) .await .unwrap(); let bp_value: String = sqlx::Row::get(&bp_row, "value"); assert!(bp_value.contains("132")); // Weight mirrored into weight_log with the date portion only. let wg: f64 = sqlx::query_scalar("SELECT weight_kg FROM weight_log WHERE date = '2026-08-20'") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(wg, 83.7); } #[tokio::test] async fn test_ingest_samples_is_idempotent() { use nutrition_mcp::ingest::{IngestRequest, IngestSample}; use nutrition_mcp::ingest::ingest_samples; let db = TestDb::new().await; let req = IngestRequest { samples: vec![IngestSample { sample_type: "weight".into(), timestamp_utc: "2026-08-20T08:00:00Z".into(), value: json!(83.7), source: Some("apple_health".into()), }], }; let _first = ingest_samples(&db.pool, &req).await; // Re-posting the same (type, timestamp_utc) upserts, not duplicates. let _second = ingest_samples(&db.pool, &req).await; let vitals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM vitals WHERE type = 'weight'") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(vitals, 1); } #[tokio::test] async fn test_ingest_samples_rejects_bad_timestamp() { use nutrition_mcp::ingest::{IngestRequest, IngestSample}; use nutrition_mcp::ingest::ingest_samples; let db = TestDb::new().await; let req = IngestRequest { samples: vec![IngestSample { sample_type: "spo2".into(), timestamp_utc: "not-a-date".into(), value: json!(97), source: None, }], }; let report = ingest_samples(&db.pool, &req).await; assert_eq!(report.status, "partial"); assert_eq!(report.ingested, 0); assert_eq!(report.skipped, 1); assert_eq!(report.errors.len(), 1); assert!(report.errors[0].reason.contains("timestamp")); // Nothing should have been written. let vitals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM vitals") .fetch_one(&db.pool) .await .unwrap(); assert_eq!(vitals, 0); } // recent_vitals reads back from vitals with the same grouped query the // tool uses, so this test guards the read path: ingest a few types, then // fetch-and-group exactly like recent_vitals does. #[tokio::test] async fn test_recent_vitals_reads_grouped_by_type() { use nutrition_mcp::ingest::{IngestRequest, IngestSample, ingest_samples}; let db = TestDb::new().await; // Two weight readings, two BP readings, one spo2 — all today. let req = IngestRequest { samples: vec![ IngestSample { sample_type: "weight".into(), timestamp_utc: "2026-08-20T07:00:00Z".into(), value: json!(83.5), source: Some("apple_health".into()), }, IngestSample { sample_type: "weight".into(), timestamp_utc: "2026-08-19T07:00:00Z".into(), value: json!(84.0), source: Some("apple_health".into()), }, IngestSample { sample_type: "blood_pressure".into(), timestamp_utc: "2026-08-20T08:00:00Z".into(), value: json!({"systolic": 130, "diastolic": 85}), source: Some("apple_health".into()), }, IngestSample { sample_type: "spo2".into(), timestamp_utc: "2026-08-20T08:05:00Z".into(), value: json!(97), source: Some("apple_health".into()), }, ], }; let report = ingest_samples(&db.pool, &req).await; assert_eq!(report.ingested, 4, "all 4 samples should ingest"); // Same grouping query recent_vitals runs (last 7 days). let count: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM vitals WHERE timestamp_utc >= date('now', '-7 days')"#, ) .fetch_one(&db.pool) .await .unwrap(); assert_eq!(count, 4); // Distinct types should be 3 (weight, blood_pressure, spo2). let distinct: i64 = sqlx::query_scalar( r#"SELECT COUNT(DISTINCT type) FROM vitals WHERE timestamp_utc >= date('now', '-7 days')"#, ) .fetch_one(&db.pool) .await .unwrap(); assert_eq!(distinct, 3, "expect weight, blood_pressure, spo2"); } // Empty-window read path: a fresh DB must yield zero rows, which the // tool turns into the "nothing ingested yet" message. #[tokio::test] async fn test_recent_vitals_empty_window() { let db = TestDb::new().await; let count: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM vitals WHERE timestamp_utc >= date('now', '-7 days')"#, ) .fetch_one(&db.pool) .await .unwrap(); assert_eq!(count, 0); } }