feat: sort tasks by due time
This commit is contained in:
+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