Files
habit-tracker/tests/test_log_service.py
T
shinalokandClaude Sonnet 5 cee589bb3e Initial commit: habit tracker PWA with Google OAuth, push notifications
FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine
server-rendered frontend. Multi-user via Google OAuth, daily habit
tracking, monthly/weekly history views, Web Push reminders via
APScheduler, and PWA support (manifest, service worker, offline caching).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 18:06:17 +09:00

332 lines
13 KiB
Python

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]