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 # ---- 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]