feat: repeat tasks after completion
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend import recurrence_service
|
||||
from backend.models import Task, User
|
||||
|
||||
|
||||
def boot(client):
|
||||
response = client.post(
|
||||
"/api/v1/setup/initialize",
|
||||
json={"username": "owner", "password": "correct horse battery staple"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return client.get("/api/v1/lists").json()[0]
|
||||
|
||||
|
||||
def create_after_completion_task(client, inbox, **overrides):
|
||||
payload = {
|
||||
"title": "完成后重复",
|
||||
"list_id": inbox["id"],
|
||||
"due_at": "2026-03-08T01:30:00Z",
|
||||
"due_has_time": True,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return client.post("/api/v1/tasks", json=payload)
|
||||
|
||||
|
||||
def test_browser_timezone_can_be_persisted_and_is_returned_by_bootstrap(client):
|
||||
boot(client)
|
||||
|
||||
updated = client.patch("/api/v1/me", json={"timezone": "America/New_York"})
|
||||
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["timezone"] == "America/New_York"
|
||||
assert client.get("/api/v1/bootstrap").json()["user"]["timezone"] == "America/New_York"
|
||||
assert client.patch("/api/v1/me", json={"timezone": "Not/A_Zone"}).status_code == 422
|
||||
assert client.get("/api/v1/me").json()["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
||||
user = User(username="owner", password_hash="hash", timezone="America/New_York")
|
||||
gap_task = Task(
|
||||
title="gap",
|
||||
user_id=None,
|
||||
list_id=None,
|
||||
due_at=datetime(2026, 3, 7, 7, 30, tzinfo=UTC), # local 02:30
|
||||
due_has_time=True,
|
||||
)
|
||||
gap_due = recurrence_service._after_completion_due(
|
||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
|
||||
)
|
||||
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
||||
|
||||
fold_task = Task(
|
||||
title="fold",
|
||||
user_id=None,
|
||||
list_id=None,
|
||||
due_at=datetime(2026, 10, 31, 5, 30, tzinfo=UTC), # local 01:30
|
||||
due_has_time=True,
|
||||
)
|
||||
fold_due = recurrence_service._after_completion_due(
|
||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
|
||||
)
|
||||
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
||||
|
||||
|
||||
def test_user_timezone_is_the_calendar_contract_for_after_completion(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(
|
||||
client,
|
||||
inbox,
|
||||
due_at="2026-03-08T09:30:00Z",
|
||||
after_completion_days=1,
|
||||
).json()
|
||||
|
||||
assert client.patch("/api/v1/me", json={"timezone": "America/New_York"}).status_code == 200
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 3, 8, 5, 30, tzinfo=UTC), # local 00:30 on DST transition day
|
||||
)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
# Original local due time was 05:30; next local day is DST, therefore UTC is 09:30.
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||
inbox = boot(client)
|
||||
|
||||
created = create_after_completion_task(client, inbox)
|
||||
|
||||
assert created.status_code == 201
|
||||
recurrence = client.get(f"/api/v1/tasks/{created.json()['id']}/recurrence")
|
||||
assert recurrence.status_code == 200
|
||||
assert recurrence.json() == {
|
||||
"id": recurrence.json()["id"],
|
||||
"task_id": created.json()["id"],
|
||||
"rrule": None,
|
||||
"starts_at": recurrence.json()["starts_at"],
|
||||
"ends_at": None,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
"last_completed_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_after_completion_configuration_validation(client):
|
||||
inbox = boot(client)
|
||||
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()
|
||||
|
||||
invalid_payloads = [
|
||||
{"title": "无截止", "list_id": inbox["id"], "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||
{"title": "零天", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 0},
|
||||
{"title": "太长", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 3651},
|
||||
{"title": "子任务", "list_id": inbox["id"], "parent_id": child["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "after_completion", "after_completion_days": 1},
|
||||
]
|
||||
for payload in invalid_payloads:
|
||||
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
||||
|
||||
scheduled_without_rule = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "无规则", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled"},
|
||||
)
|
||||
assert scheduled_without_rule.status_code == 422
|
||||
|
||||
|
||||
def test_after_completion_uses_user_local_calendar_and_preserves_local_time(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(client, inbox).json()
|
||||
completed_at = datetime(2026, 3, 8, 16, 30, tzinfo=UTC) # 2026-03-09 00:30 Asia/Shanghai
|
||||
monkeypatch.setattr("backend.recurrence_service.utcnow", lambda: completed_at)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}",
|
||||
json={"completed": True, "version": task["version"]},
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["completed"] is False
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 11, 1, 30, tzinfo=UTC)
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
assert datetime.fromisoformat(recurrence["last_completed_at"]) == completed_at
|
||||
assert datetime.fromisoformat(recurrence["starts_at"]) == datetime(2026, 3, 11, 1, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_after_completion_date_only_stays_date_only(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(
|
||||
client,
|
||||
inbox,
|
||||
due_at="2026-03-08T15:59:59Z", # local 23:59:59
|
||||
due_has_time=False,
|
||||
after_completion_days=1,
|
||||
).json()
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 3, 8, 16, 30, tzinfo=UTC),
|
||||
)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["due_has_time"] is False
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 10, 15, 59, 59, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_editing_days_does_not_move_due_and_due_removal_cancels_atomically(client):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(client, inbox).json()
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
|
||||
changed = client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
json={"trigger_mode": "after_completion", "after_completion_days": 5},
|
||||
)
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["after_completion_days"] == 5
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}").json()["due_at"] == task["due_at"]
|
||||
|
||||
stale = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"due_at": None, "version": task["version"] + 100}
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() is not None
|
||||
|
||||
removed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"due_at": None, "version": task["version"]}
|
||||
)
|
||||
assert removed.status_code == 200
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}/recurrence").json() is None
|
||||
|
||||
|
||||
def test_batch_completion_uses_recurrence_service_and_versions_prevent_double_advance(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
parent = create_after_completion_task(client, inbox, after_completion_days=1).json()
|
||||
assert client.patch("/api/v1/me", json={"timezone": "America/New_York"}).status_code == 200
|
||||
child = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "已完成子任务", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||
).json()
|
||||
client.patch(
|
||||
f"/api/v1/tasks/{child['id']}", json={"completed": True, "version": child["version"]}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 3, 8, 16, 30, tzinfo=UTC),
|
||||
)
|
||||
payload = {
|
||||
"task_ids": [parent["id"]],
|
||||
"completed": True,
|
||||
"versions": {parent["id"]: parent["version"]},
|
||||
}
|
||||
|
||||
first = client.post("/api/v1/tasks/batch", json=payload)
|
||||
second = client.post("/api/v1/tasks/batch", json=payload)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 409
|
||||
detail = client.get(f"/api/v1/tasks/{parent['id']}").json()
|
||||
assert datetime.fromisoformat(detail["due_at"]) == datetime(2026, 3, 10, 0, 30, tzinfo=UTC)
|
||||
assert detail["completed"] is False
|
||||
assert detail["subtasks"][0]["completed"] is False
|
||||
|
||||
|
||||
def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(client, inbox).json()
|
||||
exported = client.get("/api/v1/export").json()
|
||||
recurrence = exported["recurrences"][0]
|
||||
assert recurrence["trigger_mode"] == "after_completion"
|
||||
assert recurrence["after_completion_days"] == 2
|
||||
assert recurrence["last_completed_at"] is None
|
||||
|
||||
restored = client.post("/api/v1/restore?mode=replace", json=exported)
|
||||
assert restored.status_code == 200
|
||||
restored_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
||||
restored_recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
||||
assert restored_recurrence["trigger_mode"] == "after_completion"
|
||||
assert restored_recurrence["after_completion_days"] == 2
|
||||
|
||||
csv_export = client.get("/api/v1/export.csv")
|
||||
assert csv_export.status_code == 200
|
||||
csv_restore = client.post(
|
||||
"/api/v1/restore.csv?mode=replace",
|
||||
files={"file": ("dodo-export.csv", csv_export.content, "text/csv")},
|
||||
)
|
||||
assert csv_restore.status_code == 200
|
||||
csv_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
||||
assert client.get(f"/api/v1/tasks/{csv_task['id']}/recurrence").json()["trigger_mode"] == "after_completion"
|
||||
|
||||
|
||||
def test_legacy_scheduled_recurrence_response_defaults_are_compatible(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "旧规则", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z", "rrule": "FREQ=DAILY"},
|
||||
).json()
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
assert recurrence["trigger_mode"] == "scheduled"
|
||||
assert recurrence["after_completion_days"] is None
|
||||
assert recurrence["last_completed_at"] is None
|
||||
+11
-2
@@ -279,7 +279,12 @@ def test_batch_complete_and_move(client):
|
||||
for i in range(2)
|
||||
]
|
||||
response = client.post(
|
||||
"/api/v1/tasks/batch", json={"task_ids": ids, "completed": True, "list_id": other["id"]}
|
||||
"/api/v1/tasks/batch", json={
|
||||
"task_ids": ids,
|
||||
"completed": True,
|
||||
"list_id": other["id"],
|
||||
"versions": {task_id: 1 for task_id in ids},
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["updated"] == 2
|
||||
@@ -473,7 +478,11 @@ def test_batch_supports_due_date_and_soft_delete_atomically(client):
|
||||
|
||||
failed = client.post(
|
||||
"/api/v1/tasks/batch",
|
||||
json={"task_ids": [ids[0], "00000000-0000-0000-0000-000000000001"], "completed": True},
|
||||
json={
|
||||
"task_ids": [ids[0], "00000000-0000-0000-0000-000000000001"],
|
||||
"completed": True,
|
||||
"versions": {ids[0]: 1, "00000000-0000-0000-0000-000000000001": 1},
|
||||
},
|
||||
)
|
||||
assert failed.status_code == 404
|
||||
assert client.get(f"/api/v1/tasks/{ids[0]}").json()["completed"] is False
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_alembic(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}"
|
||||
return subprocess.run(
|
||||
["uv", "run", "alembic", *args],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_recurrence_trigger_migration_downgrade_and_reupgrade_with_data(tmp_path: Path):
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
database = tmp_path / "migration.sqlite3"
|
||||
assert run_alembic(repo, database, "upgrade", "0015_purge_operations").returncode == 0
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO recurrence_templates "
|
||||
"(id, user_id, task_id, rrule, starts_at, ends_at, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"00000000000000000000000000000001",
|
||||
"00000000000000000000000000000002",
|
||||
"00000000000000000000000000000003",
|
||||
"FREQ=DAILY",
|
||||
"2026-03-08 07:30:00",
|
||||
None,
|
||||
"2026-03-01 00:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
upgraded = run_alembic(repo, database, "upgrade", "0016_recurrence_trigger_modes")
|
||||
assert upgraded.returncode == 0, upgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"UPDATE recurrence_templates SET trigger_mode='after_completion', "
|
||||
"after_completion_days=1, rrule=NULL"
|
||||
)
|
||||
|
||||
downgraded = run_alembic(repo, database, "downgrade", "0015_purge_operations")
|
||||
assert downgraded.returncode == 0, downgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
row = connection.execute("SELECT rrule FROM recurrence_templates").fetchone()
|
||||
assert row == ("FREQ=DAILY;INTERVAL=1",)
|
||||
|
||||
reupgraded = run_alembic(repo, database, "upgrade", "0016_recurrence_trigger_modes")
|
||||
assert reupgraded.returncode == 0, reupgraded.stderr
|
||||
Reference in New Issue
Block a user