This commit is contained in:
+7
-195
@@ -29,160 +29,12 @@ def test_bootstrap_returns_navigation_and_current_user(client):
|
||||
assert data["inbox_id"] == inbox["id"]
|
||||
|
||||
|
||||
def test_recurring_calendar_exceptions_and_scopes(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "站会", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
|
||||
).json()
|
||||
normal_task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "月内普通任务", "list_id": inbox["id"], "due_at": "2026-09-12T09:00:00Z"},
|
||||
).json()
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "月外普通任务", "list_id": inbox["id"], "due_at": "2026-10-02T09:00:00Z"},
|
||||
)
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=5"}
|
||||
)
|
||||
assert recurrence.status_code == 201
|
||||
recurrence_id = recurrence.json()["id"]
|
||||
yearly = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "年度任务", "list_id": inbox["id"], "due_at": "2026-09-06T09:00:00Z"},
|
||||
).json()
|
||||
assert client.post(
|
||||
"/api/v1/recurrences", json={"task_id": yearly["id"], "rrule": "FREQ=YEARLY;INTERVAL=1;BYMONTH=9;BYMONTHDAY=6"}
|
||||
).status_code == 201
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||
).json()
|
||||
assert any(row.get("id") == normal_task["id"] for row in calendar)
|
||||
assert any(row["title"] == "年度任务" for row in calendar)
|
||||
assert not any(row["title"] == "月外普通任务" for row in calendar)
|
||||
occurrences = [row for row in calendar if row["recurrence_id"] == recurrence_id]
|
||||
assert len(occurrences) == 5
|
||||
assert occurrences[0]["title"] == "站会"
|
||||
|
||||
at = occurrences[1]["occurrence_at"]
|
||||
edited = client.patch(
|
||||
f"/api/v1/recurrences/{recurrence_id}",
|
||||
params={"scope": "this", "occurrence_at": at},
|
||||
json={"title": "特殊站会"},
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
client.post(f"/api/v1/recurrences/{recurrence_id}/complete", json={"occurrence_at": at})
|
||||
changed = client.get(
|
||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||
).json()
|
||||
exception = next(row for row in changed if row.get("occurrence_at") == at)
|
||||
assert exception["title"] == "特殊站会" and exception["completed"] is True
|
||||
assert client.delete(
|
||||
f"/api/v1/recurrences/{recurrence_id}",
|
||||
params={"scope": "this", "occurrence_at": occurrences[2]["occurrence_at"]},
|
||||
).status_code == 204
|
||||
remaining = client.get(
|
||||
"/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}
|
||||
).json()
|
||||
assert len([row for row in remaining if row["recurrence_id"] == recurrence_id]) == 4
|
||||
assert any(row.get("id") == normal_task["id"] for row in remaining)
|
||||
|
||||
|
||||
def test_calendar_respects_timezone_boundaries_and_moved_occurrences(client):
|
||||
inbox = boot(client)
|
||||
inside = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "上海九月第一刻", "list_id": inbox["id"], "due_at": "2026-08-31T16:30:00Z"},
|
||||
).json()
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "上海十月第一刻", "list_id": inbox["id"], "due_at": "2026-09-30T16:30:00Z"},
|
||||
)
|
||||
recurring = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "跨月重复任务", "list_id": inbox["id"], "due_at": "2026-10-01T09:00:00Z"},
|
||||
).json()
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": recurring["id"], "rrule": "FREQ=WEEKLY;COUNT=2"}
|
||||
).json()
|
||||
client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
params={"scope": "this", "occurrence_at": "2026-10-01T09:00:00Z"},
|
||||
json={"due_at": "2026-09-20T09:00:00Z"},
|
||||
)
|
||||
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar",
|
||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Asia/Shanghai"},
|
||||
).json()
|
||||
|
||||
assert any(row.get("id") == inside["id"] for row in calendar)
|
||||
assert not any(row["title"] == "上海十月第一刻" for row in calendar)
|
||||
assert any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
|
||||
|
||||
|
||||
def test_calendar_rejects_unknown_timezone(client):
|
||||
def test_calendar_endpoint_is_removed(client):
|
||||
boot(client)
|
||||
response = client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Mars/Olympus"})
|
||||
assert response.status_code == 422
|
||||
assert client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
|
||||
|
||||
|
||||
def test_recurrence_mutations_reject_ghost_occurrences(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "每周任务", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
|
||||
).json()
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;COUNT=3"}
|
||||
).json()
|
||||
# 9/7 and 9/14 are real occurrences; 9/9 is NOT part of this series.
|
||||
ghost_patch = client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
params={"scope": "this", "occurrence_at": "2026-09-09T09:00:00Z"},
|
||||
json={"due_at": "2026-09-20T09:00:00Z"},
|
||||
)
|
||||
assert ghost_patch.status_code == 422
|
||||
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar",
|
||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
|
||||
).json()
|
||||
|
||||
assert not any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
|
||||
assert len([row for row in calendar if row.get("recurrence_id") == recurrence["id"]]) == 3
|
||||
|
||||
|
||||
def test_calendar_does_not_duplicate_moved_exceptions_when_both_in_range(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
|
||||
).json()
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=4"}
|
||||
).json()
|
||||
# Series: 9/14, 9/21, 9/28, 10/5. Move the 9/21 occurrence to 9/23.
|
||||
client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
|
||||
json={"due_at": "2026-09-23T09:00:00Z"},
|
||||
)
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar",
|
||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
|
||||
).json()
|
||||
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
|
||||
keys = [(row["occurrence_at"], row["due_at"]) for row in matches]
|
||||
assert len(keys) == len(set(keys))
|
||||
assert len(matches) == 3
|
||||
due_days = [row["due_at"][:10] for row in matches]
|
||||
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
|
||||
assert not any(row["due_at"].startswith("2026-09-21") for row in matches)
|
||||
|
||||
|
||||
def test_recurrence_rejects_wrong_time_of_day_and_alternate_offset(client):
|
||||
def test_recurrence_mutations_keep_exact_timestamp_validation(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
@@ -216,56 +68,16 @@ def test_recurrence_rejects_occurrence_after_cutoff(client):
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY;COUNT=10"}
|
||||
).json()
|
||||
# Cut the series off at 9/3 (ends_at = 9/3T08:59:59.999999Z)
|
||||
client.patch(
|
||||
assert client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
params={"scope": "this-and-future", "occurrence_at": "2026-09-03T09:00:00Z"},
|
||||
json={},
|
||||
)
|
||||
after_cutoff = client.patch(
|
||||
).status_code == 200
|
||||
assert client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
params={"scope": "this", "occurrence_at": "2026-09-05T09:00:00Z"},
|
||||
json={"title": "晚于截止"},
|
||||
)
|
||||
assert after_cutoff.status_code == 422
|
||||
|
||||
|
||||
def test_calendar_accepts_legacy_timezone_offset(client):
|
||||
inbox = boot(client)
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "上海边界任务", "list_id": inbox["id"], "due_at": "2026-09-01T16:00:00Z"},
|
||||
)
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar",
|
||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480},
|
||||
).json()
|
||||
assert any(row["title"] == "上海边界任务" for row in calendar)
|
||||
|
||||
|
||||
def test_calendar_does_not_duplicate_moved_exception_when_original_and_moved_in_range(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
|
||||
).json()
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"}
|
||||
).json()
|
||||
# Series: 9/14, 9/21, 9/28 — all inside the requested month.
|
||||
# Move the 9/21 occurrence to 9/23 (also inside). Regression: this used to render twice.
|
||||
client.patch(
|
||||
f"/api/v1/recurrences/{recurrence['id']}",
|
||||
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
|
||||
json={"due_at": "2026-09-23T09:00:00Z"},
|
||||
)
|
||||
calendar = client.get(
|
||||
"/api/v1/calendar",
|
||||
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
|
||||
).json()
|
||||
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
|
||||
due_days = sorted(row["due_at"][:10] for row in matches)
|
||||
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_habit_logs_support_date_range_filter(client):
|
||||
|
||||
Reference in New Issue
Block a user