test: add 25-test suite + fix sum_nutriments display keys

- 25 integration tests: DB init, default goals, FTS5 cache, upsert,
  portion scaling (100g/50g/200g/empty/non-numeric), sum_nutriments
  (single/multiple/empty/partial/no-data), meal detection, date format,
  log entry + summary round trip, custom food + sum, delete entry,
  goals get/update, weight log + history + upsert
- Fix: sum_nutriments now uses TRACKED_NUTRIENTS display names (first
  tuple element) instead of deriving from OFF field names — fixes
  proteins→protein, energy-kcal→energy_kcal mismatches
- Fix: weight_log.date is now UNIQUE for proper upsert behavior
- Fix: build_daily_summary remaining keys use display names
- Added lib.rs for integration test access
- Added regex dev-dependency

Closes nutrition-mcp-45t
This commit is contained in:
Anthony Merlo 2026-08-20 17:25:41 +01:00
parent 2eaa67145c
commit ebb44fd712
7 changed files with 592 additions and 10 deletions

13
Cargo.lock generated
View file

@ -1100,6 +1100,7 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"chrono", "chrono",
"regex",
"reqwest", "reqwest",
"rmcp", "rmcp",
"schemars", "schemars",
@ -1436,6 +1437,18 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.18" version = "0.4.18"

View file

@ -5,6 +5,14 @@ edition = "2024"
description = "MCP server for personal calorie/nutrient tracking with OpenFood Facts integration" description = "MCP server for personal calorie/nutrient tracking with OpenFood Facts integration"
license = "MIT" license = "MIT"
[lib]
name = "nutrition_mcp"
path = "src/lib.rs"
[[bin]]
name = "nutrition-mcp"
path = "src/main.rs"
[dependencies] [dependencies]
# MCP protocol # MCP protocol
rmcp = { version = "0.7", features = [ rmcp = { version = "0.7", features = [
@ -47,3 +55,6 @@ uuid = { version = "1", features = ["v4"] }
[profile.release] [profile.release]
opt-level = 2 opt-level = 2
strip = true strip = true
[dev-dependencies]
regex = "1"

View file

@ -164,7 +164,7 @@ async fn run_migrations(pool: &DbPool, db_path: &Path) -> Result<()> {
sqlx::query( sqlx::query(
r#"CREATE TABLE IF NOT EXISTS weight_log ( r#"CREATE TABLE IF NOT EXISTS weight_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, date TEXT NOT NULL UNIQUE,
weight_kg REAL NOT NULL, weight_kg REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"#, )"#,

7
src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
// Library crate — exposes modules for integration tests.
// The binary (main.rs) re-uses these same modules.
pub mod api;
pub mod config;
pub mod db;
pub mod tools;

View file

@ -164,11 +164,11 @@ pub struct BulkImportParams {
// ── Helper functions ──────────────────────────────────────────────── // ── Helper functions ────────────────────────────────────────────────
fn today() -> String { pub fn today() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string() chrono::Local::now().format("%Y-%m-%d").to_string()
} }
fn detect_meal() -> String { pub fn detect_meal() -> String {
let hour = chrono::Local::now().format("%H").to_string().parse::<u32>().unwrap_or(12); let hour = chrono::Local::now().format("%H").to_string().parse::<u32>().unwrap_or(12);
match hour { match hour {
h if h < 11 => "breakfast".to_string(), h if h < 11 => "breakfast".to_string(),
@ -180,7 +180,7 @@ fn detect_meal() -> String {
/// Scale nutrient JSON from per-100g to actual portion. /// Scale nutrient JSON from per-100g to actual portion.
/// multiplies each numeric value by (grams / 100). /// multiplies each numeric value by (grams / 100).
fn scale_nutriments(nutriments: &Value, grams: f64) -> Value { pub fn scale_nutriments(nutriments: &Value, grams: f64) -> Value {
let factor = grams / 100.0; let factor = grams / 100.0;
if let Some(obj) = nutriments.as_object() { if let Some(obj) = nutriments.as_object() {
let scaled: serde_json::Map<String, Value> = obj let scaled: serde_json::Map<String, Value> = obj
@ -202,10 +202,10 @@ fn scale_nutriments(nutriments: &Value, grams: f64) -> Value {
/// Build a summary JSON from a list of entries' nutriments. /// Build a summary JSON from a list of entries' nutriments.
/// Sums all tracked nutrients, returns "no data" for missing ones. /// Sums all tracked nutrients, returns "no data" for missing ones.
fn sum_nutriments(entries_nutriments: &[Value]) -> Value { pub fn sum_nutriments(entries_nutriments: &[Value]) -> Value {
let mut totals: serde_json::Map<String, Value> = serde_json::Map::new(); let mut totals: serde_json::Map<String, Value> = serde_json::Map::new();
for (_, tracked_key) in TRACKED_NUTRIENTS { for (display_key, tracked_key) in TRACKED_NUTRIENTS {
let mut sum = 0.0; let mut sum = 0.0;
let mut has_data = false; let mut has_data = false;
for entry_nutriments in entries_nutriments { for entry_nutriments in entries_nutriments {
@ -214,9 +214,8 @@ fn sum_nutriments(entries_nutriments: &[Value]) -> Value {
has_data = true; has_data = true;
} }
} }
let display_key = tracked_key.replace("_100g", "");
totals.insert( totals.insert(
display_key, display_key.to_string(),
if has_data { json!(sum) } else { json!("no data") }, if has_data { json!(sum) } else { json!("no data") },
); );
} }
@ -780,7 +779,7 @@ impl NutritionServer {
"totals": totals, "totals": totals,
"goals": goals, "goals": goals,
"remaining": { "remaining": {
"energy_kcal": remaining("energy-kcal"), "energy_kcal": remaining("energy_kcal"),
"protein": remaining("protein"), "protein": remaining("protein"),
"fructose": remaining("fructose"), "fructose": remaining("fructose"),
"salt": remaining("salt"), "salt": remaining("salt"),

37
tests/common/mod.rs Normal file
View file

@ -0,0 +1,37 @@
// Common test utilities.
use std::path::PathBuf;
use nutrition_mcp::db::{self, DbPool};
/// A temporary SQLite database for testing.
/// Creates a unique DB file in /tmp that is cleaned up on drop.
pub struct TestDb {
pub pool: DbPool,
_path: PathBuf,
}
impl TestDb {
pub async fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"nutrition-test-{}-{}.db",
std::process::id(),
uuid::Uuid::new_v4()
));
let pool = db::init_database(&path).await.expect("failed to init test DB");
Self {
pool,
_path: path,
}
}
}
impl Drop for TestDb {
fn drop(&mut self) {
// Best-effort cleanup — ignore errors
let _ = std::fs::remove_file(&self._path);
let wal = self._path.with_extension("db-wal");
let shm = self._path.with_extension("db-shm");
let _ = std::fs::remove_file(&wal);
let _ = std::fs::remove_file(&shm);
}
}

515
tests/integration_tests.rs Normal file
View file

@ -0,0 +1,515 @@
// 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, 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<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);
}
}