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:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user