feat: sort tasks by due time
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-10 18:16:13 +08:00
parent ab8c0a5138
commit addd49363d
8 changed files with 576 additions and 58 deletions
+212 -13
View File
@@ -1,6 +1,10 @@
import base64
import json
import os
from uuid import UUID
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker
os.environ.setdefault("DODO_DATABASE_URL", "sqlite+aiosqlite:////tmp/dodo-health-test.db")
os.environ.setdefault("DODO_AUTO_CREATE_SCHEMA", "true")
@@ -115,7 +119,7 @@ def test_reorder_tasks_persists_top_level_and_subtask_order(client):
assert [task["title"] for task in listed[1]["subtasks"]] == ["子任务 B", "子任务 A"]
def test_reorder_tasks_rejects_mixed_parent_scopes(client):
def test_reorder_tasks_rejects_mixed_parent_scopes_and_lists(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()
@@ -126,6 +130,11 @@ def test_reorder_tasks_rejects_mixed_parent_scopes(client):
response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], child["id"]]})
assert response.status_code == 400
other = client.post("/api/v1/lists", json={"name": "其他清单"}).json()
other_task = client.post("/api/v1/tasks", json={"title": "其他任务", "list_id": other["id"]}).json()
response = client.put("/api/v1/tasks/reorder", json={"task_ids": [parent["id"], other_task["id"]]})
assert response.status_code == 400
def test_task_update_rejects_null_title(client):
client.post(
@@ -395,6 +404,28 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
assert "tags" not in listed[0]
def test_trash_cursor_paginates_more_than_fifty_items(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
task_ids = [
client.post("/api/v1/tasks", json={"title": f"回收任务 {index}", "list_id": inbox["id"]}).json()["id"]
for index in range(52)
]
for task_id in task_ids:
assert client.delete(f"/api/v1/tasks/{task_id}").status_code == 204
first = client.get("/api/v1/trash", params={"limit": 50})
assert first.status_code == 200
first_page = first.json()
assert len(first_page["items"]) == 50
assert first_page["next_cursor"]
second = client.get("/api/v1/trash", params={"limit": 50, "cursor": first_page["next_cursor"]})
assert second.status_code == 200
assert len(second.json()["items"]) == 2
assert second.json()["next_cursor"] is None
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
@@ -453,16 +484,184 @@ def test_batch_supports_due_date_and_soft_delete_atomically(client):
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
fixtures = [
("未完成较晚", "2026-09-12T08:00:00Z", False),
("未完成较早 A", "2026-09-10T08:00:00Z", False),
("未完成较早 B", "2026-09-10T08:00:00Z", False),
("未完成无日期", None, False),
("已完成较早", "2026-09-09T08:00:00Z", True),
("已完成无日期", None, True),
]
for title, due_at, completed in fixtures:
task = client.post(
"/api/v1/tasks", json={"title": title, "list_id": inbox["id"], "due_at": due_at}
).json()
if completed:
response = client.patch(
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
)
assert response.status_code == 200
pages = []
cursor = None
while True:
params = {"limit": 2}
if cursor:
params["cursor"] = cursor
page = client.get("/api/v1/tasks", params=params).json()
pages.append(page)
cursor = page["next_cursor"]
if cursor is None:
break
rows = [row for page in pages for row in page["items"]]
assert [row["title"] for row in rows] == [
"未完成较早 A", "未完成较早 B", "未完成较晚", "未完成无日期", "已完成较早", "已完成无日期"
]
assert len({row["id"] for row in rows}) == len(fixtures)
assert all(page["total"] == len(fixtures) for page in pages)
assert client.get("/api/v1/tasks", params={"cursor": "broken"}).status_code == 422
def _encoded_cursor_payload(keys):
return base64.urlsafe_b64encode(json.dumps({"v": 2, "keys": keys}).encode()).decode().rstrip("=")
def test_active_cursor_rejects_malicious_key_matrix_with_stable_422(client):
client = initialized_client(client)
task_id = "00000000-0000-0000-0000-000000000001"
created_at = "2026-09-10T08:00:00Z"
valid = [0, 1, None, 0, created_at, task_id]
invalid_keys = [
[True, 1, None, 0, created_at, task_id],
[0, False, "2026-09-10T08:00:00Z", 0, created_at, task_id],
["0", 1, None, 0, created_at, task_id],
[0, 1.0, None, 0, created_at, task_id],
[9, 1, None, 0, created_at, task_id],
[0, 9, None, 0, created_at, task_id],
[0, 0, None, 0, created_at, task_id],
[0, 1, "2026-09-10T08:00:00Z", 0, created_at, task_id],
[0, 0, "2026-09-10", 0, created_at, task_id],
[0, 0, "2026-09-10T08:00:00", 0, created_at, task_id],
[0, 0, "2026-09-10T08Z", 0, created_at, task_id],
[0, 0, "9999-12-31T23:59:59-14:00", 0, created_at, task_id],
[0, 1, None, True, created_at, task_id],
[0, 1, None, "0", created_at, task_id],
[0, 1, None, -1, created_at, task_id],
[0, 1, None, 2**63, created_at, task_id],
[0, 1, None, 0, "2026-09-10", task_id],
[0, 1, None, 0, "2026-09-10T08:00:00", task_id],
[0, 1, None, 0, "9999-12-31T23:59:59-14:00", task_id],
[0, 1, None, 0, created_at, True],
[0, 1, None, 0, created_at, "not-a-uuid"],
valid[:-1],
[*valid, "extra"],
]
for keys in invalid_keys:
response = client.get("/api/v1/tasks", params={"cursor": _encoded_cursor_payload(keys)})
assert response.status_code == 422, keys
assert response.json()["detail"] == "无效的游标"
def test_task_details_do_not_include_foreign_or_wrong_list_subtasks(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
other_list = client.post("/api/v1/lists", json={"name": "其他清单"}).json()
parent = client.post("/api/v1/tasks", json={"title": "", "list_id": inbox["id"]}).json()
async def inject_malformed_children():
from sqlalchemy import select
from backend.db import get_engine
from backend.models import Task, User
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
owner = await db.scalar(select(User).where(User.username == "owner"))
foreign = User(username="foreign", password_hash="unused")
db.add(foreign)
await db.flush()
db.add_all([
Task(
user_id=foreign.id,
list_id=UUID(inbox["id"]),
parent_id=UUID(parent["id"]),
title="FOREIGN SECRET",
),
Task(
user_id=owner.id,
list_id=UUID(other_list["id"]),
parent_id=UUID(parent["id"]),
title="WRONG LIST SECRET",
),
])
await db.commit()
import asyncio
asyncio.run(inject_malformed_children())
detail = client.get(f"/api/v1/tasks/{parent['id']}")
assert detail.status_code == 200
assert detail.json()["subtasks"] == []
def test_task_details_use_same_due_and_completion_ordering(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()
fixtures = [
("子无日期", None, False),
("子较晚", "2026-09-12T08:00:00Z", False),
("子较早 A", "2026-09-10T08:00:00Z", False),
("子较早 B", "2026-09-10T08:00:00Z", False),
("子已完成", "2026-09-09T08:00:00Z", True),
]
for title, due_at, completed in fixtures:
child = client.post(
"/api/v1/tasks",
json={"title": title, "list_id": inbox["id"], "parent_id": parent["id"], "due_at": due_at},
).json()
if completed:
client.patch(f"/api/v1/tasks/{child['id']}", json={"completed": True, "version": child["version"]})
detail = client.get(f"/api/v1/tasks/{parent['id']}").json()
assert [row["title"] for row in detail["subtasks"]] == [
"子较早 A", "子较早 B", "子较晚", "子无日期", "子已完成"
]
def test_reorder_treats_offset_equivalent_due_times_as_same_tier(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
first = client.post(
"/api/v1/tasks",
json={"title": "UTC", "list_id": inbox["id"], "due_at": "2026-09-10T08:00:00Z"},
).json()
second = client.post(
"/api/v1/tasks",
json={"title": "OFFSET", "list_id": inbox["id"], "due_at": "2026-09-10T16:00:00+08:00"},
).json()
response = client.put("/api/v1/tasks/reorder", json={"task_ids": [second["id"], first["id"]]})
assert response.status_code == 204
listed = client.get("/api/v1/tasks", params={"list_id": inbox["id"], "page": 1}).json()["items"]
assert [item["title"] for item in listed] == ["OFFSET", "UTC"]
assert {item["due_at"] for item in listed} == {"2026-09-10T08:00:00Z"}
def test_reorder_tasks_rejects_different_sort_tiers(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
no_due = client.post("/api/v1/tasks", json={"title": "无日期", "list_id": inbox["id"]}).json()
due_a = client.post(
"/api/v1/tasks", json={"title": "日期 A", "list_id": inbox["id"], "due_at": "2026-09-10T08:00:00Z"}
).json()
due_b = client.post(
"/api/v1/tasks", json={"title": "日期 B", "list_id": inbox["id"], "due_at": "2026-09-11T08:00:00Z"}
).json()
completed = client.patch(
f"/api/v1/tasks/{due_a['id']}", json={"completed": True, "version": due_a["version"]}
).json()
assert client.put("/api/v1/tasks/reorder", json={"task_ids": [no_due["id"], due_b["id"]]}).status_code == 400
assert client.put("/api/v1/tasks/reorder", json={"task_ids": [completed["id"], due_b["id"]]}).status_code == 400
+101 -6
View File
@@ -1,7 +1,12 @@
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from uuid import UUID
from zoneinfo import ZoneInfo
from sqlalchemy import event
from sqlalchemy import event, select, text
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.models import RecurrenceException, RecurrenceTemplate, Task, UTCDateTime
BUSINESS_TIME_ZONE = ZoneInfo("Asia/Shanghai")
@@ -138,7 +143,7 @@ def test_completing_repeating_task_advances_due_date_instead_of_closing_it(clien
assert completed.json()["completed"] is False
assert completed.json()["due_at"].replace("Z", "") == "2026-09-08T09:00:00"
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
assert recurrence["starts_at"].replace("Z", "") == "2026-09-08T09:00:00"
assert datetime.fromisoformat(recurrence["starts_at"]) == datetime(2026, 9, 8, 9, tzinfo=UTC)
def test_completing_repeating_task_resets_completed_subtasks_for_next_occurrence(client):
@@ -207,6 +212,95 @@ def test_recurrence_mutations_keep_exact_timestamp_validation(client):
assert same_instant.status_code == 200
def test_recurrence_chain_normalizes_absolute_instants_to_utc_on_sqlite(client):
inbox = boot(client)
created = client.post(
"/api/v1/tasks",
json={
"title": "北京时间重复任务",
"list_id": inbox["id"],
"due_at": "2026-09-07T16:00:00+08:00",
"rrule": "FREQ=DAILY;COUNT=3",
},
)
assert created.status_code == 201
task = created.json()
assert datetime.fromisoformat(task["due_at"]) == datetime(2026, 9, 7, 8, tzinfo=UTC)
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
assert datetime.fromisoformat(recurrence["starts_at"]) == datetime(2026, 9, 7, 8, tzinfo=UTC)
edited = client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-08T16:00:00+08:00"},
json={"due_at": "2026-09-08T17:30:00+08:00"},
)
assert edited.status_code == 200
completed = client.post(
f"/api/v1/recurrences/{recurrence['id']}/complete",
json={"occurrence_at": "2026-09-08T16:00:00+08:00"},
)
assert completed.status_code == 200
async def stored_values():
from backend.db import get_engine
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
template = await db.scalar(
select(RecurrenceTemplate).where(RecurrenceTemplate.id == UUID(recurrence["id"]))
)
exception = await db.scalar(
select(RecurrenceException).where(RecurrenceException.template_id == template.id)
)
return template.starts_at, exception.occurrence_at, exception.due_at, exception.completed
import asyncio
starts_at, occurrence_at, due_at, is_completed = asyncio.run(stored_values())
assert starts_at == datetime(2026, 9, 7, 8, tzinfo=UTC)
assert occurrence_at == datetime(2026, 9, 8, 8, tzinfo=UTC)
assert due_at == datetime(2026, 9, 8, 9, 30, tzinfo=UTC)
assert is_completed is True
def test_utc_datetime_reads_legacy_sqlite_offset_text_as_the_same_instant(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "历史数据", "list_id": inbox["id"], "due_at": "2026-09-07T08:00:00Z"},
).json()
async def inject_and_read():
from backend.db import get_engine
engine = get_engine()
async with engine.begin() as connection:
await connection.execute(
text("UPDATE tasks SET due_at = :value WHERE id = :task_id"),
{"value": "2026-09-07 16:00:00+08:00", "task_id": task["id"]},
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as db:
return await db.scalar(select(Task.due_at).where(Task.id == UUID(task["id"])))
import asyncio
assert asyncio.run(inject_and_read()) == datetime(2026, 9, 7, 8, tzinfo=UTC)
def test_recurrence_absolute_columns_use_utc_type_without_schema_change():
for column in (
Task.__table__.c.due_at,
RecurrenceTemplate.__table__.c.starts_at,
RecurrenceTemplate.__table__.c.ends_at,
RecurrenceException.__table__.c.occurrence_at,
RecurrenceException.__table__.c.due_at,
):
assert isinstance(column.type, UTCDateTime)
assert column.type.compile(dialect=postgresql.dialect()) == "TIMESTAMP WITH TIME ZONE"
def test_recurrence_rejects_occurrence_after_cutoff(client):
inbox = boot(client)
task = client.post(
@@ -412,7 +506,8 @@ def test_tasks_support_numbered_pagination_with_total(client):
def test_tasks_support_due_range_pagination(client):
inbox = boot(client)
client.post("/api/v1/tasks", json={"title": "今天", "list_id": inbox["id"], "due_at": "2026-09-05T08:00:00Z"})
client.post("/api/v1/tasks", json={"title": "今天较晚", "list_id": inbox["id"], "due_at": "2026-09-05T18:00:00Z"})
client.post("/api/v1/tasks", json={"title": "今天较早", "list_id": inbox["id"], "due_at": "2026-09-05T08:00:00Z"})
client.post("/api/v1/tasks", json={"title": "以后", "list_id": inbox["id"], "due_at": "2026-09-08T08:00:00Z"})
client.post("/api/v1/tasks", json={"title": "无日期", "list_id": inbox["id"]})
@@ -422,8 +517,8 @@ def test_tasks_support_due_range_pagination(client):
)
assert response.status_code == 200
assert response.json()["total"] == 1
assert [item["title"] for item in response.json()["items"]] == ["今天"]
assert response.json()["total"] == 2
assert [item["title"] for item in response.json()["items"]] == ["今天较早", "今天较晚"]
def test_trash_supports_numbered_pagination_with_total(client):