From 837b7cb8c0fa51a44080650a77cfc84989141724 Mon Sep 17 00:00:00 2001 From: ghostfaccee Date: Fri, 10 Jul 2026 13:29:50 +0900 Subject: [PATCH 1/4] feat(database): Added the ability to manage migrations using alembic --- .github/workflows/ci.yml | 2 +- .github/workflows/docker.yml | 2 +- .gitignore | 3 +- alembic.ini | 149 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 94 +++++++++++ alembic/script.py.mako | 28 ++++ .../versions/ff3e014eb17f_init_migration.py | 32 ++++ app/models/habit.py | 5 +- app/models/users.py | 13 ++ docker-compose.yml | 2 +- readme.md | 19 ++- requirements.txt | 2 + 13 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/ff3e014eb17f_init_migration.py create mode 100644 app/models/users.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88a8187..5ad468c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ main, dev ] + branches: [ main, dev, feat-jwt ] pull_request: branches: [ main ] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5d006cf..90133fb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,7 +2,7 @@ name: Docker Image on: push: - branches: [ "main", "dev" ] + branches: [ "main", "dev", "feat-jwt" ] pull_request: branches: [ "main" ] diff --git a/.gitignore b/.gitignore index d05c75e..af34ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__ .env check-list-docker.md ideas.md -my_notes.md \ No newline at end of file +my_notes.md +alembic-tutorial.md diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d1d81ed --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..01cbc4a --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,94 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +from app.core.config import settings + +config.set_main_option('sqlalchemy.url', settings.DATABASE_URL) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from app.core.database import Base +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/ff3e014eb17f_init_migration.py b/alembic/versions/ff3e014eb17f_init_migration.py new file mode 100644 index 0000000..5b494b1 --- /dev/null +++ b/alembic/versions/ff3e014eb17f_init_migration.py @@ -0,0 +1,32 @@ +"""init migration + +Revision ID: ff3e014eb17f +Revises: +Create Date: 2026-07-10 04:14:32.260696 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ff3e014eb17f' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/app/models/habit.py b/app/models/habit.py index 23eff18..a64dd88 100644 --- a/app/models/habit.py +++ b/app/models/habit.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy.sql import func from sqlalchemy.orm import relationship from app.core.database import Base @@ -7,9 +7,10 @@ class Habit(Base): __tablename__ = 'habits' id = Column(Integer, primary_key = True, index = True) + user_id = Column(Integer, ForeignKey('users.id'), nullable = False) name = Column(String(50), nullable = False) description = Column(String(100), nullable = True) created_at = Column(DateTime(timezone = True), server_default = func.now()) logs = relationship('Log', back_populates = 'habit', cascade = 'all, delete-orphan') - + user = relationship('User', back_populates = 'habits') diff --git a/app/models/users.py b/app/models/users.py new file mode 100644 index 0000000..f0c0896 --- /dev/null +++ b/app/models/users.py @@ -0,0 +1,13 @@ +from app.core.database import Base +from sqlalchemy import Column, Integer, Boolean, String +from sqlalchemy.orm import relationship + +class User(Base): + __tablename__ = 'users' + + id = Column(Integer, primary_key = True, index = True) + username = Column(String(30), unique = True, index = True, nullable = False) + email = Column(String(255), unique = True, nullable = True) + hashed_pass = Column(String(255), nullable = False) + + habits = relationship('Habit', back_populates = 'user', cascade = 'all, delete-orphan') \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index de3c198..7cc9439 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: - DATABASE_URL=${DATABASE_URL} - DEBUG=${DEBUG} volumes: - - ./app:/app/app + - .:/app command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload restart: unless-stopped diff --git a/readme.md b/readme.md index 0f5ad43..d6a4b07 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,24 @@ API for tracking habits. Written in FastAPI with asynchronous database operation ``` git clone https://github.com/ghostfaccee/TrackIt-API.git cd TrackIt-API -docker compose up --build +docker compose up --build -d +``` + +### Applying new migrations +``` +docker compose exec api alembic upgrade head +``` + +### Stop container +``` +docker compose down +``` + +### Change in the project (for developers) +If you decide to add changes to the tables yourself or add new ones, create your own migration using alembic inside the docker container +``` +docker compose exec api alembic revision --autogenerate -m "describe your changes" +docker compose exec api alembic upgrade head ``` **You can view the environment variables in the .env.example file.** diff --git a/requirements.txt b/requirements.txt index 631a8fd..c1ecbd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiosqlite==0.22.1 +alembic==1.18.5 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.14.1 @@ -18,6 +19,7 @@ iniconfig==2.3.0 Jinja2==3.1.6 limits==5.8.0 loguru==0.7.3 +Mako==1.3.12 MarkupSafe==3.0.3 packaging==26.2 pendulum==3.2.0 From 6d1db3abc015cf214f021b454c54a42f6a7862af Mon Sep 17 00:00:00 2001 From: ghostfaccee Date: Fri, 10 Jul 2026 13:56:53 +0900 Subject: [PATCH 2/4] refactor --- app/models/users.py | 2 +- app/repositories/log_repository.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/users.py b/app/models/users.py index f0c0896..5fa2c05 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -10,4 +10,4 @@ class User(Base): email = Column(String(255), unique = True, nullable = True) hashed_pass = Column(String(255), nullable = False) - habits = relationship('Habit', back_populates = 'user', cascade = 'all, delete-orphan') \ No newline at end of file + habits = relationship('Habit', back_populates = 'user', cascade = 'all, delete-orphan') diff --git a/app/repositories/log_repository.py b/app/repositories/log_repository.py index 090a25a..b907420 100644 --- a/app/repositories/log_repository.py +++ b/app/repositories/log_repository.py @@ -1,6 +1,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from typing import List, Optional +from typing import Optional from app.models.log import Log from app.schemas.log import LogCreate From 3d7815c0f02f32e17fea29f629c391bd724e6b2e Mon Sep 17 00:00:00 2001 From: ghostfaccee Date: Mon, 13 Jul 2026 13:02:09 +0900 Subject: [PATCH 3/4] feat(api): JWT authorization added --- .env.example | 6 +++- .gitignore | 2 ++ app/api/v1/__init__.py | 4 ++- app/api/v1/endpoints/auth.py | 20 +++++++++++++ app/api/v1/endpoints/habits.py | 23 ++++++++------- app/api/v1/endpoints/log.py | 10 ++++--- app/api/v1/endpoints/stats.py | 6 ++-- app/core/config.py | 7 +++++ app/core/exceptions.py | 23 +++++++++++++++ app/core/security.py | 26 ++++++++++++++++ app/dependencies.py | 21 +++++++++++++ app/repositories/habit_repository.py | 20 ++++++------- app/repositories/user_repository.py | 26 ++++++++++++++++ app/schemas/auth.py | 19 ++++++++++++ app/services/auth_service.py | 40 +++++++++++++++++++++++++ app/services/habit_service.py | 22 +++++++------- app/services/log_service.py | 14 +++++---- app/services/stats_service.py | 4 +-- app/services/users_service.py | 16 ++++++++++ requirements.txt | 11 +++++++ tests/conftest.py | 44 +++++++++++++++++++++++++++- tests/integration/test_habits_api.py | 32 ++++++++++---------- tests/integration/test_log_api.py | 20 ++++++------- tests/integration/test_stats_api.py | 12 ++++---- tests/unit/test_habit_service.py | 32 ++++++++++---------- tests/unit/test_log_service.py | 17 ++++++----- tests/unit/test_stats_service.py | 19 ++++++------ 27 files changed, 384 insertions(+), 112 deletions(-) create mode 100644 app/api/v1/endpoints/auth.py create mode 100644 app/core/security.py create mode 100644 app/repositories/user_repository.py create mode 100644 app/schemas/auth.py create mode 100644 app/services/auth_service.py create mode 100644 app/services/users_service.py diff --git a/.env.example b/.env.example index ced86d9..8b5fd61 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,8 @@ REDIS_URL = DEBUG = -SLOW_REQUEST_THRESHOLD = \ No newline at end of file +SLOW_REQUEST_THRESHOLD = + +SECRET_KEY = +ALGORITHM = +ACCESS_TOKEN_EXPIRE_MINUTES = <30/40/60> \ No newline at end of file diff --git a/.gitignore b/.gitignore index af34ec3..0ee39a2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ check-list-docker.md ideas.md my_notes.md alembic-tutorial.md +how-to-add-jwt.md +pytest.md \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index d38ad8f..786fdc1 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -2,6 +2,7 @@ from app.api.v1.endpoints.habits import router as router_habits from app.api.v1.endpoints.stats import router as router_stats from app.api.v1.endpoints.log import router as router_log +from app.api.v1.endpoints.auth import router as router_auth router = APIRouter() @router.get('/') @@ -9,4 +10,5 @@ async def main_page(): return {'Welcome to': 'the main page'} router.include_router(router_habits) router.include_router(router_stats) -router.include_router(router_log) \ No newline at end of file +router.include_router(router_log) +router.include_router(router_auth) \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..2c7bb33 --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends, Request +from app.dependencies import get_auth_service +from fastapi import status +from app.middlewares.rate_limit.limiter import RateLimit +from app.schemas.auth import UserRegister, UserLogin +from app.services.auth_service import AuthService +from app.schemas.auth import TokenResponse, UserResponse + +router = APIRouter() +limiter = RateLimit.get_limiter() + +@router.post('/auth/register', response_model = UserResponse, status_code = status.HTTP_201_CREATED) +@limiter.limit('5/minute') +async def register(request: Request, data: UserRegister, service: AuthService = Depends(get_auth_service)): + return await service.register(data) + +@router.post('/auth/login', response_model = TokenResponse) +@limiter.limit('5/minute') +async def login(request: Request, data: UserLogin, service: AuthService = Depends(get_auth_service)): + return await service.login(data) \ No newline at end of file diff --git a/app/api/v1/endpoints/habits.py b/app/api/v1/endpoints/habits.py index 20f8f5b..6ccb0ff 100644 --- a/app/api/v1/endpoints/habits.py +++ b/app/api/v1/endpoints/habits.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends, status, Request from typing import List -from app.dependencies import get_habit_service +from app.dependencies import get_habit_service, get_current_user from app.services.habit_service import HabitService from app.schemas.habit import HabitCreate, HabitUpdate, HabitResponse from app.middlewares.rate_limit.limiter import RateLimit from app.infrastructure.redis.cache import CacheService +from app.models.users import User router = APIRouter() limiter = RateLimit.get_limiter() @@ -12,31 +13,31 @@ @router.get('/habits', response_model = List[HabitResponse]) @CacheService.cached(expire = 50) @limiter.limit('15/minute') -async def get_all(request: Request, service: HabitService = Depends(get_habit_service)): - return await service.get_all() +async def get_all(request: Request, user: User = Depends(get_current_user), service: HabitService = Depends(get_habit_service)): + return await service.get_all(user.id) @router.post('/habit/create', response_model = HabitResponse, status_code = status.HTTP_201_CREATED) @limiter.limit('15/minute') -async def create(request: Request, data: HabitCreate, service: HabitService = Depends(get_habit_service)): +async def create(request: Request, data: HabitCreate, user: User = Depends(get_current_user), service: HabitService = Depends(get_habit_service)): await CacheService.clear() - return await service.create(data) + return await service.create(user.id, data) @router.get('/habit/{habit_id}', response_model = HabitResponse) @CacheService.cached(expire = 50) @limiter.limit('15/minute') -async def get_by_id(request: Request, habit_id: int, service: HabitService = Depends(get_habit_service)): - return await service.get_by_id(habit_id) +async def get_by_id(request: Request, habit_id: int, user: User = Depends(get_current_user), service: HabitService = Depends(get_habit_service)): + return await service.get_by_id(user.id, habit_id) @router.delete('/habit/{habit_id}', status_code = status.HTTP_204_NO_CONTENT) @limiter.limit('15/minute') -async def delete_by_id(request: Request, habit_id: int, service: HabitService = Depends(get_habit_service)): - await service.delete(habit_id) +async def delete_by_id(request: Request, habit_id: int, user: User = Depends(get_current_user), service: HabitService = Depends(get_habit_service)): + await service.delete(user.id, habit_id) await CacheService.clear() return None @router.put('/habit/{habit_id}', response_model = HabitResponse) @limiter.limit('15/minute') -async def update(request: Request, habit_id: int, data: HabitUpdate, service: HabitService = Depends(get_habit_service)): +async def update(request: Request, habit_id: int, data: HabitUpdate, user: User = Depends(get_current_user), service: HabitService = Depends(get_habit_service)): await CacheService.clear() - return await service.update(habit_id, data) + return await service.update(user.id, habit_id, data) diff --git a/app/api/v1/endpoints/log.py b/app/api/v1/endpoints/log.py index 36e3c03..9b5f61d 100644 --- a/app/api/v1/endpoints/log.py +++ b/app/api/v1/endpoints/log.py @@ -4,17 +4,19 @@ from app.schemas.log import LogCreate from app.schemas.log import LogResponse from app.middlewares.rate_limit.limiter import RateLimit +from app.dependencies import get_current_user +from app.models.users import User router = APIRouter() limiter = RateLimit.get_limiter() @router.post('/log/{habit_id}', response_model = LogResponse, status_code = status.HTTP_201_CREATED) @limiter.limit('1/day') -async def create(request: Request, habit_id: int, data: LogCreate, service: LogService = Depends(get_log_service)): - return await service.create(habit_id, data) +async def create(request: Request, habit_id: int, data: LogCreate, user: User = Depends(get_current_user), service: LogService = Depends(get_log_service)): + return await service.create(user.id, habit_id, data) @router.delete('/log/{log_id}', status_code = status.HTTP_204_NO_CONTENT) @limiter.limit('15/minute') -async def delete(request: Request, log_id: int, service: LogService = Depends(get_log_service)): - await service.delete(log_id) +async def delete(request: Request, log_id: int, user: User = Depends(get_current_user), service: LogService = Depends(get_log_service)): + await service.delete(user.id, log_id) return None diff --git a/app/api/v1/endpoints/stats.py b/app/api/v1/endpoints/stats.py index 410f771..6b9ee8d 100644 --- a/app/api/v1/endpoints/stats.py +++ b/app/api/v1/endpoints/stats.py @@ -4,6 +4,8 @@ from app.schemas.stats import StatsResponse from app.middlewares.rate_limit.limiter import RateLimit from app.infrastructure.redis.cache import CacheService +from app.dependencies import get_current_user +from app.models.users import User router = APIRouter() limiter = RateLimit.get_limiter() @@ -11,5 +13,5 @@ @router.get('/stats/{habit_id}', response_model = StatsResponse) @CacheService.cached(expire = 50) @limiter.limit('15/minute') -async def get(request: Request, habit_id: int, service: StatsService = Depends(get_stats_service)): - return await service.get_stats(habit_id) \ No newline at end of file +async def get(request: Request, habit_id: int, user: User = Depends(get_current_user), service: StatsService = Depends(get_stats_service)): + return await service.get_stats(user.id, habit_id) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 277aecd..a213a97 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,10 +6,17 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str POSTGRES_DB: str DATABASE_URL: str + REDIS_URL: str + SLOW_REQUEST_THRESHOLD: float + DEBUG: bool = True + SECRET_KEY: str + ALGORITHM: str + ACCESS_TOKEN_EXPIRE_MINUTES: int + model_config = ConfigDict(env_file = '.env', case_sensitive = True) settings = Settings() diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 4321347..71f32e7 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -12,3 +12,26 @@ class LogNotFoundError(HTTPException): def __init__(self, log_id: int): return super().__init__(status_code = status.HTTP_404_NOT_FOUND, detail = f'Log with id {log_id} not found') +class UsernameExistsError(HTTPException): + def __init__(self, username: str): + return super().__init__(status_code = status.HTTP_400_BAD_REQUEST, detail = f'Username \"{username}\" already exists') + +class UserNotFoundError(HTTPException): + def __init__(self): + return super().__init__(status_code = status.HTTP_404_NOT_FOUND, detail = 'User not found') + +class EmailExistsError(HTTPException): + def __init__(self, email: str): + return super().__init__(status_code = status.HTTP_400_BAD_REQUEST, datail = f'Email {email} already exists') + +class InvalidCredentialsError(HTTPException): + def __init__(self): + return super().__init__(status_code = status.HTTP_401_UNAUTHORIZED, detail = 'Invalid credentials') + +class CouldNotValidateCredentialsError(HTTPException): + def __init__(self): + return super().__init__(status_code = status.HTTP_401_UNAUTHORIZED, deatail = 'Could not validate credentials', headers = {'Authenticate': 'Bearer'}) + +class PermissionDeniedError(HTTPException): + def __init__(self): + return super().__init__(status_code = status.HTTP_403_FORBIDDEN, detail = 'Permission denied') \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..28d6e46 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,26 @@ +from jose import JWTError, jwt +from datetime import datetime, timedelta +from app.core.config import settings +from typing import Optional +import bcrypt + +# passwords +def hash_password(password: str) -> str: + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + +# jwt +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now() + timedelta(minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({'exp' : expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm = settings.ALGORITHM) + +def decode_token(token: str) -> Optional[dict]: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms = [settings.ALGORITHM]) + except JWTError: + return None diff --git a/app/dependencies.py b/app/dependencies.py index 094d238..c1a9ff7 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,9 +1,15 @@ from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.core.exceptions import CouldNotValidateCredentialsError +from app.core.security import decode_token from app.services.habit_service import HabitService from app.services.log_service import LogService from app.services.stats_service import StatsService +from app.services.auth_service import AuthService +from app.services.users_service import UserService +from app.models.users import User async def get_habit_service(db: AsyncSession = Depends(get_db)) -> HabitService: return HabitService(db) @@ -14,3 +20,18 @@ async def get_log_service(db: AsyncSession = Depends(get_db)) -> LogService: async def get_stats_service(db: AsyncSession = Depends(get_db)) -> StatsService: return StatsService(db) +async def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService: + return AuthService(db) + +async def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService: + return UserService(db) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl = '/v1/auth/login') +async def get_current_user(token: str = Depends(oauth2_scheme), service: UserService = Depends(get_user_service)) -> User: + payload = decode_token(token) + if not payload: + raise CouldNotValidateCredentialsError() + user_id = payload.get('sub') + if not user_id: + raise CouldNotValidateCredentialsError() + return await service.get_by_id(int(user_id)) diff --git a/app/repositories/habit_repository.py b/app/repositories/habit_repository.py index b1e258e..1d1370f 100644 --- a/app/repositories/habit_repository.py +++ b/app/repositories/habit_repository.py @@ -8,16 +8,16 @@ class HabitRepository: def __init__(self, db: AsyncSession): self.db = db - async def get_all(self) -> List[Habit]: - result = await self.db.execute(select(Habit)) + async def get_all(self, user_id: int) -> List[Habit]: + result = await self.db.execute(select(Habit).where(Habit.user_id == user_id)) return result.scalars().all() - async def get_by_id(self, habit_id: int) -> Optional[Habit]: - result = await self.db.execute(select(Habit).where(Habit.id == habit_id)) + async def get_by_id(self, user_id: int, habit_id: int) -> Optional[Habit]: + result = await self.db.execute(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id)) return result.scalar_one_or_none() - async def update(self, habit_id: int, data: HabitUpdate) -> Optional[Habit]: - habit = await self.get_by_id(habit_id) + async def update(self, user_id: int, habit_id: int, data: HabitUpdate) -> Optional[Habit]: + habit = await self.get_by_id(user_id, habit_id) if not habit: return None for key, value in data.model_dump(exclude_unset = True).items(): @@ -26,15 +26,15 @@ async def update(self, habit_id: int, data: HabitUpdate) -> Optional[Habit]: await self.db.refresh(habit) return habit - async def create(self, data: HabitCreate) -> Habit: - habit = Habit(**data.model_dump()) + async def create(self, user_id: int, data: HabitCreate) -> Habit: + habit = Habit(user_id = user_id, **data.model_dump()) self.db.add(habit) await self.db.commit() await self.db.refresh(habit) return habit - async def delete(self, habit_id: int) -> bool: - habit = await self.get_by_id(habit_id) + async def delete(self, user_id: int, habit_id: int) -> bool: + habit = await self.get_by_id(user_id, habit_id) if habit: await self.db.delete(habit) await self.db.commit() diff --git a/app/repositories/user_repository.py b/app/repositories/user_repository.py new file mode 100644 index 0000000..ea83496 --- /dev/null +++ b/app/repositories/user_repository.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.models.users import User +from typing import Optional + +class UserRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user: User) -> User: + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + return user + + async def get_by_username(self, username: str) -> Optional[User]: + user = await self.db.execute(select(User).where(User.username == username)) + return user.scalar_one_or_none() + + async def get_by_email(self, email: str) -> Optional[User]: + user = await self.db.execute(select(User).where(User.email == email)) + return user.scalar_one_or_none() + + async def get_by_id(self, user_id: int) -> Optional[User]: + user = await self.db.execute(select(User).where(User.id == user_id)) + return user.scalar_one_or_none() diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..f506a9d --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field, EmailStr +from typing import Optional + +class UserRegister(BaseModel): + username: str = Field(..., min_length = 3, max_length = 30) + email: Optional[EmailStr] = None + password: str = Field(..., min_length = 6) + +class UserLogin(BaseModel): + username: str + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = 'bearer' + +class UserResponse(BaseModel): + username: str + email: Optional[str] \ No newline at end of file diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..ac14e9e --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,40 @@ +from app.core.exceptions import InvalidCredentialsError, UsernameExistsError, EmailExistsError +from sqlalchemy.ext.asyncio import AsyncSession +from app.repositories.user_repository import UserRepository +from app.core.security import hash_password, verify_password, create_access_token +from app.schemas.auth import UserRegister, UserLogin +from app.models.users import User +from app.schemas.auth import TokenResponse + +class AuthService: + def __init__(self, db: AsyncSession): + self.repo = UserRepository(db) + + async def register(self, data: UserRegister) -> User: + existing = await self.repo.get_by_username(data.username) + if existing: + raise UsernameExistsError(data.username) + + if data.email: + existing = await self.repo.get_by_email(data.email) + if existing: + raise EmailExistsError(data.email) + + hashed_pwd = hash_password(data.password) + user = User( + username = data.username, + email = data.email, + hashed_pass = hashed_pwd + ) + return await self.repo.create(user) + + async def login(self, data: UserLogin) -> TokenResponse: + user = await self.repo.get_by_username(data.username) + if not user: + raise InvalidCredentialsError() + + if not verify_password(data.password, user.hashed_pass): + raise InvalidCredentialsError() + + token = create_access_token({'sub': str(user.id)}) + return TokenResponse(access_token = token) \ No newline at end of file diff --git a/app/services/habit_service.py b/app/services/habit_service.py index 28389b2..b396962 100644 --- a/app/services/habit_service.py +++ b/app/services/habit_service.py @@ -9,28 +9,28 @@ class HabitService: def __init__(self, db: AsyncSession): self.repo = HabitRepository(db) - async def get_all(self) -> List[Habit]: - return await self.repo.get_all() + async def get_all(self, user_id: int) -> List[Habit]: + return await self.repo.get_all(user_id) - async def get_by_id(self, habit_id: int) -> Habit: - habit = await self.repo.get_by_id(habit_id) + async def get_by_id(self, user_id: int, habit_id: int) -> Habit: + habit = await self.repo.get_by_id(user_id, habit_id) if not habit: raise HabitNotFoundError(habit_id) return habit - async def create(self, data: HabitCreate) -> Habit: + async def create(self, user_id: int, data: HabitCreate) -> Habit: if not data.name or not data.name.strip(): raise HabitNameEmptyError() - return await self.repo.create(data) + return await self.repo.create(user_id, data) - async def update(self, habit_id: int, data: HabitUpdate) -> Habit: - await self.get_by_id(habit_id) - updated = await self.repo.update(habit_id, data) + async def update(self, user_id, habit_id: int, data: HabitUpdate) -> Habit: + await self.get_by_id(user_id, habit_id) + updated = await self.repo.update(user_id, habit_id, data) if not updated: raise HabitNotFoundError(habit_id) return updated - async def delete(self, habit_id: int) -> None: - if not await self.repo.delete(habit_id): + async def delete(self, user_id: int, habit_id: int) -> None: + if not await self.repo.delete(user_id, habit_id): raise HabitNotFoundError(habit_id) return None diff --git a/app/services/log_service.py b/app/services/log_service.py index 8ec5666..2ec7615 100644 --- a/app/services/log_service.py +++ b/app/services/log_service.py @@ -2,7 +2,7 @@ from app.repositories.habit_repository import HabitRepository from app.repositories.log_repository import LogRepository from app.schemas.log import LogCreate -from app.core.exceptions import LogNotFoundError, HabitNotFoundError +from app.core.exceptions import LogNotFoundError, HabitNotFoundError, PermissionDeniedError from app.models.log import Log class LogService: @@ -10,14 +10,18 @@ def __init__(self, db: AsyncSession): self.log_repo = LogRepository(db) self.habit_repo = HabitRepository(db) - async def create(self, habit_id: int, data: LogCreate) -> Log: - habit = await self.habit_repo.get_by_id(habit_id) + async def create(self, user_id: int, habit_id: int, data: LogCreate) -> Log: + habit = await self.habit_repo.get_by_id(user_id, habit_id) if not habit: raise HabitNotFoundError(habit_id) return await self.log_repo.create(habit_id, data) - async def delete(self, log_id: int) -> None: - if not await self.log_repo.delete(log_id): + async def delete(self, user_id: int, log_id: int) -> None: + log = await self.log_repo.get_by_id(log_id) + if not log: raise LogNotFoundError(log_id) + if not await self.habit_repo.get_by_id(user_id, log.habit_id): + raise PermissionDeniedError + await self.log_repo.delete(log_id) return None diff --git a/app/services/stats_service.py b/app/services/stats_service.py index cd0894f..05c32a5 100644 --- a/app/services/stats_service.py +++ b/app/services/stats_service.py @@ -26,8 +26,8 @@ async def _calculate_streak(self, logs: list): return streak - async def get_stats(self, habit_id: int) -> dict: - habit = await self.habit_repo.get_by_id(habit_id) + async def get_stats(self, user_id: int, habit_id: int) -> dict: + habit = await self.habit_repo.get_by_id(user_id, habit_id) if not habit: raise HabitNotFoundError(habit_id) logs = await self.log_repo.get_all_by_habit_id(habit_id) diff --git a/app/services/users_service.py b/app/services/users_service.py new file mode 100644 index 0000000..ceefd7e --- /dev/null +++ b/app/services/users_service.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.repositories.user_repository import UserRepository +from app.models.users import User +from app.core.exceptions import UserNotFoundError +from typing import Optional + +class UserService: + def __init__(self, db: AsyncSession): + self.repo = UserRepository(db) + + async def get_by_id(self, user_id: int) -> Optional[User]: + user = await self.repo.get_by_id(user_id) + if not user: + raise UserNotFoundError() + return user + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c1ecbd8..681a2ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,15 @@ annotated-types==0.7.0 anyio==4.14.1 asgi-lifespan==2.1.0 asyncpg==0.31.0 +bcrypt==5.0.0 certifi==2026.6.17 +cffi==2.1.0 click==8.4.2 +cryptography==49.0.0 Deprecated==1.3.1 +dnspython==2.8.0 +ecdsa==0.19.2 +email-validator==2.3.0 fastapi==0.138.1 fastapi-cache2==0.2.2 greenlet==3.5.3 @@ -24,6 +30,8 @@ MarkupSafe==3.0.3 packaging==26.2 pendulum==3.2.0 pluggy==1.6.0 +pyasn1==0.6.4 +pycparser==3.0 pydantic==2.13.4 pydantic-settings==2.14.2 pydantic_core==2.46.4 @@ -32,7 +40,10 @@ pytest==9.1.1 pytest-asyncio==1.4.0 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 +python-jose==3.5.0 +python-multipart==0.0.32 redis==8.0.1 +rsa==4.9.1 six==1.17.0 slowapi==0.1.10 sniffio==1.3.1 diff --git a/tests/conftest.py b/tests/conftest.py index 367588e..8b70b0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,12 @@ +import random +import string + import pytest from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from app.core.database import Base, get_db +from app.repositories.user_repository import UserRepository +from app.models.users import User from app.infrastructure.redis.cache import CacheService from app.middlewares.rate_limit.limiter import RateLimit @@ -38,4 +43,41 @@ async def db_session(): async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: - yield client \ No newline at end of file + yield client + +@pytest.fixture +async def auth(client: AsyncClient): + rand_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k = 6)) + + username = f'testuser_{rand_suffix}' + email = f'{username}@test.com' + password = 'testpass123' + + register_response = await client.post('/v1/auth/register', json = { + 'username' : username, + 'email' : email, + 'password' : password + }) + assert register_response.status_code == 201, 'Failed to register testuser' + + login_response = await client.post('/v1/auth/login', json = { + 'username' : username, + 'password' : password + }) + assert login_response.status_code == 200, 'Failed to login testuser' + + token_data = login_response.json() + assert 'access_token' in token_data, 'No access_token in login_response' + + return token_data['access_token'] + +@pytest.fixture +async def test_user(db_session: AsyncSession): + repo = UserRepository(db_session) + user = User( + username = 'testuser', + email = 'email@test.com', + hashed_pass = 'hashed_test_pass' + ) + created_user = await repo.create(user) + return created_user \ No newline at end of file diff --git a/tests/integration/test_habits_api.py b/tests/integration/test_habits_api.py index c551328..b52d5b1 100644 --- a/tests/integration/test_habits_api.py +++ b/tests/integration/test_habits_api.py @@ -2,8 +2,8 @@ from httpx import AsyncClient @pytest.mark.asyncio -async def test_create_habit_api(client: AsyncClient): - response = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : 'Description'}) +async def test_create_habit_api(client: AsyncClient, auth: str): + response = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : 'Description'}, headers = {'Authorization' : f'Bearer {auth}'}) assert response.status_code == 201 data = response.json() assert data['name'] == 'Habit' @@ -11,37 +11,37 @@ async def test_create_habit_api(client: AsyncClient): assert 'id' in data @pytest.mark.asyncio -async def test_get_all_habits_api(client: AsyncClient): +async def test_get_all_habits_api(client: AsyncClient, auth: str): for _ in range(3): - await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : 'Description'}) - response = await client.get('/v1/habits') + await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : 'Description'}, headers = {'Authorization' : f'Bearer {auth}'}) + response = await client.get('/v1/habits', headers = {'Authorization' : f'Bearer {auth}'}) data = response.json() assert len(data) == 3 @pytest.mark.asyncio -async def test_get_habit_by_id_api(client: AsyncClient): - create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}) +async def test_get_habit_by_id_api(client: AsyncClient, auth: str): + create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}, headers = {'Authorization' : f'Bearer {auth}'}) habit_id = create.json()['id'] - response = await client.get(f'/v1/habit/{habit_id}') + response = await client.get(f'/v1/habit/{habit_id}', headers = {'Authorization' : f'Bearer {auth}'}) data = response.json() assert data['name'] == create.json()['name'] @pytest.mark.asyncio -async def test_delete_habit_by_id_api(client: AsyncClient): - create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}) +async def test_delete_habit_by_id_api(client: AsyncClient, auth: str): + create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}, headers = {'Authorization' : f'Bearer {auth}'}) habit_id = create.json()['id'] - response = await client.delete(f'/v1/habit/{habit_id}') + response = await client.delete(f'/v1/habit/{habit_id}', headers = {'Authorization' : f'Bearer {auth}'}) assert response.status_code == 204 @pytest.mark.asyncio -async def test_update_habit_by_id_api(client: AsyncClient): - create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}) +async def test_update_habit_by_id_api(client: AsyncClient, auth: str): + create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}, headers = {'Authorization' : f'Bearer {auth}'}) habit_id = create.json()['id'] - response = await client.put(f'/v1/habit/{habit_id}', json = {'name' : 'New name', 'description' : 'New description'}) + response = await client.put(f'/v1/habit/{habit_id}', json = {'name' : 'New name', 'description' : 'New description'}, headers = {'Authorization' : f'Bearer {auth}'}) assert response.json()['name'] != create.json()['name'] assert response.json()['description'] != create.json()['description'] @pytest.mark.asyncio -async def test_habit_not_found_api(client: AsyncClient): - response = await client.get('/v1/habit/999') +async def test_habit_not_found_api(client: AsyncClient, auth: str): + response = await client.get('/v1/habit/999', headers = {'Authorization' : f'Bearer {auth}'}) assert response.status_code == 404 diff --git a/tests/integration/test_log_api.py b/tests/integration/test_log_api.py index 10123d3..b1c36a2 100644 --- a/tests/integration/test_log_api.py +++ b/tests/integration/test_log_api.py @@ -2,23 +2,23 @@ from httpx import AsyncClient @pytest.mark.asyncio -async def test_log_create_by_id_api(client: AsyncClient): - habit_create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}) +async def test_log_create_by_id_api(client: AsyncClient, auth: str): + habit_create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}, headers = {'Authorization' : f'Bearer {auth}'}) habit_id = habit_create.json()['id'] - log_create = await client.post(f'/v1/log/{habit_id}', json = {'completed' : True}) + log_create = await client.post(f'/v1/log/{habit_id}', json = {'completed' : True}, headers = {'Authorization' : f'Bearer {auth}'}) assert log_create.json()['id'] == 1 assert log_create.json()['completed'] == True @pytest.mark.asyncio -async def test_log_delete_by_id(client: AsyncClient): - habit_create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}) +async def test_log_delete_by_id(client: AsyncClient, auth: str): + habit_create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}, headers = {'Authorization' : f'Bearer {auth}'}) habit_id = habit_create.json()['id'] - log_create = await client.post(f'/v1/log/{habit_id}', json = {'completed' : True}) + log_create = await client.post(f'/v1/log/{habit_id}', json = {'completed' : True}, headers = {'Authorization' : f'Bearer {auth}'}) log_id = log_create.json()['id'] - delete = await client.delete(f'/v1/log/{log_id}') + delete = await client.delete(f'/v1/log/{log_id}', headers = {'Authorization' : f'Bearer {auth}'}) assert delete.status_code == 204 @pytest.mark.asyncio -async def test_log_not_found_api(client: AsyncClient): - log_delete = await client.delete('/v1/log/999') - assert log_delete.status_code == 404 \ No newline at end of file +async def test_log_not_found_api(client: AsyncClient, auth: str): + log_delete = await client.delete('/v1/log/999', headers = {'Authorization' : f'Bearer {auth}'}) + assert log_delete.status_code == 404 diff --git a/tests/integration/test_stats_api.py b/tests/integration/test_stats_api.py index b305043..fabd2fd 100644 --- a/tests/integration/test_stats_api.py +++ b/tests/integration/test_stats_api.py @@ -2,15 +2,15 @@ from httpx import AsyncClient @pytest.mark.asyncio -async def test_get_stats_api(client: AsyncClient): - habit_create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}) +async def test_get_stats_api(client: AsyncClient, auth: str): + habit_create = await client.post('/v1/habit/create', json = {'name' : 'Habit', 'description' : ''}, headers = {'Authorization' : f'Bearer {auth}'}) habit_id = habit_create.json()['id'] - await client.post(f'/v1/log/{habit_id}', json = {'completed' : True}) - response = await client.get(f'/v1/stats/{habit_id}') + await client.post(f'/v1/log/{habit_id}', json = {'completed' : True}, headers = {'Authorization' : f'Bearer {auth}'}) + response = await client.get(f'/v1/stats/{habit_id}', headers = {'Authorization' : f'Bearer {auth}'}) assert response.json()['total_days'] == 1 assert response.json()['streak'] == 1 @pytest.mark.asyncio -async def test_get_stats_habit_not_found_api(client: AsyncClient): - response = await client.get('/v1/stats/999') +async def test_get_stats_habit_not_found_api(client: AsyncClient, auth: str): + response = await client.get('/v1/stats/999', headers = {'Authorization' : f'Bearer {auth}'}) assert response.status_code == 404 diff --git a/tests/unit/test_habit_service.py b/tests/unit/test_habit_service.py index dd2aa7d..3e1851f 100644 --- a/tests/unit/test_habit_service.py +++ b/tests/unit/test_habit_service.py @@ -1,43 +1,45 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.services.habit_service import HabitService +from app.models.users import User from app.schemas.habit import HabitCreate from app.core.exceptions import HabitNotFoundError, HabitNameEmptyError @pytest.mark.asyncio -async def test_create_habit(db_session: AsyncSession): - service = HabitService(db_session) - data = HabitCreate(name = 'Test Habit', description = 'Description') - habit = await service.create(data) +async def test_create_habit(db_session: AsyncSession, test_user: User): + habit_service = HabitService(db_session) + habit_data = HabitCreate(name = 'Test Habit', description = 'Description') + habit = await habit_service.create(test_user.id, habit_data) assert habit.id is not None + assert habit.user_id is not None assert habit.name == 'Test Habit' assert habit.description == 'Description' @pytest.mark.asyncio -async def test_create_habit_empty_name(db_session: AsyncSession): +async def test_create_habit_empty_name(db_session: AsyncSession, test_user: User): service = HabitService(db_session) data = HabitCreate(name = ' ', description = '') with pytest.raises(HabitNameEmptyError): - await service.create(data) + await service.create(test_user.id, data) @pytest.mark.asyncio -async def test_get_habit_not_found(db_session: AsyncSession): +async def test_get_habit_not_found(db_session: AsyncSession, test_user: User): service = HabitService(db_session) with pytest.raises(HabitNotFoundError): - await service.get_by_id(999) + await service.get_by_id(test_user.id, 999) @pytest.mark.asyncio -async def test_update_habit(db_session: AsyncSession): +async def test_update_habit(db_session: AsyncSession, test_user: User): service = HabitService(db_session) - habit = await service.create(HabitCreate(name = 'Old name', description = 'Old description')) - updated = await service.update(habit.id, HabitCreate(name = 'New name', description = 'New description')) + habit = await service.create(test_user.id, HabitCreate(name = 'Old name', description = 'Old description')) + updated = await service.update(test_user.id, habit.id, HabitCreate(name = 'New name', description = 'New description')) assert updated.name == 'New name' assert updated.description == 'New description' @pytest.mark.asyncio -async def test_delete_habit(db_session: AsyncSession): +async def test_delete_habit(db_session: AsyncSession, test_user: User): service = HabitService(db_session) - habit = await service.create(HabitCreate(name = 'Habit name', description = 'Habit description')) - await service.delete(habit.id) + habit = await service.create(test_user.id, HabitCreate(name = 'Habit name', description = 'Habit description')) + await service.delete(test_user.id, habit.id) with pytest.raises(HabitNotFoundError): - await service.get_by_id(habit.id) \ No newline at end of file + await service.get_by_id(test_user.id, habit.id) \ No newline at end of file diff --git a/tests/unit/test_log_service.py b/tests/unit/test_log_service.py index ec13710..bbca110 100644 --- a/tests/unit/test_log_service.py +++ b/tests/unit/test_log_service.py @@ -1,5 +1,6 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession +from app.models.users import User from app.services.log_service import LogService from app.services.habit_service import HabitService from app.schemas.log import LogCreate @@ -7,20 +8,20 @@ from app.core.exceptions import LogNotFoundError @pytest.mark.asyncio -async def test_log_create(db_session: AsyncSession): +async def test_log_create(db_session: AsyncSession, test_user: User): habit_service = HabitService(db_session) log_service = LogService(db_session) - habit = await habit_service.create(HabitCreate(name = 'Habit')) - log = await log_service.create(habit.id, LogCreate(completed = False)) + habit = await habit_service.create(test_user.id, HabitCreate(name = 'Habit')) + log = await log_service.create(test_user.id, habit.id, LogCreate(completed = False)) assert log.id is not None assert log.completed == False @pytest.mark.asyncio -async def test_log_delete(db_session: AsyncSession): +async def test_log_delete(db_session: AsyncSession, test_user: User): habit_service = HabitService(db_session) log_service = LogService(db_session) - habit = await habit_service.create(HabitCreate(name = 'Habit')) - log = await log_service.create(habit.id, LogCreate()) - await log_service.delete(log.id) + habit = await habit_service.create(test_user.id, HabitCreate(name = 'Habit')) + log = await log_service.create(test_user.id, habit.id, LogCreate()) + await log_service.delete(test_user.id, log.id) with pytest.raises(LogNotFoundError): - await log_service.delete(log.id) \ No newline at end of file + await log_service.delete(test_user.id, log.id) \ No newline at end of file diff --git a/tests/unit/test_stats_service.py b/tests/unit/test_stats_service.py index d647d05..d0c2cf7 100644 --- a/tests/unit/test_stats_service.py +++ b/tests/unit/test_stats_service.py @@ -4,37 +4,38 @@ from app.services.stats_service import StatsService from app.services.log_service import LogService from app.schemas.habit import HabitCreate +from app.models.users import User from app.schemas.log import LogCreate from app.core.exceptions import HabitNotFoundError @pytest.mark.asyncio -async def test_stats_unique_days(db_session: AsyncSession): +async def test_stats_unique_days(db_session: AsyncSession, test_user: User): habit_service = HabitService(db_session) stats_service = StatsService(db_session) log_service = LogService(db_session) - habit = await habit_service.create(HabitCreate(name = 'Habit')) + habit = await habit_service.create(test_user.id, HabitCreate(name = 'Habit')) for _ in range(3): - await log_service.create(habit.id, LogCreate(completed = True)) + await log_service.create(test_user.id, habit.id, LogCreate(completed = True)) - stats = await stats_service.get_stats(habit.id) + stats = await stats_service.get_stats(test_user.id, habit.id) assert stats['total_days'] == 1 assert stats['streak'] == 1 @pytest.mark.asyncio -async def test_stats_no_logs(db_session: AsyncSession): +async def test_stats_no_logs(db_session: AsyncSession, test_user: User): habit_service = HabitService(db_session) stats_service = StatsService(db_session) - habit = await habit_service.create(HabitCreate(name = 'Habit')) - stats = await stats_service.get_stats(habit.id) + habit = await habit_service.create(test_user.id, HabitCreate(name = 'Habit')) + stats = await stats_service.get_stats(test_user.id, habit.id) assert stats['total_days'] == 0 assert stats['streak'] == 0 @pytest.mark.asyncio -async def test_stats_habit_not_found(db_session: AsyncSession): +async def test_stats_habit_not_found(db_session: AsyncSession, test_user: User): stats_service = StatsService(db_session) with pytest.raises(HabitNotFoundError): - await stats_service.get_stats(999) + await stats_service.get_stats(test_user.id, 999) From 15a3e1bd599c14a061754c1dd9e0011d5159bf06 Mon Sep 17 00:00:00 2001 From: ghostfaccee Date: Mon, 13 Jul 2026 13:09:04 +0900 Subject: [PATCH 4/4] ci: secrets added --- .github/workflows/ci.yml | 3 +++ .github/workflows/docker.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ad468c..503fcd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ env: DEBUG: ${{ secrets.DEBUG }} SLOW_REQUEST_THRESHOLD: ${{ secrets.SLOW_REQUEST_THRESHOLD }} REDIS_URL: ${{ secrets.REDIS_URL }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ALGORITHM: ${{ secrets.ALGORITHM }} + ACCESS_TOKEN_EXPIRE_MINUTES: ${{ secrets.ACCESS_TOKEN_EXPIRE_MINUTES }} jobs: test: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 90133fb..9aba24c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,6 +14,9 @@ env: DEBUG: ${{ secrets.DEBUG }} SLOW_REQUEST_THRESHOLD: ${{ secrets.SLOW_REQUEST_THRESHOLD }} REDIS_URL: ${{ secrets.REDIS_URL }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ALGORITHM: ${{ secrets.ALGORITHM }} + ACCESS_TOKEN_EXPIRE_MINUTES: ${{ secrets.ACCESS_TOKEN_EXPIRE_MINUTES }} jobs: