"""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)