feat: refine archived habit management
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m28s

This commit is contained in:
2026-09-10 14:39:06 +08:00
parent bf61e69776
commit 3e80f2ecbb
8 changed files with 515 additions and 34 deletions
+261 -1
View File
@@ -1,4 +1,8 @@
from datetime import date, timedelta
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, date, datetime, timedelta
from uuid import UUID, uuid4
from sqlalchemy import func, select
from tests.test_mvp_backend import boot, local_today
@@ -161,6 +165,262 @@ def test_archived_habit_rejects_edit_logs_pause_and_active_permanent_delete(clie
assert client.delete(f"/api/v1/habits/{hid}/permanent").status_code == 204
def test_restore_archived_habit_appends_without_reordering_and_preserves_history(client):
boot(client)
first = create_habit(client, name="第一项").json()
restored_habit = create_habit(client, name="待恢复").json()
last = create_habit(client, name="最后一项").json()
day = local_today()
assert client.put(
f"/api/v1/habits/{restored_habit['id']}/logs/{day.isoformat()}", json={"value": 2}
).status_code == 200
assert client.post(
f"/api/v1/habits/{restored_habit['id']}/pauses",
json={"start_date": day.isoformat(), "end_date": day.isoformat()},
).status_code == 201
assert client.delete(f"/api/v1/habits/{restored_habit['id']}").status_code == 204
response = client.post(f"/api/v1/habits/{restored_habit['id']}/restore")
assert response.status_code == 200
restored = response.json()
assert restored["id"] == restored_habit["id"]
assert restored["archived_at"] is None
active = client.get("/api/v1/habits").json()
assert [row["id"] for row in active] == [first["id"], last["id"], restored_habit["id"]]
assert [row["position"] for row in active] == [first["position"], last["position"], last["position"] + 1]
assert client.get(f"/api/v1/habits/{restored_habit['id']}/logs").json() == [
{"day": day.isoformat(), "value": 2.0}
]
async def pause_count():
from backend.db import get_db
from backend.models import HabitPause
db_gen = get_db()
db = await anext(db_gen)
try:
return await db.scalar(
select(func.count()).select_from(HabitPause).where(
HabitPause.habit_id == UUID(restored_habit["id"])
)
)
finally:
await db_gen.aclose()
assert client.portal.call(pause_count) == 1
audit_logs = client.get("/api/v1/audit-logs").json()
assert any(
row["action"] == "restore"
and row["entity_type"] == "habit"
and row["entity_id"] == restored_habit["id"]
for row in audit_logs
)
def test_restore_habit_rejects_active_and_repeated_restore(client):
boot(client)
habit = create_habit(client).json()
url = f"/api/v1/habits/{habit['id']}/restore"
assert client.post(url).status_code == 409
assert client.delete(f"/api/v1/habits/{habit['id']}").status_code == 204
assert client.post(url).status_code == 200
assert client.post(url).status_code == 409
restores = [
row
for row in client.get("/api/v1/audit-logs").json()
if row["action"] == "restore" and row["entity_type"] == "habit"
]
assert len(restores) == 1
def test_concurrent_restore_allows_at_most_one_success(client):
boot(client)
habit = create_habit(client).json()
assert client.delete(f"/api/v1/habits/{habit['id']}").status_code == 204
url = f"/api/v1/habits/{habit['id']}/restore"
with ThreadPoolExecutor(max_workers=2) as pool:
statuses = sorted(pool.map(lambda _: client.post(url).status_code, range(2)))
assert statuses == [200, 409]
restores = [
row
for row in client.get("/api/v1/audit-logs").json()
if row["action"] == "restore" and row["entity_type"] == "habit"
]
assert len(restores) == 1
def test_concurrent_restore_of_different_habits_appends_unique_positions(
client, monkeypatch
):
import asyncio
import threading
from sqlalchemy.ext.asyncio import AsyncSession
boot(client)
active = [create_habit(client, name=name).json() for name in ("活跃一", "活跃二")]
archived = [create_habit(client, name=name).json() for name in ("归档一", "归档二")]
for habit in archived:
assert client.delete(f"/api/v1/habits/{habit['id']}").status_code == 204
original_scalar = AsyncSession.scalar
max_reads = threading.Barrier(2)
async def synchronized_scalar(self, statement, *args, **kwargs):
value = await original_scalar(self, statement, *args, **kwargs)
sql = str(statement)
if "max(habits.position)" in sql and "habits.archived_at IS NULL" in sql:
try:
await asyncio.to_thread(max_reads.wait, 1)
except threading.BrokenBarrierError:
pass
return value
monkeypatch.setattr(AsyncSession, "scalar", synchronized_scalar)
urls = [f"/api/v1/habits/{habit['id']}/restore" for habit in archived]
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(client.post, urls))
assert [response.status_code for response in responses] == [200, 200]
rows = client.get("/api/v1/habits").json()
assert [row["id"] for row in rows[:2]] == [habit["id"] for habit in active]
assert {row["id"] for row in rows[2:]} == {habit["id"] for habit in archived}
assert [row["position"] for row in rows] == [0, 1, 2, 3]
def test_concurrent_create_and_restore_keep_append_positions_unique(client, monkeypatch):
import asyncio
import threading
from sqlalchemy.ext.asyncio import AsyncSession
boot(client)
archived = create_habit(client, name="待恢复").json()
active = [create_habit(client, name=name).json() for name in ("活跃一", "活跃二")]
assert client.delete(f"/api/v1/habits/{archived['id']}").status_code == 204
original_scalar = AsyncSession.scalar
max_reads = threading.Barrier(2)
async def synchronized_scalar(self, statement, *args, **kwargs):
value = await original_scalar(self, statement, *args, **kwargs)
if "max(habits.position)" in str(statement):
try:
await asyncio.to_thread(max_reads.wait, 1)
except threading.BrokenBarrierError:
pass
return value
monkeypatch.setattr(AsyncSession, "scalar", synchronized_scalar)
with ThreadPoolExecutor(max_workers=2) as pool:
create_future = pool.submit(create_habit, client, name="并发新建")
restore_future = pool.submit(
client.post, f"/api/v1/habits/{archived['id']}/restore"
)
responses = [create_future.result(), restore_future.result()]
assert [response.status_code for response in responses] == [201, 200]
rows = client.get("/api/v1/habits").json()
assert [row["id"] for row in rows[:2]] == [habit["id"] for habit in active]
assert [row["position"] for row in rows] == [1, 2, 3, 4]
assert len({row["position"] for row in rows}) == len(rows)
def test_restore_habit_uses_owner_scoped_404(client):
boot(client)
async def add_foreign_habit():
from backend.db import get_db
from backend.models import Habit, User
db_gen = get_db()
db = await anext(db_gen)
try:
other = User(username="other", password_hash="not-used")
db.add(other)
await db.flush()
habit = Habit(
user_id=other.id,
name="别人的归档习惯",
kind="boolean",
target=1,
max_value=1,
schedule_type="daily",
start_date=local_today(),
archived_at=datetime.now(UTC),
position=0,
)
db.add(habit)
await db.commit()
return str(habit.id)
finally:
await db_gen.aclose()
foreign_id = client.portal.call(add_foreign_habit)
assert client.post(f"/api/v1/habits/{foreign_id}/restore").status_code == 404
assert client.post(f"/api/v1/habits/{uuid4()}/restore").status_code == 404
def test_restore_habit_validates_uuid_authentication_and_csrf(client):
boot(client)
habit = create_habit(client).json()
assert client.delete(f"/api/v1/habits/{habit['id']}").status_code == 204
assert client.post("/api/v1/habits/not-a-uuid/restore").status_code == 422
csrf_rejected = client.post(
f"/api/v1/habits/{habit['id']}/restore",
headers={"origin": "https://dodo.example", "x-csrf-token": "wrong"},
)
assert csrf_rejected.status_code == 403
anonymous = client.__class__(client.app)
with anonymous:
assert anonymous.post(f"/api/v1/habits/{habit['id']}/restore").status_code == 401
def test_archived_habits_sort_by_archive_time_then_creation_and_id(client):
boot(client)
habits = [
create_habit(client, name=name).json()
for name in ("旧归档", "较早创建", "同刻B", "同刻A")
]
for habit in habits:
assert client.delete(f"/api/v1/habits/{habit['id']}").status_code == 204
async def set_sort_timestamps():
from backend.db import get_db
from backend.models import Habit
db_gen = get_db()
db = await anext(db_gen)
try:
rows = list(
(await db.scalars(select(Habit).where(Habit.id.in_([UUID(row["id"]) for row in habits])))).all()
)
by_id = {str(row.id): row for row in rows}
older = datetime(2026, 9, 8, tzinfo=UTC)
newer = datetime(2026, 9, 9, tzinfo=UTC)
by_id[habits[0]["id"]].archived_at = older
for index, habit in enumerate(habits[1:]):
row = by_id[habit["id"]]
row.archived_at = newer
row.created_at = newer - timedelta(days=1) if index == 0 else newer
await db.commit()
finally:
await db_gen.aclose()
client.portal.call(set_sort_timestamps)
tied_ids = sorted([habits[2]["id"], habits[3]["id"]])
expected = tied_ids + [habits[1]["id"], habits[0]["id"]]
assert [row["id"] for row in client.get("/api/v1/habits", params={"archived": True}).json()] == expected
def test_paused_or_unscheduled_day_rejects_positive_progress_but_allows_correction(client):
boot(client)
monday = date(2026, 9, 7)