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_reorder_tasks_persists_top_level_and_subtask_order(client): client = initialized_client(client) inbox = client.get("/api/v1/lists").json()[0] first = client.post("/api/v1/tasks", json={"title": "第一个", "list_id": inbox["id"]}).json() second = client.post("/api/v1/tasks", json={"title": "第二个", "list_id": inbox["id"]}).json() child_a = client.post( "/api/v1/tasks", json={"title": "子任务 A", "list_id": inbox["id"], "parent_id": first["id"]} ).json() child_b = client.post( "/api/v1/tasks", json={"title": "子任务 B", "list_id": inbox["id"], "parent_id": first["id"]} ).json() top_level = client.put("/api/v1/tasks/reorder", json={"task_ids": [second["id"], first["id"]]}) assert top_level.status_code == 204 children = client.put("/api/v1/tasks/reorder", json={"task_ids": [child_b["id"], child_a["id"]]}) assert children.status_code == 204 client.post("/api/v1/tasks", json={"title": "第三个", "list_id": inbox["id"]}) listed = client.get("/api/v1/tasks", params={"list_id": inbox["id"], "page": 1}).json()["items"] assert [task["title"] for task in listed] == ["第二个", "第一个", "第三个"] assert [task["title"] for task in listed[1]["subtasks"]] == ["子任务 B", "子任务 A"] def test_reorder_tasks_rejects_mixed_parent_scopes(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() response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], child["id"]]}) assert response.status_code == 400 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 test_change_password_checks_current_password_and_revokes_other_sessions(client): old_password = "correct horse battery staple" new_password = "new correct horse battery staple" client.post("/api/v1/setup/initialize", json={"username": "owner", "password": old_password}) other = type(client)(client.app) try: assert other.post("/api/v1/auth/login", json={"username": "owner", "password": old_password}).status_code == 200 wrong = client.post( "/api/v1/auth/change-password", json={"current_password": "wrong password", "new_password": new_password}, ) assert wrong.status_code == 400 assert wrong.json()["detail"] == "当前密码不正确" changed = client.post( "/api/v1/auth/change-password", json={"current_password": old_password, "new_password": new_password}, ) assert changed.status_code == 204 assert client.get("/api/v1/me").status_code == 200 assert other.get("/api/v1/me").status_code == 401 assert other.post("/api/v1/auth/login", json={"username": "owner", "password": old_password}).status_code == 401 assert other.post("/api/v1/auth/login", json={"username": "owner", "password": new_password}).status_code == 200 finally: other.close() def test_change_password_validates_new_password(client): client.post( "/api/v1/setup/initialize", json={"username": "owner", "password": "correct horse battery staple"}, ) too_short = client.post( "/api/v1/auth/change-password", json={"current_password": "correct horse battery staple", "new_password": "short"}, ) assert too_short.status_code == 422 same = client.post( "/api/v1/auth/change-password", json={"current_password": "correct horse battery staple", "new_password": "correct horse battery staple"}, ) assert same.status_code == 422 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