from copy import deepcopy from datetime import date from backend.lunar_support import ( lunar_label_with_year, lunar_to_solar_safe, next_lunar_occurrence, solar_to_lunar_text, ) from backend.mvp import countdown_occurrence, countdown_status def boot(client): response = client.post( "/api/v1/setup/initialize", json={"username": "owner", "password": "correct horse battery staple"}, ) assert response.status_code == 201 def create_countdown(client, **overrides): payload = { "title": "旅行", "event_date": "2026-09-10", "kind": "countdown", "repeat_rule": "none", "icon": "🧳", } payload.update(overrides) return client.post("/api/v1/countdowns", json=payload) def test_lunar_date_math_labels_leap_month_and_finds_next_occurrence(): assert lunar_to_solar_safe(2025, -6, 1) == date(2025, 7, 25) assert lunar_to_solar_safe(2026, -6, 1) is None assert solar_to_lunar_text(date(2026, 9, 14)) == "农历八月初四" assert solar_to_lunar_text(date(2025, 7, 25)) == "农历闰六月初一" assert lunar_label_with_year(date(2025, 7, 25)) == "农历二〇二五年闰六月初一" assert next_lunar_occurrence(8, 4, True, "none", date(2026, 9, 1)) == date(2026, 9, 14) assert next_lunar_occurrence(8, 4, True, "none", date(2026, 9, 15)) == date(2027, 9, 4) assert next_lunar_occurrence(-6, 1, True, "yearly", date(2026, 1, 1)) == date(2036, 7, 23) def test_countdown_date_math_uses_date_only_recurrence_semantics(): assert countdown_status(date(2026, 9, 10), date(2026, 9, 6)) == (4, "还有 4 天") assert countdown_status(date(2026, 9, 6), date(2026, 9, 6)) == (0, "就是今天") assert countdown_status(date(2026, 9, 1), date(2026, 9, 6)) == (-5, "已经 5 天") assert countdown_occurrence(date(2026, 8, 31), "monthly", date(2026, 9, 6)) == date(2026, 9, 30) assert countdown_occurrence(date(2024, 2, 29), "yearly", date(2026, 2, 1)) == date(2026, 2, 28) assert countdown_occurrence(date(2026, 9, 1), "weekly", date(2026, 9, 8)) == date(2026, 9, 8) def test_lunar_countdown_crud_validation_and_display(client): boot(client) created = create_countdown( client, title="农历生日", event_date="2026-01-01", kind="birthday", calendar_mode="lunar", lunar_month=8, lunar_day=4, ignore_year=True, ) assert created.status_code == 201 item = created.json() assert item["event_date"] == "2026-09-14" assert item["calendar_mode"] == "lunar" assert item["lunar_month"] == 8 assert item["lunar_day"] == 4 assert item["ignore_year"] is True assert item["lunar_text"] == "农历八月初四" updated = client.patch( f"/api/v1/countdowns/{item['id']}", json={"lunar_month": -6, "lunar_day": 1, "event_date": "2025-01-01"}, ) assert updated.status_code == 200 assert updated.json()["event_date"] == "2025-07-25" assert updated.json()["lunar_text"] == "农历闰六月初一" assert create_countdown(client, calendar_mode="lunar", lunar_month=8).status_code == 422 assert create_countdown(client, calendar_mode="lunar", lunar_month=0, lunar_day=1).status_code == 422 assert create_countdown(client, calendar_mode="lunar", lunar_month=-6, lunar_day=1, event_date="2026-01-01").status_code == 422 assert create_countdown(client, calendar_mode="solar", lunar_month=8, lunar_day=4).status_code == 422 def test_editing_only_lunar_countdown_title_preserves_original_lunar_year(client): boot(client) created = create_countdown( client, title="农历纪念日", event_date="2001-01-01", calendar_mode="lunar", lunar_month=12, lunar_day=20, ) assert created.status_code == 201 before = created.json() assert before["event_date"] == "2002-02-01" assert before["lunar_year"] == 2001 updated = client.patch( f"/api/v1/countdowns/{before['id']}", json={"title": "只修改标题"}, ) assert updated.status_code == 200 after = updated.json() assert after["title"] == "只修改标题" assert after["event_date"] == before["event_date"] assert after["lunar_year"] == before["lunar_year"] assert after["lunar_month"] == before["lunar_month"] assert after["lunar_day"] == before["lunar_day"] def test_countdown_crud_single_pin_archive_restore_and_purge(client): boot(client) first = create_countdown(client).json() second = create_countdown( client, title="生日", event_date="1990-02-28", kind="birthday", repeat_rule="yearly", icon="🎂", ).json() assert client.post(f"/api/v1/countdowns/{first['id']}/pin").status_code == 200 assert client.post(f"/api/v1/countdowns/{second['id']}/pin").status_code == 200 active = client.get("/api/v1/countdowns").json() assert [item["title"] for item in active] == ["生日", "旅行"] assert [item["pinned"] for item in active] == [True, False] updated = client.patch( f"/api/v1/countdowns/{first['id']}", json={"title": "海边旅行", "event_date": "2026-09-12", "kind": "anniversary", "repeat_rule": "none", "icon": "🌊"}, ) assert updated.status_code == 200 assert updated.json()["title"] == "海边旅行" assert client.delete(f"/api/v1/countdowns/{first['id']}").status_code == 204 assert [item["id"] for item in client.get("/api/v1/countdowns", params={"archived": True}).json()] == [first["id"]] assert client.delete(f"/api/v1/countdowns/{second['id']}/purge").status_code == 409 assert client.post(f"/api/v1/countdowns/{first['id']}/restore").status_code == 200 assert client.delete(f"/api/v1/countdowns/{first['id']}").status_code == 204 assert client.delete(f"/api/v1/countdowns/{first['id']}/purge").status_code == 204 def test_countdown_validation_and_user_isolation(client): boot(client) item = create_countdown(client).json() assert create_countdown(client, kind="festival").status_code == 422 assert create_countdown(client, repeat_rule="daily").status_code == 422 client.post("/api/v1/auth/logout") from backend.auth import hash_password from backend.db import get_db from backend.models import TaskList, User async def add_other_user(): db_gen = get_db() db = await anext(db_gen) try: other = User(username="other", password_hash=hash_password("correct horse battery staple")) db.add(other) await db.flush() db.add(TaskList(user_id=other.id, name="收集箱", is_inbox=True)) await db.commit() finally: await db_gen.aclose() client.portal.call(add_other_user) assert client.post("/api/v1/auth/login", json={"username": "other", "password": "correct horse battery staple"}).status_code == 200 assert client.get("/api/v1/countdowns").json() == [] assert client.patch(f"/api/v1/countdowns/{item['id']}", json={"title": "偷改"}).status_code == 404 def test_countdowns_backup_replace_and_merge_round_trip(client): boot(client) active = create_countdown( client, title="周年", event_date="2025-01-01", kind="anniversary", repeat_rule="yearly", pinned=True, calendar_mode="lunar", lunar_month=-6, lunar_day=1, ignore_year=True, ).json() archived = create_countdown(client, title="旧日", event_date="2020-01-02").json() client.delete(f"/api/v1/countdowns/{archived['id']}") exported = client.get("/api/v1/export").json() assert {item["title"] for item in exported["countdowns"]} == {"周年", "旧日"} exported_lunar = next(item for item in exported["countdowns"] if item["title"] == "周年") assert exported_lunar["pinned"] is True assert exported_lunar["calendar_mode"] == "lunar" assert exported_lunar["lunar_month"] == -6 assert exported_lunar["lunar_day"] == 1 assert exported_lunar["ignore_year"] is True assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"] create_countdown(client, title="干扰数据") restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported) assert restored.status_code == 200 restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年") assert restored_active["calendar_mode"] == "lunar" assert restored_active["lunar_month"] == -6 assert restored_active["ignore_year"] is True assert {item["title"] for item in client.get("/api/v1/countdowns", params={"archived": True}).json()} == {"旧日"} merged = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported) assert merged.status_code == 200 all_active = client.get("/api/v1/countdowns").json() assert [item["title"] for item in all_active].count("周年") == 1 assert sum(item["pinned"] for item in all_active) == 1 assert active["title"] == "周年" def test_countdown_backup_merge_remaps_ids_owned_by_another_user(client): boot(client) original = create_countdown(client, title="跨账号纪念日").json() exported = client.get("/api/v1/export").json() client.post("/api/v1/auth/logout") from backend.auth import hash_password from backend.db import get_db from backend.models import TaskList, User async def add_other_user(): db_gen = get_db() db = await anext(db_gen) try: other = User(username="other", password_hash=hash_password("correct horse battery staple")) db.add(other) await db.flush() db.add(TaskList(user_id=other.id, name="收集箱", is_inbox=True)) await db.commit() finally: await db_gen.aclose() client.portal.call(add_other_user) assert client.post( "/api/v1/auth/login", json={"username": "other", "password": "correct horse battery staple"}, ).status_code == 200 restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported) assert restored.status_code == 200 items = client.get("/api/v1/countdowns").json() assert len(items) == 1 assert items[0]["title"] == "跨账号纪念日" assert items[0]["id"] != original["id"] merged_again = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported) assert merged_again.status_code == 200 assert len(client.get("/api/v1/countdowns").json()) == 1 exported_by_other = client.get("/api/v1/export").json() restored_again = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported_by_other) assert restored_again.status_code == 200 replaced = client.get("/api/v1/countdowns").json() assert len(replaced) == 1 assert replaced[0]["id"] == items[0]["id"] def test_countdown_rejects_blank_text_and_preserves_lunar_recurrence(client): boot(client) assert create_countdown(client, title=" ").status_code == 422 assert create_countdown(client, icon="\n\t").status_code == 422 lunar = { "calendar_mode": "lunar", "lunar_month": 8, "lunar_day": 4, "event_date": "2026-01-01", } weekly = create_countdown(client, repeat_rule="weekly", **lunar) monthly = create_countdown(client, repeat_rule="monthly", **lunar) assert weekly.status_code == 201 assert monthly.status_code == 201 assert weekly.json()["repeat_rule"] == "weekly" assert monthly.json()["repeat_rule"] == "monthly" def test_future_fixed_year_lunar_countdown_does_not_occur_before_anchor(client): boot(client) created = create_countdown( client, title="未来农历事件", event_date="2030-01-01", calendar_mode="lunar", lunar_month=1, lunar_day=1, repeat_rule="yearly", ) assert created.status_code == 201 item = created.json() assert item["event_date"] == "2030-02-03" assert item["display_date"] >= item["event_date"] def test_archived_countdown_cannot_be_edited(client): boot(client) item = create_countdown(client).json() assert client.delete(f"/api/v1/countdowns/{item['id']}").status_code == 204 response = client.patch(f"/api/v1/countdowns/{item['id']}", json={"title": "归档后偷改"}) assert response.status_code == 409 def test_stale_countdown_edit_is_rejected(client): boot(client) item = create_countdown(client).json() first = client.patch( f"/api/v1/countdowns/{item['id']}", json={"title": "第一次修改", "expected_updated_at": item["updated_at"]}, ) assert first.status_code == 200 stale = client.patch( f"/api/v1/countdowns/{item['id']}", json={"title": "过期修改", "expected_updated_at": item["updated_at"]}, ) assert stale.status_code == 409 current = client.get("/api/v1/countdowns").json()[0] assert current["title"] == "第一次修改" def test_restore_rejects_malformed_countdowns_atomically(client): boot(client) original = create_countdown(client, title="必须保留").json() exported = client.get("/api/v1/export").json() malformed = deepcopy(exported) malformed["countdowns"][0]["archived_at"] = "not-a-date" response = client.post("/api/v1/restore", params={"mode": "merge"}, json=malformed) assert response.status_code == 422 assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] for invalid in (None, 7): malformed = deepcopy(exported) malformed["countdowns"] = invalid response = client.post("/api/v1/restore", params={"mode": "merge"}, json=malformed) assert response.status_code == 422 assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] duplicate = deepcopy(exported) duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0])) response = client.post("/api/v1/restore", params={"mode": "merge"}, json=duplicate) assert response.status_code == 422 assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] two_pinned = deepcopy(exported) extra = deepcopy(two_pinned["countdowns"][0]) extra["id"] = "34deeea0-d976-4581-b378-a66f28623de8" extra["title"] = "第二个置顶" two_pinned["countdowns"][0]["pinned"] = True extra["pinned"] = True two_pinned["countdowns"].append(extra) response = client.post("/api/v1/restore", params={"mode": "merge"}, json=two_pinned) assert response.status_code == 200 assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1 def test_restore_validates_lunar_metadata_booleans_and_count(client): boot(client) item = create_countdown( client, title="农历备份", event_date="2025-01-01", calendar_mode="lunar", lunar_month=8, lunar_day=4, ).json() exported = client.get("/api/v1/export").json() contradictory = deepcopy(exported) contradictory["countdowns"][0]["event_date"] = "2025-01-01" assert client.post("/api/v1/restore", params={"mode": "replace"}, json=contradictory).status_code == 422 assert client.get("/api/v1/countdowns").json()[0]["id"] == item["id"] string_booleans = deepcopy(exported) string_booleans["countdowns"][0]["ignore_year"] = "false" string_booleans["countdowns"][0]["pinned"] = "false" assert client.post("/api/v1/restore", params={"mode": "replace"}, json=string_booleans).status_code == 422 clean = deepcopy(exported) clean["countdowns"][0]["id"] = "13ad79f4-78bc-4a53-a952-e1da84be1a9a" restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=clean) assert restored.status_code == 200 assert restored.json()["restored"] == 1 def test_restore_accepts_lunar_dates_whose_solar_anchor_is_in_next_year(client): boot(client) created = create_countdown( client, title="跨公历年农历日", event_date="2001-01-01", calendar_mode="lunar", lunar_month=12, lunar_day=20, ) assert created.status_code == 201 assert created.json()["event_date"] == "2002-02-01" exported = client.get("/api/v1/export").json() restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported) assert restored.status_code == 200 item = client.get("/api/v1/countdowns").json()[0] assert item["event_date"] == "2002-02-01" assert item["lunar_year"] == 2001