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=merge", 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=merge", 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