Files
habit-tracker/tests/test_log_service.py
shinalokandClaude Sonnet 5 9dfcbcec8f add day-detail view to monthly history calendar
날짜 셀 클릭 시 그 날 예정된 습관별 완료/실패/미완료 상태를 htmx로 불러와 보여준다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 13:21:49 +09:00

579 lines
24 KiB
Python

import calendar
from datetime import date, datetime, timedelta
from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
from app.models.habit_log import HabitLog, HabitLogStatus
from app.schemas.habit import HabitCreate
from app.schemas.habit_log import MonthlySummaryDay
from app.schemas.push import PushKeys, PushSubscribeRequest
from app.services import habit_service, log_service, push_service
def _make_habit(db_session, user_id, created_at=None, weekdays_mask=ALL_WEEKDAYS_MASK, **overrides):
data = HabitCreate(
name=overrides.pop("name", "테스트 습관"),
habit_type=overrides.pop("habit_type", HabitType.BUILD),
weekdays_mask=weekdays_mask,
condition_text=overrides.pop("condition_text", None),
reminder_time=overrides.pop("reminder_time", None),
)
habit = habit_service.create_habit(db_session, user_id, data)
if created_at is not None:
habit.created_at = created_at
db_session.commit()
db_session.refresh(habit)
return habit
def _check(db_session, habit_id, log_date):
db_session.add(HabitLog(habit_id=habit_id, log_date=log_date))
db_session.commit()
def _fail(db_session, habit_id, log_date):
db_session.add(HabitLog(habit_id=habit_id, log_date=log_date, status=HabitLogStatus.FAILED))
db_session.commit()
# ---- toggle_check ----
def test_toggle_check_sets_and_unsets(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
today = date.today()
assert log_service.toggle_check(db_session, habit.id, today) is True
assert db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=today).count() == 1
assert log_service.toggle_check(db_session, habit.id, today) is False
assert db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=today).count() == 0
# ---- mark_failed ----
def test_mark_failed_creates_failed_log(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
yesterday = date.today() - timedelta(days=1)
log_service.mark_failed(db_session, habit.id, yesterday)
log = db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=yesterday).one()
assert log.status == HabitLogStatus.FAILED
def test_mark_failed_does_not_overwrite_existing_log(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
yesterday = date.today() - timedelta(days=1)
_check(db_session, habit.id, yesterday) # 이미 완료로 체크된 상태
log_service.mark_failed(db_session, habit.id, yesterday)
log = db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=yesterday).one()
assert log.status == HabitLogStatus.DONE # 실패로 덮어쓰지 않음
# ---- toggle_check_and_celebrate ----
def test_toggle_check_and_celebrate_returns_milestone_on_streak_hit(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=6), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(6, 0, -1): # 6일 전부터 어제까지 6일 연속 체크, 오늘 체크하면 7일째
_check(db_session, habit.id, today - timedelta(days=offset))
checked, milestone, level_up = log_service.toggle_check_and_celebrate(db_session, habit, today)
assert checked is True
assert milestone == 7
assert level_up == 4 # 6일치(60xp, Lv3) -> 7일째 체크(70xp+마일스톤보너스20=90xp, Lv4)
def test_toggle_check_and_celebrate_returns_none_when_not_milestone(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
checked, milestone, level_up = log_service.toggle_check_and_celebrate(db_session, habit, date.today())
assert checked is True
assert milestone is None
assert level_up is None
def test_toggle_check_and_celebrate_returns_none_on_uncheck(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=6), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(6, -1, -1): # 오늘까지 포함해 7일 연속 체크된 상태
_check(db_session, habit.id, today - timedelta(days=offset))
# 이미 체크된 오늘을 다시 토글하면 해제되어야 하고, 마일스톤/레벨업 여부와 무관하게 None이어야 한다.
checked, milestone, level_up = log_service.toggle_check_and_celebrate(db_session, habit, today)
assert checked is False
assert milestone is None
assert level_up is None
def test_toggle_check_and_celebrate_sends_push_on_milestone(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
push_service.save_subscription(
db_session,
test_user.id,
PushSubscribeRequest(endpoint="https://push.example.com/x", keys=PushKeys(p256dh="p", auth="a")),
)
today = date.today()
created_at = datetime.combine(today - timedelta(days=6), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(6, 0, -1):
_check(db_session, habit.id, today - timedelta(days=offset))
log_service.toggle_check_and_celebrate(db_session, habit, today)
assert len(sent) == 2 # 스트릭 마일스톤 푸시 1건 + 레벨업 푸시 1건
# ---- get_today_items ----
def test_get_today_items_splits_by_type_and_checked_state(db_session, test_user):
today = date.today()
build = _make_habit(db_session, test_user.id, name="빌드", habit_type=HabitType.BUILD)
quit_ = _make_habit(db_session, test_user.id, name="퀴트", habit_type=HabitType.QUIT)
_check(db_session, build.id, today)
build_items, quit_items = log_service.get_today_items(db_session, test_user.id, today)
assert len(build_items) == 1 and build_items[0].checked is True
assert len(quit_items) == 1 and quit_items[0].checked is False
def test_get_today_items_excludes_habits_not_scheduled_today(db_session, test_user):
today = date.today()
other_day_mask = 1 << ((today.weekday() + 1) % 7) # 오늘이 아닌 요일 하나만 선택
_make_habit(db_session, test_user.id, weekdays_mask=other_day_mask)
build_items, quit_items = log_service.get_today_items(db_session, test_user.id, today)
assert build_items == []
assert quit_items == []
# ---- get_yesterday_missed_items ----
def test_get_yesterday_missed_items_includes_unchecked_scheduled_habit(db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="어제 놓친 습관")
missed = log_service.get_yesterday_missed_items(db_session, test_user.id)
assert [item.habit_id for item in missed] == [habit.id]
def test_get_yesterday_missed_items_excludes_already_checked(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
_check(db_session, habit.id, date.today() - timedelta(days=1))
missed = log_service.get_yesterday_missed_items(db_session, test_user.id)
assert missed == []
def test_get_yesterday_missed_items_excludes_habits_not_scheduled_yesterday(db_session, test_user):
yesterday = date.today() - timedelta(days=1)
other_day_mask = 1 << ((yesterday.weekday() + 1) % 7) # 어제가 아닌 요일 하나만 선택
_make_habit(db_session, test_user.id, weekdays_mask=other_day_mask)
missed = log_service.get_yesterday_missed_items(db_session, test_user.id)
assert missed == []
def test_get_yesterday_missed_items_excludes_already_failed(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
_fail(db_session, habit.id, date.today() - timedelta(days=1))
missed = log_service.get_yesterday_missed_items(db_session, test_user.id)
assert missed == []
# ---- get_habit_stats: completion_rate ----
def test_get_habit_stats_completion_rate(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=4), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
# 5일(day-4..day0) 중 4일만 체크 (day-2만 스킵)
for offset in (4, 3, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.scheduled_days == 5
assert stats.checked_days == 4
assert stats.completion_rate == 80.0
def test_get_habit_stats_does_not_count_failed_log_as_checked(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=4), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (4, 3, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
_fail(db_session, habit.id, today - timedelta(days=2)) # 실패로 확정된 날은 완료로 세지 않는다
stats = log_service.get_habit_stats(db_session, habit)
assert stats.scheduled_days == 5
assert stats.checked_days == 4
assert stats.completion_rate == 80.0
# ---- get_habit_stats: current_streak ----
def test_streak_today_unchecked_does_not_break_it(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (3, 2, 1): # 오늘(offset=0)은 의도적으로 체크 안 함
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 3
def test_streak_breaks_on_failed_day(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
_check(db_session, habit.id, today - timedelta(days=3))
_fail(db_session, habit.id, today - timedelta(days=2)) # 실패 확정은 미체크와 동일하게 스트릭을 끊는다
_check(db_session, habit.id, today - timedelta(days=1))
_check(db_session, habit.id, today)
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 2 # 어제, 오늘만 연속
def test_streak_breaks_on_past_miss(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
_check(db_session, habit.id, today - timedelta(days=3))
# day-2, day-1, 오늘 모두 미체크 -> day-1(과거)에서 스트릭이 끊긴다
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 0
def test_streak_extends_through_today_when_checked(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=2), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (2, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 3
# ---- get_habit_stats: xp/level ----
def test_get_habit_stats_xp_accumulates_per_check(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (3, 2, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.level_info.xp == 40 # 4회 체크 * XP_PER_CHECK(10), 마일스톤 미달성
def test_get_habit_stats_consecutive_misses_accelerate_xp_loss(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=9), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
# day-9 ~ day-3: 7일 연속 체크 (마일스톤 7일 보너스 +20 발생)
for offset in range(9, 2, -1):
_check(db_session, habit.id, today - timedelta(days=offset))
# day-2, day-1: 2일 연속 미체크 (오늘(offset 0)은 아직 안 지났으니 페널티 없음)
stats = log_service.get_habit_stats(db_session, habit)
# 90(=7*10+20) - 8(1회 연속 미스) - 16(2회 연속 미스, 가속) = 66
assert stats.level_info.xp == 66
def test_get_habit_stats_xp_floors_at_zero(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=5), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
# 아무것도 체크하지 않음 -> 5일 연속 미스, 페널티가 계속 누적돼도 xp는 0 밑으로 내려가지 않는다.
stats = log_service.get_habit_stats(db_session, habit)
assert stats.level_info.xp == 0
assert stats.level_info.level == 1
def test_get_habit_stats_xp_freezes_after_completion(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=10), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(10, 5, -1): # day-10 ~ day-6, 5일 연속 체크
_check(db_session, habit.id, today - timedelta(days=offset))
habit.status = HabitStatus.COMPLETED
habit.completed_at = datetime.combine(today - timedelta(days=6), datetime.min.time()) # 마지막 체크일에 완료 처리
db_session.commit()
db_session.refresh(habit)
# 완료 이후 6일(day-5 ~ 오늘)은 전부 미체크 상태로 지나갔지만, 동결되어 페널티가 반영되지 않아야 한다.
# (동결이 없었다면 6일 연속 미스로 xp가 0까지 깎였을 것)
stats = log_service.get_habit_stats(db_session, habit)
assert stats.level_info.xp == 50
# ---- get_account_level ----
def test_get_account_level_sums_xp_across_habits(db_session, test_user):
today = date.today()
habit_a = _make_habit(db_session, test_user.id, name="A", created_at=datetime.combine(today, datetime.min.time()))
habit_b = _make_habit(db_session, test_user.id, name="B", created_at=datetime.combine(today, datetime.min.time()))
_check(db_session, habit_a.id, today)
_check(db_session, habit_b.id, today)
account_level = log_service.get_account_level(db_session, test_user.id)
assert account_level.xp == 20 # 두 습관 각각 10xp
def test_get_account_level_scoped_to_user(db_session, test_user, other_user):
today = date.today()
mine = _make_habit(db_session, test_user.id, created_at=datetime.combine(today, datetime.min.time()))
_check(db_session, mine.id, today)
others = _make_habit(db_session, other_user.id, created_at=datetime.combine(today, datetime.min.time()))
_check(db_session, others.id, today)
account_level = log_service.get_account_level(db_session, test_user.id)
assert account_level.xp == 10
# ---- get_monthly_summary ----
def test_get_monthly_summary_excludes_days_before_habit_created(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 10))
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
by_date = {s.log_date: s for s in summaries}
assert by_date[date(2026, 3, 5)].scheduled_count == 0 # 생성일 이전
assert by_date[date(2026, 3, 10)].scheduled_count == 1 # 생성일 당일부터 포함
assert by_date[date(2026, 3, 20)].scheduled_count == 1
def test_get_monthly_summary_respects_weekdays_mask(db_session, test_user):
monday_only_mask = 0b0000001 # bit0 = 월요일
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1), weekdays_mask=monday_only_mask)
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
days_in_month = calendar.monthrange(2026, 3)[1]
expected_mondays = {
date(2026, 3, d) for d in range(1, days_in_month + 1) if date(2026, 3, d).weekday() == 0
}
scheduled_dates = {s.log_date for s in summaries if s.scheduled_count == 1}
assert scheduled_dates == expected_mondays
def test_get_monthly_summary_counts_checked_habits(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
_check(db_session, habit.id, date(2026, 3, 5))
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
by_date = {s.log_date: s for s in summaries}
assert by_date[date(2026, 3, 5)].checked_count == 1
assert by_date[date(2026, 3, 6)].checked_count == 0
def test_get_monthly_summary_does_not_count_failed_as_checked(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
_fail(db_session, habit.id, date(2026, 3, 5))
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
by_date = {s.log_date: s for s in summaries}
assert by_date[date(2026, 3, 5)].checked_count == 0
# ---- get_day_detail ----
def test_get_day_detail_marks_checked_and_failed(db_session, test_user):
checked_habit = _make_habit(db_session, test_user.id, name="체크됨", created_at=datetime(2026, 3, 1))
failed_habit = _make_habit(db_session, test_user.id, name="실패함", created_at=datetime(2026, 3, 1))
_check(db_session, checked_habit.id, date(2026, 3, 5))
_fail(db_session, failed_habit.id, date(2026, 3, 5))
items = log_service.get_day_detail(db_session, test_user.id, date(2026, 3, 5))
by_name = {i.name: i for i in items}
assert by_name["체크됨"].status == "checked"
assert by_name["실패함"].status == "failed"
def test_get_day_detail_marks_past_unlogged_as_missed(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
items = log_service.get_day_detail(db_session, test_user.id, date(2026, 3, 5))
assert items[0].status == "missed"
def test_get_day_detail_marks_future_unlogged_as_pending(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
future = date.today() + timedelta(days=1)
items = log_service.get_day_detail(db_session, test_user.id, future)
assert items[0].status == "pending"
def test_get_day_detail_excludes_days_before_habit_created(db_session, test_user):
_make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 10))
items = log_service.get_day_detail(db_session, test_user.id, date(2026, 3, 5))
assert items == []
def test_get_day_detail_excludes_unscheduled_weekdays(db_session, test_user):
monday_only_mask = 0b0000001 # bit0 = 월요일
_make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1), weekdays_mask=monday_only_mask)
tuesday = date(2026, 3, 3)
assert tuesday.weekday() == 1
items = log_service.get_day_detail(db_session, test_user.id, tuesday)
assert items == []
# ---- summarize_completion_rate: 미래 날짜 제외 회귀 테스트 ----
# CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다
# (실제로 6.2% -> 수정 후 50%가 된 사례).
def test_summarize_completion_rate_excludes_future_dates():
up_to = date(2026, 3, 15)
summaries = [
MonthlySummaryDay(log_date=date(2026, 3, 13), scheduled_count=1, checked_count=1),
MonthlySummaryDay(log_date=date(2026, 3, 14), scheduled_count=1, checked_count=1),
MonthlySummaryDay(log_date=date(2026, 3, 15), scheduled_count=1, checked_count=1),
# 아래 두 날짜는 미래라서 아직 체크될 수 없는데, 집계에 섞이면 완료율이 부당하게 낮아진다.
MonthlySummaryDay(log_date=date(2026, 3, 16), scheduled_count=1, checked_count=0),
MonthlySummaryDay(log_date=date(2026, 3, 17), scheduled_count=1, checked_count=0),
]
rate = log_service.summarize_completion_rate(summaries, up_to)
assert rate == 100.0
def test_summarize_completion_rate_zero_when_nothing_scheduled():
summaries = [MonthlySummaryDay(log_date=date(2026, 3, 1), scheduled_count=0, checked_count=0)]
assert log_service.summarize_completion_rate(summaries, date(2026, 3, 1)) == 0.0
# ---- get_period_completion_rate ----
def test_get_period_completion_rate_basic(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
_check(db_session, habit.id, date(2026, 3, 2))
_check(db_session, habit.id, date(2026, 3, 3))
rate, scheduled, checked = log_service.get_period_completion_rate(
db_session, test_user.id, date(2026, 3, 1), date(2026, 3, 4)
)
assert scheduled == 4
assert checked == 2
assert rate == 50.0
def test_get_period_completion_rate_excludes_days_before_habit_created(db_session, test_user):
_make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 3))
rate, scheduled, checked = log_service.get_period_completion_rate(
db_session, test_user.id, date(2026, 3, 1), date(2026, 3, 5)
)
assert scheduled == 3 # 3/3, 3/4, 3/5만 포함 (생성일 이전인 3/1, 3/2 제외)
assert checked == 0
assert rate == 0.0
def test_get_period_completion_rate_zero_when_nothing_scheduled(db_session, test_user):
rate, scheduled, checked = log_service.get_period_completion_rate(
db_session, test_user.id, date(2026, 3, 1), date(2026, 3, 5)
)
assert scheduled == 0
assert checked == 0
assert rate == 0.0
# ---- get_weekly_matrix ----
def test_get_weekly_matrix_marks_pre_creation_days_as_none(db_session, test_user):
week_start = date(2020, 1, 6) # 월요일, 확실한 과거
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 8)) # 수요일
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.checks[date(2020, 1, 6).isoformat()] is None # 생성 전(월)
assert row.checks[date(2020, 1, 7).isoformat()] is None # 생성 전(화)
assert row.checks[date(2020, 1, 8).isoformat()] is False # 생성일(수), 미체크
def test_get_weekly_matrix_treats_failed_day_as_unchecked(db_session, test_user):
week_start = date(2020, 1, 6) # 월요일, 확실한 과거
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6))
_fail(db_session, habit.id, date(2020, 1, 6))
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.checks[date(2020, 1, 6).isoformat()] is False
def test_get_weekly_matrix_completion_rate_counts_only_past_days(db_session, test_user):
week_start = date(2020, 1, 6) # 완전히 과거인 주
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6))
_check(db_session, habit.id, date(2020, 1, 6))
_check(db_session, habit.id, date(2020, 1, 7))
# 나머지 5일은 미체크
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.completion_rate == round(2 / 7 * 100, 1)
def test_get_weekly_matrix_future_week_has_zero_completion_rate(db_session, test_user):
week_start = date(2099, 1, 5) # 완전히 미래인 주
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 1))
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.completion_rate == 0.0
# ---- list_logs: 유저 스코핑 ----
def test_list_logs_scoped_to_user(db_session, test_user, other_user):
today = date.today()
mine = _make_habit(db_session, test_user.id)
others = _make_habit(db_session, other_user.id)
_check(db_session, mine.id, today)
_check(db_session, others.id, today)
logs = log_service.list_logs(db_session, test_user.id)
assert [log.habit_id for log in logs] == [mine.id]