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>
332 lines
13 KiB
Python
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]
|