Добавил тестирование

This commit is contained in:
urec56 2024-04-07 12:46:14 +05:00
parent 804564d6fa
commit 4ddfa2a7f4
12 changed files with 102 additions and 54 deletions

View file

@ -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=

2
.gitignore vendored
View file

@ -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

View file

@ -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": <URL>}`.
Для получения сокращённой ссылки необходимо отправить `POST` запрос на эндпоинт `/zip_url`. Он вернёт новую ссылку в `JSON` объекте формата `{"new_url": <URL>}`.
При обращении по этому адресу будет выполнен редирект на оригинальный `URL` и в редис добавится таймстемп с указанием количества секунд от начала эпохи, когда произошёл редирект.
Получить данные счётчика редиректов можно при обращении к эндпоинту `/stats/<URL_ID>`, где `URL_ID` это айдишник, который указан в укороченном `URL`. Этот эндпоинт возвращает `JSON` объект формата `{"visit_times": <redirected_times>, "timestamps": [<redirect_timestamp>]}`.
## Тестирование
Для начала тестирования необходимо создать файл `.test.env` и заполнить его по образцу из `.env_template`.
Для запуска тестов, так же необходимо установить все зависимости
```shell
pip install -r requirements.txt
```
Запуск тестов
```shell
pytest
```

View file

@ -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

View file

@ -1,4 +0,0 @@
import os
os.environ["MODE"] = "TEST"

View file

@ -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)

View file

@ -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)

View file

@ -6,7 +6,7 @@ class SURL(BaseModel):
class SNewUrl(BaseModel):
new_url_path: HttpUrl
new_url: HttpUrl
class SGetStats(BaseModel):

7
app/r.py Normal file
View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -1,4 +1,6 @@
[pytest]
env_files=
.test.env
pythonpath = . app
asyncio_mode = auto
python_files = *_test.py *_tests.py test_*.py
python_files = *_test.py *_tests.py test_*.py