[verified] feat: support monthly completion recurrence
This commit is contained in:
@@ -48,7 +48,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
||||
due_has_time=True,
|
||||
)
|
||||
gap_due = recurrence_service._after_completion_due(
|
||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, user
|
||||
gap_task, datetime(2026, 3, 7, 15, tzinfo=UTC), 1, "days", user
|
||||
)
|
||||
assert gap_due == datetime(2026, 3, 8, 7, 30, tzinfo=UTC) # local 03:30 after gap
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_dst_gap_rolls_forward_and_ambiguous_time_uses_first_fold():
|
||||
due_has_time=True,
|
||||
)
|
||||
fold_due = recurrence_service._after_completion_due(
|
||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, user
|
||||
fold_task, datetime(2026, 10, 31, 15, tzinfo=UTC), 1, "days", user
|
||||
)
|
||||
assert fold_due == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # first 01:30, fold=0
|
||||
|
||||
@@ -89,6 +89,30 @@ def test_user_timezone_is_the_calendar_contract_for_after_completion(client, mon
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 3, 9, 9, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_month_interval_uses_calendar_month_clamping(client, monkeypatch):
|
||||
inbox = boot(client)
|
||||
task = create_after_completion_task(
|
||||
client,
|
||||
inbox,
|
||||
due_at="2026-01-31T01:30:00Z",
|
||||
after_completion_days=1,
|
||||
after_completion_unit="months",
|
||||
).json()
|
||||
monkeypatch.setattr(
|
||||
"backend.recurrence_service.utcnow",
|
||||
lambda: datetime(2026, 1, 30, 16, 30, tzinfo=UTC), # 2026-01-31 00:30 Asia/Shanghai
|
||||
)
|
||||
|
||||
completed = client.patch(
|
||||
f"/api/v1/tasks/{task['id']}", json={"completed": True, "version": task["version"]}
|
||||
)
|
||||
|
||||
assert completed.status_code == 200
|
||||
assert datetime.fromisoformat(completed.json()["due_at"]) == datetime(2026, 2, 28, 1, 30, tzinfo=UTC)
|
||||
recurrence = client.get(f"/api/v1/tasks/{task['id']}/recurrence").json()
|
||||
assert recurrence["after_completion_unit"] == "months"
|
||||
|
||||
|
||||
def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||
inbox = boot(client)
|
||||
|
||||
@@ -105,6 +129,7 @@ def test_atomic_task_create_and_read_after_completion_recurrence(client):
|
||||
"ends_at": None,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
"after_completion_unit": "days",
|
||||
"last_completed_at": None,
|
||||
}
|
||||
|
||||
@@ -122,6 +147,8 @@ def test_after_completion_configuration_validation(client):
|
||||
{"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},
|
||||
{"title": "只有单位", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "after_completion_unit": "months"},
|
||||
{"title": "定期带月份", "list_id": inbox["id"], "due_at": "2026-03-08T01:30:00Z", "trigger_mode": "scheduled", "rrule": "FREQ=DAILY", "after_completion_unit": "months"},
|
||||
]
|
||||
for payload in invalid_payloads:
|
||||
assert client.post("/api/v1/tasks", json=payload).status_code in {400, 422}
|
||||
|
||||
@@ -200,6 +200,36 @@ def test_merge_same_backup_is_idempotent_via_import_ledger(client):
|
||||
assert asyncio.run(counts()) == (1, 1)
|
||||
|
||||
|
||||
def test_legacy_backup_without_completion_unit_is_idempotent(client):
|
||||
inbox = boot(client)
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "旧版完成后重复",
|
||||
"list_id": inbox["id"],
|
||||
"due_at": "2026-03-08T01:30:00Z",
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 2,
|
||||
},
|
||||
)
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
recurrences = _archive_rows(content, "recurrences")
|
||||
for recurrence in recurrences:
|
||||
recurrence.pop("after_completion_unit", None)
|
||||
legacy_content = _replace_entities(content, {"recurrences": recurrences})
|
||||
|
||||
first_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||
assert client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
|
||||
).status_code == 200
|
||||
second_token = _preflight(client, legacy_content).json()["preflight_token"]
|
||||
second = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["already_imported"] is True
|
||||
|
||||
|
||||
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
||||
inbox = boot(client)
|
||||
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||
|
||||
Reference in New Issue
Block a user