368 lines
14 KiB
Python
368 lines
14 KiB
Python
import os
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
os.environ.setdefault("DODO_DATABASE_URL", "sqlite+aiosqlite:////tmp/dodo-health-test.db")
|
|
os.environ.setdefault("DODO_AUTO_CREATE_SCHEMA", "true")
|
|
|
|
from backend.main import app
|
|
|
|
|
|
def test_health_live():
|
|
with TestClient(app) as client:
|
|
response = client.get("/health/live")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
def test_setup_status_starts_uninitialized(client):
|
|
response = client.get("/api/v1/setup/status")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"initialized": False}
|
|
|
|
|
|
def test_initialize_login_and_create_task(client):
|
|
initialized = client.post(
|
|
"/api/v1/setup/initialize",
|
|
json={"username": "owner", "password": "correct horse battery staple"},
|
|
)
|
|
assert initialized.status_code == 201
|
|
assert initialized.json()["username"] == "owner"
|
|
assert "dodo_session" in initialized.cookies
|
|
|
|
folder = client.post("/api/v1/folders", json={"name": "工作"})
|
|
assert folder.status_code == 201
|
|
|
|
task_list = client.post(
|
|
"/api/v1/lists", json={"name": "本周", "folder_id": folder.json()["id"]}
|
|
)
|
|
assert task_list.status_code == 201
|
|
|
|
task = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "完成 dodo 第一阶段", "list_id": task_list.json()["id"], "priority": 3},
|
|
)
|
|
assert task.status_code == 201
|
|
assert task.json()["title"] == "完成 dodo 第一阶段"
|
|
|
|
tasks = client.get("/api/v1/tasks")
|
|
assert tasks.status_code == 200
|
|
assert len(tasks.json()["items"]) == 1
|
|
|
|
|
|
def test_initialize_is_closed_after_first_user(client):
|
|
payload = {"username": "owner", "password": "correct horse battery staple"}
|
|
assert client.post("/api/v1/setup/initialize", json=payload).status_code == 201
|
|
response = client.post("/api/v1/setup/initialize", json=payload)
|
|
assert response.status_code == 409
|
|
|
|
|
|
def test_unauthenticated_task_access_is_rejected(client):
|
|
response = client.get("/api/v1/tasks")
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_task_can_be_updated_completed_and_soft_deleted(client):
|
|
client.post(
|
|
"/api/v1/setup/initialize",
|
|
json={"username": "owner", "password": "correct horse battery staple"},
|
|
)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
task = client.post(
|
|
"/api/v1/tasks", json={"title": "旧标题", "list_id": inbox["id"]}
|
|
).json()
|
|
|
|
updated = client.patch(
|
|
f"/api/v1/tasks/{task['id']}",
|
|
json={"title": "新标题", "completed": True, "version": task["version"]},
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["title"] == "新标题"
|
|
assert updated.json()["completed"] is True
|
|
assert updated.json()["version"] == 2
|
|
|
|
conflict = client.patch(
|
|
f"/api/v1/tasks/{task['id']}",
|
|
json={"title": "冲突标题", "version": task["version"]},
|
|
)
|
|
assert conflict.status_code == 409
|
|
|
|
deleted = client.delete(f"/api/v1/tasks/{task['id']}")
|
|
assert deleted.status_code == 204
|
|
assert client.get("/api/v1/tasks").json()["items"] == []
|
|
|
|
|
|
def test_task_update_rejects_null_title(client):
|
|
client.post(
|
|
"/api/v1/setup/initialize",
|
|
json={"username": "owner", "password": "correct horse battery staple"},
|
|
)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
task = client.post("/api/v1/tasks", json={"title": "任务", "list_id": inbox["id"]}).json()
|
|
response = client.patch(
|
|
f"/api/v1/tasks/{task['id']}", json={"title": None, "version": task["version"]}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_subtask_parent_must_belong_to_same_user_and_list(client):
|
|
client.post(
|
|
"/api/v1/setup/initialize",
|
|
json={"username": "owner", "password": "correct horse battery staple"},
|
|
)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
other = client.post("/api/v1/lists", json={"name": "其他"}).json()
|
|
parent = client.post("/api/v1/tasks", json={"title": "父任务", "list_id": inbox["id"]}).json()
|
|
response = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "子任务", "list_id": other["id"], "parent_id": parent["id"]},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_logout_revokes_current_session(client):
|
|
client.post(
|
|
"/api/v1/setup/initialize",
|
|
json={"username": "owner", "password": "correct horse battery staple"},
|
|
)
|
|
assert client.get("/api/v1/me").status_code == 200
|
|
assert client.post("/api/v1/auth/logout").status_code == 204
|
|
assert client.get("/api/v1/me").status_code == 401
|
|
|
|
|
|
def initialized_client(client):
|
|
client.post(
|
|
"/api/v1/setup/initialize",
|
|
json={"username": "owner", "password": "correct horse battery staple"},
|
|
)
|
|
return client
|
|
|
|
|
|
def test_folder_and_list_lifecycle(client):
|
|
client = initialized_client(client)
|
|
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
|
|
renamed = client.patch(f"/api/v1/folders/{folder['id']}", json={"name": "生活"})
|
|
assert renamed.status_code == 200
|
|
assert renamed.json()["name"] == "生活"
|
|
|
|
task_list = client.post(
|
|
"/api/v1/lists", json={"name": "采购", "folder_id": folder["id"]}
|
|
).json()
|
|
renamed_list = client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": "购物"})
|
|
assert renamed_list.status_code == 200
|
|
assert renamed_list.json()["name"] == "购物"
|
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
|
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204
|
|
|
|
|
|
def test_task_search_subtasks_and_recycle_bin(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
parent = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"]},
|
|
).json()
|
|
child = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "比较价格", "list_id": inbox["id"], "parent_id": parent["id"]},
|
|
)
|
|
assert child.status_code == 201
|
|
detail = client.get(f"/api/v1/tasks/{parent['id']}")
|
|
assert detail.status_code == 200
|
|
assert "tags" not in detail.json()
|
|
assert len(detail.json()["subtasks"]) == 1
|
|
listed = client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]
|
|
assert len(listed) == 1
|
|
assert "tags" not in listed[0]
|
|
assert listed[0]["subtasks"][0]["title"] == "比较价格"
|
|
|
|
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
|
|
trash = client.get("/api/v1/trash").json()["items"]
|
|
assert len(trash) == 1
|
|
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 200
|
|
|
|
|
|
def test_batch_complete_and_move(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
other = client.post("/api/v1/lists", json={"name": "稍后"}).json()
|
|
ids = [
|
|
client.post("/api/v1/tasks", json={"title": f"任务{i}", "list_id": inbox["id"]}).json()["id"]
|
|
for i in range(2)
|
|
]
|
|
response = client.post(
|
|
"/api/v1/tasks/batch", json={"task_ids": ids, "completed": True, "list_id": other["id"]}
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["updated"] == 2
|
|
rows = client.get("/api/v1/tasks").json()["items"]
|
|
assert all(row["completed"] and row["list_id"] == other["id"] for row in rows)
|
|
|
|
|
|
def test_batch_move_rejects_standalone_subtask_and_delete_cascades(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
other = client.post("/api/v1/lists", json={"name": "稍后"}).json()
|
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
|
child = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
|
).json()
|
|
|
|
moved_child = client.post(
|
|
"/api/v1/tasks/batch", json={"task_ids": [child["id"]], "list_id": other["id"]}
|
|
)
|
|
assert moved_child.status_code == 400
|
|
|
|
deleted_parent = client.post(
|
|
"/api/v1/tasks/batch", json={"task_ids": [parent["id"]], "soft_delete": True}
|
|
)
|
|
assert deleted_parent.status_code == 200
|
|
assert client.get(f"/api/v1/tasks/{parent['id']}").status_code == 404
|
|
assert client.get(f"/api/v1/tasks/{child['id']}").status_code == 404
|
|
|
|
|
|
def test_inbox_is_protected_and_deleted_collections_are_hidden(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
assert client.patch(f"/api/v1/lists/{inbox['id']}", json={"name": "x"}).status_code == 409
|
|
assert client.delete(f"/api/v1/lists/{inbox['id']}").status_code == 409
|
|
|
|
folder = client.post("/api/v1/folders", json={"name": "项目"}).json()
|
|
task_list = client.post(
|
|
"/api/v1/lists", json={"name": "待办", "folder_id": folder["id"]}
|
|
).json()
|
|
task = client.post(
|
|
"/api/v1/tasks", json={"title": "保留任务", "list_id": task_list["id"]}
|
|
).json()
|
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
|
assert all(row["id"] != task_list["id"] for row in client.get("/api/v1/lists").json())
|
|
archived = client.get("/api/v1/lists", params={"archived": True})
|
|
assert archived.status_code == 200
|
|
assert [row["id"] for row in archived.json()] == [task_list["id"]]
|
|
assert client.post(f"/api/v1/lists/{task_list['id']}/restore").status_code == 200
|
|
assert any(row["id"] == task_list["id"] for row in client.get("/api/v1/lists").json())
|
|
moved = client.get(f"/api/v1/tasks/{task['id']}").json()
|
|
assert moved["list_id"] == inbox["id"]
|
|
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204
|
|
assert client.get("/api/v1/folders").json() == []
|
|
|
|
|
|
def test_nested_subtasks_are_rejected(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
|
child = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
|
).json()
|
|
nested = client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "孙", "list_id": inbox["id"], "parent_id": child["id"]},
|
|
)
|
|
assert nested.status_code == 400
|
|
|
|
|
|
|
|
def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "备份任务", "list_id": inbox["id"]},
|
|
).json()
|
|
client.post(
|
|
"/api/v1/habits",
|
|
json={"name": "俯卧撑", "kind": "boolean", "schedule_type": "daily"},
|
|
).json()
|
|
exported = client.get("/api/v1/export")
|
|
assert exported.status_code == 200
|
|
assert "tags" not in exported.json()
|
|
assert "task_tags" not in exported.json()
|
|
|
|
client.post(
|
|
"/api/v1/habits",
|
|
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},
|
|
)
|
|
restored = client.post("/api/v1/restore?mode=replace", json=exported.json())
|
|
assert restored.status_code == 200
|
|
|
|
assert client.get("/api/v1/tags").status_code == 404
|
|
habits = client.get("/api/v1/habits").json()
|
|
assert [row["name"] for row in habits] == ["俯卧撑"]
|
|
listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
|
|
assert "tags" not in listed[0]
|
|
|
|
|
|
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
|
client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
|
)
|
|
client.delete(f"/api/v1/tasks/{parent['id']}")
|
|
assert len(client.get("/api/v1/trash").json()["items"]) == 1
|
|
restored = client.post(f"/api/v1/tasks/{parent['id']}/restore")
|
|
assert restored.status_code == 200
|
|
assert len(client.get(f"/api/v1/tasks/{parent['id']}").json()["subtasks"]) == 1
|
|
client.delete(f"/api/v1/tasks/{parent['id']}")
|
|
assert client.delete(f"/api/v1/trash/{parent['id']}").status_code == 204
|
|
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 404
|
|
|
|
|
|
def test_batch_supports_due_date_and_soft_delete_atomically(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
ids = [
|
|
client.post("/api/v1/tasks", json={"title": str(i), "list_id": inbox["id"]}).json()["id"]
|
|
for i in range(2)
|
|
]
|
|
due_at = "2026-09-10T08:00:00+08:00"
|
|
response = client.post(
|
|
"/api/v1/tasks/batch",
|
|
json={"task_ids": ids, "due_at": due_at},
|
|
)
|
|
assert response.status_code == 200 and response.json()["updated"] == 2
|
|
assert all(client.get(f"/api/v1/tasks/{task_id}").json()["due_at"] is not None for task_id in ids)
|
|
|
|
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
|
client.post(
|
|
"/api/v1/tasks",
|
|
json={"title": "子", "list_id": inbox["id"], "parent_id": parent["id"]},
|
|
)
|
|
other = client.post("/api/v1/lists", json={"name": "批量目标"}).json()
|
|
assert client.post(
|
|
"/api/v1/tasks/batch", json={"task_ids": [parent["id"]], "list_id": other["id"]}
|
|
).status_code == 200
|
|
moved = client.get(f"/api/v1/tasks/{parent['id']}").json()
|
|
assert moved["subtasks"][0]["list_id"] == other["id"]
|
|
|
|
failed = client.post(
|
|
"/api/v1/tasks/batch",
|
|
json={"task_ids": [ids[0], "00000000-0000-0000-0000-000000000001"], "completed": True},
|
|
)
|
|
assert failed.status_code == 404
|
|
assert client.get(f"/api/v1/tasks/{ids[0]}").json()["completed"] is False
|
|
assert client.post("/api/v1/tasks/batch", json={"task_ids": ids, "soft_delete": True}).status_code == 200
|
|
assert len(client.get("/api/v1/trash").json()["items"]) == 2
|
|
|
|
|
|
def test_cursor_pagination_is_stable_and_rejects_bad_cursor(client):
|
|
client = initialized_client(client)
|
|
inbox = client.get("/api/v1/lists").json()[0]
|
|
for i in range(5):
|
|
client.post("/api/v1/tasks", json={"title": f"任务{i}", "list_id": inbox["id"]})
|
|
first = client.get("/api/v1/tasks", params={"limit": 2}).json()
|
|
second = client.get(
|
|
"/api/v1/tasks", params={"limit": 2, "cursor": first["next_cursor"]}
|
|
).json()
|
|
third = client.get(
|
|
"/api/v1/tasks", params={"limit": 2, "cursor": second["next_cursor"]}
|
|
).json()
|
|
ids = [row["id"] for page in (first, second, third) for row in page["items"]]
|
|
assert len(ids) == len(set(ids)) == 5
|
|
assert third["next_cursor"] is None
|
|
assert client.get("/api/v1/tasks", params={"cursor": "broken"}).status_code == 422
|