Files
Craig 601c0d461e TICKET-002: Targets CRUD with single-active-target invariant
- 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)
2026-07-26 13:08:23 +01:00

513 lines
18 KiB
Python
Raw Permalink 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.
"""Targets CRUD tests — TICKET-002 (spec §2.4, §3.4, §8.1 rules 23, 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)