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
+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):