TICKET-004: Day summary endpoint
- GET /api/log/summary?date= with totals vs historical target - Nutrition math consolidated in services/nutrition.py (weight vs count) - Null nutrition contributes 0; meals contribute 0 (TODO TICKET-007) - Full suite green (104 passed)
This commit is contained in:
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate
|
from schemas import DaySummaryResponse, LogEntryCreate, LogEntryRead, LogEntryUpdate
|
||||||
from services import log as svc
|
from services import log as svc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/log", tags=["log"])
|
router = APIRouter(prefix="/api/log", tags=["log"])
|
||||||
@@ -29,6 +29,12 @@ def create_log_entry(data: LogEntryCreate, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=DaySummaryResponse)
|
||||||
|
def get_day_summary(date: date, db: Session = Depends(get_db)):
|
||||||
|
"""Computed nutrition totals for the day vs. the applicable target (§3.3)."""
|
||||||
|
return svc.get_day_summary(db, date)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{entry_id}", response_model=LogEntryRead)
|
@router.put("/{entry_id}", response_model=LogEntryRead)
|
||||||
def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)):
|
def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)):
|
||||||
"""Update quantity, meal_slot, and/or sort_order.
|
"""Update quantity, meal_slot, and/or sort_order.
|
||||||
|
|||||||
@@ -182,3 +182,38 @@ class TargetRead(BaseModel):
|
|||||||
protein_g: float | None
|
protein_g: float | None
|
||||||
carbs_g: float | None
|
carbs_g: float | None
|
||||||
fat_g: float | None
|
fat_g: float | None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Day Summary (TICKET-004) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class DaySummaryNutrition(BaseModel):
|
||||||
|
"""Summed nutrition totals for a single day.
|
||||||
|
|
||||||
|
All fields are floats (summed from scaled per-unit values).
|
||||||
|
Foods with null nutrition fields contribute 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
calories: float
|
||||||
|
protein_g: float
|
||||||
|
carbs_g: float
|
||||||
|
fat_g: float
|
||||||
|
fiber_g: float
|
||||||
|
saturated_fat_g: float
|
||||||
|
sugars_g: float
|
||||||
|
sodium_g: float
|
||||||
|
|
||||||
|
|
||||||
|
class DaySummaryResponse(BaseModel):
|
||||||
|
"""Response shape for GET /api/log/summary — the contract the frontend
|
||||||
|
ProgressBar consumes.
|
||||||
|
|
||||||
|
Remaining-vs-target arithmetic: LEFT TO THE FRONTEND.
|
||||||
|
The server returns raw totals and the applicable target (or null).
|
||||||
|
The frontend computes remaining = target.calories - totals.calories
|
||||||
|
and similar for macros where both target and total exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
totals: DaySummaryNutrition
|
||||||
|
target: TargetRead | None
|
||||||
|
|||||||
+55
-1
@@ -15,7 +15,15 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from models import DailyLogEntry, Food
|
from models import DailyLogEntry, Food
|
||||||
from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate
|
from schemas import (
|
||||||
|
DaySummaryNutrition,
|
||||||
|
DaySummaryResponse,
|
||||||
|
LogEntryCreate,
|
||||||
|
LogEntryRead,
|
||||||
|
LogEntryUpdate,
|
||||||
|
)
|
||||||
|
from services import nutrition
|
||||||
|
from services import targets as targets_svc
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -125,3 +133,49 @@ class FoodNotAvailableError(Exception):
|
|||||||
def __init__(self, food_id: int):
|
def __init__(self, food_id: int):
|
||||||
super().__init__(f"Food {food_id} not available for logging")
|
super().__init__(f"Food {food_id} not available for logging")
|
||||||
self.food_id = food_id
|
self.food_id = food_id
|
||||||
|
|
||||||
|
|
||||||
|
# ── Day Summary (TICKET-004) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
|
||||||
|
"""Compute nutrition totals for a date vs. the applicable target.
|
||||||
|
|
||||||
|
1. Loads all log entries for the date with food data eagerly joined.
|
||||||
|
2. For each entry, scales the food's per-unit nutrition to the logged
|
||||||
|
quantity via nutrition.entry_nutrition() — weight-type foods get
|
||||||
|
(qty/100)× scaling, count-type foods get qty× scaling (§2.1).
|
||||||
|
3. Meal entries (is_meal=True) currently contribute 0 because their
|
||||||
|
per_unit fields are null per the CHECK constraint.
|
||||||
|
TODO: TICKET-007 — replace with recursive component summation.
|
||||||
|
4. Looks up the target covering the date via the half-open interval
|
||||||
|
lookup from targets.get_target_for_date(). Returns null if none.
|
||||||
|
|
||||||
|
Returns a DaySummaryResponse with summed totals and the applicable
|
||||||
|
target (or null).
|
||||||
|
"""
|
||||||
|
entries = db.scalars(
|
||||||
|
select(DailyLogEntry)
|
||||||
|
.where(DailyLogEntry.date == lookup_date)
|
||||||
|
.options(joinedload(DailyLogEntry.food))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Sum nutrition across all entries for the date.
|
||||||
|
# TODO: TICKET-007 — meal entries currently contribute 0 because their
|
||||||
|
# per_unit fields are null per the CHECK constraint. Real meal nutrition
|
||||||
|
# will be derived by recursively summing component foods' nutrition.
|
||||||
|
# When that lands, replace the flat entry_nutrition() call with a
|
||||||
|
# meal-aware sum function from services/nutrition.py.
|
||||||
|
totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS}
|
||||||
|
for entry in entries:
|
||||||
|
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
|
||||||
|
for field, value in entry_nut.items():
|
||||||
|
totals[field] += value
|
||||||
|
|
||||||
|
target = targets_svc.get_target_for_date(db, lookup_date)
|
||||||
|
|
||||||
|
return DaySummaryResponse(
|
||||||
|
date=lookup_date,
|
||||||
|
totals=DaySummaryNutrition(**totals),
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ Routers never compute nutrition; the frontend never re-derives it.
|
|||||||
|
|
||||||
from models import Food
|
from models import Food
|
||||||
|
|
||||||
|
# All nutrition fields tracked on foods. Each has a corresponding *_per_unit
|
||||||
|
# column on the Food model. Used by entry_nutrition() to iterate over fields.
|
||||||
|
NUTRITION_FIELDS = [
|
||||||
|
"calories", "protein_g", "carbs_g", "fat_g",
|
||||||
|
"fiber_g", "saturated_fat_g", "sugars_g", "sodium_g",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Maps each NUTRITION_FIELDS key to the Food model's per_unit column name.
|
||||||
|
# Naming is slightly irregular (e.g. "protein_g" → "protein_per_unit",
|
||||||
|
# not "protein_g_per_unit").
|
||||||
|
_FIELD_TO_PER_UNIT_COL = {
|
||||||
|
"calories": "calories_per_unit",
|
||||||
|
"protein_g": "protein_per_unit",
|
||||||
|
"carbs_g": "carbs_per_unit",
|
||||||
|
"fat_g": "fat_per_unit",
|
||||||
|
"fiber_g": "fiber_per_unit",
|
||||||
|
"saturated_fat_g": "saturated_fat_per_unit",
|
||||||
|
"sugars_g": "sugars_per_unit",
|
||||||
|
"sodium_g": "sodium_per_unit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -> float:
|
def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -> float:
|
||||||
"""Scale a per-unit nutrition value to a logged quantity. Missing values count as 0."""
|
"""Scale a per-unit nutrition value to a logged quantity. Missing values count as 0."""
|
||||||
@@ -28,3 +49,24 @@ def entry_calories(food: Food, quantity: float) -> float:
|
|||||||
(recursively, with cycle detection — spec §2.2).
|
(recursively, with cycle detection — spec §2.2).
|
||||||
"""
|
"""
|
||||||
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
|
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_nutrition(food: Food, quantity: float) -> dict[str, float]:
|
||||||
|
"""Return all nutrition fields scaled to a logged quantity.
|
||||||
|
|
||||||
|
Each field is resolved via scale_to_quantity using the food's unit_type.
|
||||||
|
NULL per-unit values contribute 0.0, not an error.
|
||||||
|
|
||||||
|
Meal foods (is_meal=True) have null per_unit fields per the CHECK
|
||||||
|
constraint, so they naturally contribute 0 for all fields.
|
||||||
|
TODO: TICKET-007 — real meal nutrition will sum scaled component
|
||||||
|
foods recursively. Until then, meal entries contribute 0.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
field: scale_to_quantity(
|
||||||
|
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
|
||||||
|
quantity,
|
||||||
|
food.unit_type,
|
||||||
|
)
|
||||||
|
for field in NUTRITION_FIELDS
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Day summary tests — TICKET-004 (spec §2.1 quantity interpretation, §3.3 /api/log/summary, §8.1 rule 1).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- weight-type scaling (150g of 380kcal/100g food = 570)
|
||||||
|
- count-type scaling (2 × 70kcal egg = 140)
|
||||||
|
- mixed entries summing
|
||||||
|
- null nutrition fields contribute 0
|
||||||
|
- correct target selected for historical dates
|
||||||
|
- no-target case (target: null)
|
||||||
|
- meal entries contribute 0 (TODO TICKET-007)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _create_food(client, **overrides) -> dict:
|
||||||
|
"""Create a food via POST and return the response JSON."""
|
||||||
|
payload = {
|
||||||
|
"name": "Test Food",
|
||||||
|
"calories_per_unit": 250.0,
|
||||||
|
"source": "manual",
|
||||||
|
"unit_type": "weight",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = client.post("/api/foods", json=payload)
|
||||||
|
assert resp.status_code == 201, f"food create failed: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_entry(client, food_id, quantity, date="2025-06-15", **overrides) -> dict:
|
||||||
|
"""Create a log entry and return the parsed JSON (asserts 201)."""
|
||||||
|
payload = {"food_id": food_id, "quantity": quantity, "date": date}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = client.post("/api/log", json=payload)
|
||||||
|
assert resp.status_code == 201, f"log create failed: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_summary(client, date="2025-06-15") -> dict:
|
||||||
|
"""Call the summary endpoint and return parsed JSON (asserts 200)."""
|
||||||
|
resp = client.get("/api/log/summary", params={"date": date})
|
||||||
|
assert resp.status_code == 200, f"summary failed: {resp.text}"
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Basic shape ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_empty_date_returns_zeros_and_null_target(client):
|
||||||
|
"""A date with no log entries → all nutrition fields zero, target null."""
|
||||||
|
data = _get_summary(client, "2099-01-01")
|
||||||
|
assert data["date"] == "2099-01-01"
|
||||||
|
assert data["target"] is None
|
||||||
|
t = data["totals"]
|
||||||
|
assert t["calories"] == 0.0
|
||||||
|
assert t["protein_g"] == 0.0
|
||||||
|
assert t["carbs_g"] == 0.0
|
||||||
|
assert t["fat_g"] == 0.0
|
||||||
|
# Bonus fields
|
||||||
|
assert t["fiber_g"] == 0.0
|
||||||
|
assert t["saturated_fat_g"] == 0.0
|
||||||
|
assert t["sugars_g"] == 0.0
|
||||||
|
assert t["sodium_g"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Weight-type scaling (§2.1) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_type_scaling_150g_of_380kcal_per_100g(client):
|
||||||
|
"""150g of a 380kcal/100g food = 570 kcal. Math: 380 × 150/100 = 570."""
|
||||||
|
food = _create_food(client, name="Olive Oil", calories_per_unit=380.0, unit_type="weight")
|
||||||
|
_log_entry(client, food["id"], quantity=150.0, date="2025-07-01")
|
||||||
|
data = _get_summary(client, "2025-07-01")
|
||||||
|
assert data["totals"]["calories"] == 570.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_type_scaling_macros(client):
|
||||||
|
"""Macros also scale per 100g for weight-type foods."""
|
||||||
|
food = _create_food(
|
||||||
|
client,
|
||||||
|
unit_type="weight",
|
||||||
|
calories_per_unit=200.0,
|
||||||
|
protein_per_unit=10.0,
|
||||||
|
carbs_per_unit=20.0,
|
||||||
|
fat_per_unit=5.0,
|
||||||
|
)
|
||||||
|
_log_entry(client, food["id"], quantity=250.0, date="2025-07-02")
|
||||||
|
data = _get_summary(client, "2025-07-02")
|
||||||
|
t = data["totals"]
|
||||||
|
assert t["calories"] == 500.0 # 200 × 250/100
|
||||||
|
assert t["protein_g"] == 25.0 # 10 × 250/100
|
||||||
|
assert t["carbs_g"] == 50.0 # 20 × 250/100
|
||||||
|
assert t["fat_g"] == 12.5 # 5 × 250/100
|
||||||
|
|
||||||
|
|
||||||
|
# ── Count-type scaling (§2.1) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_type_scaling_2_eggs(client):
|
||||||
|
"""2 × 70kcal egg = 140 kcal. Math: 70 × 2 = 140."""
|
||||||
|
food = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
|
||||||
|
_log_entry(client, food["id"], quantity=2.0, date="2025-07-03")
|
||||||
|
data = _get_summary(client, "2025-07-03")
|
||||||
|
assert data["totals"]["calories"] == 140.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_type_scaling_macros(client):
|
||||||
|
"""Count-type macros scale per item."""
|
||||||
|
food = _create_food(
|
||||||
|
client,
|
||||||
|
unit_type="count",
|
||||||
|
calories_per_unit=90.0,
|
||||||
|
protein_per_unit=6.0,
|
||||||
|
carbs_per_unit=1.0,
|
||||||
|
fat_per_unit=7.0,
|
||||||
|
)
|
||||||
|
_log_entry(client, food["id"], quantity=3.0, date="2025-07-04")
|
||||||
|
data = _get_summary(client, "2025-07-04")
|
||||||
|
t = data["totals"]
|
||||||
|
assert t["calories"] == 270.0 # 90 × 3
|
||||||
|
assert t["protein_g"] == 18.0 # 6 × 3
|
||||||
|
assert t["carbs_g"] == 3.0 # 1 × 3
|
||||||
|
assert t["fat_g"] == 21.0 # 7 × 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixed entries summing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_entries_summed(client):
|
||||||
|
"""Two weight-type foods on the same date → totals are summed."""
|
||||||
|
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||||
|
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||||
|
|
||||||
|
_log_entry(client, f1["id"], quantity=200.0, date="2025-07-05") # 260 kcal
|
||||||
|
_log_entry(client, f2["id"], quantity=150.0, date="2025-07-05") # 247.5 kcal
|
||||||
|
|
||||||
|
data = _get_summary(client, "2025-07-05")
|
||||||
|
assert data["totals"]["calories"] == 507.5 # 260 + 247.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_unit_types_summed(client):
|
||||||
|
"""Weight + count entries on the same date sum correctly."""
|
||||||
|
weight_food = _create_food(
|
||||||
|
client, name="Pasta", calories_per_unit=350.0, unit_type="weight",
|
||||||
|
protein_per_unit=12.0, carbs_per_unit=70.0, fat_per_unit=2.0,
|
||||||
|
)
|
||||||
|
count_food = _create_food(
|
||||||
|
client, name="Meatball", calories_per_unit=50.0, unit_type="count",
|
||||||
|
protein_per_unit=4.0, carbs_per_unit=1.0, fat_per_unit=3.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
_log_entry(client, weight_food["id"], quantity=200.0, date="2025-07-06") # 700 kcal
|
||||||
|
_log_entry(client, count_food["id"], quantity=4.0, date="2025-07-06") # 200 kcal
|
||||||
|
|
||||||
|
data = _get_summary(client, "2025-07-06")
|
||||||
|
t = data["totals"]
|
||||||
|
assert t["calories"] == 900.0 # 700 + 200
|
||||||
|
assert t["protein_g"] == 40.0 # 24 + 16
|
||||||
|
assert t["carbs_g"] == 144.0 # 140 + 4
|
||||||
|
assert t["fat_g"] == 16.0 # 4 + 12
|
||||||
|
|
||||||
|
|
||||||
|
# ── Null nutrition fields contribute 0 ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_calories_contributes_zero(client):
|
||||||
|
"""A food with null calories_per_unit contributes 0 (not an error)."""
|
||||||
|
# Per the CHECK constraint, non-meal foods MUST have calories_per_unit,
|
||||||
|
# so we use is_meal=True to get null calories.
|
||||||
|
food = _create_food(
|
||||||
|
client, name="Null Cal Food", is_meal=True, calories_per_unit=None,
|
||||||
|
source="meal",
|
||||||
|
)
|
||||||
|
_log_entry(client, food["id"], quantity=100.0, date="2025-07-07")
|
||||||
|
data = _get_summary(client, "2025-07-07")
|
||||||
|
assert data["totals"]["calories"] == 0.0 # meals → 0 for now
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_macros_contribute_zero(client):
|
||||||
|
"""Foods with null macro fields contribute 0 for those fields."""
|
||||||
|
# Create a food with calories but no macros (all null by default)
|
||||||
|
food = _create_food(
|
||||||
|
client, name="Sugar Water", calories_per_unit=40.0,
|
||||||
|
protein_per_unit=None, carbs_per_unit=10.0, fat_per_unit=None,
|
||||||
|
unit_type="weight",
|
||||||
|
)
|
||||||
|
_log_entry(client, food["id"], quantity=200.0, date="2025-07-08")
|
||||||
|
data = _get_summary(client, "2025-07-08")
|
||||||
|
t = data["totals"]
|
||||||
|
assert t["calories"] == 80.0 # 40 × 200/100
|
||||||
|
assert t["carbs_g"] == 20.0 # 10 × 200/100
|
||||||
|
assert t["protein_g"] == 0.0 # null → 0
|
||||||
|
assert t["fat_g"] == 0.0 # null → 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bonus nutrition fields included ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_fiber_satfat_sugars_sodium(client):
|
||||||
|
"""fiber, saturated_fat, sugars, sodium are included in summary."""
|
||||||
|
food = _create_food(
|
||||||
|
client,
|
||||||
|
calories_per_unit=200.0, unit_type="weight",
|
||||||
|
fiber_per_unit=3.0,
|
||||||
|
saturated_fat_per_unit=2.0,
|
||||||
|
sugars_per_unit=5.0,
|
||||||
|
sodium_per_unit=0.4,
|
||||||
|
)
|
||||||
|
_log_entry(client, food["id"], quantity=100.0, date="2025-07-09")
|
||||||
|
data = _get_summary(client, "2025-07-09")
|
||||||
|
t = data["totals"]
|
||||||
|
assert t["fiber_g"] == 3.0
|
||||||
|
assert t["saturated_fat_g"] == 2.0
|
||||||
|
assert t["sugars_g"] == 5.0
|
||||||
|
assert t["sodium_g"] == 0.4
|
||||||
|
|
||||||
|
|
||||||
|
# ── Target selection (historical lookup from TICKET-002) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_includes_correct_target_for_date(client):
|
||||||
|
"""Summary returns the target whose half-open date range covers the log date."""
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import Target
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
|
||||||
|
# Create targets with sequential start dates; each auto-closes the previous
|
||||||
|
resp1 = client.post("/api/targets", json={"start_date": "2025-01-01", "calories": 2000})
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
tid1 = resp1.json()["id"]
|
||||||
|
|
||||||
|
resp2 = client.post("/api/targets", json={"start_date": "2025-04-01", "calories": 2200})
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
tid2 = resp2.json()["id"]
|
||||||
|
|
||||||
|
resp3 = client.post("/api/targets", json={"start_date": "2025-07-01", "calories": 2500})
|
||||||
|
assert resp3.status_code == 201
|
||||||
|
tid3 = resp3.json()["id"]
|
||||||
|
|
||||||
|
# Close the last active target to clean up after ourselves
|
||||||
|
client.put(f"/api/targets/{tid3}", json={"end_date": "2025-12-31"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Feb 2025 → target 1 (2000 kcal)
|
||||||
|
data = _get_summary(client, "2025-02-15")
|
||||||
|
assert data["target"] is not None
|
||||||
|
assert data["target"]["id"] == tid1
|
||||||
|
assert data["target"]["calories"] == 2000
|
||||||
|
|
||||||
|
# May 2025 → target 2 (2200 kcal)
|
||||||
|
data = _get_summary(client, "2025-05-15")
|
||||||
|
assert data["target"] is not None
|
||||||
|
assert data["target"]["id"] == tid2
|
||||||
|
assert data["target"]["calories"] == 2200
|
||||||
|
|
||||||
|
# Sep 2025 → target 3 (2500 kcal)
|
||||||
|
data = _get_summary(client, "2025-09-15")
|
||||||
|
assert data["target"] is not None
|
||||||
|
assert data["target"]["id"] == tid3
|
||||||
|
assert data["target"]["calories"] == 2500
|
||||||
|
finally:
|
||||||
|
# Hard-delete these targets so they don't leak into other tests
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.execute(sa_delete(Target).where(Target.id.in_([tid1, tid2, tid3])))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_no_target_returns_null(client):
|
||||||
|
"""When no target covers the requested date, target is null."""
|
||||||
|
# Use a date far in the past before any target was created
|
||||||
|
data = _get_summary(client, "2000-01-01")
|
||||||
|
assert data["target"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Meal entries contribute 0 (TODO TICKET-007) ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_meal_entry_contributes_zero(client):
|
||||||
|
"""Meal foods have null calories_per_unit per the CHECK constraint,
|
||||||
|
so they temporarily contribute 0 to the day's totals."""
|
||||||
|
meal = _create_food(
|
||||||
|
client, name="My Meal", is_meal=True, calories_per_unit=None,
|
||||||
|
source="meal",
|
||||||
|
)
|
||||||
|
_log_entry(client, meal["id"], quantity=1.0, date="2025-07-10")
|
||||||
|
data = _get_summary(client, "2025-07-10")
|
||||||
|
assert data["totals"]["calories"] == 0.0
|
||||||
|
assert data["totals"]["protein_g"] == 0.0
|
||||||
|
assert data["totals"]["carbs_g"] == 0.0
|
||||||
|
assert data["totals"]["fat_g"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_meal_mixed_with_regular_foods(client):
|
||||||
|
"""Meal entries contribute 0 while regular foods contribute normally."""
|
||||||
|
regular = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||||
|
meal = _create_food(
|
||||||
|
client, name="Lunch Meal", is_meal=True, calories_per_unit=None,
|
||||||
|
source="meal",
|
||||||
|
)
|
||||||
|
|
||||||
|
_log_entry(client, regular["id"], quantity=200.0, date="2025-07-11") # 260 kcal
|
||||||
|
_log_entry(client, meal["id"], quantity=1.0, date="2025-07-11") # 0 kcal
|
||||||
|
|
||||||
|
data = _get_summary(client, "2025-07-11")
|
||||||
|
assert data["totals"]["calories"] == 260.0 # only the regular food
|
||||||
|
|
||||||
|
|
||||||
|
# ── Past/future dates ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_arbitrary_past_date(client):
|
||||||
|
"""Summary works for any past date with correct historical data."""
|
||||||
|
food = _create_food(client, name="Old Food", calories_per_unit=100.0)
|
||||||
|
_log_entry(client, food["id"], quantity=50.0, date="2023-12-25")
|
||||||
|
data = _get_summary(client, "2023-12-25")
|
||||||
|
assert data["totals"]["calories"] == 50.0 # 100 × 50/100
|
||||||
|
|
||||||
|
|
||||||
|
def test_arbitrary_future_date(client):
|
||||||
|
"""Summary works for future dates (no entries → zeros)."""
|
||||||
|
data = _get_summary(client, "2030-06-15")
|
||||||
|
assert data["date"] == "2030-06-15"
|
||||||
|
assert data["totals"]["calories"] == 0.0
|
||||||
|
# target may be null or a still-active target — just verify totals are correct
|
||||||
|
|
||||||
|
|
||||||
|
# ── Date isolation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_isolated_by_date(client):
|
||||||
|
"""Entries on different dates don't cross-contaminate summaries."""
|
||||||
|
f1 = _create_food(client, name="Monday Food", calories_per_unit=100.0)
|
||||||
|
f2 = _create_food(client, name="Tuesday Food", calories_per_unit=200.0)
|
||||||
|
|
||||||
|
_log_entry(client, f1["id"], quantity=100.0, date="2025-07-12") # 100 kcal
|
||||||
|
_log_entry(client, f2["id"], quantity=100.0, date="2025-07-13") # 200 kcal
|
||||||
|
|
||||||
|
assert _get_summary(client, "2025-07-12")["totals"]["calories"] == 100.0
|
||||||
|
assert _get_summary(client, "2025-07-13")["totals"]["calories"] == 200.0
|
||||||
Reference in New Issue
Block a user