This commit is contained in:
+14
-36
@@ -155,13 +155,12 @@ def test_folder_and_list_lifecycle(client):
|
||||
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204
|
||||
|
||||
|
||||
def test_task_search_tags_subtasks_and_recycle_bin(client):
|
||||
def test_task_search_subtasks_and_recycle_bin(client):
|
||||
client = initialized_client(client)
|
||||
inbox = client.get("/api/v1/lists").json()[0]
|
||||
tag = client.post("/api/v1/tags", json={"name": "重要", "color": "#f15a29"}).json()
|
||||
parent = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"], "tag_ids": [tag["id"]]},
|
||||
json={"title": "购买咖啡豆", "description": "手冲用", "list_id": inbox["id"]},
|
||||
).json()
|
||||
child = client.post(
|
||||
"/api/v1/tasks",
|
||||
@@ -170,11 +169,11 @@ def test_task_search_tags_subtasks_and_recycle_bin(client):
|
||||
assert child.status_code == 201
|
||||
detail = client.get(f"/api/v1/tasks/{parent['id']}")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["tags"][0]["name"] == "重要"
|
||||
assert "tags" not in detail.json()
|
||||
assert len(detail.json()["subtasks"]) == 1
|
||||
listed = client.get("/api/v1/tasks", params={"q": "咖啡"}).json()["items"]
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["tags"][0]["name"] == "重要"
|
||||
assert "tags" not in listed[0]
|
||||
assert listed[0]["subtasks"][0]["title"] == "比较价格"
|
||||
|
||||
assert client.delete(f"/api/v1/tasks/{parent['id']}").status_code == 204
|
||||
@@ -264,33 +263,13 @@ def test_nested_subtasks_are_rejected(client):
|
||||
assert nested.status_code == 400
|
||||
|
||||
|
||||
def test_tags_are_global_searchable_and_validated(client):
|
||||
|
||||
def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
|
||||
client = initialized_client(client)
|
||||
inbox = client.get("/api/v1/lists").json()[0]
|
||||
errands = client.post("/api/v1/lists", json={"name": "跑腿清单"}).json()
|
||||
tag = client.post("/api/v1/tags", json={"name": "蓝色标签", "color": "#123abc"})
|
||||
assert tag.status_code == 201
|
||||
assert client.get("/api/v1/tags").json()[0]["color"] == "#123abc"
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "普通标题", "description": "特殊说明", "list_id": errands["id"], "tag_ids": [tag.json()["id"]]},
|
||||
).json()
|
||||
for query in ("普通", "特殊", "蓝色", "跑腿"):
|
||||
assert [row["id"] for row in client.get("/api/v1/tasks", params={"q": query}).json()["items"]] == [task["id"]]
|
||||
invalid = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "非法标签", "list_id": inbox["id"], "tag_ids": ["00000000-0000-0000-0000-000000000001"]},
|
||||
)
|
||||
assert invalid.status_code == 404
|
||||
|
||||
|
||||
def test_restore_replace_recovers_tags_habits_and_task_links(client):
|
||||
client = initialized_client(client)
|
||||
inbox = client.get("/api/v1/lists").json()[0]
|
||||
tag = client.post("/api/v1/tags", json={"name": "备份标签", "color": "#f15a29"}).json()
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "备份任务", "list_id": inbox["id"], "tag_ids": [tag["id"]]},
|
||||
json={"title": "备份任务", "list_id": inbox["id"]},
|
||||
).json()
|
||||
client.post(
|
||||
"/api/v1/habits",
|
||||
@@ -298,8 +277,9 @@ def test_restore_replace_recovers_tags_habits_and_task_links(client):
|
||||
).json()
|
||||
exported = client.get("/api/v1/export")
|
||||
assert exported.status_code == 200
|
||||
assert "tags" not in exported.json()
|
||||
assert "task_tags" not in exported.json()
|
||||
|
||||
client.post("/api/v1/tags", json={"name": "现有标签", "color": "#123abc"})
|
||||
client.post(
|
||||
"/api/v1/habits",
|
||||
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},
|
||||
@@ -307,12 +287,11 @@ def test_restore_replace_recovers_tags_habits_and_task_links(client):
|
||||
restored = client.post("/api/v1/restore?mode=replace", json=exported.json())
|
||||
assert restored.status_code == 200
|
||||
|
||||
tags = client.get("/api/v1/tags").json()
|
||||
assert [row["name"] for row in tags] == ["备份标签"]
|
||||
assert client.get("/api/v1/tags").status_code == 404
|
||||
habits = client.get("/api/v1/habits").json()
|
||||
assert [row["name"] for row in habits] == ["俯卧撑"]
|
||||
listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
|
||||
assert listed[0]["tags"][0]["name"] == "备份标签"
|
||||
assert "tags" not in listed[0]
|
||||
|
||||
|
||||
def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
|
||||
@@ -333,10 +312,9 @@ def test_recycle_bin_restore_and_permanent_delete_include_subtasks(client):
|
||||
assert client.post(f"/api/v1/tasks/{parent['id']}/restore").status_code == 404
|
||||
|
||||
|
||||
def test_batch_supports_due_date_tags_and_soft_delete_atomically(client):
|
||||
def test_batch_supports_due_date_and_soft_delete_atomically(client):
|
||||
client = initialized_client(client)
|
||||
inbox = client.get("/api/v1/lists").json()[0]
|
||||
tag = client.post("/api/v1/tags", json={"name": "批量", "color": "red"}).json()
|
||||
ids = [
|
||||
client.post("/api/v1/tasks", json={"title": str(i), "list_id": inbox["id"]}).json()["id"]
|
||||
for i in range(2)
|
||||
@@ -344,10 +322,10 @@ def test_batch_supports_due_date_tags_and_soft_delete_atomically(client):
|
||||
due_at = "2026-09-10T08:00:00+08:00"
|
||||
response = client.post(
|
||||
"/api/v1/tasks/batch",
|
||||
json={"task_ids": ids, "due_at": due_at, "tag_ids": [tag["id"]]},
|
||||
json={"task_ids": ids, "due_at": due_at},
|
||||
)
|
||||
assert response.status_code == 200 and response.json()["updated"] == 2
|
||||
assert all(client.get(f"/api/v1/tasks/{task_id}").json()["tags"] for task_id in ids)
|
||||
assert all(client.get(f"/api/v1/tasks/{task_id}").json()["due_at"] is not None for task_id in ids)
|
||||
|
||||
parent = client.post("/api/v1/tasks", json={"title": "父", "list_id": inbox["id"]}).json()
|
||||
client.post(
|
||||
|
||||
+1
-164
@@ -15,7 +15,6 @@ def boot(client):
|
||||
def test_bootstrap_returns_navigation_and_current_user(client):
|
||||
inbox = boot(client)
|
||||
client.post("/api/v1/folders", json={"name": "工作"})
|
||||
client.post("/api/v1/tags", json={"name": "重要", "color": "#ff0000"})
|
||||
client.post("/api/v1/tasks", json={"title": "首屏任务", "list_id": inbox["id"]})
|
||||
|
||||
response = client.get("/api/v1/bootstrap")
|
||||
@@ -25,172 +24,10 @@ def test_bootstrap_returns_navigation_and_current_user(client):
|
||||
assert data["user"]["username"] == "owner"
|
||||
assert any(row["id"] == inbox["id"] for row in data["lists"])
|
||||
assert [row["name"] for row in data["folders"]] == ["工作"]
|
||||
assert [row["name"] for row in data["tags"]] == ["重要"]
|
||||
assert "tags" not in data
|
||||
assert data["inbox_id"] == inbox["id"]
|
||||
|
||||
|
||||
def test_calendar_endpoint_is_removed(client):
|
||||
boot(client)
|
||||
assert client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
|
||||
|
||||
|
||||
def test_calendar_parser_handles_tzid_recurrence_folding_and_multiday():
|
||||
from backend.mvp import parse_ics_events
|
||||
|
||||
ics = """BEGIN:VCALENDAR\r
|
||||
VERSION:2.0\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:tzid-recurring\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
|
||||
DTEND;TZID=Asia/Shanghai:20260906T100000\r
|
||||
RRULE:FREQ=DAILY;COUNT=2\r
|
||||
EXDATE;TZID=Asia/Shanghai:20260907T090000\r
|
||||
SUMMARY:折叠\r
|
||||
标题\r
|
||||
END:VEVENT\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:multi-day\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260905T230000\r
|
||||
DTEND;TZID=Asia/Shanghai:20260906T010000\r
|
||||
SUMMARY:跨天事件\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
"""
|
||||
events = parse_ics_events(
|
||||
ics,
|
||||
"测试日历",
|
||||
"#fff",
|
||||
datetime(2026, 9, 5, 16, tzinfo=UTC),
|
||||
datetime(2026, 9, 6, 16, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
|
||||
("折叠标题", "2026-09-06T01:00:00+00:00"),
|
||||
("跨天事件", "2026-09-05T15:00:00+00:00"),
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_parser_keeps_recurrence_in_wall_clock_timezone_across_dst():
|
||||
from backend.mvp import parse_ics_events
|
||||
|
||||
ics = """BEGIN:VCALENDAR\r
|
||||
VERSION:2.0\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:dst-recurring\r
|
||||
DTSTART;TZID=America/New_York:20260307T090000\r
|
||||
DTEND;TZID=America/New_York:20260307T100000\r
|
||||
RRULE:FREQ=DAILY;COUNT=3\r
|
||||
SUMMARY:Morning meeting\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
"""
|
||||
|
||||
events = parse_ics_events(
|
||||
ics,
|
||||
"DST calendar",
|
||||
"#fff",
|
||||
datetime(2026, 3, 7, tzinfo=UTC),
|
||||
datetime(2026, 3, 11, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert [row["starts_at"].isoformat() for row in events] == [
|
||||
"2026-03-07T14:00:00+00:00",
|
||||
"2026-03-08T13:00:00+00:00",
|
||||
"2026-03-09T13:00:00+00:00",
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_parser_replaces_master_occurrence_with_override():
|
||||
from backend.mvp import parse_ics_events
|
||||
|
||||
ics = """BEGIN:VCALENDAR\r
|
||||
VERSION:2.0\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:overridden-series\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
|
||||
DTEND;TZID=Asia/Shanghai:20260906T100000\r
|
||||
RRULE:FREQ=DAILY;COUNT=2\r
|
||||
SUMMARY:原始事件\r
|
||||
END:VEVENT\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:overridden-series\r
|
||||
RECURRENCE-ID;TZID=Asia/Shanghai:20260907T090000\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260907T110000\r
|
||||
DTEND;TZID=Asia/Shanghai:20260907T120000\r
|
||||
SUMMARY:改期事件\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
"""
|
||||
|
||||
events = parse_ics_events(
|
||||
ics,
|
||||
"测试日历",
|
||||
"#fff",
|
||||
datetime(2026, 9, 5, tzinfo=UTC),
|
||||
datetime(2026, 9, 8, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
|
||||
("原始事件", "2026-09-06T01:00:00+00:00"),
|
||||
("改期事件", "2026-09-07T03:00:00+00:00"),
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_parser_skips_cancelled_events_and_cancelled_overrides():
|
||||
from backend.mvp import parse_ics_events
|
||||
|
||||
ics = """BEGIN:VCALENDAR\r
|
||||
VERSION:2.0\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:cancelled-single\r
|
||||
DTSTART:20260906T090000\r
|
||||
STATUS:CANCELLED\r
|
||||
SUMMARY:已取消单次事件\r
|
||||
END:VEVENT\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:partly-cancelled-series\r
|
||||
DTSTART;TZID=Asia/Shanghai:20260906T090000\r
|
||||
RRULE:FREQ=DAILY;COUNT=2\r
|
||||
SUMMARY:重复事件\r
|
||||
END:VEVENT\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:partly-cancelled-series\r
|
||||
RECURRENCE-ID;TZID=Asia/Shanghai:20260907T090000\r
|
||||
STATUS:CANCELLED\r
|
||||
SUMMARY:已取消实例\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
"""
|
||||
|
||||
events = parse_ics_events(
|
||||
ics,
|
||||
"测试日历",
|
||||
"#fff",
|
||||
datetime(2026, 9, 5, tzinfo=UTC),
|
||||
datetime(2026, 9, 8, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert [(row["title"], row["starts_at"].isoformat()) for row in events] == [
|
||||
("重复事件", "2026-09-06T01:00:00+00:00"),
|
||||
]
|
||||
|
||||
|
||||
def test_calendar_url_rejects_private_addresses(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.mvp import _validated_calendar_target
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.mvp.socket.getaddrinfo",
|
||||
lambda *_args, **_kwargs: [(None, None, None, None, ("127.0.0.1", 80))],
|
||||
)
|
||||
try:
|
||||
_validated_calendar_target("http://example.com/calendar.ics")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 422
|
||||
else:
|
||||
raise AssertionError("private calendar target should be rejected")
|
||||
|
||||
|
||||
def test_recurrence_mutations_keep_exact_timestamp_validation(client):
|
||||
inbox = boot(client)
|
||||
|
||||
Reference in New Issue
Block a user