OFF data is sparse for fresh produce — spinach, oats, most frozen fruit have no potassium. Added a curated fallback table (114 foods, USDA-sourced) that injects estimated K/Ca/Mg at log time when OFF has no data. How it works: - data/fallback_nutrients.json: 114 foods with mg/100g values - FallbackTable: case-insensitive exact + substring name matching - log_food: injects _est keys (potassium_100g_est etc.) when OFF missing - sum_nutriments: tries OFF value first (grams×1000), then _est (already mg) Results for today's smoothie + lunch: - Potassium: 691mg → 2,530mg (47% of 5400mg goal) - Calcium: 14mg → 304mg (30% of 1000mg goal) - Magnesium: 61mg → 330mg (73% of 450mg goal) 38 tests passing (7 new: fallback lookup, est fallback, OFF vs est priority)
778 lines
No EOL
28 KiB
Rust
778 lines
No EOL
28 KiB
Rust
// 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<Value> = 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 (in grams, as OFF stores it), another doesn't
|
||
// 0.1g = 100mg, 0.05g = 50mg → sum should be 150mg
|
||
let entries = vec![
|
||
json!({"potassium_100g": 0.1}),
|
||
json!({"potassium_100g": 0.05, "energy-kcal_100g": 200.0}),
|
||
];
|
||
let totals = tools::sum_nutriments(&entries);
|
||
assert_eq!(totals["potassium"], json!(150.0)); // 100mg + 50mg = 150mg
|
||
assert_eq!(totals["energy_kcal"], json!(200.0));
|
||
}
|
||
|
||
#[test]
|
||
fn test_sum_nutriments_uses_est_fallback() {
|
||
// Entry has only _est potassium (from fallback table, already in mg)
|
||
let entries = vec![json!({"potassium_100g_est": 558.0})];
|
||
let totals = tools::sum_nutriments(&entries);
|
||
assert_eq!(totals["potassium"], json!(558.0));
|
||
}
|
||
|
||
#[test]
|
||
fn test_sum_nutriments_prefers_off_over_est() {
|
||
// Entry has both OFF value (0.32g = 320mg) and _est (558mg)
|
||
// OFF value should win
|
||
let entries = vec![json!({"potassium_100g": 0.32, "potassium_100g_est": 558.0})];
|
||
let totals = tools::sum_nutriments(&entries);
|
||
assert_eq!(totals["potassium"], json!(320.0)); // 0.32g × 1000 = 320mg
|
||
}
|
||
|
||
#[test]
|
||
fn test_sum_nutriments_mixed_off_and_est() {
|
||
// Entry 1 has OFF potassium (0.32g = 320mg)
|
||
// Entry 2 has only _est potassium (558mg from fallback)
|
||
let entries = vec![
|
||
json!({"potassium_100g": 0.32}),
|
||
json!({"potassium_100g_est": 558.0}),
|
||
];
|
||
let totals = tools::sum_nutriments(&entries);
|
||
assert_eq!(totals["potassium"], json!(878.0)); // 320 + 558
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_lookup_exact() {
|
||
let table = nutrition_mcp::api::FallbackTable::load();
|
||
let result = table.lookup("spinach");
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap().potassium_100g, Some(558.0));
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_lookup_substring() {
|
||
let table = nutrition_mcp::api::FallbackTable::load();
|
||
// "Leaf spinach" should match "spinach" via substring
|
||
let result = table.lookup("Leaf spinach");
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap().potassium_100g, Some(558.0));
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_lookup_no_match() {
|
||
let table = nutrition_mcp::api::FallbackTable::load();
|
||
let result = table.lookup("alien space dust");
|
||
assert!(result.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_lookup_case_insensitive() {
|
||
let table = nutrition_mcp::api::FallbackTable::load();
|
||
let result = table.lookup("BANANA");
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap().potassium_100g, Some(358.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<Value> = 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);
|
||
}
|
||
} |