fix: audit findings — list details, batch integrity, migrations, restore fidelity
ci / docker (push) Successful in 3m17s

This commit is contained in:
2026-09-06 11:16:52 +08:00
parent d21c6629ba
commit 0cc486069e
8 changed files with 245 additions and 42 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ async def issue_session(db: AsyncSession, response: Response, user: User, reques
db.add(Session( db.add(Session(
token_hash=hash_token(token), user_id=user.id, expires_at=expires, token_hash=hash_token(token), user_id=user.id, expires_at=expires,
ip_address=request.client.host if request and request.client else None, ip_address=request.client.host if request and request.client else None,
user_agent=request.headers.get("user-agent", "")[:500] if request else None, user_agent=request.headers.get("user-agent", "")[:255] if request else None,
)) ))
await db.commit() await db.commit()
response.set_cookie( response.set_cookie(
+54 -17
View File
@@ -39,6 +39,7 @@ from .schemas import (
ListOut, ListOut,
LoginRequest, LoginRequest,
NameUpdate, NameUpdate,
SessionOut,
TagCreate, TagCreate,
TagOut, TagOut,
TaskCreate, TaskCreate,
@@ -213,7 +214,7 @@ async def logout(
return Response(status_code=204, headers=response.headers) return Response(status_code=204, headers=response.headers)
@app.get("/api/v1/sessions") @app.get("/api/v1/sessions", response_model=list[SessionOut])
async def list_sessions( async def list_sessions(
token: str = Depends(session_token), token: str = Depends(session_token),
user: User = Depends(current_user), user: User = Depends(current_user),
@@ -559,7 +560,7 @@ async def list_tasks(
if page is not None: if page is not None:
size = page_size or limit size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=items, total=total, page=page, page_size=size) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor: if cursor:
created_at, task_id = _decode_cursor(cursor) created_at, task_id = _decode_cursor(cursor)
query = query.where( query = query.where(
@@ -569,31 +570,48 @@ async def list_tasks(
has_more = len(rows) > limit has_more = len(rows) > limit
items = rows[:limit] items = rows[:limit]
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
return TaskPage(items=items, next_cursor=next_cursor, total=total, page=1, page_size=limit) return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut: async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]:
tags = list( if not tasks:
( return []
await db.scalars( task_ids = [task.id for task in tasks]
select(Tag) tag_rows = (
.join(TaskTag, TaskTag.tag_id == Tag.id) await db.execute(
.where(TaskTag.task_id == task.id) select(TaskTag.task_id, Tag)
.join(Tag, Tag.id == TaskTag.tag_id)
.where(TaskTag.task_id.in_(task_ids))
.order_by(Tag.name, Tag.id) .order_by(Tag.name, Tag.id)
) )
).all() ).all()
)
subtasks = list( subtasks = list(
( (
await db.scalars( await db.scalars(
select(Task) select(Task)
.where(Task.parent_id == task.id, Task.deleted_at.is_(None)) .where(Task.parent_id.in_(task_ids), Task.deleted_at.is_(None))
.order_by(Task.position, Task.created_at, Task.id) .order_by(Task.position, Task.created_at, Task.id)
) )
).all() ).all()
) )
data = TaskOut.model_validate(task).model_dump() tags_by_task: dict[UUID, list[Tag]] = defaultdict(list)
return TaskDetailOut(**data, tags=tags, subtasks=subtasks) subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for task_id, tag in tag_rows:
tags_by_task[task_id].append(tag)
for subtask in subtasks:
subtasks_by_task[subtask.parent_id].append(subtask)
return [
TaskDetailOut(
**TaskOut.model_validate(task).model_dump(),
tags=tags_by_task[task.id],
subtasks=subtasks_by_task[task.id],
)
for task in tasks
]
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
return (await _task_details(db, [task]))[0]
@app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut) @app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
@@ -707,7 +725,7 @@ async def list_trash(
if page is not None: if page is not None:
size = page_size or limit size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=items, total=total, page=page, page_size=size) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor: if cursor:
created_at, task_id = _decode_cursor(cursor) created_at, task_id = _decode_cursor(cursor)
query = query.where( query = query.where(
@@ -717,7 +735,7 @@ async def list_trash(
has_more = len(rows) > limit has_more = len(rows) > limit
items = rows[:limit] items = rows[:limit]
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
return TaskPage(items=items, next_cursor=next_cursor, total=total, page=1, page_size=limit) return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut) @app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
@@ -791,6 +809,9 @@ async def batch_update_tasks(
raise HTTPException(status_code=404, detail="一个或多个任务不存在") raise HTTPException(status_code=404, detail="一个或多个任务不存在")
if payload.list_id is not None: if payload.list_id is not None:
await _owned_list(db, user.id, payload.list_id) await _owned_list(db, user.id, payload.list_id)
standalone_children = [task for task in tasks if task.parent_id is not None]
if standalone_children:
raise HTTPException(status_code=400, detail="子任务不能脱离父任务单独移动")
tag_ids = await _validate_tags(db, user.id, payload.tag_ids) tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"}) changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"})
if payload.soft_delete: if payload.soft_delete:
@@ -798,9 +819,25 @@ async def batch_update_tasks(
if changes: if changes:
changes["version"] = Task.version + 1 changes["version"] = Task.version + 1
changes["updated_at"] = utcnow() changes["updated_at"] = utcnow()
target_ids = set(task_ids)
if payload.soft_delete:
parent_ids = [task.id for task in tasks if task.parent_id is None]
if parent_ids:
child_ids = list(
(
await db.scalars(
select(Task.id).where(
Task.parent_id.in_(parent_ids),
Task.user_id == user.id,
Task.deleted_at.is_(None),
)
)
).all()
)
target_ids.update(child_ids)
await db.execute( await db.execute(
update(Task) update(Task)
.where(Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None)) .where(Task.id.in_(target_ids), Task.user_id == user.id, Task.deleted_at.is_(None))
.values(**changes) .values(**changes)
) )
if payload.list_id is not None: if payload.list_id is not None:
+1 -1
View File
@@ -52,7 +52,7 @@ class Session(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True) ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
class Folder(Base): class Folder(Base):
+96 -11
View File
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, Up
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from icalendar import Calendar from icalendar import Calendar
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from sqlalchemy import case, delete, func, select from sqlalchemy import case, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from .auth import current_user from .auth import current_user
@@ -36,6 +36,7 @@ from .models import (
Tag, Tag,
Task, Task,
TaskList, TaskList,
TaskTag,
User, User,
new_id, new_id,
utcnow, utcnow,
@@ -718,29 +719,113 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
def serialize(row, fields): def serialize(row, fields):
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields} return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tags = list((await db.scalars(select(Tag).where(Tag.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()) folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tags = list((await db.scalars(select(Tag).where(Tag.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
return {"version": 1, "exported_at": utcnow(), "folders": [serialize(x,["id","name","position","deleted_at"]) for x in folders], "lists": [serialize(x,["id","folder_id","name","is_inbox","position","deleted_at"]) for x in lists], "tags": [serialize(x,["id","name","color"]) for x in tags], "tasks": [serialize(x,["id","list_id","parent_id","title","description","priority","completed","due_at","external_id","deleted_at"]) for x in tasks], "habits": [serialize(x,["id","name","kind","target","max_value","schedule_type","weekdays","month_days","interval_days","start_date","archived_at"]) for x in habits]} return {
"version": 1,
"exported_at": utcnow(),
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
"tags": [serialize(x, ["id", "name", "color"]) for x in tags],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
"task_tags": [{"task_id": str(x.task_id), "tag_id": str(x.tag_id)} for x in (await db.scalars(select(TaskTag))).all() if x.task_id in {task.id for task in tasks}],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
}
@router.post("/restore") @router.post("/restore")
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if payload.get("version") != 1: raise HTTPException(422, "不支持的备份版本") if payload.get("version") != 1:
raise HTTPException(422, "不支持的备份版本")
if mode == "replace": if mode == "replace":
await db.execute(delete(Task).where(Task.user_id == user.id)); await db.execute(delete(TaskList).where(TaskList.user_id == user.id)); await db.execute(delete(Folder).where(Folder.user_id == user.id)) await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(select(Task.id).where(Task.user_id == user.id))))
await db.execute(delete(Task).where(Task.user_id == user.id))
await db.execute(delete(Tag).where(Tag.user_id == user.id))
await db.execute(delete(Habit).where(Habit.user_id == user.id))
await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
await db.execute(delete(Folder).where(Folder.user_id == user.id))
id_map = {} id_map = {}
task_id_map = {}
tag_id_map = {}
for raw in payload.get("folders", []): for raw in payload.get("folders", []):
old = raw["id"]; row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position",0)); db.add(row); await db.flush(); id_map[old] = row.id old = raw["id"]
row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position", 0))
db.add(row)
await db.flush()
id_map[old] = row.id
inbox = None inbox = None
for raw in payload.get("lists", []): for raw in payload.get("lists", []):
row = TaskList(user_id=user.id, folder_id=id_map.get(raw.get("folder_id")), name=raw["name"], is_inbox=raw.get("is_inbox",False), position=raw.get("position",0)); db.add(row); await db.flush(); id_map[raw["id"]] = row.id row = TaskList(
if row.is_inbox: inbox = row user_id=user.id,
if not inbox: inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True); db.add(inbox); await db.flush() folder_id=id_map.get(raw.get("folder_id")),
name=raw["name"],
is_inbox=raw.get("is_inbox", False),
position=raw.get("position", 0),
)
db.add(row)
await db.flush()
id_map[raw["id"]] = row.id
if row.is_inbox:
inbox = row
if not inbox:
inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True)
db.add(inbox)
await db.flush()
for raw in payload.get("tags", []):
row = Tag(user_id=user.id, name=raw["name"], color=raw.get("color", "#f15a29"))
db.add(row)
await db.flush()
tag_id_map[raw["id"]] = row.id
restored = 0 restored = 0
pending_tasks = []
for raw in payload.get("tasks", []): for raw in payload.get("tasks", []):
ext = raw.get("external_id") ext = raw.get("external_id")
existing = await db.scalar(select(Task).where(Task.user_id == user.id, Task.external_id == ext)) if ext else None existing = await db.scalar(select(Task).where(Task.user_id == user.id, Task.external_id == ext)) if ext else None
if existing and mode == "merge": continue if existing and mode == "merge":
row = Task(user_id=user.id, list_id=id_map.get(raw.get("list_id"), inbox.id), title=raw["title"], description=raw.get("description", ""), priority=raw.get("priority",0), completed=raw.get("completed",False), due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None, external_id=ext); db.add(row); restored += 1 task_id_map[raw["id"]] = existing.id
audit(db, user.id, "restore", "backup", count=restored, mode=mode); await db.commit(); return {"restored": restored, "mode": mode} continue
row = Task(
user_id=user.id,
list_id=id_map.get(raw.get("list_id"), inbox.id),
title=raw["title"],
description=raw.get("description", ""),
priority=raw.get("priority", 0),
completed=raw.get("completed", False),
due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None,
external_id=ext,
)
db.add(row)
await db.flush()
task_id_map[raw["id"]] = row.id
pending_tasks.append((row.id, raw.get("parent_id")))
restored += 1
for task_id, old_parent_id in pending_tasks:
if old_parent_id and old_parent_id in task_id_map:
await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id]))
task_tag_rows = []
for raw in payload.get("task_tags", []):
task_id = task_id_map.get(raw.get("task_id"))
tag_id = tag_id_map.get(raw.get("tag_id"))
if task_id and tag_id:
task_tag_rows.append(TaskTag(task_id=task_id, tag_id=tag_id))
if task_tag_rows:
db.add_all(task_tag_rows)
for raw in payload.get("habits", []):
row = Habit(
user_id=user.id,
name=raw["name"],
kind=raw.get("kind", "boolean"),
target=raw.get("target", 1),
max_value=raw.get("max_value"),
schedule_type=raw.get("schedule_type", "daily"),
weekdays=raw.get("weekdays"),
month_days=raw.get("month_days"),
interval_days=raw.get("interval_days"),
start_date=date.fromisoformat(raw["start_date"]),
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
)
db.add(row)
audit(db, user.id, "restore", "backup", count=restored, mode=mode)
await db.commit()
return {"restored": restored, "mode": mode}
@router.get("/audit-logs") @router.get("/audit-logs")
+12 -1
View File
@@ -20,6 +20,17 @@ class UserOut(BaseModel):
username: str username: str
class SessionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
current: bool
created_at: datetime
last_seen_at: datetime
expires_at: datetime
ip_address: str | None
user_agent: str | None
class NameUpdate(BaseModel): class NameUpdate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
@@ -106,7 +117,7 @@ class TaskDetailOut(TaskOut):
class TaskPage(BaseModel): class TaskPage(BaseModel):
items: list[TaskOut] items: list[TaskDetailOut]
next_cursor: str | None = None next_cursor: str | None = None
total: int = 0 total: int = 0
page: int = 1 page: int = 1
+6
View File
@@ -13,7 +13,13 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name
op.add_column("tasks", sa.Column("external_id", sa.String(255), nullable=True)) op.add_column("tasks", sa.Column("external_id", sa.String(255), nullable=True))
if dialect == "sqlite":
with op.batch_alter_table("tasks") as batch_op:
batch_op.create_unique_constraint("uq_tasks_external_id", ["user_id", "external_id"])
else:
op.create_unique_constraint("uq_tasks_external_id", "tasks", ["user_id", "external_id"]) op.create_unique_constraint("uq_tasks_external_id", "tasks", ["user_id", "external_id"])
op.add_column("sessions", sa.Column("ip_address", sa.String(64), nullable=True)) op.add_column("sessions", sa.Column("ip_address", sa.String(64), nullable=True))
@@ -3,6 +3,7 @@ Revision ID: 0006
Revises: 0005 Revises: 0005
""" """
import sqlalchemy as sa
from alembic import op from alembic import op
revision = "0006" revision = "0006"
@@ -12,10 +13,16 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
bind = op.get_bind()
indexes = {index["name"] for index in sa.inspect(bind).get_indexes("habit_logs")}
if "ix_habit_logs_habit_day" in indexes:
op.drop_index("ix_habit_logs_habit_day", table_name="habit_logs") op.drop_index("ix_habit_logs_habit_day", table_name="habit_logs")
def downgrade() -> None: def downgrade() -> None:
bind = op.get_bind()
indexes = {index["name"] for index in sa.inspect(bind).get_indexes("habit_logs")}
if "ix_habit_logs_habit_day" not in indexes:
op.create_index( op.create_index(
"ix_habit_logs_habit_day", "ix_habit_logs_habit_day",
"habit_logs", "habit_logs",
+58 -1
View File
@@ -172,7 +172,10 @@ def test_task_search_tags_subtasks_and_recycle_bin(client):
assert detail.status_code == 200 assert detail.status_code == 200
assert detail.json()["tags"][0]["name"] == "重要" assert detail.json()["tags"][0]["name"] == "重要"
assert len(detail.json()["subtasks"]) == 1 assert len(detail.json()["subtasks"]) == 1
assert len(client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]) == 1 listed = client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]
assert len(listed) == 1
assert listed[0]["tags"][0]["name"] == "重要"
assert listed[0]["subtasks"][0]["title"] == "比较价格"
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204 assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
trash = client.get("/api/v1/trash").json()["items"] trash = client.get("/api/v1/trash").json()["items"]
@@ -197,6 +200,29 @@ def test_batch_complete_and_move(client):
assert all(row["completed"] and row["list_id"] == other["id"] for row in rows) 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): def test_inbox_is_protected_and_deleted_collections_are_hidden(client):
client = initialized_client(client) client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0] inbox = client.get("/api/v1/lists").json()[0]
@@ -258,6 +284,37 @@ def test_tags_are_global_searchable_and_validated(client):
assert invalid.status_code == 404 assert invalid.status_code == 404
def test_restore_replace_recovers_tags_habits_and_task_links(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
tag = client.post("/api/v1/tags", json={"name": "备份标签", "color": "#f15a29"}).json()
client.post(
"/api/v1/tasks",
json={"title": "备份任务", "list_id": inbox["id"], "tag_ids": [tag["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
client.post("/api/v1/tags", json={"name": "现有标签", "color": "#123abc"})
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
tags = client.get("/api/v1/tags").json()
assert [row["name"] for row in tags] == ["备份标签"]
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 listed[0]["tags"][0]["name"] == "备份标签"
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client): def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
client = initialized_client(client) client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0] inbox = client.get("/api/v1/lists").json()[0]