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>
This commit is contained in:
2026-07-16 18:06:17 +09:00
co-authored by Claude Sonnet 5
commit cee589bb3e
80 changed files with 4695 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.main import app
from app.models.user import User
from app.security import create_session_token
from app.config import settings
@pytest.fixture()
def engine():
eng = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@event.listens_for(eng, "connect")
def _enable_fk(dbapi_connection, _):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(eng)
yield eng
Base.metadata.drop_all(eng)
eng.dispose()
@pytest.fixture()
def db_session(engine):
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = session_factory()
try:
yield session
finally:
session.close()
@pytest.fixture()
def client(db_session):
def _override_get_db():
yield db_session
app.dependency_overrides[get_db] = _override_get_db
# TestClient를 컨텍스트 매니저(`with`)로 쓰지 않으므로 lifespan(스케줄러 기동)이 실행되지 않는다 —
# 테스트가 실제 운영 MariaDB에 붙는 APScheduler 백그라운드 잡을 우연히 건드리지 않게 하기 위함.
test_client = TestClient(app)
yield test_client
app.dependency_overrides.clear()
@pytest.fixture()
def test_user(db_session):
user = User(google_sub="test-sub-1", email="tester@example.com", name="테스터")
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture()
def other_user(db_session):
user = User(google_sub="test-sub-2", email="other@example.com", name="다른유저")
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture()
def auth_client(client, test_user):
token = create_session_token(test_user.id)
client.cookies.set(settings.session_cookie_name, token)
return client
+81
View File
@@ -0,0 +1,81 @@
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.schemas.habit import HabitCreate
from app.services import habit_service
def _make_habit(db_session, user_id, name="테스트 습관"):
data = HabitCreate(name=name, habit_type=HabitType.BUILD, weekdays_mask=ALL_WEEKDAYS_MASK)
return habit_service.create_habit(db_session, user_id, data)
def test_list_habits_requires_login(client):
response = client.get("/api/habits")
assert response.status_code == 401
def test_create_and_list_habit(auth_client):
response = auth_client.post(
"/api/habits", json={"name": "아침 운동", "habit_type": "build", "weekdays_mask": ALL_WEEKDAYS_MASK}
)
assert response.status_code == 201
created = response.json()
assert created["name"] == "아침 운동"
listed = auth_client.get("/api/habits").json()
assert [h["name"] for h in listed] == ["아침 운동"]
def test_create_habit_rejects_blank_name(auth_client):
response = auth_client.post(
"/api/habits", json={"name": " ", "habit_type": "build", "weekdays_mask": ALL_WEEKDAYS_MASK}
)
assert response.status_code == 422
def test_get_other_users_habit_returns_404(auth_client, db_session, other_user):
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
response = auth_client.get(f"/api/habits/{others_habit.id}")
assert response.status_code == 404
def test_delete_other_users_habit_returns_404_and_does_not_delete(auth_client, db_session, other_user):
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
response = auth_client.delete(f"/api/habits/{others_habit.id}")
assert response.status_code == 404
assert habit_service.get_habit(db_session, others_habit.id, other_user.id) is not None
def test_update_own_habit(auth_client, db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="원래 이름")
response = auth_client.put(
f"/api/habits/{habit.id}",
json={"name": "새 이름", "habit_type": "build", "weekdays_mask": ALL_WEEKDAYS_MASK},
)
assert response.status_code == 200
assert response.json()["name"] == "새 이름"
def test_complete_and_reactivate_habit(auth_client, db_session, test_user):
habit = _make_habit(db_session, test_user.id)
complete_res = auth_client.post(f"/api/habits/{habit.id}/complete")
assert complete_res.status_code == 200
assert complete_res.json()["status"] == "completed"
reactivate_res = auth_client.post(f"/api/habits/{habit.id}/reactivate")
assert reactivate_res.status_code == 200
assert reactivate_res.json()["status"] == "active"
def test_reorder_endpoint(auth_client, db_session, test_user):
a = _make_habit(db_session, test_user.id, name="A")
b = _make_habit(db_session, test_user.id, name="B")
response = auth_client.post("/api/habits/reorder", json={"habit_ids": [b.id, a.id]})
assert response.status_code == 200
listed = auth_client.get("/api/habits").json()
assert [h["name"] for h in listed] == ["B", "A"]
+133
View File
@@ -0,0 +1,133 @@
from datetime import date, time
from app.models.habit import ALL_WEEKDAYS_MASK, Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.schemas.habit import HabitCreate, HabitUpdate
from app.services import habit_service
def _make_habit(db_session, user_id, name="아침 일찍 일어나기", **overrides):
data = HabitCreate(
name=name,
habit_type=overrides.pop("habit_type", HabitType.BUILD),
weekdays_mask=overrides.pop("weekdays_mask", ALL_WEEKDAYS_MASK),
condition_text=overrides.pop("condition_text", None),
reminder_time=overrides.pop("reminder_time", None),
)
return habit_service.create_habit(db_session, user_id, data)
def test_create_habit_defaults_to_active(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
assert habit.status == HabitStatus.ACTIVE
assert habit.user_id == test_user.id
assert habit.id is not None
def test_list_habits_scoped_to_user(db_session, test_user, other_user):
_make_habit(db_session, test_user.id, name="내 습관")
_make_habit(db_session, other_user.id, name="남의 습관")
mine = habit_service.list_habits(db_session, test_user.id)
assert [h.name for h in mine] == ["내 습관"]
def test_get_habit_returns_none_for_other_users_habit(db_session, test_user, other_user):
other_habit = _make_habit(db_session, other_user.id, name="남의 습관")
assert habit_service.get_habit(db_session, other_habit.id, test_user.id) is None
assert habit_service.get_habit(db_session, other_habit.id, other_user.id) is not None
def test_list_habits_filters_by_type_and_status(db_session, test_user):
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)
habit_service.complete_habit(db_session, quit_)
active_builds = habit_service.list_habits(db_session, test_user.id, habit_type=HabitType.BUILD)
assert [h.id for h in active_builds] == [build.id]
completed = habit_service.list_habits(db_session, test_user.id, status=HabitStatus.COMPLETED)
assert [h.id for h in completed] == [quit_.id]
def test_update_habit_overwrites_fields(db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="원래 이름")
updated = habit_service.update_habit(
db_session,
habit,
HabitUpdate(name="바뀐 이름", habit_type=HabitType.BUILD, weekdays_mask=0b0000001, condition_text=" "),
)
assert updated.name == "바뀐 이름"
assert updated.weekdays_mask == 0b0000001
assert updated.condition_text is None # blank_condition_to_none 검증기 통과 확인
def test_delete_habit_removes_row_and_cascades_logs(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
db_session.add(HabitLog(habit_id=habit.id, log_date=date.today()))
db_session.commit()
habit_service.delete_habit(db_session, habit)
assert db_session.get(Habit, habit.id) is None
assert db_session.query(HabitLog).filter_by(habit_id=habit.id).count() == 0
def test_complete_and_reactivate_habit(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
completed = habit_service.complete_habit(db_session, habit)
assert completed.status == HabitStatus.COMPLETED
assert completed.completed_at is not None
reactivated = habit_service.reactivate_habit(db_session, habit)
assert reactivated.status == HabitStatus.ACTIVE
assert reactivated.completed_at is None
def test_reorder_habits_applies_given_order(db_session, test_user):
a = _make_habit(db_session, test_user.id, name="A")
b = _make_habit(db_session, test_user.id, name="B")
c = _make_habit(db_session, test_user.id, name="C")
habit_service.reorder_habits(db_session, test_user.id, [c.id, a.id, b.id])
ordered = habit_service.list_habits(db_session, test_user.id)
assert [h.name for h in ordered] == ["C", "A", "B"]
def test_reorder_habits_ignores_other_users_ids(db_session, test_user, other_user):
mine = _make_habit(db_session, test_user.id, name="내 것")
others = _make_habit(db_session, other_user.id, name="남의 것")
# 남의 habit_id가 섞여 들어와도 그 습관의 sort_order는 바뀌지 않아야 한다 (IDOR 방지).
habit_service.reorder_habits(db_session, test_user.id, [others.id, mine.id])
assert others.sort_order is None
assert mine.sort_order is not None
def test_list_active_habits_with_reminders_is_not_user_scoped(db_session, test_user, other_user):
with_reminder = _make_habit(db_session, test_user.id, name="알림 있음", reminder_time=time(9, 0))
_make_habit(db_session, other_user.id, name="알림 없음")
result = habit_service.list_active_habits_with_reminders(db_session)
assert [h.id for h in result] == [with_reminder.id]
def test_list_active_user_ids_is_not_user_scoped(db_session, test_user, other_user):
_make_habit(db_session, test_user.id)
_make_habit(db_session, other_user.id)
result = habit_service.list_active_user_ids(db_session)
assert set(result) == {test_user.id, other_user.id}
def test_list_active_user_ids_excludes_completed_only_users(db_session, test_user, other_user):
_make_habit(db_session, test_user.id)
completed_only = _make_habit(db_session, other_user.id)
habit_service.complete_habit(db_session, completed_only)
result = habit_service.list_active_user_ids(db_session)
assert result == [test_user.id]
+331
View File
@@ -0,0 +1,331 @@
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]
+40
View File
@@ -0,0 +1,40 @@
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.schemas.habit import HabitCreate
from app.services import habit_service
def _make_habit(db_session, user_id, name="아침 운동"):
data = HabitCreate(name=name, habit_type=HabitType.BUILD, weekdays_mask=ALL_WEEKDAYS_MASK)
return habit_service.create_habit(db_session, user_id, data)
def test_today_page_redirects_to_login_when_not_authenticated(client):
response = client.get("/today", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_today_page_shows_created_habit(auth_client, db_session, test_user):
_make_habit(db_session, test_user.id, name="아침 운동")
response = auth_client.get("/today")
assert response.status_code == 200
assert "아침 운동" in response.text
def test_toggle_today_page_updates_checked_state(auth_client, db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="아침 운동")
response = auth_client.post(f"/today/{habit.id}/toggle")
assert response.status_code == 200
# 서버가 실제로 로그를 남겼는지 today API로 재확인
today_items = auth_client.get("/api/today").json()
assert today_items[0]["checked"] is True
def test_toggle_today_page_for_other_users_habit_returns_404(auth_client, db_session, other_user):
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
response = auth_client.post(f"/today/{others_habit.id}/toggle")
assert response.status_code == 404
+79
View File
@@ -0,0 +1,79 @@
from pywebpush import WebPushException
from app.models.push_subscription import PushSubscription
from app.schemas.push import PushKeys, PushSubscribeRequest
from app.services import push_service
class _FakeResponse:
def __init__(self, status_code):
self.status_code = status_code
def _subscribe_request(endpoint="https://push.example.com/1"):
return PushSubscribeRequest(endpoint=endpoint, keys=PushKeys(p256dh="p256dh-key", auth="auth-key"))
# ---- save_subscription ----
def test_save_subscription_creates_new_row(db_session, test_user):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
assert sub.user_id == test_user.id
assert sub.endpoint == "https://push.example.com/1"
def test_save_subscription_reassigns_existing_endpoint_to_new_user(db_session, test_user, other_user):
request = _subscribe_request()
push_service.save_subscription(db_session, other_user.id, request)
# 같은 기기(endpoint)에서 다른 유저(test_user)로 재구독하면 소유자가 갱신되어야 한다.
updated = push_service.save_subscription(db_session, test_user.id, request)
assert updated.user_id == test_user.id
assert db_session.query(PushSubscription).filter_by(endpoint=request.endpoint).count() == 1
def test_delete_subscription_removes_row(db_session, test_user):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
push_service.delete_subscription(db_session, sub.endpoint)
assert db_session.query(PushSubscription).filter_by(endpoint=sub.endpoint).count() == 0
# ---- send_to_user ----
def test_send_to_user_counts_successful_sends(db_session, test_user, monkeypatch):
push_service.save_subscription(db_session, test_user.id, _subscribe_request("https://push.example.com/a"))
push_service.save_subscription(db_session, test_user.id, _subscribe_request("https://push.example.com/b"))
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: None)
sent = push_service.send_to_user(db_session, test_user.id, title="제목", body="본문")
assert sent == 2
def test_send_to_user_deletes_expired_subscription_on_410(db_session, test_user, monkeypatch):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
def _raise_gone(**kwargs):
raise WebPushException("gone", response=_FakeResponse(410))
monkeypatch.setattr(push_service, "webpush", _raise_gone)
sent = push_service.send_to_user(db_session, test_user.id, title="제목", body="본문")
assert sent == 0
assert db_session.query(PushSubscription).filter_by(id=sub.id).count() == 0
def test_send_to_user_keeps_subscription_on_other_errors(db_session, test_user, monkeypatch):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
def _raise_server_error(**kwargs):
raise WebPushException("server error", response=_FakeResponse(500))
monkeypatch.setattr(push_service, "webpush", _raise_server_error)
sent = push_service.send_to_user(db_session, test_user.id, title="제목", body="본문")
assert sent == 0
assert db_session.query(PushSubscription).filter_by(id=sub.id).count() == 1
+110
View File
@@ -0,0 +1,110 @@
from datetime import date, datetime, timedelta
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.models.notification_log import SummaryNotificationLog
from app.schemas.habit import HabitCreate
from app.schemas.push import PushKeys, PushSubscribeRequest
from app.services import habit_service, push_service, scheduler_service
# scheduler_service._tick/_weekly_summary_tick/_monthly_summary_tick은 자체적으로
# app.database.SessionLocal()을 열어 실제 운영 DB에 붙으므로(conftest의 client 픽스처가 lifespan을
# 건너뛰는 이유와 동일) 여기서는 db_session을 직접 주입할 수 있는 _send_period_summaries/
# _claim_summary_slot만 단위 테스트한다.
def _make_habit(db_session, user_id, created_at=None, **overrides):
data = HabitCreate(
name=overrides.pop("name", "테스트 습관"),
habit_type=overrides.pop("habit_type", HabitType.BUILD),
weekdays_mask=overrides.pop("weekdays_mask", ALL_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 _subscribe(db_session, user_id, endpoint):
push_service.save_subscription(
db_session, user_id, PushSubscribeRequest(endpoint=endpoint, keys=PushKeys(p256dh="p", auth="a"))
)
# ---- _claim_summary_slot ----
def test_claim_summary_slot_prevents_duplicate(db_session, test_user):
today = date.today()
assert scheduler_service._claim_summary_slot(db_session, test_user.id, "weekly", today) is True
assert scheduler_service._claim_summary_slot(db_session, test_user.id, "weekly", today) is False
assert db_session.query(SummaryNotificationLog).filter_by(user_id=test_user.id).count() == 1
# ---- _send_period_summaries ----
def test_send_period_summaries_sends_push_and_claims_slot(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
_subscribe(db_session, test_user.id, "https://push.example.com/x")
today = date.today()
_make_habit(db_session, test_user.id, created_at=datetime.combine(today, datetime.min.time()))
scheduler_service._send_period_summaries(
db_session,
period_type="weekly",
period_start=today,
range_start=today,
range_end=today,
title="이번 주 습관 리포트",
url="/history",
)
assert len(sent) == 1
assert (
db_session.query(SummaryNotificationLog).filter_by(user_id=test_user.id, period_type="weekly").count() == 1
)
def test_send_period_summaries_skips_when_nothing_scheduled_in_range(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
_subscribe(db_session, test_user.id, "https://push.example.com/y")
today = date.today()
# 습관이 range_end 이후에 생성되어, 요청한 기간에는 예정된 게 하나도 없다.
_make_habit(db_session, test_user.id, created_at=datetime.combine(today + timedelta(days=1), datetime.min.time()))
scheduler_service._send_period_summaries(
db_session, period_type="weekly", period_start=today, range_start=today, range_end=today, title="t", url="/h"
)
assert sent == []
assert db_session.query(SummaryNotificationLog).count() == 0
def test_send_period_summaries_does_not_resend_when_already_claimed(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
_subscribe(db_session, test_user.id, "https://push.example.com/z")
today = date.today()
_make_habit(db_session, test_user.id, created_at=datetime.combine(today, datetime.min.time()))
for _ in range(2):
scheduler_service._send_period_summaries(
db_session,
period_type="weekly",
period_start=today,
range_start=today,
range_end=today,
title="t",
url="/h",
)
assert len(sent) == 1
+51
View File
@@ -0,0 +1,51 @@
import pytest
from pydantic import ValidationError
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.schemas.habit import HabitCreate
def _base_kwargs(**overrides):
kwargs = {"name": "습관", "habit_type": HabitType.BUILD}
kwargs.update(overrides)
return kwargs
def test_blank_name_rejected():
with pytest.raises(ValidationError):
HabitCreate(**_base_kwargs(name=" "))
def test_name_is_stripped():
habit = HabitCreate(**_base_kwargs(name=" 아침 운동 "))
assert habit.name == "아침 운동"
def test_blank_condition_text_becomes_none():
habit = HabitCreate(**_base_kwargs(condition_text=" "))
assert habit.condition_text is None
def test_condition_text_is_stripped_when_present():
habit = HabitCreate(**_base_kwargs(condition_text=" 30분 이상 "))
assert habit.condition_text == "30분 이상"
def test_weekdays_mask_zero_rejected():
with pytest.raises(ValidationError):
HabitCreate(**_base_kwargs(weekdays_mask=0))
def test_weekdays_mask_over_max_rejected():
with pytest.raises(ValidationError):
HabitCreate(**_base_kwargs(weekdays_mask=ALL_WEEKDAYS_MASK + 1))
def test_weekdays_mask_single_day_accepted():
habit = HabitCreate(**_base_kwargs(weekdays_mask=0b0000001))
assert habit.weekdays_mask == 0b0000001
def test_weekdays_mask_defaults_to_all_days():
habit = HabitCreate(**_base_kwargs())
assert habit.weekdays_mask == ALL_WEEKDAYS_MASK
+31
View File
@@ -0,0 +1,31 @@
from app.models.habit import ALL_WEEKDAYS_MASK
from app.template_utils import heatmap_opacity, weekday_label
def test_weekday_label_all_days_shows_daily():
assert weekday_label(ALL_WEEKDAYS_MASK) == "매일"
def test_weekday_label_lists_selected_days_in_order():
monday_and_wednesday = 0b0000001 | 0b0000100
assert weekday_label(monday_and_wednesday) == "월, 수"
def test_weekday_label_no_days_selected():
assert weekday_label(0) == "선택된 요일 없음"
def test_heatmap_opacity_zero_when_nothing_scheduled():
assert heatmap_opacity(checked_count=0, scheduled_count=0) == 0.0
def test_heatmap_opacity_zero_when_nothing_checked():
assert heatmap_opacity(checked_count=0, scheduled_count=5) == 0.0
def test_heatmap_opacity_full_ratio_caps_at_point_nine():
assert heatmap_opacity(checked_count=5, scheduled_count=5) == 0.9
def test_heatmap_opacity_partial_ratio():
assert heatmap_opacity(checked_count=1, scheduled_count=2) == round(0.12 + 0.5 * 0.78, 2)