Files
dodo/tests/test_countdowns.py
T
bboysoul 1571c3946f
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m31s
feat: refine task and countdown interactions
2026-09-17 09:32:22 +08:00

421 lines
17 KiB
Python

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]
unpinned = client.delete(f"/api/v1/countdowns/{second['id']}/pin")
assert unpinned.status_code == 200
assert unpinned.json()["pinned"] is False
active = client.get("/api/v1/countdowns").json()
assert [item["pinned"] for item in active] == [False, 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