fix: harden countdown validation and recurrence
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m23s

This commit is contained in:
2026-09-09 12:40:40 +08:00
parent e163f6f368
commit 489d120159
4 changed files with 288 additions and 40 deletions
+117 -35
View File
@@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
from sqlalchemy import case, delete, func, select, update from sqlalchemy import case, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -325,11 +325,19 @@ class CountdownInput(BaseModel):
calendar_mode: str = Field("solar", pattern="^(solar|lunar)$") calendar_mode: str = Field("solar", pattern="^(solar|lunar)$")
lunar_month: int | None = None lunar_month: int | None = None
lunar_day: int | None = None lunar_day: int | None = None
ignore_year: bool = False ignore_year: StrictBool = False
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$") kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$") repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
icon: str = Field("📅", min_length=1, max_length=32) icon: str = Field("📅", min_length=1, max_length=32)
pinned: bool = False pinned: StrictBool = False
@field_validator("title", "icon")
@classmethod
def non_blank_text(cls, value: str):
value = value.strip()
if not value:
raise ValueError("text cannot be blank")
return value
@model_validator(mode="after") @model_validator(mode="after")
def calendar_fields_valid(self): def calendar_fields_valid(self):
@@ -356,10 +364,21 @@ class CountdownUpdate(BaseModel):
calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$") calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$")
lunar_month: int | None = None lunar_month: int | None = None
lunar_day: int | None = None lunar_day: int | None = None
ignore_year: bool | None = None ignore_year: StrictBool | None = None
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$") kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$") repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
icon: str | None = Field(None, min_length=1, max_length=32) icon: str | None = Field(None, min_length=1, max_length=32)
expected_updated_at: datetime | None = None
@field_validator("title", "icon")
@classmethod
def non_blank_text(cls, value: str | None):
if value is None:
return value
value = value.strip()
if not value:
raise ValueError("text cannot be blank")
return value
@model_validator(mode="after") @model_validator(mode="after")
def reject_explicit_nulls(self): def reject_explicit_nulls(self):
@@ -375,10 +394,10 @@ class CountdownUpdate(BaseModel):
def countdown_dict(row: Countdown, today: date | None = None): def countdown_dict(row: Countdown, today: date | None = None):
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date() today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule != "none"): if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule == "yearly"):
# 农历按年重复:忽略年份或指定重复时,都按“每年农历”语义计算下一次 search_from = max(today, row.event_date) if not row.ignore_year else today
display_date = next_lunar_occurrence( display_date = next_lunar_occurrence(
row.lunar_month, row.lunar_day, True, "yearly", today row.lunar_month, row.lunar_day, True, "yearly", search_from
) or row.event_date ) or row.event_date
effective_repeat = "yearly" effective_repeat = "yearly"
else: else:
@@ -392,7 +411,7 @@ def countdown_dict(row: Countdown, today: date | None = None):
lunar_year, _, _ = solar_to_lunar_parts(row.event_date) lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
lunar_text = ( lunar_text = (
solar_to_lunar_text(display_date) solar_to_lunar_text(display_date)
if row.ignore_year or row.repeat_rule != "none" if row.ignore_year or row.repeat_rule == "yearly"
else lunar_label_with_year(row.event_date) else lunar_label_with_year(row.event_date)
) )
return { return {
@@ -452,8 +471,25 @@ async def list_countdowns(archived: bool = False, user: User = Depends(current_u
@router.patch("/countdowns/{countdown_id}") @router.patch("/countdowns/{countdown_id}")
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id) row = await db.scalar(
select(Countdown)
.where(Countdown.id == countdown_id, Countdown.user_id == user.id)
.with_for_update()
)
if not row:
raise HTTPException(404, "倒数日不存在")
if row.archived_at is not None:
raise HTTPException(409, "请先恢复倒数日再编辑")
values = payload.model_dump(exclude_unset=True) values = payload.model_dump(exclude_unset=True)
expected_updated_at = values.pop("expected_updated_at", None)
if expected_updated_at is not None:
actual_updated_at = row.updated_at
if actual_updated_at.tzinfo is None and expected_updated_at.tzinfo is not None:
actual_updated_at = actual_updated_at.replace(tzinfo=UTC)
if expected_updated_at.tzinfo is None and actual_updated_at.tzinfo is not None:
expected_updated_at = expected_updated_at.replace(tzinfo=UTC)
if actual_updated_at != expected_updated_at:
raise HTTPException(409, "倒数日已被其他操作更新,请刷新后重试")
combined = { combined = {
"event_date": values.get("event_date", row.event_date), "event_date": values.get("event_date", row.event_date),
"calendar_mode": values.get("calendar_mode", row.calendar_mode), "calendar_mode": values.get("calendar_mode", row.calendar_mode),
@@ -899,10 +935,74 @@ async def restore_csv(
return await restore_json(payload, mode, user, db) return await restore_json(payload, mode, user, db)
def _parse_backup_datetime(value, field_name: str):
if value in (None, ""):
return None
if not isinstance(value, str):
raise HTTPException(422, f"无效的倒数日备份字段:{field_name}")
try:
return datetime.fromisoformat(value)
except ValueError as exc:
raise HTTPException(422, f"无效的倒数日备份字段:{field_name}") from exc
def _validate_countdown_backups(payload: dict) -> list[dict]:
raw_items = payload.get("countdowns", [])
if not isinstance(raw_items, list):
raise HTTPException(422, "倒数日备份必须是数组")
parsed = []
seen_ids = set()
for raw in raw_items:
if not isinstance(raw, dict):
raise HTTPException(422, "无效的倒数日备份数据")
try:
source_id = UUID(raw["id"])
if source_id in seen_ids:
raise HTTPException(422, "倒数日备份包含重复 ID")
seen_ids.add(source_id)
raw_event_date = date.fromisoformat(raw["event_date"])
calendar_mode = raw.get("calendar_mode", "solar")
lunar_month = raw.get("lunar_month")
lunar_day = raw.get("lunar_day")
lunar_year = None
validation_date = raw_event_date
if calendar_mode == "lunar":
lunar_year, actual_month, actual_day = solar_to_lunar_parts(raw_event_date)
if (actual_month, actual_day) != (lunar_month, lunar_day):
raise HTTPException(422, "倒数日备份中的公历与农历日期不一致")
validation_date = date(lunar_year, 1, 1)
item = CountdownInput(
title=raw["title"],
event_date=validation_date,
calendar_mode=calendar_mode,
lunar_month=lunar_month,
lunar_day=lunar_day,
ignore_year=raw.get("ignore_year", False),
kind=raw.get("kind", "countdown"),
repeat_rule=raw.get("repeat_rule", "none"),
icon=raw.get("icon", "📅"),
pinned=raw.get("pinned", False),
)
except HTTPException:
raise
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的倒数日备份数据") from exc
item.event_date = raw_event_date
parsed.append({
"source_id": source_id,
"item": item,
"archived_at": _parse_backup_datetime(raw.get("archived_at"), "archived_at"),
"created_at": _parse_backup_datetime(raw.get("created_at"), "created_at") or utcnow(),
"updated_at": _parse_backup_datetime(raw.get("updated_at"), "updated_at") or utcnow(),
})
return parsed
@router.post("/restore") @router.post("/restore")
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if payload.get("version") != 1: if payload.get("version") != 1:
raise HTTPException(422, "不支持的备份版本") raise HTTPException(422, "不支持的备份版本")
parsed_countdowns = _validate_countdown_backups(payload)
if mode == "replace": if mode == "replace":
await db.execute(delete(Countdown).where(Countdown.user_id == user.id)) await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
await db.execute(delete(Task).where(Task.user_id == user.id)) await db.execute(delete(Task).where(Task.user_id == user.id))
@@ -992,41 +1092,22 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all()) existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
occupied_countdown_ids = dict((await db.execute(select(Countdown.id, Countdown.user_id))).all()) occupied_countdown_ids = dict((await db.execute(select(Countdown.id, Countdown.user_id))).all())
has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True)))) has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True))))
for raw in payload.get("countdowns", []): for parsed in parsed_countdowns:
try: source_id = parsed["source_id"]
source_id = UUID(raw["id"])
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的倒数日备份 ID") from exc
row_id = source_id row_id = source_id
while row_id in occupied_countdown_ids and occupied_countdown_ids[row_id] != user.id: while row_id in occupied_countdown_ids and occupied_countdown_ids[row_id] != user.id:
row_id = uuid5(user.id, str(row_id)) row_id = uuid5(user.id, str(row_id))
if mode == "merge" and row_id in existing_countdown_ids: if mode == "merge" and row_id in existing_countdown_ids:
continue continue
try: item = parsed["item"]
item = CountdownInput( item.pinned = item.pinned and not has_pinned_countdown
title=raw["title"],
event_date=date.fromisoformat(raw["event_date"]),
calendar_mode=raw.get("calendar_mode", "solar"),
lunar_month=raw.get("lunar_month"),
lunar_day=raw.get("lunar_day"),
ignore_year=bool(raw.get("ignore_year", False)),
kind=raw.get("kind", "countdown"),
repeat_rule=raw.get("repeat_rule", "none"),
icon=raw.get("icon", "📅"),
pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown,
)
# Backups store the canonical solar anchor; validation above converts
# lunar input again, so preserve the exact exported anchor on restore.
item.event_date = date.fromisoformat(raw["event_date"])
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的倒数日备份数据") from exc
row = Countdown( row = Countdown(
id=row_id, id=row_id,
user_id=user.id, user_id=user.id,
**item.model_dump(), **item.model_dump(),
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None, archived_at=parsed["archived_at"],
created_at=datetime.fromisoformat(raw["created_at"]) if raw.get("created_at") else utcnow(), created_at=parsed["created_at"],
updated_at=datetime.fromisoformat(raw["updated_at"]) if raw.get("updated_at") else utcnow(), updated_at=parsed["updated_at"],
) )
if row.archived_at is not None: if row.archived_at is not None:
row.pinned = False row.pinned = False
@@ -1034,6 +1115,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
db.add(row) db.add(row)
existing_countdown_ids.add(row_id) existing_countdown_ids.add(row_id)
occupied_countdown_ids[row_id] = user.id occupied_countdown_ids[row_id] = user.id
restored += 1
audit(db, user.id, "restore", "backup", count=restored, mode=mode) audit(db, user.id, "restore", "backup", count=restored, mode=mode)
await db.commit() await db.commit()
return {"restored": restored, "mode": mode} return {"restored": restored, "mode": mode}
+16
View File
@@ -52,6 +52,22 @@ describe('countdown modal accessibility', () => {
expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])") expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])")
}) })
it('prevents duplicate submits and sends the edit precondition', () => {
expect(source).toContain('if (busy.value) return')
expect(source).toContain('payload.expected_updated_at=editingItem.value?.updated_at')
expect(source).toContain(':disabled="busy"')
})
it('keeps weekly and monthly recurrence available for lunar dates', () => {
expect(source).toContain('<option value="weekly">每周</option>')
expect(source).toContain('<option value="monthly">每月</option>')
})
it('does not repeat the lunar label in the converted date line', () => {
expect(source).toContain("return `${formatDate(item.display_date)}${suffix}`")
expect(source).not.toContain("`${formatDate(item.display_date)} · ${item.lunar_text}${suffix}`")
})
it('keeps the empty state directly actionable', () => { it('keeps the empty state directly actionable', () => {
expect(source).toContain('添加第一个重要日子') expect(source).toContain('添加第一个重要日子')
expect(source).toContain('@click="openFromEmpty"') expect(source).toContain('@click="openFromEmpty"')
+8 -5
View File
@@ -8,14 +8,14 @@ type Countdown = {
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday' id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number
calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null
ignore_year: boolean; lunar_text: string|null ignore_year: boolean; lunar_text: string|null; updated_at: string
} }
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean } type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
type CountdownGroup = { key: string; title: string; items: Countdown[] } type CountdownGroup = { key: string; title: string; items: Countdown[] }
const emit = defineEmits<{ notice: [message: string] }>() const emit = defineEmits<{ notice: [message: string] }>()
const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([]) const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
const showArchived = ref(false), open = ref(false), busy = ref(false) const showArchived = ref(false), open = ref(false), busy = ref(false)
const editingId = ref<string|null>(null), error = ref('') const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false) const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false }) const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
@@ -55,7 +55,7 @@ function primaryDate(item: Countdown) {
function secondaryDate(item: Countdown) { function secondaryDate(item: Countdown) {
if (item.calendar_mode === 'lunar' && item.lunar_text) { if (item.calendar_mode === 'lunar' && item.lunar_text) {
const suffix = item.ignore_year ? ' · 每年农历' : '' const suffix = item.ignore_year ? ' · 每年农历' : ''
return `${formatDate(item.display_date)} · ${item.lunar_text}${suffix}` return `${formatDate(item.display_date)}${suffix}`
} }
return '' return ''
} }
@@ -122,6 +122,7 @@ async function load() {
function edit(item:Countdown) { function edit(item:Countdown) {
detailItem.value=null detailItem.value=null
editingId.value=item.id editingId.value=item.id
editingItem.value=item
showAdvanced.value=false showAdvanced.value=false
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year } form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
open.value=true open.value=true
@@ -132,9 +133,11 @@ function applyKindDefaults() {
if (form.value.kind === 'countdown') form.value.repeat_rule='none' if (form.value.kind === 'countdown') form.value.repeat_rule='none'
} }
async function save() { async function save() {
if (busy.value) return
if (!form.value.title.trim()) return if (!form.value.title.trim()) return
await safe(async()=>{ await safe(async()=>{
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year } const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day } if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns' const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) }) await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
@@ -162,7 +165,7 @@ function trapDetailFocus(event: KeyboardEvent) {
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
} }
function openFromEmpty(){openCountdownComposer()} function openFromEmpty(){openCountdownComposer()}
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()} function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
defineExpose({ openCountdownComposer }) defineExpose({ openCountdownComposer })
onMounted(load) onMounted(load)
onBeforeUnmount(() => { previousFocus = null }) onBeforeUnmount(() => { previousFocus = null })
@@ -219,7 +222,7 @@ onBeforeUnmount(() => { previousFocus = null })
<label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">每年按农历计算</span></label> <label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">每年按农历计算</span></label>
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label> <label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
</details></div> </details></div>
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer> <footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
</form></div> </form></div>
</Transition> </Transition>
</section> </section>
+147
View File
@@ -1,3 +1,4 @@
from copy import deepcopy
from datetime import date from datetime import date
from backend.lunar_support import ( from backend.lunar_support import (
@@ -265,3 +266,149 @@ def test_countdown_backup_merge_remaps_ids_owned_by_another_user(client):
replaced = client.get("/api/v1/countdowns").json() replaced = client.get("/api/v1/countdowns").json()
assert len(replaced) == 1 assert len(replaced) == 1
assert replaced[0]["id"] == items[0]["id"] 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": "replace"}, 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": "replace"}, 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": "replace"}, 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": "replace"}, 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": "replace"}, 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