import calendar from datetime import date, datetime, timedelta from app.models.habit import ALL_WEEKDAYS_MASK, HabitType from app.models.habit_log import HabitLog 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() # ---- 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 # ---- 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 = log_service.toggle_check_and_celebrate(db_session, habit, today) assert checked is True assert milestone == 7 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 = log_service.toggle_check_and_celebrate(db_session, habit, date.today()) assert checked is True assert milestone 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 = log_service.toggle_check_and_celebrate(db_session, habit, today) assert checked is False assert milestone 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) == 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_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 # ---- 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_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_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 # ---- 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_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]