feat: sort tasks by due time
This commit is contained in:
+212
-13
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user