fix: harden habit input and lifecycle
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from tests.test_mvp_backend import boot
|
||||
|
||||
|
||||
def create_habit(client, **overrides):
|
||||
payload = {"name": "阅读", "kind": "numeric", "target": 2, "max_value": 4, "schedule_type": "daily"}
|
||||
payload.update(overrides)
|
||||
return client.post("/api/v1/habits", json=payload)
|
||||
|
||||
|
||||
def test_habit_patch_updates_only_submitted_fields_and_trims_name(client):
|
||||
boot(client)
|
||||
habit = create_habit(client).json()
|
||||
|
||||
response = client.patch(f"/api/v1/habits/{habit['id']}", json={"name": " 深度阅读 "})
|
||||
|
||||
assert response.status_code == 200
|
||||
updated = response.json()
|
||||
assert updated["name"] == "深度阅读"
|
||||
assert updated["kind"] == "numeric"
|
||||
assert updated["target"] == 2
|
||||
assert updated["max_value"] == 4
|
||||
|
||||
|
||||
def test_habit_name_rejects_blank_on_create_and_update(client):
|
||||
boot(client)
|
||||
assert create_habit(client, name=" ").status_code == 422
|
||||
habit = create_habit(client).json()
|
||||
assert client.patch(f"/api/v1/habits/{habit['id']}", json={"name": " \t "}).status_code == 422
|
||||
|
||||
|
||||
def test_habit_schedule_validation_and_irrelevant_fields_are_cleared(client):
|
||||
boot(client)
|
||||
invalid_payloads = [
|
||||
{"schedule_type": "weekly"},
|
||||
{"schedule_type": "weekly", "weekdays": [1, 1]},
|
||||
{"schedule_type": "weekly", "weekdays": [-1]},
|
||||
{"schedule_type": "weekly", "weekdays": [7]},
|
||||
{"schedule_type": "monthly"},
|
||||
{"schedule_type": "monthly", "month_days": [1, 1]},
|
||||
{"schedule_type": "monthly", "month_days": [0]},
|
||||
{"schedule_type": "monthly", "month_days": [32]},
|
||||
{"schedule_type": "interval"},
|
||||
{"schedule_type": "interval", "interval_days": 0},
|
||||
]
|
||||
for payload in invalid_payloads:
|
||||
assert create_habit(client, **payload).status_code == 422, payload
|
||||
|
||||
response = create_habit(
|
||||
client,
|
||||
schedule_type="weekly",
|
||||
weekdays=[1, 3],
|
||||
month_days=[8],
|
||||
interval_days=2,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["weekdays"] == [1, 3]
|
||||
assert response.json()["month_days"] is None
|
||||
assert response.json()["interval_days"] is None
|
||||
|
||||
|
||||
def test_habit_patch_validates_merged_schedule_and_clears_old_schedule_fields(client):
|
||||
boot(client)
|
||||
habit = create_habit(client, schedule_type="weekly", weekdays=[1, 3]).json()
|
||||
|
||||
invalid = client.patch(f"/api/v1/habits/{habit['id']}", json={"weekdays": []})
|
||||
assert invalid.status_code == 422
|
||||
|
||||
changed = client.patch(
|
||||
f"/api/v1/habits/{habit['id']}",
|
||||
json={"schedule_type": "monthly", "month_days": [10]},
|
||||
)
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["weekdays"] is None
|
||||
assert changed.json()["month_days"] == [10]
|
||||
assert changed.json()["interval_days"] is None
|
||||
|
||||
|
||||
def test_habit_create_rejects_non_finite_numeric_target_and_max_value(client):
|
||||
boot(client)
|
||||
for field in ("target", "max_value"):
|
||||
for value in ("NaN", "Infinity", "-Infinity"):
|
||||
response = create_habit(client, **{field: value})
|
||||
assert response.status_code == 422, (field, value, response.text)
|
||||
|
||||
|
||||
def test_habit_update_rejects_non_finite_numeric_target_and_max_value(client):
|
||||
boot(client)
|
||||
habit = create_habit(client).json()
|
||||
for field in ("target", "max_value"):
|
||||
for value in ("NaN", "Infinity", "-Infinity"):
|
||||
response = client.patch(f"/api/v1/habits/{habit['id']}", json={field: value})
|
||||
assert response.status_code == 422, (field, value, response.text)
|
||||
|
||||
|
||||
def test_habit_log_post_rejects_non_finite_value(client):
|
||||
boot(client)
|
||||
habit = create_habit(client).json()
|
||||
day = datetime.now(UTC).date().isoformat()
|
||||
|
||||
for value in ("NaN", "Infinity", "-Infinity"):
|
||||
response = client.post(
|
||||
f"/api/v1/habits/{habit['id']}/logs",
|
||||
json={"day": day, "value": value},
|
||||
)
|
||||
assert response.status_code == 422, (value, response.text)
|
||||
|
||||
|
||||
def test_habit_log_put_rejects_non_finite_value(client):
|
||||
boot(client)
|
||||
habit = create_habit(client).json()
|
||||
day = datetime.now(UTC).date().isoformat()
|
||||
|
||||
for value in ("NaN", "Infinity", "-Infinity"):
|
||||
response = client.put(
|
||||
f"/api/v1/habits/{habit['id']}/logs/{day}",
|
||||
json={"value": value},
|
||||
)
|
||||
assert response.status_code == 422, (value, response.text)
|
||||
|
||||
|
||||
def test_habit_numeric_targets_and_boolean_normalization(client):
|
||||
boot(client)
|
||||
for payload in (
|
||||
{"kind": "numeric", "target": 0},
|
||||
{"kind": "numeric", "max_value": 0},
|
||||
{"kind": "numeric", "target": 5, "max_value": 4},
|
||||
):
|
||||
assert create_habit(client, **payload).status_code == 422, payload
|
||||
|
||||
boolean = create_habit(client, kind="boolean", target=9, max_value=12).json()
|
||||
assert boolean["target"] == 1
|
||||
assert boolean["max_value"] == 1
|
||||
updated = client.patch(
|
||||
f"/api/v1/habits/{boolean['id']}", json={"target": 7, "max_value": 8}
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["target"] == 1
|
||||
assert updated.json()["max_value"] == 1
|
||||
|
||||
|
||||
def test_archived_habit_rejects_edit_logs_pause_and_active_permanent_delete(client):
|
||||
boot(client)
|
||||
active = create_habit(client).json()
|
||||
assert client.delete(f"/api/v1/habits/{active['id']}/permanent").status_code == 409
|
||||
|
||||
habit = create_habit(client).json()
|
||||
hid = habit["id"]
|
||||
day = datetime.now(UTC).date().isoformat()
|
||||
assert client.put(f"/api/v1/habits/{hid}/logs/{day}", json={"value": 1}).status_code == 200
|
||||
assert client.delete(f"/api/v1/habits/{hid}").status_code == 204
|
||||
|
||||
assert client.patch(f"/api/v1/habits/{hid}", json={"name": "不可编辑"}).status_code == 409
|
||||
assert client.post(f"/api/v1/habits/{hid}/logs", json={"day": day, "value": 1}).status_code == 409
|
||||
assert client.put(f"/api/v1/habits/{hid}/logs/{day}", json={"value": 0}).status_code == 409
|
||||
assert client.delete(f"/api/v1/habits/{hid}/logs/{day}").status_code == 409
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{hid}/pauses", json={"start_date": day, "end_date": day}
|
||||
).status_code == 409
|
||||
assert client.delete(f"/api/v1/habits/{hid}/permanent").status_code == 204
|
||||
|
||||
|
||||
def test_paused_or_unscheduled_day_rejects_positive_progress_but_allows_correction(client):
|
||||
boot(client)
|
||||
monday = date(2026, 9, 7)
|
||||
tuesday = monday + timedelta(days=1)
|
||||
habit = create_habit(
|
||||
client,
|
||||
schedule_type="weekly",
|
||||
weekdays=[monday.weekday()],
|
||||
start_date=monday.isoformat(),
|
||||
).json()
|
||||
hid = habit["id"]
|
||||
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{hid}/logs", json={"day": tuesday.isoformat(), "value": 1}
|
||||
).status_code == 409
|
||||
assert client.get(f"/api/v1/habits/{hid}/stats").json()["total"] == 0
|
||||
|
||||
assert client.put(
|
||||
f"/api/v1/habits/{hid}/logs/{monday.isoformat()}", json={"value": 2}
|
||||
).status_code == 200
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{hid}/pauses",
|
||||
json={"start_date": monday.isoformat(), "end_date": monday.isoformat()},
|
||||
).status_code == 201
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{hid}/logs", json={"day": monday.isoformat(), "value": 1}
|
||||
).status_code == 409
|
||||
assert client.put(
|
||||
f"/api/v1/habits/{hid}/logs/{monday.isoformat()}", json={"value": 3}
|
||||
).status_code == 409
|
||||
corrected = client.put(
|
||||
f"/api/v1/habits/{hid}/logs/{monday.isoformat()}", json={"value": 0}
|
||||
)
|
||||
assert corrected.status_code == 200
|
||||
assert corrected.json()["value"] == 0
|
||||
assert client.delete(f"/api/v1/habits/{hid}/logs/{monday.isoformat()}").status_code == 204
|
||||
|
||||
|
||||
def test_task_folder_and_list_names_are_trimmed_and_blank_rejected(client):
|
||||
inbox = boot(client)
|
||||
|
||||
assert client.post("/api/v1/folders", json={"name": " "}).status_code == 422
|
||||
folder_response = client.post("/api/v1/folders", json={"name": " 工作 "})
|
||||
assert folder_response.status_code == 201
|
||||
folder = folder_response.json()
|
||||
assert folder["name"] == "工作"
|
||||
assert client.patch(f"/api/v1/folders/{folder['id']}", json={"name": " \t "}).status_code == 422
|
||||
assert client.patch(f"/api/v1/folders/{folder['id']}", json={"name": " 生活 "}).json()["name"] == "生活"
|
||||
|
||||
assert client.post("/api/v1/lists", json={"name": " "}).status_code == 422
|
||||
list_response = client.post("/api/v1/lists", json={"name": " 清单 ", "folder_id": folder["id"]})
|
||||
assert list_response.status_code == 201
|
||||
task_list = list_response.json()
|
||||
assert task_list["name"] == "清单"
|
||||
assert client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": " "}).status_code == 422
|
||||
assert client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": " 新清单 "}).json()["name"] == "新清单"
|
||||
|
||||
assert client.post("/api/v1/tasks", json={"title": " ", "list_id": inbox["id"]}).status_code == 422
|
||||
task_response = client.post("/api/v1/tasks", json={"title": " 待办 ", "list_id": inbox["id"]})
|
||||
assert task_response.status_code == 201
|
||||
task = task_response.json()
|
||||
assert task["title"] == "待办"
|
||||
assert client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"title": " ", "version": task["version"]}
|
||||
).status_code == 422
|
||||
renamed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"title": " 已更新 ", "version": task["version"]}
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["title"] == "已更新"
|
||||
@@ -246,7 +246,15 @@ def test_habit_reorder_rejects_foreign_or_missing_ids(client):
|
||||
|
||||
def test_habit_logs_support_date_range_filter(client):
|
||||
boot(client)
|
||||
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()
|
||||
habit = client.post(
|
||||
"/api/v1/habits",
|
||||
json={
|
||||
"name": "跑步",
|
||||
"kind": "boolean",
|
||||
"schedule_type": "daily",
|
||||
"start_date": "2026-08-01",
|
||||
},
|
||||
).json()
|
||||
hid = habit["id"]
|
||||
for day in ("2026-08-01", "2026-08-15", "2026-09-01"):
|
||||
client.post(f"/api/v1/habits/{hid}/logs", json={"day": day, "value": 1})
|
||||
@@ -323,6 +331,7 @@ def test_habit_permanent_delete_removes_habit_and_history(client):
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
assert client.put(f"/api/v1/habits/{habit_id}/logs/{today}", json={"value": 2}).status_code == 200
|
||||
|
||||
assert client.delete(f"/api/v1/habits/{habit_id}").status_code == 204
|
||||
assert client.delete(f"/api/v1/habits/{habit_id}/permanent").status_code == 204
|
||||
assert all(row["id"] != habit_id for row in client.get("/api/v1/habits").json())
|
||||
assert all(row["id"] != habit_id for row in client.get("/api/v1/habits", params={"archived": True}).json())
|
||||
|
||||
Reference in New Issue
Block a user