feat: refine archived habit management
This commit is contained in:
+58
-1
@@ -702,8 +702,24 @@ async def require_positive_log_day(db, habit, day):
|
|||||||
raise HTTPException(409, "暂停日不可记录正向进度")
|
raise HTTPException(409, "暂停日不可记录正向进度")
|
||||||
|
|
||||||
|
|
||||||
|
async def lock_habit_order(db: AsyncSession, user_id: UUID) -> None:
|
||||||
|
"""Serialize active-habit order changes for one user.
|
||||||
|
|
||||||
|
PostgreSQL supports a row-level user lock. SQLite ignores ``FOR UPDATE``, so
|
||||||
|
a no-op write acquires its transaction-wide write lock before order reads.
|
||||||
|
"""
|
||||||
|
connection = await db.connection()
|
||||||
|
if connection.dialect.name == "sqlite":
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user_id).values(id=User.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await db.scalar(select(User.id).where(User.id == user_id).with_for_update())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/habits", status_code=201)
|
@router.post("/habits", status_code=201)
|
||||||
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
await lock_habit_order(db, user.id)
|
||||||
max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id))
|
max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id))
|
||||||
row = Habit(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None)
|
row = Habit(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None)
|
||||||
db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row)
|
db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row)
|
||||||
@@ -712,11 +728,24 @@ async def create_habit(payload: HabitCreate, user: User = Depends(current_user),
|
|||||||
@router.get("/habits")
|
@router.get("/habits")
|
||||||
async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None)
|
condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None)
|
||||||
return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.position, Habit.created_at))).all()]
|
order = (
|
||||||
|
(Habit.archived_at.desc(), Habit.created_at.desc(), Habit.id.asc())
|
||||||
|
if archived
|
||||||
|
else (Habit.position, Habit.created_at)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
habit_dict(h)
|
||||||
|
for h in (
|
||||||
|
await db.scalars(
|
||||||
|
select(Habit).where(Habit.user_id == user.id, condition).order_by(*order)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.put("/habits/reorder", status_code=204)
|
@router.put("/habits/reorder", status_code=204)
|
||||||
async def reorder_habits(payload: HabitReorder, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def reorder_habits(payload: HabitReorder, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
await lock_habit_order(db, user.id)
|
||||||
rows = list((await db.scalars(select(Habit).where(
|
rows = list((await db.scalars(select(Habit).where(
|
||||||
Habit.id.in_(payload.habit_ids), Habit.user_id == user.id, Habit.archived_at.is_(None)
|
Habit.id.in_(payload.habit_ids), Habit.user_id == user.id, Habit.archived_at.is_(None)
|
||||||
))).all())
|
))).all())
|
||||||
@@ -759,6 +788,34 @@ async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db:
|
|||||||
row = await owned_habit(db, user.id, habit_id); row.archived_at = utcnow(); await db.commit(); return Response(status_code=204)
|
row = await owned_habit(db, user.id, habit_id); row.archived_at = utcnow(); await db.commit(); return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/habits/{habit_id}/restore")
|
||||||
|
async def restore_habit(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
await lock_habit_order(db, user.id)
|
||||||
|
row = await owned_habit(db, user.id, habit_id)
|
||||||
|
if row.archived_at is None:
|
||||||
|
raise HTTPException(409, "习惯未归档")
|
||||||
|
max_position = await db.scalar(select(func.max(Habit.position)).where(
|
||||||
|
Habit.user_id == user.id, Habit.archived_at.is_(None)
|
||||||
|
))
|
||||||
|
position = (max_position if max_position is not None else -1) + 1
|
||||||
|
result = await db.execute(
|
||||||
|
update(Habit)
|
||||||
|
.where(
|
||||||
|
Habit.id == habit_id,
|
||||||
|
Habit.user_id == user.id,
|
||||||
|
Habit.archived_at.is_not(None),
|
||||||
|
)
|
||||||
|
.values(archived_at=None, position=position)
|
||||||
|
)
|
||||||
|
if result.rowcount != 1:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(409, "习惯未归档")
|
||||||
|
audit(db, user.id, "restore", "habit", row.id)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return habit_dict(row)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/habits/{habit_id}/permanent", status_code=204)
|
@router.delete("/habits/{habit_id}/permanent", status_code=204)
|
||||||
async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
row = await owned_habit(db, user.id, habit_id)
|
row = await owned_habit(db, user.id, habit_id)
|
||||||
|
|||||||
+57
-14
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||||
import { moveItemWithinScope } from './lib/task-utils'
|
import { moveItemWithinScope } from './lib/task-utils'
|
||||||
import { changedHabitFields, dateKey, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
|
import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse } from './lib/completion-motion'
|
import { createCompletionPulse } from './lib/completion-motion'
|
||||||
|
|
||||||
@@ -19,7 +19,10 @@ const emit = defineEmits<{
|
|||||||
const habits = ref<Habit[]>([])
|
const habits = ref<Habit[]>([])
|
||||||
const archivedHabits = ref<Habit[]>([])
|
const archivedHabits = ref<Habit[]>([])
|
||||||
const showArchivedHabits = ref(false)
|
const showArchivedHabits = ref(false)
|
||||||
const archivedHabitsLoaded = ref(false)
|
const archiveState = ref<ArchivePanelState>('idle')
|
||||||
|
const archiveError = ref('')
|
||||||
|
const archiveFlags = computed(() => archivePanelFlags(archiveState.value, archivedHabits.value.length))
|
||||||
|
const habitArchiveToggle = ref<HTMLButtonElement | null>(null)
|
||||||
const sessions = ref<Session[]>([])
|
const sessions = ref<Session[]>([])
|
||||||
const audit = ref<any[]>([])
|
const audit = ref<any[]>([])
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
@@ -387,7 +390,10 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
|||||||
}
|
}
|
||||||
function closeHabitDetail() {
|
function closeHabitDetail() {
|
||||||
selectedHabit.value = null
|
selectedHabit.value = null
|
||||||
void nextTick(() => habitDetailOpener?.focus())
|
void nextTick(() => {
|
||||||
|
if (habitDetailOpener?.isConnected) habitDetailOpener.focus()
|
||||||
|
else habitArchiveToggle.value?.focus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
defineExpose({ openHabitComposer })
|
defineExpose({ openHabitComposer })
|
||||||
async function archiveHabit(h: Habit) {
|
async function archiveHabit(h: Habit) {
|
||||||
@@ -400,22 +406,53 @@ async function archiveHabit(h: Habit) {
|
|||||||
emit('notice', '习惯已归档')
|
emit('notice', '习惯已归档')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
async function restoreHabit(h: Habit) {
|
||||||
|
if (!h.archived_at) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await performHabitRestore({
|
||||||
|
restore: () => request(`/habits/${h.id}/restore`, { method: 'POST' }),
|
||||||
|
commitRestore: () => {
|
||||||
|
archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)
|
||||||
|
selectedHabit.value = null
|
||||||
|
invalidateHabitGridCache()
|
||||||
|
},
|
||||||
|
refreshGrid: () => loadHabits(),
|
||||||
|
})
|
||||||
|
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
|
||||||
|
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||||
|
} catch (reason) {
|
||||||
|
error.value = reason instanceof Error ? reason.message : '恢复失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
async function deleteHabit(h: Habit) {
|
async function deleteHabit(h: Habit) {
|
||||||
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
||||||
await safe(async () => {
|
error.value = ''
|
||||||
|
try {
|
||||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||||
|
archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)
|
||||||
selectedHabit.value = null
|
selectedHabit.value = null
|
||||||
await loadArchivedHabits()
|
|
||||||
emit('notice', '习惯已永久删除')
|
emit('notice', '习惯已永久删除')
|
||||||
})
|
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||||
|
} catch (reason) {
|
||||||
|
error.value = reason instanceof Error ? reason.message : '永久删除失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function loadArchivedHabits() {
|
async function loadArchivedHabits() {
|
||||||
|
if (props.view !== 'habits' || archiveState.value === 'loading') return
|
||||||
|
archiveState.value = 'loading'
|
||||||
|
archiveError.value = ''
|
||||||
|
try {
|
||||||
archivedHabits.value = await request('/habits?archived=true') as Habit[]
|
archivedHabits.value = await request('/habits?archived=true') as Habit[]
|
||||||
archivedHabitsLoaded.value = true
|
archiveState.value = 'success'
|
||||||
|
} catch (reason) {
|
||||||
|
archiveState.value = 'error'
|
||||||
|
archiveError.value = reason instanceof Error ? reason.message : '归档习惯加载失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function toggleArchivedHabits() {
|
async function toggleArchivedHabits() {
|
||||||
showArchivedHabits.value = !showArchivedHabits.value
|
showArchivedHabits.value = !showArchivedHabits.value
|
||||||
if (showArchivedHabits.value && !archivedHabitsLoaded.value) await safe(loadArchivedHabits)
|
if (showArchivedHabits.value && archiveState.value !== 'success') await loadArchivedHabits()
|
||||||
}
|
}
|
||||||
function refreshHabitDay() {
|
function refreshHabitDay() {
|
||||||
const next = dateKey(new Date())
|
const next = dateKey(new Date())
|
||||||
@@ -434,8 +471,10 @@ async function loadHabits() {
|
|||||||
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
|
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
|
||||||
habits.value = data.habits ?? []
|
habits.value = data.habits ?? []
|
||||||
writeHabitGridCache(week, habits.value)
|
writeHabitGridCache(week, habits.value)
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : '请求失败'
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
if (!cached) busy.value = false
|
if (!cached) busy.value = false
|
||||||
}
|
}
|
||||||
@@ -578,19 +617,23 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏。' : '还没有习惯,从一件容易坚持的小事开始。' }}</div>
|
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏。' : '还没有习惯,从一件容易坚持的小事开始。' }}</div>
|
||||||
<button class="archived-toggle habit-archive-toggle" type="button" @click="toggleArchivedHabits"><ArchiveRestore/>{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}</button>
|
|
||||||
<div v-if="showArchivedHabits" class="archived-habits">
|
|
||||||
<button v-for="h in archivedHabits" :key="h.id" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span>{{ h.name }}</span><small>查看详情</small></button>
|
|
||||||
<p v-if="archivedHabitsLoaded && !archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<section v-if="view === 'habits'" class="habit-archive-section">
|
||||||
|
<button ref="habitArchiveToggle" class="archived-toggle habit-archive-toggle" type="button" :aria-expanded="showArchivedHabits" aria-controls="archived-habits-panel" @click="toggleArchivedHabits"><ArchiveRestore/><span>{{ archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档' }}</span><ChevronRight :class="{ expanded: showArchivedHabits }" aria-hidden="true"/></button>
|
||||||
|
<div v-if="showArchivedHabits" id="archived-habits-panel" class="archived-habits" aria-live="polite">
|
||||||
|
<p v-if="archiveState === 'loading'" class="habit-archive-status">正在加载归档习惯…</p>
|
||||||
|
<div v-else-if="archiveState === 'error'" class="habit-archive-status inline-error" role="alert"><span>{{ archiveError }}</span><button type="button" class="soft-button" @click="loadArchivedHabits">重试</button></div>
|
||||||
|
<p v-else-if="archiveFlags.empty" class="empty-panel">暂无已归档习惯。</p>
|
||||||
|
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<Transition name="countdown-detail">
|
<Transition name="countdown-detail">
|
||||||
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
|
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
|
||||||
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
||||||
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||||
<div class="app-sheet__body"><div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div></div>
|
<div class="app-sheet__body"><div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div></div>
|
||||||
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
||||||
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, performHabitRestore, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||||
|
|
||||||
describe('MVP view utilities', () => {
|
describe('MVP view utilities', () => {
|
||||||
it('formats a local date as YYYY-MM-DD', () => {
|
it('formats a local date as YYYY-MM-DD', () => {
|
||||||
@@ -196,6 +196,49 @@ describe('MVP view utilities', () => {
|
|||||||
expect(formatLocalShortDateTime('2026-04-31T12:00:00+08:00', { timeZone: 'UTC' })).toBe('未知时间')
|
expect(formatLocalShortDateTime('2026-04-31T12:00:00+08:00', { timeZone: 'UTC' })).toBe('未知时间')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a successful restore committed when the grid refresh fails', async () => {
|
||||||
|
const events: string[] = []
|
||||||
|
const result = await performHabitRestore({
|
||||||
|
restore: async () => { events.push('restored') },
|
||||||
|
commitRestore: () => { events.push('archive-removed') },
|
||||||
|
refreshGrid: async () => false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events).toEqual(['restored', 'archive-removed'])
|
||||||
|
expect(result).toEqual({ restored: true, refreshed: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates a restored habit grid cache before reconciliation', () => {
|
||||||
|
writeHabitGridCache('2026-09-07', [{ id: 'restored-habit' }])
|
||||||
|
invalidateHabitGridCache()
|
||||||
|
expect(readHabitGridCache('2026-09-07')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not commit a failed restore or attempt a grid refresh', async () => {
|
||||||
|
const events: string[] = []
|
||||||
|
await expect(performHabitRestore({
|
||||||
|
restore: async () => { throw new Error('恢复失败') },
|
||||||
|
commitRestore: () => { events.push('archive-removed') },
|
||||||
|
refreshGrid: async () => { events.push('grid-refreshed') },
|
||||||
|
})).rejects.toThrow('恢复失败')
|
||||||
|
expect(events).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats archive timestamps locally and rejects every malformed calendar value', () => {
|
||||||
|
expect(formatArchivedAt('2026-09-10T12:08:00Z', { timeZone: 'Asia/Shanghai' })).toMatch(/^归档于 2026\/9\/10 20:08$/)
|
||||||
|
for (const value of [null, '', 'not-a-date', '2026-02-30T12:00:00Z', '2026-13-01T00:00:00Z', '2026-09-10T25:00:00Z']) {
|
||||||
|
expect(formatArchivedAt(value, { timeZone: 'UTC' })).toBe('归档时间未知')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes archive loading, error, and empty states mutually exclusive', () => {
|
||||||
|
expect(archivePanelFlags('loading', 0)).toEqual({ loading: true, error: false, empty: false, list: false })
|
||||||
|
expect(archivePanelFlags('error', 0)).toEqual({ loading: false, error: true, empty: false, list: false })
|
||||||
|
expect(archivePanelFlags('success', 0)).toEqual({ loading: false, error: false, empty: true, list: false })
|
||||||
|
expect(archivePanelFlags('success', 2)).toEqual({ loading: false, error: false, empty: false, list: true })
|
||||||
|
expect(archivePanelFlags('idle', 0)).toEqual({ loading: false, error: false, empty: false, list: false })
|
||||||
|
})
|
||||||
|
|
||||||
it('localizes known audit actions and entities and hides unknown actions', () => {
|
it('localizes known audit actions and entities and hides unknown actions', () => {
|
||||||
expect(['create', 'update', 'complete', 'delete', 'archive', 'restore', 'move', 'import'].map(formatAuditAction)).toEqual(['创建', '更新', '完成', '删除', '归档', '恢复', '移动', '导入'])
|
expect(['create', 'update', 'complete', 'delete', 'archive', 'restore', 'move', 'import'].map(formatAuditAction)).toEqual(['创建', '更新', '完成', '删除', '归档', '恢复', '移动', '导入'])
|
||||||
expect(['task', 'list', 'folder', 'countdown', 'backup'].map(formatAuditEntity)).toEqual(['任务', '清单', '文件夹', '倒数日', '备份'])
|
expect(['task', 'list', 'folder', 'countdown', 'backup'].map(formatAuditEntity)).toEqual(['任务', '清单', '文件夹', '倒数日', '备份'])
|
||||||
|
|||||||
@@ -269,6 +269,25 @@ export function writeHabitGridCache<T>(week: string, habits: T[]) {
|
|||||||
habitGridCache = { week, habits }
|
habitGridCache = { week, habits }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidateHabitGridCache() {
|
||||||
|
habitGridCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performHabitRestore(actions: {
|
||||||
|
restore: () => Promise<unknown>
|
||||||
|
commitRestore: () => void
|
||||||
|
refreshGrid: () => Promise<boolean | unknown>
|
||||||
|
}) {
|
||||||
|
await actions.restore()
|
||||||
|
actions.commitRestore()
|
||||||
|
try {
|
||||||
|
const refreshed = await actions.refreshGrid()
|
||||||
|
return { restored: true as const, refreshed: refreshed !== false }
|
||||||
|
} catch {
|
||||||
|
return { restored: true as const, refreshed: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function readCountdownCache<T>() {
|
export function readCountdownCache<T>() {
|
||||||
if (!countdownCache) return null
|
if (!countdownCache) return null
|
||||||
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
|
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
|
||||||
@@ -369,6 +388,22 @@ export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean
|
|||||||
return { writable: true, reason: '' }
|
return { writable: true, reason: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ArchivePanelState = 'idle' | 'loading' | 'success' | 'error'
|
||||||
|
|
||||||
|
export function archivePanelFlags(state: ArchivePanelState, count: number) {
|
||||||
|
return {
|
||||||
|
loading: state === 'loading',
|
||||||
|
error: state === 'error',
|
||||||
|
empty: state === 'success' && count === 0,
|
||||||
|
list: state === 'success' && count > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatArchivedAt(value?: string | null, options: { timeZone?: string } = {}) {
|
||||||
|
const formatted = formatLocalShortDateTime(value, options)
|
||||||
|
return formatted === '未知时间' ? '归档时间未知' : `归档于 ${formatted}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUserAgent(userAgent?: string | null): string {
|
export function formatUserAgent(userAgent?: string | null): string {
|
||||||
const ua = userAgent?.trim() ?? ''
|
const ua = userAgent?.trim() ?? ''
|
||||||
let device = '未知设备'
|
let device = '未知设备'
|
||||||
|
|||||||
@@ -219,18 +219,20 @@ describe('request generation protection', () => {
|
|||||||
describe('habit request boundaries', () => {
|
describe('habit request boundaries', () => {
|
||||||
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
|
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
|
||||||
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
|
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
|
||||||
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function deleteHabit'))
|
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function restoreHabit'))
|
||||||
|
const loadBlock = habits.slice(habits.indexOf('async function loadArchivedHabits'), habits.indexOf('async function toggleArchivedHabits'))
|
||||||
expect(mounted).not.toContain('loadArchivedHabits()')
|
expect(mounted).not.toContain('loadArchivedHabits()')
|
||||||
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
|
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
|
||||||
expect(habits).toContain('if (showArchivedHabits.value && !archivedHabitsLoaded.value)')
|
expect(loadBlock).toContain("if (props.view !== 'habits'")
|
||||||
expect(habits).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}")
|
expect(habits).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render an empty archive until loading succeeds and allows collapse-reopen retry', () => {
|
it('does not render an empty archive until loading succeeds and retries after failure', () => {
|
||||||
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
|
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
|
||||||
expect(toggleBlock).toContain('showArchivedHabits.value && !archivedHabitsLoaded.value')
|
expect(toggleBlock).toContain("showArchivedHabits.value && archiveState.value !== 'success'")
|
||||||
expect(habits).toContain('v-if="archivedHabitsLoaded && !archivedHabits.length && !busy"')
|
expect(habits).toContain('v-else-if="archiveFlags.empty"')
|
||||||
expect(habits).not.toContain('v-if="!archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。')
|
expect(habits).toContain("v-else-if=\"archiveState === 'error'\"")
|
||||||
|
expect(habits).toContain('@click="loadArchivedHabits">重试</button>')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ main{background:var(--surface-base)}
|
|||||||
.soft-button,.danger-button,.file-button,.secondary,.restore,.archived-toggle,.archived-countdowns button{background:var(--surface-raised);border-color:var(--border-cream);box-shadow:var(--highlight-inner)}
|
.soft-button,.danger-button,.file-button,.secondary,.restore,.archived-toggle,.archived-countdowns button{background:var(--surface-raised);border-color:var(--border-cream);box-shadow:var(--highlight-inner)}
|
||||||
.primary,.primary-small,.unified-fab{background:var(--accent);color:#fff}
|
.primary,.primary-small,.unified-fab{background:var(--accent);color:#fff}
|
||||||
.danger-button{color:var(--danger);border-color:#dba99d;background:#fff8f5}.inline-error,.purge-list-error{color:var(--danger);background:#fff0ec}.task-check[aria-pressed="true"] .task-check-mark,.habit-row.done .habit-check .task-check-mark{background:var(--success);border-color:var(--success)}
|
.danger-button{color:var(--danger);border-color:#dba99d;background:#fff8f5}.inline-error,.purge-list-error{color:var(--danger);background:#fff0ec}.task-check[aria-pressed="true"] .task-check-mark,.habit-row.done .habit-check .task-check-mark{background:var(--success);border-color:var(--success)}
|
||||||
@media(max-width:930px){.habit-archive-toggle{width:100%;min-height:44px;appearance:none;background:var(--surface-raised);border:1px solid var(--border-cream);box-shadow:none;justify-content:flex-start}.archived-habits{width:100%;display:grid;gap:0}.archived-habits>button{width:100%;min-height:44px;appearance:none;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;color:inherit;text-align:left;background:var(--surface-raised);border:0;border-bottom:1px solid var(--border-cream);border-radius:0;font:inherit}.archived-habits>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.archived-habits>button small{color:var(--muted);white-space:nowrap}}
|
.habit-archive-section{display:grid;gap:0}.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;text-align:left}.habit-archive-toggle>span{min-width:0}.habit-archive-toggle>svg:last-child{transition:transform .16s ease}.habit-archive-toggle>svg.expanded{transform:rotate(90deg)}.archived-habits{width:100%;display:grid;gap:0;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-card);overflow:hidden}.archived-habit-row{width:100%;min-height:44px;appearance:none;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;color:inherit;text-align:left;background:var(--surface-raised);border:0;border-bottom:1px solid var(--border-cream);font:inherit}.archived-habit-row:last-child{border-bottom:0}.archived-habit-row>span{min-width:0;display:grid;gap:3px}.archived-habit-row b,.archived-habit-row small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.archived-habit-row small{color:var(--muted);font-size:11px}.archived-habit-row>svg{flex:none}.habit-archive-status{min-height:44px;margin:0;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px}.app-sheet__danger{display:flex;gap:8px}.app-sheet__danger>button{min-height:44px}.habit-restore-button{flex:1}.habit-delete-button{flex:1}
|
||||||
|
@media(max-width:930px){.habit-archive-section{width:100%;gap:0}.habit-archive-toggle{width:100%;min-height:44px;appearance:none;background:var(--surface-raised);border:1px solid var(--border-cream);box-shadow:none}.archived-habits{width:100%;display:grid;gap:0}.archived-habit-row{width:100%;min-height:44px;appearance:none;grid-template-columns:minmax(0,1fr) auto;background:var(--surface-raised);border-bottom:1px solid var(--border-cream)}.habit-detail-sheet.app-sheet--detail{width:100%;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}}
|
||||||
input,select,textarea,.search{background:var(--surface-raised);border-color:var(--border-cream);border-radius:var(--radius-control);box-shadow:var(--highlight-inner)}
|
input,select,textarea,.search{background:var(--surface-raised);border-color:var(--border-cream);border-radius:var(--radius-control);box-shadow:var(--highlight-inner)}
|
||||||
.search input,.detail-title textarea{background:transparent;box-shadow:none}
|
.search input,.detail-title textarea{background:transparent;box-shadow:none}
|
||||||
.sidebar-popover button,.sidebar-action-sheet .app-sheet__body button,.app-sheet--actions .app-sheet__body>button,.more-sheet>button{background:var(--surface-raised)}
|
.sidebar-popover button,.sidebar-action-sheet .app-sheet__body button,.app-sheet--actions .app-sheet__body>button,.more-sheet>button{background:var(--surface-raised)}
|
||||||
|
|||||||
@@ -267,16 +267,56 @@ describe('mobile list row language', () => {
|
|||||||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('provides a minimal archived-habit viewing path', () => {
|
it('provides an archived-habit viewing path', () => {
|
||||||
expect(mvpPanel).toContain("request('/habits?archived=true')")
|
expect(mvpPanel).toContain("request('/habits?archived=true')")
|
||||||
expect(mvpPanel).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}")
|
expect(mvpPanel).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档'")
|
||||||
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
|
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
|
||||||
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
|
expect(mvpPanel).toContain('archiveFlags.list ? archivedHabits : []')
|
||||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-toggle\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\)/)
|
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-section\{[^}]*width:100%/)
|
||||||
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
|
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
|
||||||
expect(css).toMatch(/\.archived-habits>button\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*grid-template-columns:minmax\(0,1fr\) auto;[^}]*background:var\(--surface-raised\);[^}]*border-bottom:1px solid var\(--border-cream\)/)
|
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
||||||
expect(css).toMatch(/\.archived-habits>button span\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
|
expect(css).toMatch(/\.archived-habit-row b,\.archived-habit-row small\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
|
||||||
expect(css).toMatch(/\.archived-habits>button small\{[^}]*white-space:nowrap/)
|
})
|
||||||
|
|
||||||
|
it('renders a separate accessible archive section with explicit states and lifecycle actions', () => {
|
||||||
|
const activeList = mvpPanel.slice(mvpPanel.indexOf('<div v-if="view === \'habits\'" class="habit-list">'), mvpPanel.indexOf('<section v-if="view === \'habits\'" class="habit-archive-section"'))
|
||||||
|
const todayList = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
|
||||||
|
expect(activeList).not.toContain('habit-archive-toggle')
|
||||||
|
expect(todayList).not.toContain('habit-archive-section')
|
||||||
|
expect(mvpPanel).toContain('class="habit-archive-section"')
|
||||||
|
expect(mvpPanel).toContain('aria-controls="archived-habits-panel"')
|
||||||
|
expect(mvpPanel).toContain(':aria-expanded="showArchivedHabits"')
|
||||||
|
expect(mvpPanel).toContain('id="archived-habits-panel"')
|
||||||
|
expect(mvpPanel).toContain('archiveState === \'loading\'')
|
||||||
|
expect(mvpPanel).toContain('archiveState === \'error\'')
|
||||||
|
expect(mvpPanel).toContain('@click="loadArchivedHabits"')
|
||||||
|
expect(mvpPanel).toContain('formatArchivedAt(h.archived_at)')
|
||||||
|
expect(mvpPanel).toContain('<ChevronRight aria-hidden="true"/>')
|
||||||
|
expect(mvpPanel).toContain('@click="restoreHabit(selectedHabit)"')
|
||||||
|
expect(mvpPanel).toContain("method: 'POST'")
|
||||||
|
expect(mvpPanel).toContain('archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)')
|
||||||
|
expect(mvpPanel).toContain('恢复习惯')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves archived detail after restore or purge failures and falls focus back to disclosure after removal', () => {
|
||||||
|
const restoreBlock = mvpPanel.slice(mvpPanel.indexOf('async function restoreHabit'), mvpPanel.indexOf('async function deleteHabit'))
|
||||||
|
const deleteBlock = mvpPanel.slice(mvpPanel.indexOf('async function deleteHabit'), mvpPanel.indexOf('async function loadArchivedHabits'))
|
||||||
|
expect(restoreBlock).toContain('catch (reason)')
|
||||||
|
expect(deleteBlock).toContain('catch (reason)')
|
||||||
|
expect(restoreBlock).toContain('performHabitRestore({')
|
||||||
|
expect(restoreBlock).toContain('invalidateHabitGridCache()')
|
||||||
|
expect(restoreBlock).toContain("result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试'")
|
||||||
|
expect(restoreBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(restoreBlock.indexOf("method: 'POST'"))
|
||||||
|
expect(deleteBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(deleteBlock.indexOf("method: 'DELETE'"))
|
||||||
|
expect(mvpPanel).toContain('habitArchiveToggle.value?.focus()')
|
||||||
|
expect(mvpPanel).toContain('ref="habitArchiveToggle"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('styles archive rows as 44px full-width desktop rows and a mobile bottom sheet detail', () => {
|
||||||
|
expect(css).toMatch(/\.habit-archive-toggle\{[^}]*min-height:44px/)
|
||||||
|
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
||||||
|
expect(css).toContain('@media(max-width:930px){.habit-archive-section')
|
||||||
|
expect(css).toContain('.habit-detail-sheet.app-sheet--detail{')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses the habit composer for edits and patches only changed fields', () => {
|
it('reuses the habit composer for edits and patches only changed fields', () => {
|
||||||
|
|||||||
+261
-1
@@ -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
|
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
|
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):
|
def test_paused_or_unscheduled_day_rejects_positive_progress_but_allows_correction(client):
|
||||||
boot(client)
|
boot(client)
|
||||||
monday = date(2026, 9, 7)
|
monday = date(2026, 9, 7)
|
||||||
|
|||||||
Reference in New Issue
Block a user