601c0d461e
- POST auto-closes previous target in one transaction - GET /targets (ordered), GET /targets/current (404 when none) - PUT with invariant enforcement (409 on double-active attempt) - Service-layer historical target lookup for TICKET-004 - Full suite green (62 passed)
513 lines
18 KiB
Python
513 lines
18 KiB
Python
"""Targets CRUD tests — TICKET-002 (spec §2.4, §3.4, §8.1 rules 2–3, 11).
|
||
|
||
Auto-close semantics (documented in services/targets.py):
|
||
- When a new target is created, the previous active target's end_date is set
|
||
to the new target's start_date (same day).
|
||
- Historical lookup uses an EXCLUSIVE end_date: a target covers dates where
|
||
start_date <= date AND (end_date IS NULL OR end_date > date).
|
||
This makes intervals half-open [start_date, end_date), so there is never
|
||
overlap or ambiguity.
|
||
|
||
Tests share a session-scoped DB. Each test cleans up after itself by closing
|
||
any active targets it leaves behind, so subsequent tests start with a clean
|
||
slate (no active target). Tests that need a fully empty targets table use
|
||
direct DB access to delete all rows.
|
||
"""
|
||
|
||
from datetime import date
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def create_target(client, **overrides) -> dict:
|
||
"""Create a target via POST and return the response JSON."""
|
||
payload = {
|
||
"start_date": "2025-01-01",
|
||
"calories": 2000,
|
||
"protein_g": 150.0,
|
||
"carbs_g": 200.0,
|
||
"fat_g": 65.0,
|
||
}
|
||
payload.update(overrides)
|
||
resp = client.post("/api/targets", json=payload)
|
||
return resp
|
||
|
||
|
||
def assert_422(resp):
|
||
assert resp.status_code == 422, f"expected 422, got {resp.status_code}: {resp.text}"
|
||
|
||
|
||
def close_active_target(client):
|
||
"""Close the active target (if any) by giving it an end_date.
|
||
Uses a date far in the future so it always passes date validation."""
|
||
current = client.get("/api/targets/current")
|
||
if current.status_code == 200:
|
||
tid = current.json()["id"]
|
||
client.put(f"/api/targets/{tid}", json={"end_date": "2099-12-31"})
|
||
|
||
|
||
# ── Create + current round-trip ──────────────────────────────────────────────
|
||
|
||
|
||
def test_create_and_read_current(client):
|
||
"""POST creates a target (201), GET /current returns it."""
|
||
try:
|
||
resp = create_target(client)
|
||
assert resp.status_code == 201, resp.text
|
||
data = resp.json()
|
||
|
||
assert data["start_date"] == "2025-01-01"
|
||
assert data["end_date"] is None
|
||
assert data["calories"] == 2000
|
||
assert data["protein_g"] == 150.0
|
||
assert data["carbs_g"] == 200.0
|
||
assert data["fat_g"] == 65.0
|
||
assert "id" in data
|
||
|
||
# GET /current should return the same target
|
||
resp2 = client.get("/api/targets/current")
|
||
assert resp2.status_code == 200
|
||
assert resp2.json() == data
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_current_404_when_none(client):
|
||
"""GET /current returns 404 when no active target exists."""
|
||
close_active_target(client) # Ensure clean state
|
||
resp = client.get("/api/targets/current")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ── Auto-close of previous target ────────────────────────────────────────────
|
||
|
||
|
||
def test_auto_close_previous_target(client):
|
||
"""Creating a second target auto-closes the first: its end_date is set
|
||
to the new target's start_date, and /current returns the new one."""
|
||
try:
|
||
# Create first target (active)
|
||
resp1 = create_target(client, start_date="2025-01-01", calories=2000)
|
||
assert resp1.status_code == 201
|
||
id1 = resp1.json()["id"]
|
||
assert resp1.json()["end_date"] is None
|
||
|
||
# Create second target with later start_date
|
||
resp2 = create_target(client, start_date="2025-06-01", calories=2200)
|
||
assert resp2.status_code == 201
|
||
id2 = resp2.json()["id"]
|
||
assert resp2.json()["end_date"] is None
|
||
|
||
# First target should now have an end_date equal to the second's start_date
|
||
resp_get = client.get("/api/targets")
|
||
all_targets = resp_get.json()
|
||
t1 = next(t for t in all_targets if t["id"] == id1)
|
||
assert t1["end_date"] == "2025-06-01"
|
||
|
||
# /current should return the second target
|
||
current = client.get("/api/targets/current").json()
|
||
assert current["id"] == id2
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_auto_close_with_earlier_start_date(client):
|
||
"""If the new target's start_date is BEFORE the existing active target's
|
||
start_date, the operation is rejected (422) because it would create an
|
||
invalid date range on the auto-closed target."""
|
||
try:
|
||
# Create first target (active starting 2025-06-01)
|
||
create_target(client, start_date="2025-06-01", calories=2000)
|
||
|
||
# Try to create a target with start_date before the active one's start_date
|
||
resp = create_target(client, start_date="2025-01-01", calories=1800)
|
||
# This would set the existing target's end_date to 2025-01-01,
|
||
# which is before its start_date of 2025-06-01 → rejected
|
||
assert_422(resp)
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_only_one_active_after_create(client):
|
||
"""After creating 3 targets sequentially, exactly one has end_date IS NULL."""
|
||
try:
|
||
for i, start in enumerate(["2025-01-01", "2025-04-01", "2025-07-01"]):
|
||
resp = create_target(client, start_date=start, calories=2000 + i * 100)
|
||
assert resp.status_code == 201, f"iteration {i}: {resp.text}"
|
||
|
||
all_targets = client.get("/api/targets").json()
|
||
active = [t for t in all_targets if t["end_date"] is None]
|
||
assert len(active) == 1
|
||
assert active[0]["start_date"] == "2025-07-01"
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
# ── List targets ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_list_targets_ordered_by_start_date(client):
|
||
"""GET /api/targets returns all targets ordered by start_date."""
|
||
try:
|
||
# Use unique calorie values to identify our targets
|
||
base_cal = 9000 # Distinct from other tests
|
||
dates = ["2025-03-01", "2025-01-01", "2025-02-01"]
|
||
for d in dates:
|
||
create_target(client, start_date=d, calories=base_cal)
|
||
base_cal += 1
|
||
|
||
resp = client.get("/api/targets")
|
||
assert resp.status_code == 200
|
||
results = resp.json()
|
||
|
||
# Verify ALL targets are ordered by start_date
|
||
for i in range(len(results) - 1):
|
||
assert results[i]["start_date"] <= results[i + 1]["start_date"], (
|
||
f"out of order at index {i}: "
|
||
f"{results[i]['start_date']} > {results[i+1]['start_date']}"
|
||
)
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_list_targets_returns_list(client):
|
||
"""GET /api/targets returns a list (may be empty or populated)."""
|
||
resp = client.get("/api/targets")
|
||
assert resp.status_code == 200
|
||
assert isinstance(resp.json(), list)
|
||
|
||
|
||
# ── Update target ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_update_target_values(client):
|
||
"""PUT updates calorie/macro values on a target."""
|
||
try:
|
||
resp = create_target(client, calories=2000, protein_g=100.0)
|
||
assert resp.status_code == 201, resp.text
|
||
tid = resp.json()["id"]
|
||
|
||
resp = client.put(
|
||
f"/api/targets/{tid}",
|
||
json={"calories": 2500, "protein_g": 120.0, "carbs_g": 250.0, "fat_g": 70.0},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["calories"] == 2500
|
||
assert data["protein_g"] == 120.0
|
||
assert data["carbs_g"] == 250.0
|
||
assert data["fat_g"] == 70.0
|
||
# unchanged
|
||
assert data["start_date"] == "2025-01-01"
|
||
assert data["end_date"] is None
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_update_target_date_range(client):
|
||
"""PUT updates end_date on a target, closing it."""
|
||
try:
|
||
resp = create_target(client, start_date="2025-01-01", calories=2000)
|
||
assert resp.status_code == 201, resp.text
|
||
tid = resp.json()["id"]
|
||
|
||
# Close this target by giving it an end_date
|
||
resp = client.put(
|
||
f"/api/targets/{tid}",
|
||
json={"end_date": "2025-06-01"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["end_date"] == "2025-06-01"
|
||
|
||
# Now there's no active target
|
||
assert client.get("/api/targets/current").status_code == 404
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_update_end_date_null_when_another_active(client):
|
||
"""Setting end_date to null on a historical target when another target
|
||
is already active is rejected (409)."""
|
||
try:
|
||
# Create two targets: t1 closed, t2 active
|
||
resp1 = create_target(client, start_date="2025-01-01", calories=2000)
|
||
assert resp1.status_code == 201, resp1.text
|
||
id1 = resp1.json()["id"]
|
||
|
||
resp2 = create_target(client, start_date="2025-06-01", calories=2200)
|
||
assert resp2.status_code == 201, resp2.text
|
||
id2 = resp2.json()["id"]
|
||
|
||
# t1 was auto-closed, t2 is active. Try to set t1.end_date = null
|
||
resp = client.put(f"/api/targets/{id1}", json={"end_date": None})
|
||
assert resp.status_code == 409
|
||
assert "already an active target" in resp.json()["detail"].lower()
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_update_does_not_break_invariant(client):
|
||
"""Updating a historical target's unrelated fields doesn't affect
|
||
the active target."""
|
||
try:
|
||
# Create two targets
|
||
resp1 = create_target(client, start_date="2025-01-01", calories=2000)
|
||
assert resp1.status_code == 201, resp1.text
|
||
id1 = resp1.json()["id"]
|
||
|
||
create_target(client, start_date="2025-06-01", calories=2200)
|
||
|
||
# Update t1's calories — should work, active target unchanged
|
||
resp = client.put(f"/api/targets/{id1}", json={"calories": 2100})
|
||
assert resp.status_code == 200
|
||
|
||
# Active target is still the second one
|
||
current = client.get("/api/targets/current").json()
|
||
assert current["start_date"] == "2025-06-01"
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_update_partial(client):
|
||
"""PUT with partial data only changes supplied fields."""
|
||
try:
|
||
resp = create_target(client, calories=2000, protein_g=100.0, carbs_g=200.0)
|
||
assert resp.status_code == 201, resp.text
|
||
tid = resp.json()["id"]
|
||
|
||
resp = client.put(f"/api/targets/{tid}", json={"calories": 1800})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["calories"] == 1800
|
||
assert data["protein_g"] == 100.0 # unchanged
|
||
assert data["carbs_g"] == 200.0 # unchanged
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_update_404(client):
|
||
resp = client.put("/api/targets/99999", json={"calories": 2000})
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ── Validation failures (§8.1 rule 11) ───────────────────────────────────────
|
||
|
||
|
||
def test_calories_not_positive(client):
|
||
resp = create_target(client, calories=0)
|
||
assert_422(resp)
|
||
resp = create_target(client, calories=-100)
|
||
assert_422(resp)
|
||
|
||
|
||
def test_calories_must_be_integer(client):
|
||
"""Calories must be a positive integer, not a string."""
|
||
resp = create_target(client, calories="not-a-number")
|
||
assert_422(resp)
|
||
|
||
|
||
def test_macros_non_negative(client):
|
||
for field in ("protein_g", "carbs_g", "fat_g"):
|
||
resp = create_target(client, **{field: -1.0})
|
||
assert_422(resp)
|
||
# zero is allowed for all macros
|
||
resp = create_target(client, protein_g=0.0, carbs_g=0.0, fat_g=0.0)
|
||
assert resp.status_code == 201, resp.text
|
||
close_active_target(client)
|
||
|
||
|
||
def test_bad_start_date_format(client):
|
||
resp = create_target(client, start_date="01-01-2025")
|
||
assert_422(resp)
|
||
resp = create_target(client, start_date="not-a-date")
|
||
assert_422(resp)
|
||
|
||
|
||
def test_end_date_before_start_date_on_create(client):
|
||
"""TargetCreate has no end_date field — it's auto-managed."""
|
||
pass # end_date is not in TargetCreate
|
||
|
||
|
||
def test_end_date_must_be_after_start_date_on_update(client):
|
||
"""PUT with end_date <= start_date should fail validation."""
|
||
try:
|
||
resp = create_target(client, start_date="2025-06-01", calories=2000)
|
||
assert resp.status_code == 201, resp.text
|
||
tid = resp.json()["id"]
|
||
|
||
# end_date = start_date → invalid
|
||
resp = client.put(f"/api/targets/{tid}", json={"end_date": "2025-06-01"})
|
||
assert_422(resp)
|
||
|
||
# end_date < start_date → invalid
|
||
resp = client.put(f"/api/targets/{tid}", json={"end_date": "2025-05-01"})
|
||
assert_422(resp)
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_start_date_must_be_before_end_date_on_update(client):
|
||
"""When both start_date and end_date are updated, end_date must still
|
||
be after start_date."""
|
||
try:
|
||
create_target(client, start_date="2025-06-01", calories=2000)
|
||
|
||
# Create a second target to auto-close the first
|
||
resp = create_target(client, start_date="2025-07-01", calories=2200)
|
||
assert resp.status_code == 201, resp.text
|
||
tid = resp.json()["id"]
|
||
|
||
# Try to set start_date after end_date
|
||
resp = client.put(
|
||
f"/api/targets/{tid}",
|
||
json={"start_date": "2025-08-01", "end_date": "2025-07-01"},
|
||
)
|
||
assert_422(resp)
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_missing_start_date(client):
|
||
resp = client.post("/api/targets", json={"calories": 2000})
|
||
assert_422(resp)
|
||
|
||
|
||
def test_missing_calories(client):
|
||
resp = client.post("/api/targets", json={"start_date": "2025-01-01"})
|
||
assert_422(resp)
|
||
|
||
|
||
# ── Historical lookup by date (service function) ─────────────────────────────
|
||
|
||
|
||
def test_get_target_for_date_returns_correct_target(client):
|
||
"""Verify that sequencing three targets produces correct half-open
|
||
date ranges."""
|
||
try:
|
||
# Use unique calorie values to avoid collisions
|
||
cals = [8001, 8002, 8003]
|
||
create_target(client, start_date="2025-01-01", calories=cals[0])
|
||
create_target(client, start_date="2025-04-01", calories=cals[1])
|
||
create_target(client, start_date="2025-07-01", calories=cals[2])
|
||
|
||
all_targets = client.get("/api/targets").json()
|
||
our_targets = sorted(
|
||
[t for t in all_targets if t["calories"] in cals],
|
||
key=lambda t: t["start_date"],
|
||
)
|
||
|
||
assert len(our_targets) == 3
|
||
|
||
# t1: [2025-01-01, 2025-04-01)
|
||
assert our_targets[0]["start_date"] == "2025-01-01"
|
||
assert our_targets[0]["end_date"] == "2025-04-01"
|
||
|
||
# t2: [2025-04-01, 2025-07-01)
|
||
assert our_targets[1]["start_date"] == "2025-04-01"
|
||
assert our_targets[1]["end_date"] == "2025-07-01"
|
||
|
||
# t3: [2025-07-01, ∞)
|
||
assert our_targets[2]["start_date"] == "2025-07-01"
|
||
assert our_targets[2]["end_date"] is None
|
||
finally:
|
||
close_active_target(client)
|
||
|
||
|
||
def test_get_target_for_date_service_direct():
|
||
"""Test the service function directly with explicit date ranges.
|
||
This validates the half-open interval logic before TICKET-004 needs it.
|
||
|
||
Uses direct DB access for a fully controlled setup — no API dependency.
|
||
"""
|
||
from services.targets import get_target_for_date
|
||
from database import SessionLocal
|
||
from models import Target
|
||
from sqlalchemy import delete as sa_delete
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
# Clean slate
|
||
db.execute(sa_delete(Target))
|
||
db.commit()
|
||
|
||
# Create targets with explicit date ranges (half-open)
|
||
t1 = Target(start_date=date(2025, 1, 1), end_date=date(2025, 4, 1), calories=2000)
|
||
t2 = Target(start_date=date(2025, 4, 1), end_date=date(2025, 7, 1), calories=2200)
|
||
t3 = Target(start_date=date(2025, 7, 1), end_date=None, calories=2500)
|
||
db.add_all([t1, t2, t3])
|
||
db.commit()
|
||
|
||
# Test boundary dates
|
||
# Jan 1 → t1
|
||
target = get_target_for_date(db, date(2025, 1, 1))
|
||
assert target is not None and target.id == t1.id
|
||
|
||
# Mar 31 → t1 (day before t2 starts, t1 still covers it)
|
||
target = get_target_for_date(db, date(2025, 3, 31))
|
||
assert target is not None and target.id == t1.id
|
||
|
||
# Apr 1 → t2 (t2's start_date, exclusive end of t1)
|
||
target = get_target_for_date(db, date(2025, 4, 1))
|
||
assert target is not None and target.id == t2.id
|
||
|
||
# Jun 30 → t2
|
||
target = get_target_for_date(db, date(2025, 6, 30))
|
||
assert target is not None and target.id == t2.id
|
||
|
||
# Jul 1 → t3 (active)
|
||
target = get_target_for_date(db, date(2025, 7, 1))
|
||
assert target is not None and target.id == t3.id
|
||
|
||
# Dec 31, 2030 → t3 (still active)
|
||
target = get_target_for_date(db, date(2030, 12, 31))
|
||
assert target is not None and target.id == t3.id
|
||
|
||
# Date before first target → None
|
||
target = get_target_for_date(db, date(2024, 12, 31))
|
||
assert target is None
|
||
|
||
# Clean up
|
||
db.execute(sa_delete(Target))
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_get_target_for_date_none_when_no_targets():
|
||
"""get_target_for_date returns None when no targets exist."""
|
||
from services.targets import get_target_for_date
|
||
from database import SessionLocal
|
||
from models import Target
|
||
from sqlalchemy import delete as sa_delete
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
db.execute(sa_delete(Target))
|
||
db.commit()
|
||
|
||
target = get_target_for_date(db, date(2025, 6, 15))
|
||
assert target is None
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
# ── Macros nullable on create ────────────────────────────────────────────────
|
||
|
||
|
||
def test_create_target_without_macros(client):
|
||
"""Creating a target with only calories (no macros) should work."""
|
||
try:
|
||
resp = client.post(
|
||
"/api/targets",
|
||
json={"start_date": "2025-01-01", "calories": 2000},
|
||
)
|
||
assert resp.status_code == 201, resp.text
|
||
data = resp.json()
|
||
assert data["calories"] == 2000
|
||
assert data["protein_g"] is None
|
||
assert data["carbs_g"] is None
|
||
assert data["fat_g"] is None
|
||
finally:
|
||
close_active_target(client)
|