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_tags_subtasks_and_recycle_bin(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] tag = client.post("/api/v1/tags", json={"name": "重要", "color": "#f15a29"}).json() parent = client.post( "/api/v1/tasks", json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"], "tag_ids": [tag["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 detail.json()["tags"][0]["name"] == "重要" assert len(detail.json()["subtasks"]) == 1 assert len(client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]) == 1 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_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_tags_are_global_searchable_and_validated(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] errands = client.post("/api/v1/lists", json={"name": "跑腿清单"}).json() tag = client.post("/api/v1/tags", json={"name": "蓝色标签", "color": "#123abc"}) assert tag.status_code == 201 assert client.get("/api/v1/tags").json()[0]["color"] == "#123abc" task = client.post( "/api/v1/tasks", json={"title": "普通标题", "description": "特殊说明", "list_id": errands["id"], "tag_ids": [tag.json()["id"]]}, ).json() for query in ("普通", "特殊", "蓝色", "跑腿"): assert [row["id"] for row in client.get("/api/v1/tasks", params={"q": query}).json()["items"]] == [task["id"]] invalid = client.post( "/api/v1/tasks", json={"title": "非法标签", "list_id": inbox["id"], "tag_ids": ["00000000-0000-0000-0000-000000000001"]}, ) assert invalid.status_code == 404 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_tags_and_soft_delete_atomically(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] tag = client.post("/api/v1/tags", json={"name": "批量", "color": "red"}).json() 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, "tag_ids": [tag["id"]]}, ) assert response.status_code == 200 and response.json()["updated"] == 2 assert all(client.get(f"/api/v1/tasks/{task_id}").json()["tags"] 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