diff --git a/pyrannic/contracts/container/container.py b/pyrannic/contracts/container/container.py index ae5ba9e..6a485cd 100644 --- a/pyrannic/contracts/container/container.py +++ b/pyrannic/contracts/container/container.py @@ -103,7 +103,12 @@ async def resolve( """Resolve the given type from the container.""" @abstractmethod - async def call(self, callback: type[T] | Callable[..., Any]) -> T: + async def call( + self, + callback: type[T] | Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> T: """Call the given callback (Closure, class@method...) and inject its dependencies.""" @abstractmethod diff --git a/pyrannic/orm/sqlalchemy/__init__.py b/pyrannic/orm/sqlalchemy/__init__.py index 8d76337..75d2338 100644 --- a/pyrannic/orm/sqlalchemy/__init__.py +++ b/pyrannic/orm/sqlalchemy/__init__.py @@ -2,6 +2,7 @@ from .async_repository import AsyncRepository as AsyncRepository from .connector import AsyncConnector as AsyncConnector from .connector import Connector as Connector +from .mixins.has_timestamps import HasTimestamp as HasTimestamp from .mixins.has_timestamps import HasTimestamps as HasTimestamps from .mixins.soft_deletes import SoftDeletes as SoftDeletes from .model import Model as Model diff --git a/pyrannic/orm/sqlalchemy/abstract_query_builder.py b/pyrannic/orm/sqlalchemy/abstract_query_builder.py index 373b3d0..fa775b7 100644 --- a/pyrannic/orm/sqlalchemy/abstract_query_builder.py +++ b/pyrannic/orm/sqlalchemy/abstract_query_builder.py @@ -145,12 +145,12 @@ def filter(self, *filters: ColumnExpressionArgument[Any]) -> Self: @overload def filter(self, **kwargs: Any) -> Self: - self._prepare_query() - - if isinstance(self._query, (Select, Delete)): - self._query = self._query.filter_by(**kwargs) + """ + Apply filtering conditions to the query using keyword arguments. - return self + :param kwargs: Column-value pairs for filtering. + :return: The current instance of the query builder. + """ def filter(self, *filters: ColumnExpressionArgument[Any], **kwargs: Any) -> Self: self._prepare_query() diff --git a/pyrannic/orm/sqlalchemy/serializable.py b/pyrannic/orm/sqlalchemy/serializable.py index dfb4a54..586db9a 100644 --- a/pyrannic/orm/sqlalchemy/serializable.py +++ b/pyrannic/orm/sqlalchemy/serializable.py @@ -24,7 +24,7 @@ def to_dict( :return: dict """ result = dict[str, Any]() - columns: list[str] = self.columns + self.properties # pyright: ignore[reportUnknownMemberType] + columns: list[str] = self.columns + self.properties if exclude is None: view_cols = columns @@ -32,10 +32,7 @@ def to_dict( view_cols = filter(lambda e: e not in exclude, columns) for key in view_cols: - try: - result[key] = getattr(self, key) - except Exception as e: - print(key, e) + result[key] = getattr(self, key) if hybrid_attributes: for key in self.hybrid_properties: diff --git a/pyrannic/support/string.py b/pyrannic/support/string.py index c4e9afb..92aefad 100644 --- a/pyrannic/support/string.py +++ b/pyrannic/support/string.py @@ -1,6 +1,18 @@ import re +def to_kebab_case(value: str) -> str: + """Convert a string to kebab case.""" + + value = ( + re.sub(r"(?<=[a-z])(?=[A-Z])|[^a-zA-Z0-9]", " ", value) + .strip() + .replace(" ", "-") + ) + + return "".join(value.lower()) + + def to_snake_case(value: str) -> str: """Convert a string to snake case.""" diff --git a/tests/application/app/http/middlewares/middleware_a.py b/tests/application/app/http/middlewares/middleware_a.py index b4aebfb..45810dc 100644 --- a/tests/application/app/http/middlewares/middleware_a.py +++ b/tests/application/app/http/middlewares/middleware_a.py @@ -6,7 +6,5 @@ class A_Middleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any: - print("A_Middleware: Before request") response = await call_next(request) - print("A_Middleware: After request") return response diff --git a/tests/application/app/http/middlewares/middleware_b.py b/tests/application/app/http/middlewares/middleware_b.py index 2786c9c..7dbf948 100644 --- a/tests/application/app/http/middlewares/middleware_b.py +++ b/tests/application/app/http/middlewares/middleware_b.py @@ -6,7 +6,5 @@ class B_Middleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any: - print("B_Middleware: Before request") response = await call_next(request) - print("B_Middleware: After request") return response diff --git a/tests/application/app/http/routers/heroes.py b/tests/application/app/http/routers/heroes.py index 04cf42a..57c2a48 100644 --- a/tests/application/app/http/routers/heroes.py +++ b/tests/application/app/http/routers/heroes.py @@ -27,13 +27,6 @@ def index( repository: HeroesRepository = Depends(), # repository: Scoped[Repository[HeroModel]], ) -> HeroesCollection: - print( - "Container in index endpoint", - repository, - # repository2, - # foo.get_app_name(), - # bar.foo.get_app_name(), - ) return HeroesCollection(repository.where(HeroModel.name.like("%man%")).paginate()) diff --git a/tests/unit/container/container/test_make.py b/tests/unit/container/container/test_make.py index f4391e3..1f1f1d9 100644 --- a/tests/unit/container/container/test_make.py +++ b/tests/unit/container/container/test_make.py @@ -71,7 +71,6 @@ async def test_make_with_interface_not_bound(container: ContainerInterface): await container.make(FooInterface) error = str(exc_info.value) - print(error) assert "No binding found for interface FooInterface" in error @@ -81,5 +80,4 @@ async def test_make_with_key_not_bound(container: ContainerInterface): await container.make("FooInterface") error = str(exc_info.value) - print(error) assert "No binding found for key FooInterface" in error diff --git a/tests/unit/container/container/test_resolve.py b/tests/unit/container/container/test_resolve.py index e69117c..6300a66 100644 --- a/tests/unit/container/container/test_resolve.py +++ b/tests/unit/container/container/test_resolve.py @@ -71,7 +71,6 @@ async def test_resolve_with_interface_not_bound(container: ContainerInterface): await container.resolve(FooInterface) error = str(exc_info.value) - print(error) assert "No binding found for interface FooInterface" in error @@ -81,5 +80,4 @@ async def test_resolve_with_key_not_bound(container: ContainerInterface): await container.resolve("FooInterface") error = str(exc_info.value) - print(error) assert "No binding found for key FooInterface" in error diff --git a/tests/unit/http/providers/application/app/http/middlewares/middleware_a.py b/tests/unit/http/providers/application/app/http/middlewares/middleware_a.py index b4aebfb..45810dc 100644 --- a/tests/unit/http/providers/application/app/http/middlewares/middleware_a.py +++ b/tests/unit/http/providers/application/app/http/middlewares/middleware_a.py @@ -6,7 +6,5 @@ class A_Middleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any: - print("A_Middleware: Before request") response = await call_next(request) - print("A_Middleware: After request") return response diff --git a/tests/unit/http/providers/application/app/http/middlewares/middleware_b.py b/tests/unit/http/providers/application/app/http/middlewares/middleware_b.py index 2786c9c..7dbf948 100644 --- a/tests/unit/http/providers/application/app/http/middlewares/middleware_b.py +++ b/tests/unit/http/providers/application/app/http/middlewares/middleware_b.py @@ -6,7 +6,5 @@ class B_Middleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any: - print("B_Middleware: Before request") response = await call_next(request) - print("B_Middleware: After request") return response diff --git a/tests/unit/http/providers/application/app/http/routers/heroes.py b/tests/unit/http/providers/application/app/http/routers/heroes.py index 26a7ae9..71fa0ea 100644 --- a/tests/unit/http/providers/application/app/http/routers/heroes.py +++ b/tests/unit/http/providers/application/app/http/routers/heroes.py @@ -3,8 +3,8 @@ from fastapi import APIRouter, Depends from pyrannic import ResourceNotFoundException -from pyrannic.ioc import App, Container, Resolve from pyrannic.contracts import ContainerInterface +from pyrannic.ioc import App, Container, Resolve from tests.application.app.http.resources.hero import Hero, HeroesCollection from tests.application.app.models.hero import Hero as HeroModel from tests.application.app.repositories.heroes import HeroesRepository @@ -28,11 +28,6 @@ def index( bar: Resolve[BarService], repository2: HeroesRepository = Depends(), ) -> HeroesCollection: - print( - "Container in index endpoint", - foo.get_app_name(), - bar.foo.get_app_name(), - ) return HeroesCollection( repository2.where(HeroModel.name.like("%batman%")).paginate() ) diff --git a/tests/unit/http/providers/application_no_providers/app/http/middlewares/a_middleware.py b/tests/unit/http/providers/application_no_providers/app/http/middlewares/a_middleware.py index 474fdd9..1d87eb4 100644 --- a/tests/unit/http/providers/application_no_providers/app/http/middlewares/a_middleware.py +++ b/tests/unit/http/providers/application_no_providers/app/http/middlewares/a_middleware.py @@ -6,7 +6,5 @@ class AMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any: - print("A_Middleware: Before request") response = await call_next(request) - print("A_Middleware: After request") return response diff --git a/tests/unit/http/providers/application_no_providers/app/http/middlewares/b_middleware.py b/tests/unit/http/providers/application_no_providers/app/http/middlewares/b_middleware.py index 6ee29fe..722215a 100644 --- a/tests/unit/http/providers/application_no_providers/app/http/middlewares/b_middleware.py +++ b/tests/unit/http/providers/application_no_providers/app/http/middlewares/b_middleware.py @@ -6,7 +6,5 @@ class BMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any: - print("B_Middleware: Before request") response = await call_next(request) - print("B_Middleware: After request") return response diff --git a/tests/unit/http/providers/application_no_providers/app/http/routers/heroes.py b/tests/unit/http/providers/application_no_providers/app/http/routers/heroes.py index 26a7ae9..71fa0ea 100644 --- a/tests/unit/http/providers/application_no_providers/app/http/routers/heroes.py +++ b/tests/unit/http/providers/application_no_providers/app/http/routers/heroes.py @@ -3,8 +3,8 @@ from fastapi import APIRouter, Depends from pyrannic import ResourceNotFoundException -from pyrannic.ioc import App, Container, Resolve from pyrannic.contracts import ContainerInterface +from pyrannic.ioc import App, Container, Resolve from tests.application.app.http.resources.hero import Hero, HeroesCollection from tests.application.app.models.hero import Hero as HeroModel from tests.application.app.repositories.heroes import HeroesRepository @@ -28,11 +28,6 @@ def index( bar: Resolve[BarService], repository2: HeroesRepository = Depends(), ) -> HeroesCollection: - print( - "Container in index endpoint", - foo.get_app_name(), - bar.foo.get_app_name(), - ) return HeroesCollection( repository2.where(HeroModel.name.like("%batman%")).paginate() ) diff --git a/tests/unit/http/resources/utils.py b/tests/unit/http/resources/utils.py index 6bedc26..4f9cccd 100644 --- a/tests/unit/http/resources/utils.py +++ b/tests/unit/http/resources/utils.py @@ -35,9 +35,6 @@ class FooResourceWithRelationships(Resource, HasTimestamps, SoftDeletes): @classmethod def _relationships(cls, model: SerializableInterface) -> dict[str, Any]: - - print("_relationships called with model:", getattr(model, "children", [])) - return { "children": [ FooResourceWithRelationships.from_model(child) diff --git a/tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamp.py b/tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamp.py new file mode 100644 index 0000000..021e37b --- /dev/null +++ b/tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamp.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone + +from pyrannic.support.datetime import get_current_utc_datetime +from tests.unit.orm.sqlalchemy.utils import HasTimestampModel + + +def test_initial_created_at() -> None: + created_at = get_current_utc_datetime() + model = HasTimestampModel() + + assert model.created_at.tzinfo == timezone.utc + assert abs((model.created_at - created_at).total_seconds()) < 1 + + +def test_set_created_at() -> None: + model = HasTimestampModel() + assert model.created_at is not None + + new_created_at = datetime(2024, 1, 1, 12, 0, 0) + model.set_created_at(new_created_at) + assert model.created_at == new_created_at diff --git a/tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamps.py b/tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamps.py new file mode 100644 index 0000000..6bcc366 --- /dev/null +++ b/tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamps.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone + +from pyrannic.support.datetime import get_current_utc_datetime +from tests.unit.orm.sqlalchemy.utils import HasTimestampsModel + + +def test_initial_created_at() -> None: + created_at = get_current_utc_datetime() + model = HasTimestampsModel() + + assert model.created_at.tzinfo == timezone.utc + assert abs((model.created_at - created_at).total_seconds()) < 1 + + +def test_set_created_at() -> None: + model = HasTimestampsModel() + assert model.created_at is not None + + new_created_at = datetime(2024, 1, 1, 12, 0, 0) + model.set_created_at(new_created_at) + assert model.created_at == new_created_at + + +def test_initial_updated_at() -> None: + updated_at = get_current_utc_datetime() + model = HasTimestampsModel() + + assert model.updated_at.tzinfo == timezone.utc + assert abs((model.updated_at - updated_at).total_seconds()) < 1 + + +def test_set_updated_at() -> None: + model = HasTimestampsModel() + assert model.updated_at is not None + + new_updated_at = datetime(2024, 1, 1, 12, 0, 0) + model.set_updated_at(new_updated_at) + assert model.updated_at == new_updated_at diff --git a/tests/unit/orm/sqlalchemy/mixins/test_sa_soft_deletes.py b/tests/unit/orm/sqlalchemy/mixins/test_sa_soft_deletes.py new file mode 100644 index 0000000..6ecb61a --- /dev/null +++ b/tests/unit/orm/sqlalchemy/mixins/test_sa_soft_deletes.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from tests.unit.orm.sqlalchemy.utils import SoftDeletesModel + + +def test_initial_deleted_at() -> None: + model = SoftDeletesModel() + assert model.deleted_at is None + + +def test_set_deleted_at() -> None: + model = SoftDeletesModel() + assert model.deleted_at is None + + new_deleted_at = datetime(2024, 1, 1, 12, 0, 0) + model.set_deleted_at(new_deleted_at) + assert model.deleted_at == new_deleted_at + + +def test_is_deleted_property() -> None: + model = SoftDeletesModel() + assert model.is_deleted is False + + model.set_deleted_at(datetime(2024, 1, 1, 12, 0, 0)) + assert model.is_deleted is True + + +def test_deleted_at_column_name() -> None: + assert SoftDeletesModel.deleted_at_column() == "deleted_at" diff --git a/tests/unit/orm/sqlalchemy/test_sa_connector.py b/tests/unit/orm/sqlalchemy/test_sa_connector.py new file mode 100644 index 0000000..9cf004d --- /dev/null +++ b/tests/unit/orm/sqlalchemy/test_sa_connector.py @@ -0,0 +1,49 @@ +from logging import Logger +from unittest.mock import Mock + +import pytest +from pytest import MonkeyPatch +from sqlalchemy.ext.asyncio import AsyncEngine + +from pyrannic.contracts import ApplicationInterface +from pyrannic.orm.sqlalchemy import AsyncConnector, Connector + + +@pytest.mark.asyncio +async def test_connector_disconnect( + application: ApplicationInterface, + monkeypatch: MonkeyPatch, +) -> None: + container = application.container + connector = Connector( + application, + await container.resolve(Logger), + await container.resolve("config"), + ) + + mock = Mock() + + monkeypatch.setattr(connector, "_engine", mock) + + await connector.disconnect() + mock.dispose.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_connector_disconnect( + application: ApplicationInterface, + monkeypatch: MonkeyPatch, +) -> None: + container = application.container + connector = AsyncConnector( + application, + await container.resolve(Logger), + await container.resolve("config"), + ) + + mock = Mock(spec=AsyncEngine) + + monkeypatch.setattr(connector, "_engine", mock) + + await connector.disconnect() + mock.dispose.assert_called_once() diff --git a/tests/unit/orm/sqlalchemy/test_sa_inspectionable.py b/tests/unit/orm/sqlalchemy/test_sa_inspectionable.py new file mode 100644 index 0000000..0b46cb5 --- /dev/null +++ b/tests/unit/orm/sqlalchemy/test_sa_inspectionable.py @@ -0,0 +1,39 @@ +from tests.unit.orm.sqlalchemy.utils import BarModel + + +def test_properties(): + assert BarModel.properties == ["upper_name"] + + +def test_columns(): + assert BarModel.columns == ["id", "name", "parent_id"] + + +def test_primary_keys_full(): + assert BarModel.primary_keys_full[0].key == "id" + + +def test_primary_keys(): + assert BarModel.primary_keys == ["id"] + + +def test_relations(): + assert BarModel.relations == ["parent", "children"] + + +def test_settable_relations(): + assert BarModel.settable_relations == ["parent", "children"] + + +def test_hybrid_properties(): + assert BarModel.hybrid_properties == ["slug"] + + +# TODO: Add hybrid methods to BarModel +def test_hybrid_methods_full(): + assert BarModel.hybrid_methods_full == {} + + +# TODO: Add hybrid methods to BarModel +def test_hybrid_methods(): + assert BarModel.hybrid_methods == [] diff --git a/tests/unit/orm/sqlalchemy/test_sa_query_builder.py b/tests/unit/orm/sqlalchemy/test_sa_query_builder.py index cf98976..2b59a5c 100644 --- a/tests/unit/orm/sqlalchemy/test_sa_query_builder.py +++ b/tests/unit/orm/sqlalchemy/test_sa_query_builder.py @@ -18,7 +18,6 @@ @pytest.mark.asyncio async def test_query_builder(application: ApplicationInterface): repository = await application.container.resolve(Repository[BarModel]) - assert isinstance(repository.session, Session) diff --git a/tests/unit/orm/sqlalchemy/test_sa_repository.py b/tests/unit/orm/sqlalchemy/test_sa_repository.py new file mode 100644 index 0000000..81ecf5a --- /dev/null +++ b/tests/unit/orm/sqlalchemy/test_sa_repository.py @@ -0,0 +1,13 @@ +import pytest + +from pyrannic.contracts.application import ApplicationInterface +from pyrannic.orm.sqlalchemy import ( + Repository, +) +from tests.unit.orm.sqlalchemy.utils import BarModel + + +@pytest.mark.asyncio +async def test_model(application: ApplicationInterface): + repository = await application.container.resolve(Repository[BarModel]) + assert repository.model == BarModel diff --git a/tests/unit/orm/sqlalchemy/test_sa_schema.py b/tests/unit/orm/sqlalchemy/test_sa_schema.py index 7e2e096..e2d7c14 100644 --- a/tests/unit/orm/sqlalchemy/test_sa_schema.py +++ b/tests/unit/orm/sqlalchemy/test_sa_schema.py @@ -125,8 +125,11 @@ async def test_async_drop(application: ApplicationInterface): @pytest.mark.asyncio async def test_log_on_exception( - caplog: pytest.LogCaptureFixture, application: ApplicationInterface + caplog: pytest.LogCaptureFixture, + application: ApplicationInterface, ): + # Use a driver that will raise an exception when trying to create/drop the table + Config.set("database.connections.sqlite.driver", "sqlite+aiosqlite") Config.set("database.connections.sqlite.database", ":memory:") application.container.singleton(ConnectorInterface, Connector) diff --git a/tests/unit/orm/sqlalchemy/test_sa_serializable.py b/tests/unit/orm/sqlalchemy/test_sa_serializable.py new file mode 100644 index 0000000..9e47f43 --- /dev/null +++ b/tests/unit/orm/sqlalchemy/test_sa_serializable.py @@ -0,0 +1,67 @@ +from tests.unit.orm.sqlalchemy.utils import BarModel + + +def test_to_dict(): + bar = BarModel(id=1, name="Bar") + data = bar.to_dict() + + assert data == {"id": 1, "name": "Bar", "parent_id": None, "upper_name": "BAR"} + + +def test_to_dict_with_exclude(): + bar = BarModel(id=1, name="Bar") + data = bar.to_dict(exclude=["name"]) + + assert data == {"id": 1, "parent_id": None, "upper_name": "BAR"} + + +def test_to_dict_with_hybrid_property(): + bar = BarModel(id=1, name="BarBarBar") + data = bar.to_dict(hybrid_attributes=True) + + assert data == { + "id": 1, + "name": "BarBarBar", + "slug": "bar-bar-bar", + "parent_id": None, + "upper_name": "BARBARBAR", + } + + +def test_to_dict_with_exclude_and_hybrid_property(): + bar = BarModel(id=1, name="BarBarBar") + data = bar.to_dict(exclude=["name"], hybrid_attributes=True) + + assert data == { + "id": 1, + "slug": "bar-bar-bar", + "parent_id": None, + "upper_name": "BARBARBAR", + } + + +def test_to_dict_with_missing_exclude(): + bar = BarModel(id=1, name="Bar") + data = bar.to_dict(exclude=["non_existent_property"]) + + assert data == {"id": 1, "name": "Bar", "parent_id": None, "upper_name": "BAR"} + + +def test_to_dict_with_nested_relationship(): + bar1 = BarModel(id=1, name="Bar1") + bar2 = BarModel(id=2, name="Bar2") + + bar1.parent_id = bar2.id + bar1.parent = bar2 + bar2.children.append(bar1) + + data = bar1.to_dict(nested=True) + + assert data == { + "id": 1, + "name": "Bar1", + "upper_name": "BAR1", + "parent_id": 2, + "parent": {"id": 2, "name": "Bar2", "parent_id": None, "upper_name": "BAR2"}, + "children": [], + } diff --git a/tests/unit/orm/sqlalchemy/utils.py b/tests/unit/orm/sqlalchemy/utils.py index d488509..a40475c 100644 --- a/tests/unit/orm/sqlalchemy/utils.py +++ b/tests/unit/orm/sqlalchemy/utils.py @@ -1,13 +1,41 @@ -from sqlalchemy import Integer, String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import Mapped, mapped_column, relationship +import pyrannic.support.string as string from pyrannic.database.migration import Migration -from pyrannic.orm.sqlalchemy.model import Model +from pyrannic.orm.sqlalchemy import HasTimestamp, HasTimestamps, Model, SoftDeletes class BarModel(Model): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255)) + parent_id: Mapped[int] = mapped_column(ForeignKey("bars.id"), nullable=True) + parent: Mapped["BarModel | None"] = relationship( + remote_side=[id], + back_populates="children", + ) + children: Mapped[list["BarModel"]] = relationship(back_populates="parent") + + @hybrid_property + def slug(self) -> str: + return string.to_kebab_case(self.name) + + @property + def upper_name(self) -> str: + return self.name.upper() + + +class HasTimestampModel(Model, HasTimestamp): + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + +class HasTimestampsModel(Model, HasTimestamps): + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + +class SoftDeletesModel(Model, SoftDeletes): + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) class BarsTable(Migration): diff --git a/tests/unit/support/string/test_to_kebab_case.py b/tests/unit/support/string/test_to_kebab_case.py new file mode 100644 index 0000000..80bfe5f --- /dev/null +++ b/tests/unit/support/string/test_to_kebab_case.py @@ -0,0 +1,23 @@ +import pytest + +from pyrannic.support.string import to_kebab_case + + +@pytest.mark.parametrize( + "input_val,expected", + [ + ("HelloWorld", "hello-world"), + ("Hello World", "hello-world"), + ("hello World Test", "hello-world-test"), + ("HTTP Response Code", "http-response-code"), + ("already_snake", "already-snake"), + ("with spaces", "with-spaces"), + ("with-dash", "with-dash"), + ("numbers123", "numbers123"), + ("Numbers123Numbers", "numbers123numbers"), + ("", ""), + ], +) +def test_to_kebab_case_various(input_val: str, expected: str) -> None: + """Test the to_kebab_case function with various input strings.""" + assert to_kebab_case(input_val) == expected