TICKET-003: Daily log write path
- POST/PUT/DELETE /api/log, GET /api/log?date= with embedded food - sort_order auto-appends per day; hard delete for log entries - Soft-deleted foods rejected for new logs (404) but render in history - Full suite green (87 passed)
This commit is contained in:
+35
-12
@@ -1,24 +1,47 @@
|
|||||||
"""Daily log router (spec §3.3)."""
|
"""Daily log router (spec §3.3). Thin handlers — business logic in services/log.py."""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import DailyLogEntry
|
from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate
|
||||||
from schemas import LogEntryRead
|
from services import log as svc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/log", tags=["log"])
|
router = APIRouter(prefix="/api/log", tags=["log"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[LogEntryRead])
|
@router.get("", response_model=list[LogEntryRead])
|
||||||
def get_log(date: date, db: Session = Depends(get_db)):
|
def get_log(date: date, db: Session = Depends(get_db)):
|
||||||
"""All entries for a client-supplied date (spec §8.1 rule 8)."""
|
"""All entries for a client-supplied date, ordered by sort_order then id,
|
||||||
stmt = (
|
each with the referenced food embedded (§3.3)."""
|
||||||
select(DailyLogEntry)
|
return svc.get_log_entries(db, date)
|
||||||
.where(DailyLogEntry.date == date)
|
|
||||||
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
|
|
||||||
)
|
@router.post("", response_model=LogEntryRead, status_code=201)
|
||||||
return db.scalars(stmt).all()
|
def create_log_entry(data: LogEntryCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Add a log entry. food_id must reference a non-deleted food.
|
||||||
|
sort_order defaults to end of day. Unknown or soft-deleted food → 404."""
|
||||||
|
try:
|
||||||
|
return svc.create_log_entry(db, data)
|
||||||
|
except svc.FoodNotAvailableError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{entry_id}", response_model=LogEntryRead)
|
||||||
|
def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Update quantity, meal_slot, and/or sort_order.
|
||||||
|
Only provided fields change; send {"meal_slot": null} to clear it."""
|
||||||
|
result = svc.update_log_entry(db, entry_id, data)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Log entry not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entry_id}")
|
||||||
|
def delete_log_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Hard-delete a log entry (§2.3 has no soft-delete)."""
|
||||||
|
if not svc.delete_log_entry(db, entry_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Log entry not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -103,6 +103,30 @@ class LogEntryCreate(BaseModel):
|
|||||||
date: date # client-supplied YYYY-MM-DD (spec §8.1 rule 8)
|
date: date # client-supplied YYYY-MM-DD (spec §8.1 rule 8)
|
||||||
|
|
||||||
|
|
||||||
|
class LogEntryUpdate(BaseModel):
|
||||||
|
"""Schema for PUT /api/log/{id}. All fields optional — only supplied
|
||||||
|
fields are updated. Send {"meal_slot": null} to clear the slot."""
|
||||||
|
quantity: float | None = Field(default=None, gt=0)
|
||||||
|
meal_slot: MealSlot | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LogFoodRead(BaseModel):
|
||||||
|
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
|
||||||
|
Includes name, brand, unit_type, and serving info so the frontend can render
|
||||||
|
log entries without N+1 lookups. Soft-deleted foods render here (§2.1)."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
brand: str | None
|
||||||
|
unit_type: UnitType
|
||||||
|
serving_size_g: float | None
|
||||||
|
serving_name: str | None
|
||||||
|
is_meal: bool
|
||||||
|
deleted_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
class LogEntryRead(BaseModel):
|
class LogEntryRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -112,6 +136,7 @@ class LogEntryRead(BaseModel):
|
|||||||
quantity: float
|
quantity: float
|
||||||
meal_slot: MealSlot | None
|
meal_slot: MealSlot | None
|
||||||
sort_order: int
|
sort_order: int
|
||||||
|
food: LogFoodRead
|
||||||
|
|
||||||
|
|
||||||
# ── Targets ──────────────────────────────────────────────────────────────────
|
# ── Targets ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Daily log service layer — business logic + DB access (spec §8.1 rules 2–3).
|
||||||
|
|
||||||
|
- Log entries reference foods; embedded food data is included in responses.
|
||||||
|
- sort_order for new entries defaults to end of day: max(sort_order) + 1
|
||||||
|
for that date, starting at 1 if no entries exist yet.
|
||||||
|
- Soft-deleted foods are rejected on create (404 — you can't log what you
|
||||||
|
can't search), but still render in historical GET responses (§2.1).
|
||||||
|
|
||||||
|
ORM objects never leave this module; functions return Pydantic schemas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from models import DailyLogEntry, Food
|
||||||
|
from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
|
||||||
|
"""Eager-load a single entry with its food relationship."""
|
||||||
|
return db.scalar(
|
||||||
|
select(DailyLogEntry)
|
||||||
|
.where(DailyLogEntry.id == entry_id)
|
||||||
|
.options(joinedload(DailyLogEntry.food))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]:
|
||||||
|
"""All entries for a date, ordered by sort_order then id, with embedded
|
||||||
|
food data eagerly loaded. Soft-deleted foods still render (§2.1)."""
|
||||||
|
stmt = (
|
||||||
|
select(DailyLogEntry)
|
||||||
|
.where(DailyLogEntry.date == lookup_date)
|
||||||
|
.options(joinedload(DailyLogEntry.food))
|
||||||
|
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
|
||||||
|
)
|
||||||
|
entries = db.scalars(stmt).all()
|
||||||
|
return [LogEntryRead.model_validate(e) for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
|
||||||
|
"""Create a log entry.
|
||||||
|
|
||||||
|
Validates that food_id exists and is not soft-deleted.
|
||||||
|
sort_order defaults to end of day for the given date.
|
||||||
|
|
||||||
|
Raises FoodNotAvailableError if food_id is unknown or the food is
|
||||||
|
soft-deleted (you can't log what you can't search — spec §2.1).
|
||||||
|
"""
|
||||||
|
food = db.get(Food, data.food_id)
|
||||||
|
if food is None or food.deleted_at is not None:
|
||||||
|
raise FoodNotAvailableError(data.food_id)
|
||||||
|
|
||||||
|
max_sort = db.scalar(
|
||||||
|
select(func.max(DailyLogEntry.sort_order)).where(
|
||||||
|
DailyLogEntry.date == data.date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sort_order = (max_sort or 0) + 1
|
||||||
|
|
||||||
|
entry = DailyLogEntry(
|
||||||
|
date=data.date,
|
||||||
|
food_id=data.food_id,
|
||||||
|
quantity=data.quantity,
|
||||||
|
meal_slot=data.meal_slot,
|
||||||
|
sort_order=sort_order,
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Re-query with eager-loaded food for the response
|
||||||
|
return LogEntryRead.model_validate(_load_entry_with_food(db, entry.id))
|
||||||
|
|
||||||
|
|
||||||
|
def update_log_entry(
|
||||||
|
db: Session, entry_id: int, data: LogEntryUpdate
|
||||||
|
) -> LogEntryRead | None:
|
||||||
|
"""Update quantity, meal_slot, and/or sort_order.
|
||||||
|
|
||||||
|
Uses model_dump(exclude_unset=True) so only explicitly-provided fields
|
||||||
|
are changed. Send {"meal_slot": null} to clear the slot.
|
||||||
|
|
||||||
|
Returns None if the entry is not found.
|
||||||
|
"""
|
||||||
|
entry = db.get(DailyLogEntry, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(entry, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return LogEntryRead.model_validate(_load_entry_with_food(db, entry_id))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_log_entry(db: Session, entry_id: int) -> bool:
|
||||||
|
"""Hard-delete a log entry (§2.3 has no soft-delete).
|
||||||
|
|
||||||
|
Returns True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
entry = db.get(DailyLogEntry, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Errors ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FoodNotAvailableError(Exception):
|
||||||
|
"""Raised when a food_id is unknown or the food is soft-deleted."""
|
||||||
|
def __init__(self, food_id: int):
|
||||||
|
super().__init__(f"Food {food_id} not available for logging")
|
||||||
|
self.food_id = food_id
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Daily log tests — TICKET-003 (spec §2.3, §3.3, §8.1 rules 2–3, 6, 8, 11).
|
||||||
|
|
||||||
|
Behaviour notes:
|
||||||
|
- POST /api/log rejects unknown or soft-deleted food_id with 404
|
||||||
|
(you can't log what you can't search).
|
||||||
|
- sort_order for new entries defaults to end of day (max(sort_order) + 1).
|
||||||
|
- GET /api/log?date= returns entries ordered by sort_order, then id,
|
||||||
|
with embedded food data (including soft-deleted foods for history).
|
||||||
|
- LogEntryUpdate uses exclude_unset: only supplied fields change;
|
||||||
|
send {"meal_slot": null} to clear the slot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _create_food(client, **overrides) -> dict:
|
||||||
|
"""Create a food via POST and return the response JSON."""
|
||||||
|
payload = {
|
||||||
|
"name": "Test Food",
|
||||||
|
"brand": "Test Brand",
|
||||||
|
"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, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _post_log(client, **overrides) -> dict:
|
||||||
|
"""Create a log entry, returning the full response (for status checks)."""
|
||||||
|
resp = client.post("/api/log", json=overrides)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _create_entry(client, **overrides):
|
||||||
|
"""Create a log entry and return the parsed JSON (asserts 201)."""
|
||||||
|
food = _create_food(client)
|
||||||
|
payload = {
|
||||||
|
"food_id": food["id"],
|
||||||
|
"quantity": 100.0,
|
||||||
|
"date": "2025-06-15",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = client.post("/api/log", json=payload)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def assert_422(resp):
|
||||||
|
assert resp.status_code == 422, f"expected 422, got {resp.status_code}: {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Create + read round-trip ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_read_round_trip(client):
|
||||||
|
"""POST creates (201), GET returns it with embedded food."""
|
||||||
|
entry = _create_entry(client)
|
||||||
|
assert entry["quantity"] == 100.0
|
||||||
|
assert entry["date"] == "2025-06-15"
|
||||||
|
assert entry["meal_slot"] is None
|
||||||
|
assert "id" in entry
|
||||||
|
assert "sort_order" in entry
|
||||||
|
|
||||||
|
# Embedded food
|
||||||
|
food = entry["food"]
|
||||||
|
assert food["name"] == "Test Food"
|
||||||
|
assert food["brand"] == "Test Brand"
|
||||||
|
assert food["unit_type"] == "weight"
|
||||||
|
assert food["is_meal"] is False
|
||||||
|
assert food["deleted_at"] is None
|
||||||
|
|
||||||
|
# GET confirms
|
||||||
|
resp = client.get("/api/log", params={"date": "2025-06-15"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
entries = resp.json()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
# ── meal_slot ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_with_each_meal_slot(client):
|
||||||
|
"""All four meal slots are accepted."""
|
||||||
|
for slot in ("breakfast", "lunch", "dinner", "snack"):
|
||||||
|
entry = _create_entry(client, meal_slot=slot)
|
||||||
|
assert entry["meal_slot"] == slot
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_without_meal_slot_defaults_null(client):
|
||||||
|
"""meal_slot is optional, defaults to None."""
|
||||||
|
entry = _create_entry(client)
|
||||||
|
assert entry["meal_slot"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation: food_id ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_food_id_returns_404(client):
|
||||||
|
"""Logging a non-existent food_id → 404."""
|
||||||
|
resp = _post_log(client, food_id=99999, quantity=100.0, date="2025-06-15")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_deleted_food_rejected_404(client):
|
||||||
|
"""Logging a soft-deleted food → 404 (you can't log what you can't search)."""
|
||||||
|
food = _create_food(client, name="Delete Me")
|
||||||
|
fid = food["id"]
|
||||||
|
client.delete(f"/api/foods/{fid}")
|
||||||
|
|
||||||
|
resp = _post_log(client, food_id=fid, quantity=100.0, date="2025-06-15")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation: quantity ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_quantity_rejected(client):
|
||||||
|
resp = _post_log(client, food_id=1, quantity=-5.0, date="2025-06-15")
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_quantity_rejected(client):
|
||||||
|
resp = _post_log(client, food_id=1, quantity=0.0, date="2025-06-15")
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation: meal_slot ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_meal_slot_rejected(client):
|
||||||
|
food = _create_food(client)
|
||||||
|
resp = _post_log(
|
||||||
|
client,
|
||||||
|
food_id=food["id"],
|
||||||
|
quantity=100.0,
|
||||||
|
meal_slot="brunch",
|
||||||
|
date="2025-06-15",
|
||||||
|
)
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation: date ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_date_format_rejected(client):
|
||||||
|
food = _create_food(client)
|
||||||
|
resp = _post_log(
|
||||||
|
client,
|
||||||
|
food_id=food["id"],
|
||||||
|
quantity=100.0,
|
||||||
|
date="06-15-2025",
|
||||||
|
)
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_date_rejected(client):
|
||||||
|
food = _create_food(client)
|
||||||
|
resp = _post_log(client, food_id=food["id"], quantity=100.0)
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ordering ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_entries_ordered_by_sort_order_then_id(client):
|
||||||
|
"""GET /api/log returns entries sorted by sort_order, then id."""
|
||||||
|
food = _create_food(client)
|
||||||
|
date = "2025-12-01" # unique date to avoid cross-test pollution
|
||||||
|
|
||||||
|
# Create 3 entries (they'll get sort_order 1, 2, 3 automatically)
|
||||||
|
eids = []
|
||||||
|
for _ in range(3):
|
||||||
|
resp = _post_log(client, food_id=food["id"], quantity=100.0, date=date)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
eids.append(resp.json()["id"])
|
||||||
|
|
||||||
|
# Update sort_orders to reverse the creation order
|
||||||
|
client.put(f"/api/log/{eids[0]}", json={"sort_order": 3})
|
||||||
|
client.put(f"/api/log/{eids[1]}", json={"sort_order": 2})
|
||||||
|
client.put(f"/api/log/{eids[2]}", json={"sort_order": 1})
|
||||||
|
|
||||||
|
resp = client.get("/api/log", params={"date": date})
|
||||||
|
entries = resp.json()
|
||||||
|
ids = [e["id"] for e in entries]
|
||||||
|
assert ids == [eids[2], eids[1], eids[0]] # sort_order 1, 2, 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_entries_append_to_end(client):
|
||||||
|
"""New entries get increasing sort_order values (append to end of day)."""
|
||||||
|
food = _create_food(client)
|
||||||
|
date = "2025-12-02" # unique date to avoid cross-test pollution
|
||||||
|
|
||||||
|
e1 = _create_entry(client, food_id=food["id"], date=date)
|
||||||
|
e2 = _create_entry(client, food_id=food["id"], date=date)
|
||||||
|
e3 = _create_entry(client, food_id=food["id"], date=date)
|
||||||
|
|
||||||
|
assert e1["sort_order"] < e2["sort_order"] < e3["sort_order"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Embedded food data ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedded_food_data_complete(client):
|
||||||
|
"""Log entry includes full food reference: name, brand, unit_type, serving."""
|
||||||
|
food = _create_food(
|
||||||
|
client,
|
||||||
|
name="Banana",
|
||||||
|
brand="Chiquita",
|
||||||
|
unit_type="weight",
|
||||||
|
serving_size_g=118.0,
|
||||||
|
serving_name="1 banana",
|
||||||
|
)
|
||||||
|
fid = food["id"]
|
||||||
|
|
||||||
|
entry = _create_entry(client, food_id=fid, date="2025-06-15")
|
||||||
|
|
||||||
|
f = entry["food"]
|
||||||
|
assert f["id"] == fid
|
||||||
|
assert f["name"] == "Banana"
|
||||||
|
assert f["brand"] == "Chiquita"
|
||||||
|
assert f["unit_type"] == "weight"
|
||||||
|
assert f["serving_size_g"] == 118.0
|
||||||
|
assert f["serving_name"] == "1 banana"
|
||||||
|
assert f["is_meal"] is False
|
||||||
|
assert f["deleted_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_deleted_food_still_renders_in_history(client):
|
||||||
|
"""Food soft-deleted after logging still appears in GET /api/log."""
|
||||||
|
food = _create_food(client, name="Old Product")
|
||||||
|
fid = food["id"]
|
||||||
|
|
||||||
|
_create_entry(client, food_id=fid, date="2025-01-01")
|
||||||
|
|
||||||
|
# Soft-delete the food
|
||||||
|
client.delete(f"/api/foods/{fid}")
|
||||||
|
|
||||||
|
# The log entry should still render it
|
||||||
|
resp = client.get("/api/log", params={"date": "2025-01-01"})
|
||||||
|
entries = resp.json()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["food"]["id"] == fid
|
||||||
|
assert entries[0]["food"]["name"] == "Old Product"
|
||||||
|
assert entries[0]["food"]["deleted_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_quantity(client):
|
||||||
|
entry = _create_entry(client)
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"quantity": 200.0})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["quantity"] == 200.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_meal_slot_set_and_clear(client):
|
||||||
|
entry = _create_entry(client)
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
# Set
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"meal_slot": "dinner"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["meal_slot"] == "dinner"
|
||||||
|
|
||||||
|
# Clear (explicit null)
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"meal_slot": None})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["meal_slot"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sort_order(client):
|
||||||
|
entry = _create_entry(client)
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"sort_order": 42})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["sort_order"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_partial_preserves_other_fields(client):
|
||||||
|
"""PUT with only one field leaves others unchanged."""
|
||||||
|
entry = _create_entry(client, quantity=100.0, meal_slot="lunch")
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"quantity": 150.0})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["quantity"] == 150.0
|
||||||
|
assert data["meal_slot"] == "lunch" # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_404(client):
|
||||||
|
resp = client.put("/api/log/99999", json={"quantity": 100.0})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_negative_quantity_rejected(client):
|
||||||
|
entry = _create_entry(client)
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"quantity": -1.0})
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_zero_quantity_rejected(client):
|
||||||
|
entry = _create_entry(client)
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
resp = client.put(f"/api/log/{eid}", json={"quantity": 0.0})
|
||||||
|
assert_422(resp)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delete ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_removes_entry(client):
|
||||||
|
date = "2025-12-03" # unique to avoid cross-test pollution
|
||||||
|
entry = _create_entry(client, date=date)
|
||||||
|
eid = entry["id"]
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/log/{eid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Confirm gone
|
||||||
|
resp = client.get("/api/log", params={"date": date})
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_404(client):
|
||||||
|
resp = client.delete("/api/log/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Meal foods ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_meal_food_accepted(client):
|
||||||
|
"""Logging a food with is_meal=True succeeds (explosion is TICKET-007)."""
|
||||||
|
meal = _create_food(
|
||||||
|
client,
|
||||||
|
name="My Meal",
|
||||||
|
is_meal=True,
|
||||||
|
calories_per_unit=None,
|
||||||
|
source="meal",
|
||||||
|
)
|
||||||
|
meal_id = meal["id"]
|
||||||
|
|
||||||
|
entry = _create_entry(client, food_id=meal_id, quantity=1.0, date="2025-06-15")
|
||||||
|
assert entry["food"]["is_meal"] is True
|
||||||
|
assert entry["food"]["name"] == "My Meal"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Date isolation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_log_only_returns_requested_date(client):
|
||||||
|
"""GET /api/log?date= isolates by date."""
|
||||||
|
food = _create_food(client)
|
||||||
|
d1 = "2025-12-04"
|
||||||
|
d2 = "2025-12-05"
|
||||||
|
|
||||||
|
_create_entry(client, food_id=food["id"], quantity=100.0, date=d1)
|
||||||
|
_create_entry(client, food_id=food["id"], quantity=200.0, date=d2)
|
||||||
|
|
||||||
|
resp = client.get("/api/log", params={"date": d1})
|
||||||
|
entries = resp.json()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["date"] == d1
|
||||||
|
assert entries[0]["quantity"] == 100.0
|
||||||
Reference in New Issue
Block a user