diff --git a/.env_template b/.env_template index 51f2358..1b8d5c6 100644 --- a/.env_template +++ b/.env_template @@ -6,12 +6,6 @@ DB_USER= DB_PASS= DB_NAME= -TEST_DB_HOST= -TEST_DB_PORT= -TEST_DB_USER= -TEST_DB_PASS= -TEST_DB_NAME= - REDIS_HOST= REDIS_PORT= REDIS_DB= diff --git a/.gitignore b/.gitignore index f295d3d..fca7fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ +.test.env + diff --git a/README.md b/README.md index e8f3dde..dda79aa 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ Данный проект предоставляет `API` для сокращения ссылок и отслеживания переходов по этим ссылкам. -Для работы данного сервиса необходима готовя БД `PostgreSQL`(продакшн и тестовая) и `Redis`. +Для работы данного сервиса необходима готовя БД `PostgreSQL` и `Redis`. ## Начало работы -В корне проекта создаём файл `.env` и заполняем его по шаблону из файла `.env_template`. В поле `MODE` указываем `DEV`, а в поле `SHORTENER_HOST` - домен сервера, где будет хоститься сервис. +В корне проекта создаём файл `.env` и заполняем его по шаблону из файла `.env_template`. В поле `MODE` указываем `DEV`, +а в поле `SHORTENER_HOST` - домен сервера, где будет хоститься сервис. Собираем докер образ ```shell @@ -25,8 +26,22 @@ docker compose up ## Работа с `API` -Для получения сокращённой ссылки необходимо отправить `POST` запрос на эндпоинт `/zip_url`. Он вернёт новую ссылку в `JSON` объекте формата `{"new_url_path": }`. +Для получения сокращённой ссылки необходимо отправить `POST` запрос на эндпоинт `/zip_url`. Он вернёт новую ссылку в `JSON` объекте формата `{"new_url": }`. При обращении по этому адресу будет выполнен редирект на оригинальный `URL` и в редис добавится таймстемп с указанием количества секунд от начала эпохи, когда произошёл редирект. Получить данные счётчика редиректов можно при обращении к эндпоинту `/stats/`, где `URL_ID` это айдишник, который указан в укороченном `URL`. Этот эндпоинт возвращает `JSON` объект формата `{"visit_times": , "timestamps": []}`. + +## Тестирование + +Для начала тестирования необходимо создать файл `.test.env` и заполнить его по образцу из `.env_template`. + +Для запуска тестов, так же необходимо установить все зависимости +```shell +pip install -r requirements.txt +``` + +Запуск тестов +```shell +pytest +``` diff --git a/app/config.py b/app/config.py index 6074798..54dc664 100644 --- a/app/config.py +++ b/app/config.py @@ -14,12 +14,6 @@ class Settings(BaseSettings): DB_PASS: str DB_NAME: str - TEST_DB_HOST: str - TEST_DB_PORT: int - TEST_DB_USER: str - TEST_DB_PASS: str - TEST_DB_NAME: str - REDIS_HOST: str REDIS_PORT: int REDIS_DB: int diff --git a/app/conftest.py b/app/conftest.py deleted file mode 100644 index 9da598e..0000000 --- a/app/conftest.py +++ /dev/null @@ -1,4 +0,0 @@ -import os - -os.environ["MODE"] = "TEST" - diff --git a/app/database.py b/app/database.py index 95df25e..1998da0 100644 --- a/app/database.py +++ b/app/database.py @@ -4,16 +4,11 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker from app.config import settings -if settings.MODE == "TEST": - DATABASE_URL = f"""postgresql+asyncpg://{settings.TEST_DB_USER}: - {settings.TEST_DB_PASS}@{settings.TEST_DB_HOST}: - {settings.TEST_DB_PORT}/{settings.TEST_DB_NAME}""" - DATABASE_PARAMS = {"poolclass": NullPool} -else: - DATABASE_URL = f"""postgresql+asyncpg://{settings.DB_USER}: - {settings.DB_PASS}@{settings.DB_HOST}: - {settings.DB_PORT}/{settings.DB_NAME}""" - DATABASE_PARAMS = {} + +DATABASE_URL = f"""postgresql+asyncpg://{settings.DB_USER}: + {settings.DB_PASS}@{settings.DB_HOST}: + {settings.DB_PORT}/{settings.DB_NAME}""" +DATABASE_PARAMS = {"poolclass": NullPool} engine = create_async_engine(DATABASE_URL, **DATABASE_PARAMS) diff --git a/app/main.py b/app/main.py index 5c8a884..b47421f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,20 +1,18 @@ import time from urllib.parse import urljoin -from fastapi import FastAPI, status, HTTPException +from fastapi import FastAPI, status, HTTPException, Depends from fastapi.responses import RedirectResponse -import redis from app.config import settings from app.dao import URLsDAO from app.migrations.shemas import SURL, SNewUrl, SGetStats +from app.r import get_redis_client app = FastAPI( title="Сокращатель ссылок", ) -r = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB) - @app.post("/zip_url", response_model=SNewUrl) async def zip_url(url: SURL): @@ -22,20 +20,20 @@ async def zip_url(url: SURL): url_hash = await URLsDAO.add_new_url(original_url=url) - new_url_path = urljoin(settings.SHORTENER_HOST, str(url_hash)) + new_url = urljoin(settings.SHORTENER_HOST, str(url_hash)) - return {"new_url_path": new_url_path} + return {"new_url": new_url} @app.get("/{url_hash}", response_class=RedirectResponse, status_code=status.HTTP_307_TEMPORARY_REDIRECT) -async def redirect(url_hash: int): +async def redirect(url_hash: int, r=Depends(get_redis_client)): if r.exists(str(url_hash)): url_bytes = r.get(str(url_hash)) url = url_bytes.decode('utf-8') else: urls = await URLsDAO.find_by_hash(url_hash) if not urls: - raise HTTPException(status_code=404, detail="Этой ссылки ещё не существует") + raise HTTPException(status_code=404, detail="Этого адреса не существует") url = urls.original_url r.set(str(url_hash), url) r.expire(str(url_hash), 60) @@ -48,7 +46,7 @@ async def redirect(url_hash: int): @app.get("/stats/{url_hash}", response_model=SGetStats) -async def get_stats(url_hash: int): +async def get_stats(url_hash: int, r=Depends(get_redis_client)): list_key = f"visit_times:{url_hash}" if r.exists(list_key): timestamps = r.lrange(list_key, 0, -1) diff --git a/app/migrations/shemas.py b/app/migrations/shemas.py index 1b6d957..03ab522 100644 --- a/app/migrations/shemas.py +++ b/app/migrations/shemas.py @@ -6,7 +6,7 @@ class SURL(BaseModel): class SNewUrl(BaseModel): - new_url_path: HttpUrl + new_url: HttpUrl class SGetStats(BaseModel): diff --git a/app/r.py b/app/r.py new file mode 100644 index 0000000..e7ef280 --- /dev/null +++ b/app/r.py @@ -0,0 +1,7 @@ +import redis + +from app.config import settings + + +def get_redis_client(): + return redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index dd66511..c4f8700 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -8,9 +8,10 @@ from app.config import settings from app.database import Base, async_session_maker, engine from app.models import URLs from app.main import app as fastapi_app +from app.r import get_redis_client -@pytest.fixture(autouse=True, scope='module') +@pytest.fixture(autouse=True, scope="module") async def prepare_database(): assert settings.MODE == "TEST" @@ -18,21 +19,13 @@ async def prepare_database(): await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) - def open_mock_json(model: str): - with open(f"app/tests/mock_{model}.json", 'r', encoding='utf8') as file: - return json.load(file) - urls = open_mock_json("urls") +@pytest.fixture(autouse=True, scope="module") +async def prepare_redis(): + assert settings.MODE == "TEST" - - async with async_session_maker() as session: - add_users = insert(URLs).values(urls) - - - await session.execute(add_users) - - - await session.commit() + r = get_redis_client() + r.flushdb() @pytest.fixture(scope="function") diff --git a/app/tests/tests/api_test.py b/app/tests/tests/api_test.py new file mode 100644 index 0000000..5367219 --- /dev/null +++ b/app/tests/tests/api_test.py @@ -0,0 +1,52 @@ +from urllib.parse import urlparse + +import pytest +from httpx import AsyncClient + + +@pytest.mark.parametrize("url,new_url_path,status_code", [ + ("https://www.youtube.com/", "/1", 200), + ("https://www.youtube.com/", "/2", 200), + ("https://www.youtube.com/", "/3", 200), + ("some_str", None, 422), + (1, None, 422), + ("https://google.com/", "/4", 200), +]) +async def test_get_new_url(url: str, new_url_path: str, status_code: int, ac: AsyncClient): + response = await ac.post("/zip_url", json={ + "url": url + }) + assert response.status_code == status_code + if status_code == 200: + new_url = response.json()["new_url"] + parsed_url = urlparse(new_url) + assert parsed_url.path == new_url_path + + +@pytest.mark.parametrize("new_url_path,original_url,status_code", [ + ("/1", "https://www.youtube.com/", 307), + ("/1", "https://www.youtube.com/", 307), + ("/3", "https://www.youtube.com/", 307), + ("/4", "https://google.com/", 307), + ("/5", None, 404) +]) +async def test_get_original_url(new_url_path: str, original_url: str, status_code: int, ac: AsyncClient): + response = await ac.get(new_url_path) + + assert response.status_code == status_code + if response.status_code == 307: + assert response.headers["Location"] == original_url + + +@pytest.mark.parametrize("new_url_path,visit_times,status_code", [ + ("/1", 2, 200), + ("/1", 2, 200), + ("/3", 1, 200), + ("/5", 0, 200) +]) +async def test_get_stats(new_url_path: str, visit_times: int, status_code: int, ac: AsyncClient): + response = await ac.get(f"/stats{new_url_path}") + + assert response.status_code == status_code + assert response.json()["visit_times"] == visit_times + assert len(response.json()["timestamps"]) == visit_times diff --git a/pytest.ini b/pytest.ini index c794028..6267178 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,6 @@ [pytest] +env_files= + .test.env pythonpath = . app asyncio_mode = auto -python_files = *_test.py *_tests.py test_*.py \ No newline at end of file +python_files = *_test.py *_tests.py test_*.py