Добавил тестирование
This commit is contained in:
parent
804564d6fa
commit
4ddfa2a7f4
12 changed files with 102 additions and 54 deletions
|
@ -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
2
.gitignore
vendored
|
@ -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
|
||||
|
||||
|
||||
|
|
21
README.md
21
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": <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
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
import os
|
||||
|
||||
os.environ["MODE"] = "TEST"
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
16
app/main.py
16
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)
|
||||
|
|
|
@ -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
7
app/r.py
Normal 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)
|
|
@ -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")
|
||||
|
|
52
app/tests/tests/api_test.py
Normal file
52
app/tests/tests/api_test.py
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue