nutrition-mcp/tests/integration_tests.rs
Anthony Merlo d0b7b003f9 feat: fallback nutrient estimation for potassium/calcium/magnesium
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)
2026-08-21 11:36:14 +01:00

778 lines
No EOL
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
}