From 7052de31f8fb900f0ca9c0bf07d10f2ae12c49af Mon Sep 17 00:00:00 2001 From: sipmine Date: Tue, 23 Sep 2025 21:42:13 +0700 Subject: [PATCH] feat: subscription service: promocode mub interface --- alembic/versions/056_promocodes.py | 54 ++++ app/main.py | 2 + app/subscriptions/__init__.py | 3 + app/subscriptions/dependencies/__init__.py | 0 .../dependencies/promocodes_dep.py | 37 +++ app/subscriptions/main.py | 39 +++ app/subscriptions/models/__init__.py | 0 app/subscriptions/models/promocodes_db.py | 53 ++++ app/subscriptions/routes/__init__.py | 0 app/subscriptions/routes/promocodes_mub.py | 99 +++++++ poetry.lock | 8 +- pyproject.toml | 2 +- tests/subscriptions/__init__.py | 0 tests/subscriptions/conftest.py | 41 +++ tests/subscriptions/factories.py | 39 +++ tests/subscriptions/functional/__init__.py | 0 .../functional/test_promocodes_list_mub.py | 69 +++++ .../functional/test_promocodes_mub.py | 252 ++++++++++++++++++ 18 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/056_promocodes.py create mode 100644 app/subscriptions/__init__.py create mode 100644 app/subscriptions/dependencies/__init__.py create mode 100644 app/subscriptions/dependencies/promocodes_dep.py create mode 100644 app/subscriptions/main.py create mode 100644 app/subscriptions/models/__init__.py create mode 100644 app/subscriptions/models/promocodes_db.py create mode 100644 app/subscriptions/routes/__init__.py create mode 100644 app/subscriptions/routes/promocodes_mub.py create mode 100644 tests/subscriptions/__init__.py create mode 100644 tests/subscriptions/conftest.py create mode 100644 tests/subscriptions/factories.py create mode 100644 tests/subscriptions/functional/__init__.py create mode 100644 tests/subscriptions/functional/test_promocodes_list_mub.py create mode 100644 tests/subscriptions/functional/test_promocodes_mub.py diff --git a/alembic/versions/056_promocodes.py b/alembic/versions/056_promocodes.py new file mode 100644 index 00000000..c42cbe3f --- /dev/null +++ b/alembic/versions/056_promocodes.py @@ -0,0 +1,54 @@ +"""promocodes + +Revision ID: 056 +Revises: 055 +Create Date: 2026-01-14 13:05:54.325494 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "056" +down_revision: Union[str, None] = "055" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "promocodes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=100), nullable=False), + sa.Column("code", sa.String(length=10), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_promocodes")), + schema="xi_back_2", + ) + op.create_index( + op.f("ix_xi_back_2_promocodes_code"), + "promocodes", + ["code"], + unique=True, + schema="xi_back_2", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_xi_back_2_promocodes_code"), + table_name="promocodes", + schema="xi_back_2", + ) + op.drop_table("promocodes", schema="xi_back_2") + # ### end Alembic commands ### diff --git a/app/main.py b/app/main.py index 154b0214..3d694b72 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ posts, scheduler, storage_v2, + subscriptions, supbot, users, ) @@ -187,6 +188,7 @@ async def custom_swagger_ui_html() -> Response: app.include_router(supbot.api_router) app.include_router(classrooms.api_router) app.include_router(users.api_router) +app.include_router(subscriptions.api_router) old_openapi = app.openapi diff --git a/app/subscriptions/__init__.py b/app/subscriptions/__init__.py new file mode 100644 index 00000000..7d9835dc --- /dev/null +++ b/app/subscriptions/__init__.py @@ -0,0 +1,3 @@ +from app.subscriptions.main import api_router + +__all__ = ["api_router"] diff --git a/app/subscriptions/dependencies/__init__.py b/app/subscriptions/dependencies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/subscriptions/dependencies/promocodes_dep.py b/app/subscriptions/dependencies/promocodes_dep.py new file mode 100644 index 00000000..e061783c --- /dev/null +++ b/app/subscriptions/dependencies/promocodes_dep.py @@ -0,0 +1,37 @@ +from typing import Annotated + +from fastapi import Depends, Path +from starlette import status + +from app.common.fastapi_ext import Responses, with_responses +from app.subscriptions.models.promocodes_db import Promocode + + +class PromocodeResponses(Responses): + PROMOCODE_NOT_FOUND = status.HTTP_404_NOT_FOUND, "Promocode not found" + + +@with_responses(PromocodeResponses) +async def get_promocode_by_id( + promocode_id: Annotated[int, Path()], +) -> Promocode: + promocode = await Promocode.find_first_by_id(promocode_id) + if promocode is None: + raise PromocodeResponses.PROMOCODE_NOT_FOUND + return promocode + + +PromocodeByID = Annotated[Promocode, Depends(get_promocode_by_id)] + + +@with_responses(PromocodeResponses) +async def get_promocode_by_code( + code: Annotated[str, Path()], +) -> Promocode: + promocode = await Promocode.find_first_by_kwargs(code=code) + if promocode is None: + raise PromocodeResponses.PROMOCODE_NOT_FOUND + return promocode + + +PromocodeByCode = Annotated[Promocode, Depends(get_promocode_by_code)] diff --git a/app/subscriptions/main.py b/app/subscriptions/main.py new file mode 100644 index 00000000..f5cbfb8e --- /dev/null +++ b/app/subscriptions/main.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from app.common.dependencies.api_key_dep import APIKeyProtection +from app.common.dependencies.authorization_dep import ProxyAuthorized +from app.common.dependencies.mub_dep import MUBProtection +from app.common.fastapi_ext import APIRouterExt +from app.subscriptions.routes import promocodes_mub + +outside_router = APIRouterExt(prefix="/api/public/subscription-service") + +authorized_router = APIRouterExt( + dependencies=[ProxyAuthorized], + prefix="/api/protected/subscription-service", +) + +mub_router = APIRouterExt( + dependencies=[MUBProtection], + prefix="/mub/subscription-service", +) +mub_router.include_router(promocodes_mub.router) + +internal_router = APIRouterExt( + dependencies=[APIKeyProtection], + prefix="/internal/subscription-service", +) + + +@asynccontextmanager +async def lifespan(_: Any) -> AsyncIterator[None]: + yield + + +api_router = APIRouterExt(lifespan=lifespan) +api_router.include_router(outside_router) +api_router.include_router(authorized_router) +api_router.include_router(mub_router) +api_router.include_router(internal_router) diff --git a/app/subscriptions/models/__init__.py b/app/subscriptions/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/subscriptions/models/promocodes_db.py b/app/subscriptions/models/promocodes_db.py new file mode 100644 index 00000000..dcd481ee --- /dev/null +++ b/app/subscriptions/models/promocodes_db.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import AwareDatetime, Field +from pydantic_marshals.sqlalchemy import MappedModel +from sqlalchemy import DateTime, String, select +from sqlalchemy.orm import Mapped, mapped_column + +from app.common.config import Base +from app.common.sqlalchemy_ext import db +from app.common.utils.datetime import datetime_utc_now + + +class Promocode(Base): + __tablename__ = "promocodes" + + id: Mapped[int] = mapped_column(primary_key=True) + + title: Mapped[str] = mapped_column(String(100)) + code: Mapped[str] = mapped_column(String(10), index=True, unique=True) + + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) + valid_until: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime_utc_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime_utc_now + ) + + TitleType = Annotated[str, Field(min_length=1, max_length=100)] + CodeType = Annotated[str, Field(min_length=1, max_length=10)] + + InputSchema = MappedModel.create( + columns=[ + (title, TitleType), + (code, CodeType), + (valid_from, AwareDatetime | None), + (valid_until, AwareDatetime | None), + ] + ) + ResponseSchema = InputSchema.extend( + columns=[id, (created_at, AwareDatetime), (updated_at, AwareDatetime)] + ) + + @classmethod + async def is_present_by_code(cls, code: str) -> bool: + return await db.is_present(select(cls).filter_by(code=code)) diff --git a/app/subscriptions/routes/__init__.py b/app/subscriptions/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/subscriptions/routes/promocodes_mub.py b/app/subscriptions/routes/promocodes_mub.py new file mode 100644 index 00000000..4875ff3a --- /dev/null +++ b/app/subscriptions/routes/promocodes_mub.py @@ -0,0 +1,99 @@ +from collections.abc import Sequence +from typing import Annotated, Self + +from fastapi import Query +from pydantic import model_validator +from starlette import status + +from app.common.fastapi_ext import APIRouterExt, Responses +from app.common.utils.datetime import datetime_utc_now +from app.subscriptions.dependencies.promocodes_dep import PromocodeByCode, PromocodeByID +from app.subscriptions.models.promocodes_db import Promocode + +router = APIRouterExt(tags=["promocodes mub"]) + + +@router.get( + "/promocodes/", + response_model=list[Promocode.ResponseSchema], + summary="List paginated promocodes", +) +async def list_promocodes( + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 100, +) -> Sequence[Promocode]: + return await Promocode.find_paginated_by_kwargs( + offset, limit, Promocode.created_at.desc() + ) + + +class PromocodeInputSchema(Promocode.InputSchema): + @model_validator(mode="after") + def validate_promocode_valid_from_and_until_date(self) -> Self: + if ( + self.valid_from is not None and self.valid_until is not None + ) and self.valid_from >= self.valid_until: + raise ValueError("the end date cannot be earlier than the start date") + return self + + +class PromocodeConflictResponses(Responses): + PROMOCODE_ALREADY_EXISTS = status.HTTP_409_CONFLICT, "Promocode already exists" + + +@router.post( + "/promocodes/", + status_code=status.HTTP_201_CREATED, + response_model=Promocode.ResponseSchema, + responses=PromocodeConflictResponses.responses(), + summary="Create a new promocode", +) +async def create_promocode(data: PromocodeInputSchema) -> Promocode: + if await Promocode.is_present_by_code(code=data.code): + raise PromocodeConflictResponses.PROMOCODE_ALREADY_EXISTS + return await Promocode.create(**data.model_dump()) + + +@router.get( + "/promocodes/by-id/{promocode_id}/", + response_model=Promocode.ResponseSchema, + summary="Retrieve any promocode by id", +) +async def retrieve_promocode_by_id(promocode: PromocodeByID) -> Promocode: + return promocode + + +@router.get( + "/promocodes/by-code/{code}/", + response_model=Promocode.ResponseSchema, + summary="Retrieve any promocode by code", +) +async def retrieve_promocode_by_code(promocode: PromocodeByCode) -> Promocode: + return promocode + + +@router.put( + "/promocodes/{promocode_id}/", + response_model=Promocode.ResponseSchema, + responses=PromocodeConflictResponses.responses(), + summary="Update any promocode by id", +) +async def put_promocode( + promocode: PromocodeByID, + data: PromocodeInputSchema, +) -> Promocode: + if data.code != promocode.code and await Promocode.is_present_by_code( + code=data.code + ): + raise PromocodeConflictResponses.PROMOCODE_ALREADY_EXISTS + promocode.update(**data.model_dump(), updated_at=datetime_utc_now()) + return promocode + + +@router.delete( + "/promocodes/{promocode_id}/", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete any promocode by id", +) +async def delete_promocode(promocode: PromocodeByID) -> None: + await promocode.delete() diff --git a/poetry.lock b/poetry.lock index 17826cec..ab0b99b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3155,14 +3155,14 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-lazy-fixtures" -version = "1.3.4" +version = "1.4.0" description = "Allows you to use fixtures in @pytest.mark.parametrize." optional = false python-versions = ">=3.8" groups = ["tests"] files = [ - {file = "pytest_lazy_fixtures-1.3.4-py3-none-any.whl", hash = "sha256:3fcb1032f1ffcde367588f4229f7fc6ff64b7a0522c9cd305a08baf39c1c4f5c"}, - {file = "pytest_lazy_fixtures-1.3.4.tar.gz", hash = "sha256:7dd2c110830897b83f041d3a503cbdda10c98ced6dca7602fc43e2f6017c27ed"}, + {file = "pytest_lazy_fixtures-1.4.0-py3-none-any.whl", hash = "sha256:c5db4506fa0ade5887189d1a18857fec4c329b4f49043fef6732c67c9553389a"}, + {file = "pytest_lazy_fixtures-1.4.0.tar.gz", hash = "sha256:f544b60c96b909b307558a62cc1f28f026f11e9f03d7f583a1dc636de3dbcb10"}, ] [package.dependencies] @@ -4256,4 +4256,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "~=3.12,<4.0" -content-hash = "aac06f7746a11c72445f6adb78f2fe9b38416f940765b033c5e82ef868c4f430" +content-hash = "ce2caa8369ff8b651eff2569fa2f3a03b009d4c2bec31e3a5ead53b778af01d1" diff --git a/pyproject.toml b/pyproject.toml index 7af023c6..00ec9785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ dlint = "0.14.0" [tool.poetry.group.tests.dependencies] pytest = "^8.3.5" pytest-cov = "^6.1.1" -pytest-lazy-fixtures = "^1.3.4" +pytest-lazy-fixtures = "^1.4.0" pydantic-marshals = {extras = ["assert-contains"], version = "0.3.18"} freezegun = "^1.5.1" respx = "^0.22.0" diff --git a/tests/subscriptions/__init__.py b/tests/subscriptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py new file mode 100644 index 00000000..ffab221a --- /dev/null +++ b/tests/subscriptions/conftest.py @@ -0,0 +1,41 @@ +import pytest +from faker import Faker + +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.active_session import ActiveSession +from tests.common.types import AnyJSON +from tests.subscriptions import factories + + +@pytest.fixture() +async def promocode(active_session: ActiveSession, faker: Faker) -> Promocode: + async with active_session(): + return await Promocode.create( + **factories.LimitedPromocodeInputFactory.build_python( + code=faker.pystr(min_chars=10, max_chars=10), + ) + ) + + +@pytest.fixture() +async def promocode_data(promocode: Promocode) -> AnyJSON: + return Promocode.ResponseSchema.model_validate(promocode).model_dump(mode="json") + + +@pytest.fixture() +async def other_promocode(active_session: ActiveSession, faker: Faker) -> Promocode: + async with active_session(): + return await Promocode.create( + **factories.LimitedPromocodeInputFactory.build_python( + code=faker.pystr(min_chars=9, max_chars=9), + ) + ) + + +@pytest.fixture() +async def deleted_promocode( + active_session: ActiveSession, promocode: Promocode +) -> Promocode: + async with active_session(): + await promocode.delete() + return promocode diff --git a/tests/subscriptions/factories.py b/tests/subscriptions/factories.py new file mode 100644 index 00000000..7e58c38d --- /dev/null +++ b/tests/subscriptions/factories.py @@ -0,0 +1,39 @@ +from datetime import timezone + +from polyfactory import PostGenerated +from pydantic import AwareDatetime + +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.polyfactory_ext import BaseModelFactory + + +class PromocodeInputSchema(Promocode.InputSchema): + valid_from: AwareDatetime + valid_until: AwareDatetime + + +class LimitedPromocodeInputFactory(BaseModelFactory[PromocodeInputSchema]): + __model__ = PromocodeInputSchema + + valid_until = PostGenerated( + lambda _, values: BaseModelFactory.__faker__.date_time_between( + start_date=values["valid_from"], tzinfo=timezone.utc + ) + ) + + +class UnlimitedPromocodeInputFactory(BaseModelFactory[Promocode.InputSchema]): + __model__ = Promocode.InputSchema + + valid_from = None + valid_until = None + + +class InvalidPeriodPromocodeInputFactory(BaseModelFactory[PromocodeInputSchema]): + __model__ = PromocodeInputSchema + + valid_from = PostGenerated( + lambda _, values: BaseModelFactory.__faker__.date_time_between( + start_date=values["valid_until"], tzinfo=timezone.utc + ) + ) diff --git a/tests/subscriptions/functional/__init__.py b/tests/subscriptions/functional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/subscriptions/functional/test_promocodes_list_mub.py b/tests/subscriptions/functional/test_promocodes_list_mub.py new file mode 100644 index 00000000..a7406e6d --- /dev/null +++ b/tests/subscriptions/functional/test_promocodes_list_mub.py @@ -0,0 +1,69 @@ +from collections.abc import AsyncIterator + +import pytest +from faker import Faker +from starlette.testclient import TestClient + +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.active_session import ActiveSession +from tests.common.assert_contains_ext import assert_response +from tests.subscriptions.factories import LimitedPromocodeInputFactory + +pytestmark = pytest.mark.anyio + +PROMOCODES_LIST_SIZE = 5 +MAX_CODE_CHAR = 10 + + +@pytest.fixture() +async def promocodes( + faker: Faker, + active_session: ActiveSession, +) -> AsyncIterator[list[Promocode]]: + async with active_session(): + promocodes: list[Promocode] = [ + await Promocode.create( + **LimitedPromocodeInputFactory.build_python( + code=faker.pystr(min_chars=1 + i, max_chars=i + 1) + ) + ) + for i in range(PROMOCODES_LIST_SIZE) + ] + + promocodes.sort(key=lambda promocode: promocode.created_at, reverse=True) + + yield promocodes + + async with active_session(): + for promocode in promocodes: + await promocode.delete() + + +@pytest.mark.parametrize( + ("offset", "limit"), + [ + pytest.param(0, PROMOCODES_LIST_SIZE, id="start_to_end"), + pytest.param( + PROMOCODES_LIST_SIZE // 2, PROMOCODES_LIST_SIZE, id="middle_to_end" + ), + pytest.param(0, PROMOCODES_LIST_SIZE // 2, id="start_to_middle"), + ], +) +async def test_promocodes_listing( + mub_client: TestClient, + promocodes: list[Promocode], + offset: int, + limit: int, +) -> None: + assert_response( + mub_client.get( + "/mub/subscription-service/promocodes/", + params={"offset": offset, "limit": limit}, + ), + expected_json=[ + Promocode.ResponseSchema.model_validate( + promocode, from_attributes=True + ).model_dump(mode="json") + for promocode in promocodes[offset:limit] + ], + ) diff --git a/tests/subscriptions/functional/test_promocodes_mub.py b/tests/subscriptions/functional/test_promocodes_mub.py new file mode 100644 index 00000000..7b9917de --- /dev/null +++ b/tests/subscriptions/functional/test_promocodes_mub.py @@ -0,0 +1,252 @@ +from typing import Any + +import pytest +from freezegun import freeze_time +from pytest_lazy_fixtures import lfc +from starlette import status +from starlette.testclient import TestClient + +from app.common.utils.datetime import datetime_utc_now +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.active_session import ActiveSession +from tests.common.assert_contains_ext import ( + assert_nodata_response, + assert_response, +) +from tests.common.polyfactory_ext import BaseModelFactory +from tests.common.types import AnyJSON +from tests.subscriptions import factories + +pytestmark = pytest.mark.anyio + + +promocode_body_factory_parametrization = pytest.mark.parametrize( + "body_factory", + [ + pytest.param( + factories.LimitedPromocodeInputFactory, + id="limited_promocode", + ), + pytest.param( + factories.UnlimitedPromocodeInputFactory, + id="unlimited_promocode", + ), + ], +) + + +@promocode_body_factory_parametrization +@freeze_time() +async def test_promocode_creation( + active_session: ActiveSession, + mub_client: TestClient, + body_factory: type[BaseModelFactory[Any]], +) -> None: + promocode_input_data: AnyJSON = body_factory.build_json() + + promocode_id: int = assert_response( + mub_client.post( + "/mub/subscription-service/promocodes/", + json=promocode_input_data, + ), + expected_code=status.HTTP_201_CREATED, + expected_json={ + **promocode_input_data, + "id": int, + "created_at": datetime_utc_now(), + "updated_at": datetime_utc_now(), + }, + ).json()["id"] + + async with active_session(): + promocode = await Promocode.find_first_by_id(promocode_id) + assert promocode is not None + await promocode.delete() + + +async def test_promocode_creation_invalid_period( + mub_client: TestClient, +) -> None: + assert_response( + mub_client.post( + "/mub/subscription-service/promocodes/", + json=factories.InvalidPeriodPromocodeInputFactory.build_json(), + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, the end date cannot be earlier than the start date", + } + ] + }, + ) + + +async def test_promocode_creation_promocode_already_exists( + mub_client: TestClient, + other_promocode: Promocode, +) -> None: + assert_response( + mub_client.post( + "/mub/subscription-service/promocodes/", + json=factories.LimitedPromocodeInputFactory.build_json( + code=other_promocode.code + ), + ), + expected_code=status.HTTP_409_CONFLICT, + expected_json={"detail": "Promocode already exists"}, + ) + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + lfc(lambda promocode: f"by-id/{promocode.id}/"), + id="by_id", + ), + pytest.param( + lfc(lambda promocode: f"by-code/{promocode.code}/"), + id="by_code", + ), + ], +) +async def test_promocode_retrieving( + mub_client: TestClient, + promocode_data: AnyJSON, + path: str, +) -> None: + assert_response( + mub_client.get( + f"/mub/subscription-service/promocodes/{path}", + ), + expected_json=promocode_data, + ) + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + lfc(lambda deleted_promocode: f"by-id/{deleted_promocode.id}/"), + id="by_id", + ), + pytest.param( + lfc(lambda deleted_promocode: f"by-code/{deleted_promocode.code}/"), + id="by_code", + ), + ], +) +async def test_promocode_retrieving_promocode_not_found( + mub_client: TestClient, + path: str, +) -> None: + assert_response( + mub_client.get( + f"/mub/subscription-service/promocodes/{path}", + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Promocode not found"}, + ) + + +@promocode_body_factory_parametrization +@freeze_time() +async def test_promocode_updating( + mub_client: TestClient, + promocode: Promocode, + promocode_data: AnyJSON, + body_factory: type[BaseModelFactory[Any]], +) -> None: + promocode_put_data = body_factory.build_json() + + assert_response( + mub_client.put( + f"/mub/subscription-service/promocodes/{promocode.id}/", + json=promocode_put_data, + ), + expected_json={ + **promocode_data, + **promocode_put_data, + "updated_at": datetime_utc_now(), + }, + ) + + +async def test_promocode_updating_invalid_period( + mub_client: TestClient, + promocode: Promocode, +) -> None: + assert_response( + mub_client.put( + f"/mub/subscription-service/promocodes/{promocode.id}/", + json=factories.InvalidPeriodPromocodeInputFactory.build_json(), + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, the end date cannot be earlier than the start date", + } + ] + }, + ) + + +async def test_promocode_updating_promocode_already_exists( + mub_client: TestClient, + promocode: Promocode, + other_promocode: Promocode, +) -> None: + assert_response( + mub_client.put( + f"/mub/subscription-service/promocodes/{promocode.id}/", + json=factories.LimitedPromocodeInputFactory.build_json( + code=other_promocode.code + ), + ), + expected_code=status.HTTP_409_CONFLICT, + expected_json={"detail": "Promocode already exists"}, + ) + + +async def test_promocode_deleting( + active_session: ActiveSession, + mub_client: TestClient, + promocode: Promocode, +) -> None: + assert_nodata_response( + mub_client.delete(f"/mub/subscription-service/promocodes/{promocode.id}/"), + ) + + async with active_session(): + assert await Promocode.find_first_by_id(promocode.id) is None + + +@pytest.mark.parametrize( + ("method", "body_factory"), + [ + pytest.param("PUT", factories.LimitedPromocodeInputFactory, id="put"), + pytest.param("DELETE", None, id="delete"), + ], +) +async def test_promocode_not_finding( + mub_client: TestClient, + deleted_promocode: Promocode, + method: str, + body_factory: type[BaseModelFactory[Any]] | None, +) -> None: + assert_response( + mub_client.request( + method, + f"/mub/subscription-service/promocodes/{deleted_promocode.id}/", + json=body_factory and body_factory.build_json(), + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Promocode not found"}, + )