main
Сергей Ванюшкин 2025-03-09 17:01:54 +00:00
parent ca83ca6e2a
commit 53aeaef20f
31 changed files with 1003 additions and 459 deletions

983
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,8 @@ uvicorn = "^0.30.6"
sqlalchemy = "^2.0.32" sqlalchemy = "^2.0.32"
asyncpg = "^0.29.0" asyncpg = "^0.29.0"
alembic = "^1.13.2" alembic = "^1.13.2"
pytest-xdist = "^3.6.1"
httpx = "^0.28.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
@ -30,7 +32,12 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.poetry.scripts]
api = "fastfood_two.__main__:main" app = "fastfood_two.__main__:main"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
pythonpath = [
"src"
]
testpaths = "tests"
addopts = "-n=2 --cov=fastfood_two"

View File

@ -4,6 +4,7 @@ from uuid import UUID
@dataclass(frozen=True) @dataclass(frozen=True)
class AddMenuDTO: class AddMenuDTO:
id: UUID | None
title: str title: str
description: str | None description: str | None
@ -23,3 +24,8 @@ class DeleteMenuDTO:
@dataclass(frozen=True) @dataclass(frozen=True)
class GetMenuByIdDTO: class GetMenuByIdDTO:
id: UUID id: UUID
@dataclass(frozen=True)
class AddSubMenuDTO(AddMenuDTO):
parent_menu: UUID

View File

@ -7,3 +7,8 @@ class MenuDTO:
id: UUID id: UUID
title: str title: str
description: str | None description: str | None
@dataclass(frozen=True)
class SubMenuDTO(MenuDTO):
parent_menu: UUID

View File

@ -15,7 +15,7 @@ class AddMenu(Interactor[AddMenuDTO, MenuDTO]):
menu = await self._menu_gateway.insert_menu( menu = await self._menu_gateway.insert_menu(
menu=Menu( menu=Menu(
id=MenuId(uuid4()), id=MenuId(uuid4()) if request.id is None else MenuId(request.id),
title=Title(request.title), title=Title(request.title),
description=Description(request.description), description=Description(request.description),
) )

View File

@ -0,0 +1,33 @@
from uuid import uuid4
from fastfood_two.application.abstractions.interactor import Interactor
from fastfood_two.application.contracts.requests import AddMenuDTO, AddSubMenuDTO
from fastfood_two.application.contracts.responses import SubMenuDTO
from fastfood_two.domain.submenu.gateway import SubMenuGateway
from fastfood_two.domain.submenu.submenu_entity import (
Description,
SubMenu,
SubMenuId,
Title,
)
class AddMenu(Interactor[AddSubMenuDTO, SubMenuDTO]):
def __init__(self, gateway: SubMenuGateway) -> None:
self._menu_gateway = gateway
async def __call__(self, request: AddSubMenuDTO) -> SubMenuDTO:
menu = await self._menu_gateway.insert_submenu(
menu=SubMenu(
id=SubMenuId(uuid4()) if request.id is None else SubMenuId(request.id),
title=Title(request.title),
description=Description(request.description),
)
)
return SubMenuDTO(
id=menu.id.value,
parent_menu=menu.parent_menu.value,
title=menu.title.value,
description=menu.description.value,
)

View File

@ -0,0 +1,9 @@
from fastfood_two.domain.core.error import DomainError
class SubMenuNotFoundError(DomainError):
pass
class SubMenuDataValidationError(DomainError):
pass

View File

@ -0,0 +1,30 @@
from typing import Protocol
from fastfood_two.domain.submenu.submenu_entity import (
Description,
SubMenu,
SubMenuId,
Title,
)
class SubMenuGateway(Protocol):
async def get_submenu_by_id(self, id: SubMenuId) -> SubMenu | None:
raise NotImplementedError
async def insert_submenu(self, menu: SubMenu) -> SubMenu:
raise NotImplementedError
async def get_all_submenus_by_menu_id(self) -> list[SubMenu]:
raise NotImplementedError
async def update_submenu(
self,
id: SubMenuId,
title: Title | None,
description: Description | None,
) -> SubMenu:
raise NotImplementedError
async def delete_submenu(self, id: SubMenuId) -> None:
raise NotImplementedError

View File

@ -0,0 +1,34 @@
from dataclasses import dataclass
from uuid import UUID
from fastfood_two.domain.core.entity import DomainEntity
from fastfood_two.domain.core.value_obj import DomainValueObject
@dataclass(frozen=True)
class SubMenuId(DomainValueObject):
value: UUID
@dataclass(frozen=True)
class Title(DomainValueObject):
value: str
@dataclass(frozen=True)
class Description(DomainValueObject):
value: str | None
@dataclass
class SubMenu(DomainEntity[SubMenuId]):
title: Title
description: Description
@staticmethod
def create(id: UUID, title: str, description: str | None) -> "Menu":
return SubMenu(
id=SubMenuId(id),
title=Title(title),
description=Description(description),
)

View File

@ -12,18 +12,18 @@ class MenuGatewayImpl:
self._session = session self._session = session
async def get_all_menus(self) -> list[Menu]: async def get_all_menus(self) -> list[Menu]:
query = text("SELECT * FROM menu;") query = text("SELECT * FROM menus;")
menus = await self._session.execute(query) menus = await self._session.execute(query)
return [db_entity_to_domain(menu.tuple()) for menu in menus] return [db_entity_to_domain(menu._tuple()) for menu in menus]
async def get_menu_by_id(self, id: MenuId) -> Menu | None: async def get_menu_by_id(self, id: MenuId) -> Menu | None:
query = text("SELECT * FROM menu WHERE id = :id;") query = text("SELECT * FROM menus WHERE id = :id;")
menu = (await self._session.execute(query, {"id": id.value})).tuples().first() menu = (await self._session.execute(query, {"id": id.value})).tuples().first()
return db_entity_to_domain(menu) if menu else None return db_entity_to_domain(menu) if menu else None
async def insert_menu(self, menu: Menu) -> Menu: async def insert_menu(self, menu: Menu) -> Menu:
query = text("INSERT INTO menu (id, title, description) VALUES (:id, :title, :description);") query = text("INSERT INTO menus (id, title, description) VALUES (:id, :title, :description);")
await self._session.execute( await self._session.execute(
query, query,
{ {
@ -37,7 +37,7 @@ class MenuGatewayImpl:
async def update_menu(self, id: MenuId, title: Title | None, description: Description | None) -> Menu: async def update_menu(self, id: MenuId, title: Title | None, description: Description | None) -> Menu:
query = text( query = text(
"UPDATE menu SET {}{} {} WHERE id = :id RETURNING *;".format( "UPDATE menus SET {}{} {} WHERE id = :id RETURNING *;".format(
"title = :title " if title is not None else "", "title = :title " if title is not None else "",
"," if all([title is not None, description is not None]) else "", "," if all([title is not None, description is not None]) else "",
"description = :description" if description is not None else "", "description = :description" if description is not None else "",
@ -52,9 +52,9 @@ class MenuGatewayImpl:
}, },
) )
await self._session.commit() await self._session.commit()
return db_entity_to_domain(menu.one().tuple()) return db_entity_to_domain(menu.one()._tuple())
async def delete_menu(self, id: MenuId) -> None: async def delete_menu(self, id: MenuId) -> None:
query = text("DELETE FROM menu WHERE id = :id;") query = text("DELETE FROM menus WHERE id = :id;")
await self._session.execute(query, {"id": id.value}) await self._session.execute(query, {"id": id.value})
await self._session.commit() await self._session.commit()

View File

@ -8,11 +8,11 @@ from .common_attrs import str_25, uuidpk
class Dish(Base): class Dish(Base):
__tablename__ = "dish" __tablename__ = "dishes"
id: Mapped[uuidpk] id: Mapped[uuidpk]
title: Mapped[str_25] title: Mapped[str_25]
description: Mapped[str | None] description: Mapped[str | None]
price: Mapped[float] price: Mapped[float]
parent_submenu: Mapped[uuid.UUID] = mapped_column(ForeignKey("menu.id", ondelete="CASCADE")) parent_submenu: Mapped[uuid.UUID] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"))

View File

@ -8,15 +8,19 @@ from .common_attrs import str_25, uuidpk
class SQLAMenu(Base): class SQLAMenu(Base):
__tablename__ = "menus" __tablename__ = "menus"
id: Mapped[uuidpk] id: Mapped[uuidpk] = mapped_column(primary_key=True, unique=True)
title: Mapped[str_25] title: Mapped[str_25]
description: Mapped[str | None] description: Mapped[str | None]
parent: Mapped[uuidpk | None] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"), nullable=True) parent: Mapped["SQLAMenu"] = mapped_column(ForeignKey("menus.id", ondelete="CASCADE"), nullable=True)
submenus: Mapped[list["SQLAMenu"]] = relationship( submenus: Mapped[list["SQLAMenu"]] = relationship(
"SQLAMenu", "SQLAMenu", backref="menus", lazy="selectin", cascade="all, delete", remote_side=id
backref="menus",
lazy="selectin",
cascade="all, delete",
) )
def to_json(self):
return {
"id": str(self.id),
"title": self.title,
"description": self.description,
}

View File

@ -1,7 +1,10 @@
from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
class AddMenuPDModel(BaseModel): class AddMenuPDModel(BaseModel):
id: UUID | None = None
title: str title: str
description: str | None description: str | None

View File

@ -56,6 +56,8 @@ async def add_menu(
Parameters Parameters
---------- ----------
Optional id: UUID
id of the menu
title: str title: str
title of the menu title of the menu
description: str description: str
@ -66,7 +68,7 @@ async def add_menu(
MenuDTO MenuDTO
created menu created menu
""" """
menus = await usecase(request=AddMenuDTO(title=request.title, description=request.description)) menus = await usecase(request=AddMenuDTO(id=request.id, title=request.title, description=request.description))
return menus return menus

99
tests/conftest.py Normal file
View File

@ -0,0 +1,99 @@
import logging
import os
import xdist
from pytest import Session, fixture, hookimpl
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from fastfood_two.application.config import Config
from tests.utils import db_creator, db_deleter, get_test_settings
def pytest_configure(config):
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id is not None:
logging.basicConfig(
format=config.getini("log_file_format"),
filename=f"tests_{worker_id}.log",
level=config.getini("log_file_level"),
)
@hookimpl(trylast=True)
def pytest_sessionstart(session: Session) -> None:
"""Hook запуска приложения в отдельном процессе."""
worker_id = xdist.get_xdist_worker_id(session)
import asyncio
db_name = f"fastfood_two_test_db_{worker_id}"
loop = asyncio.get_event_loop_policy().new_event_loop()
loop.run_until_complete(db_creator(name=db_name))
loop.close()
@hookimpl(trylast=True)
def pytest_sessionfinish(session: Session) -> None:
"""Hook остановки приложения."""
worker_id = xdist.get_xdist_worker_id(session)
import asyncio
db_name = f"fastfood_two_test_db_{worker_id}"
loop = asyncio.get_event_loop_policy().new_event_loop()
loop.run_until_complete(db_deleter(name=db_name))
loop.close()
@fixture(scope="session")
def app_settings() -> Config:
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id == None:
worker_id = "master"
settings = get_test_settings(db_name=f"fastfood_two_test_db_{worker_id}")
return settings
@fixture
def get_loop():
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@fixture
def test_engine(app_settings):
return create_async_engine(url=app_settings.db.url)
@fixture
def test_sessionmaker(test_engine):
return async_sessionmaker(test_engine, expire_on_commit=False)
@fixture
async def test_session(test_sessionmaker):
async with test_sessionmaker() as session:
try:
yield session
finally:
pass
@fixture
def app(app_settings, test_engine, test_sessionmaker, test_session):
from fastfood_two.presentation.fastapi_backend.main import app_factory
app = app_factory()
app.dependency_overrides[Config] = lambda: app_settings
app.dependency_overrides[AsyncEngine] = lambda: test_engine
app.dependency_overrides[async_sessionmaker[AsyncSession]] = lambda: test_sessionmaker
app.dependency_overrides[AsyncSession] = lambda: test_session
return app

View File

View File

View File

View File

@ -0,0 +1,74 @@
from uuid import uuid4
from _pytest.fixtures import SubRequest
from pytest import fixture
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from fastfood_two.infrastructure.pg_storage.models import SQLAMenu
def generate_str():
return str(uuid4())
@fixture
async def menu(request: SubRequest, test_session: AsyncSession):
if request.param is not None:
id = uuid4()
title = "test_menu"
description = None
match len(request.param):
case 2:
need_create, need_delete = request.param
if not isinstance(need_create, bool):
raise ValueError
if not isinstance(need_delete, bool):
raise ValueError
case 3:
need_create, need_delete, title = request.param
if not isinstance(need_create, bool):
raise ValueError
if not isinstance(need_delete, bool):
raise ValueError
if not isinstance(title, str | bool):
raise ValueError
if isinstance(title, bool):
title = "test_menu" if not title else generate_str
case 4:
need_create, need_delete, title, description = request.param
if not isinstance(need_create, bool):
raise ValueError
if not isinstance(need_delete, bool):
raise ValueError
if not isinstance(title, str | bool):
raise ValueError
if not isinstance(description, str | bool | None):
raise ValueError
if isinstance(title, bool):
title = "test_menu" if not title else generate_str
if isinstance(description, bool):
description = "test_desc" if not description else generate_str
case _:
raise ValueError
menu = SQLAMenu(id=id, title=title, description=description)
if need_create is True:
test_session.add(menu)
await test_session.commit()
yield menu
if need_delete is True:
stmt = delete(SQLAMenu).where(SQLAMenu.id == menu.id)
await test_session.execute(stmt)
await test_session.commit()
else:
raise ValueError

View File

@ -0,0 +1,65 @@
from pytest import mark
@mark.parametrize(
"menu, is_created",
[((False, False), False), ((True, True), True)],
ids=["Get empty menu list", "Get non-empty menu list"],
indirect=["menu"],
)
async def test_get_menu_list(client, menu, is_created: bool):
response = await client.get("menu/")
assert response.status_code == 200
if is_created:
expected = [menu.to_json()]
else:
expected = []
assert response.json() == expected
@mark.parametrize(
"menu",
[(False, True, False, False), (False, True, False, None)],
ids=["Post menu with description", "Post menu without description"],
indirect=True,
)
async def test_post_menu_add(client, menu):
response = await client.post("menu/", json=menu.to_json())
assert response.status_code == 200
assert response.json() == menu.to_json()
@mark.parametrize(
"menu, json",
[
((True, True), {"title": "updated_title"}),
((True, True), {"title": "updated_title", "description": "updated_description"}),
((True, True), {"title": "updated_title", "description": None}),
],
ids=[
"Update menu title",
"Update menu title and description",
"Update menu title and description to None",
],
indirect=["menu"],
)
async def test_update_menu(client, menu, json):
response = await client.patch(f"menu/{menu.id}", json=json)
assert response.status_code == 200
assert response.json() == {
"id": str(menu.id),
"title": json.get("title"),
"description": json.get("description", None),
}
@mark.parametrize(
"menu",
[(True, False)],
ids=["Delete menu"],
indirect=True,
)
async def test_delete_menu(client, menu):
response = await client.delete(f"menu/{menu.id}")
assert response.status_code == 204

View File

@ -0,0 +1,13 @@
from httpx import ASGITransport, AsyncClient
from pytest import fixture
@fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://127.0.0.1:8000/api/v1/",
) as async_client:
yield async_client

View File

View File

View File

@ -1,6 +0,0 @@
import pytest
@pytest.mark.asyncio
async def test_coreapp():
assert 1 == 1

View File

View File

@ -0,0 +1,3 @@
class TestMenuDomain:
def test_entity(self):
assert 1 == 1

50
tests/utils.py Normal file
View File

@ -0,0 +1,50 @@
import contextlib
import os
from functools import lru_cache
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.ext.asyncio import create_async_engine
from fastfood_two.application.config import Config
from fastfood_two.infrastructure.pg_storage.config import PostgresConfig
from fastfood_two.infrastructure.pg_storage.models import Base
async def db_creator(name: str) -> None:
sql = f"create database {name} with owner test_user;"
async with create_async_engine(
url="postgresql+asyncpg://pi3c:test_password@localhost:5432/test_db",
isolation_level="AUTOCOMMIT",
).begin() as conn:
with contextlib.suppress(ProgrammingError):
await conn.execute(text(sql))
async with create_async_engine(
url=f"postgresql+asyncpg://test_user:test_password@localhost:5432/{name}",
).begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async def db_deleter(name: str) -> None:
sql = f"DROP DATABASE {name} WITH (FORCE);"
async with create_async_engine(
url="postgresql+asyncpg://pi3c:test_password@localhost:5432/test_db",
isolation_level="AUTOCOMMIT",
).begin() as conn:
await conn.execute(text(sql))
@lru_cache()
def get_test_settings(db_name: str) -> Config:
"""Возвращает настройки приложения"""
return Config(
db=PostgresConfig(
user=os.getenv("DB_USER", "test_user"),
password=os.getenv("DB_PASS", "test_password"),
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", 5432)),
dbname=db_name,
),
)