From 9d8bf8cacc9c034dc5b999ad587f6de71600afa6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:29:09 +0000 Subject: [PATCH 01/11] Add comprehensive unit tests for uncovered areas Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/22cc2300-8b7e-4938-90e1-9a50c67f8730 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- .../v1/test_unit/test_enums_and_exceptions.py | 104 +++++ tests/v1/test_unit/test_schemas_base.py | 231 +++++++++++ tests/v1/test_unit/test_utils.py | 54 +++ tests/v2/test_unit/test_exceptions.py | 88 ++++ tests/v2/test_unit/test_factory.py | 125 ++++++ tests/v2/test_unit/test_payload_mixin.py | 50 +++ tests/v2/test_unit/test_query_dsl_tokens.py | 376 ++++++++++++++++++ tests/v2/test_unit/test_schemas_base.py | 45 +++ tests/v2/test_unit/test_serializer_mixin.py | 126 ++++++ tests/v2/test_unit/test_updated_by_mixin.py | 79 ++++ 10 files changed, 1278 insertions(+) create mode 100644 tests/v1/test_unit/test_enums_and_exceptions.py create mode 100644 tests/v1/test_unit/test_schemas_base.py create mode 100644 tests/v1/test_unit/test_utils.py create mode 100644 tests/v2/test_unit/test_exceptions.py create mode 100644 tests/v2/test_unit/test_factory.py create mode 100644 tests/v2/test_unit/test_payload_mixin.py create mode 100644 tests/v2/test_unit/test_query_dsl_tokens.py create mode 100644 tests/v2/test_unit/test_schemas_base.py create mode 100644 tests/v2/test_unit/test_serializer_mixin.py create mode 100644 tests/v2/test_unit/test_updated_by_mixin.py diff --git a/tests/v1/test_unit/test_enums_and_exceptions.py b/tests/v1/test_unit/test_enums_and_exceptions.py new file mode 100644 index 0000000..e7dd4be --- /dev/null +++ b/tests/v1/test_unit/test_enums_and_exceptions.py @@ -0,0 +1,104 @@ +import pytest + +from notora.v1.enums.base import OrderByDirections +from notora.v1.exceptions.common import AlreadyExistsError, FKNotFoundError, NotFoundError + + +class TestOrderByDirections: + def test_asc_value(self) -> None: + assert OrderByDirections.ASC == 'asc' + + def test_desc_value(self) -> None: + assert OrderByDirections.DESC == 'desc' + + def test_is_str_enum(self) -> None: + assert isinstance(OrderByDirections.ASC, str) + assert isinstance(OrderByDirections.DESC, str) + + def test_can_be_used_as_string(self) -> None: + assert f'{OrderByDirections.ASC}' == 'asc' + assert f'{OrderByDirections.DESC}' == 'desc' + + +class TestFKNotFoundError: + def test_stores_fk_name(self) -> None: + err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') + assert err.fk_name == 'user_id_fkey' + + def test_stores_table_name(self) -> None: + err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') + assert err.table_name == 'post' + + def test_message_is_accessible(self) -> None: + err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') + assert str(err) == 'Related object not found.' + + def test_is_exception(self) -> None: + err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') + assert isinstance(err, Exception) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(FKNotFoundError) as exc_info: + raise FKNotFoundError('err', fk_name='fk', table_name='tbl') + assert exc_info.value.fk_name == 'fk' + + +class TestAlreadyExistsError: + def test_default_message(self) -> None: + err = AlreadyExistsError() + assert str(err) == 'Entity already exists.' + + def test_custom_message(self) -> None: + err = AlreadyExistsError('Custom message.') + assert str(err) == 'Custom message.' + + def test_constraint_name_stored(self) -> None: + err = AlreadyExistsError(constraint_name='users_email_key') + assert err.constraint_name == 'users_email_key' + + def test_constraint_name_none_by_default(self) -> None: + err = AlreadyExistsError() + assert err.constraint_name is None + + def test_message_and_constraint_together(self) -> None: + err = AlreadyExistsError('Dup', constraint_name='my_constraint') + assert str(err) == 'Dup' + assert err.constraint_name == 'my_constraint' + + def test_is_exception(self) -> None: + assert isinstance(AlreadyExistsError(), Exception) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(AlreadyExistsError): + raise AlreadyExistsError('dup') + + +class TestNotFoundError: + def test_entity_id_none_by_default(self) -> None: + err = NotFoundError('not found') + assert err.entity_id is None + + def test_entity_id_stored(self) -> None: + err = NotFoundError('not found', entity_id=42) + assert err.entity_id == 42 + + def test_entity_id_uuid(self) -> None: + from uuid import uuid4 + uid = uuid4() + err = NotFoundError('not found', entity_id=uid) + assert err.entity_id == uid + + def test_message_preserved(self) -> None: + err = NotFoundError('Resource not found.') + assert str(err) == 'Resource not found.' + + def test_is_exception(self) -> None: + assert isinstance(NotFoundError('x'), Exception) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(NotFoundError): + raise NotFoundError('missing') + + def test_no_positional_args(self) -> None: + err = NotFoundError() + assert err.entity_id is None diff --git a/tests/v1/test_unit/test_schemas_base.py b/tests/v1/test_unit/test_schemas_base.py new file mode 100644 index 0000000..de21163 --- /dev/null +++ b/tests/v1/test_unit/test_schemas_base.py @@ -0,0 +1,231 @@ +from datetime import UTC, datetime, timedelta, timezone +from ipaddress import IPv4Address, IPv6Address +from uuid import uuid4 + +import pytest + +from notora.v1.schemas.base import ( + AdminMeta, + ClientMeta, + Filter, + OrFilterGroup, + OrderBy, + PaginationMetaSchema, + datetime_encoder, + normalize_datetime_to_utc, + utc_datetime_encoder, +) +from notora.v1.enums.base import OrderByDirections + + +class TestNormalizeDatetimeToUtc: + def test_naive_datetime_gets_utc_tzinfo(self) -> None: + naive = datetime(2024, 6, 15, 12, 0, 0) + result = normalize_datetime_to_utc(naive) + assert result.tzinfo == UTC + + def test_naive_datetime_value_unchanged(self) -> None: + naive = datetime(2024, 6, 15, 12, 0, 0) + result = normalize_datetime_to_utc(naive) + assert result.replace(tzinfo=None) == naive + + def test_utc_aware_datetime_unchanged(self) -> None: + aware = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + result = normalize_datetime_to_utc(aware) + assert result == aware + + def test_offset_aware_datetime_converted_to_utc(self) -> None: + tz_plus2 = timezone(timedelta(hours=2)) + aware = datetime(2024, 6, 15, 14, 0, 0, tzinfo=tz_plus2) + result = normalize_datetime_to_utc(aware) + assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + assert result.tzinfo == UTC + + def test_negative_offset_converted_to_utc(self) -> None: + tz_minus5 = timezone(timedelta(hours=-5)) + aware = datetime(2024, 6, 15, 7, 0, 0, tzinfo=tz_minus5) + result = normalize_datetime_to_utc(aware) + assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + + +class TestUtcDatetimeEncoder: + def test_returns_iso_string_with_z(self) -> None: + dt = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC) + result = utc_datetime_encoder(dt) + assert result == '2024-01-20T09:15:30Z' + + def test_naive_datetime_treated_as_utc(self) -> None: + naive = datetime(2024, 1, 20, 9, 15, 30) + result = utc_datetime_encoder(naive) + assert result == '2024-01-20T09:15:30Z' + + def test_offset_aware_datetime_converted(self) -> None: + tz_plus3 = timezone(timedelta(hours=3)) + dt = datetime(2024, 1, 20, 12, 15, 30, tzinfo=tz_plus3) + result = utc_datetime_encoder(dt) + assert result == '2024-01-20T09:15:30Z' + + def test_does_not_contain_plus00_00(self) -> None: + dt = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) + result = utc_datetime_encoder(dt) + assert '+00:00' not in result + + +class TestDatetimeEncoder: + def test_returns_float_timestamp(self) -> None: + dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + result = datetime_encoder(dt) + assert isinstance(result, float) + + def test_naive_datetime_treated_as_utc(self) -> None: + naive = datetime(2024, 1, 1, 0, 0, 0) + aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert datetime_encoder(naive) == datetime_encoder(aware) + + def test_offset_datetime_normalized(self) -> None: + tz_plus2 = timezone(timedelta(hours=2)) + offset = datetime(2024, 1, 1, 2, 0, 0, tzinfo=tz_plus2) + utc = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert datetime_encoder(offset) == datetime_encoder(utc) + + +class TestPaginationMetaSchema: + def test_first_page_full(self) -> None: + meta = PaginationMetaSchema.calculate(total=100, limit=10, offset=0) + assert meta.current_page == 1 + assert meta.last_page == 10 + assert meta.total == 100 + assert meta.limit == 10 + + def test_second_page(self) -> None: + meta = PaginationMetaSchema.calculate(total=100, limit=10, offset=10) + assert meta.current_page == 2 + + def test_last_page_calculated(self) -> None: + meta = PaginationMetaSchema.calculate(total=25, limit=10, offset=0) + assert meta.last_page == 3 + + def test_zero_total_gives_page_1(self) -> None: + meta = PaginationMetaSchema.calculate(total=0, limit=10, offset=0) + assert meta.current_page == 1 + assert meta.last_page == 1 + assert meta.total == 0 + + def test_exact_multiple_total(self) -> None: + meta = PaginationMetaSchema.calculate(total=20, limit=10, offset=0) + assert meta.last_page == 2 + + def test_total_less_than_limit_gives_page_1(self) -> None: + meta = PaginationMetaSchema.calculate(total=5, limit=10, offset=0) + assert meta.current_page == 1 + assert meta.last_page == 1 + + +class TestAdminMeta: + def test_deleted_at_is_none_by_default(self) -> None: + meta = AdminMeta( + created_at=datetime(2024, 1, 1, tzinfo=UTC), + updated_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + assert meta.deleted_at is None + + def test_deleted_at_can_be_set(self) -> None: + dt = datetime(2024, 6, 1, 12, 0, tzinfo=UTC) + meta = AdminMeta( + created_at=datetime(2024, 1, 1, tzinfo=UTC), + updated_at=datetime(2024, 1, 1, tzinfo=UTC), + deleted_at=dt, + ) + assert meta.deleted_at == dt + + def test_timestamps_normalized_to_utc(self) -> None: + naive = datetime(2024, 1, 1, 10, 0, 0) + meta = AdminMeta(created_at=naive, updated_at=naive) + assert meta.created_at.tzinfo == UTC + assert meta.updated_at.tzinfo == UTC + + +class TestClientMeta: + def test_both_fields_none_by_default(self) -> None: + client = ClientMeta() + assert client.ip_address is None + assert client.user_agent is None + + def test_ipv4_address_accepted(self) -> None: + client = ClientMeta(ip_address=IPv4Address('127.0.0.1')) + assert isinstance(client.ip_address, IPv4Address) + + def test_ipv6_address_accepted(self) -> None: + client = ClientMeta(ip_address=IPv6Address('::1')) + assert isinstance(client.ip_address, IPv6Address) + + def test_user_agent_stored(self) -> None: + client = ClientMeta(user_agent='Mozilla/5.0') + assert client.user_agent == 'Mozilla/5.0' + + def test_ip_address_serialized_as_string(self) -> None: + client = ClientMeta(ip_address=IPv4Address('192.168.0.1')) + dumped = client.model_dump() + assert dumped['ip_address'] == '192.168.0.1' + + +class TestFilter: + def test_default_op_is_eq(self) -> None: + f = Filter(field='name', value='alice') + assert f.op == '=' + + def test_custom_op(self) -> None: + f = Filter(field='age', op='gt', value=18) + assert f.op == 'gt' + + def test_value_none_allowed(self) -> None: + f = Filter(field='deleted_at', op='is', value=None) + assert f.value is None + + def test_model_none_by_default(self) -> None: + f = Filter(field='name', value='x') + assert f.model is None + + def test_model_can_be_set(self) -> None: + class FakeModel: + pass + f = Filter(field='name', value='x', model=FakeModel) + assert f.model is FakeModel + + def test_all_ops_accepted(self) -> None: + valid_ops = ('eq', '=', 'ilike', '~=', 'is', 'is_not', 'in', 'gt', '>', 'ge', '>=', 'lt', '<', 'le', '<=') + for op in valid_ops: + f = Filter(field='x', op=op, value=1) # type: ignore[arg-type] + assert f.op == op + + +class TestOrFilterGroup: + def test_stores_filters(self) -> None: + f1 = Filter(field='name', value='a') + f2 = Filter(field='name', value='b') + group = OrFilterGroup(filters=[f1, f2]) + assert len(group.filters) == 2 + + def test_empty_filters_allowed(self) -> None: + group = OrFilterGroup(filters=[]) + assert group.filters == [] + + +class TestOrderBy: + def test_default_direction_is_asc(self) -> None: + ob = OrderBy(field='name') + assert ob.direction == OrderByDirections.ASC + + def test_desc_direction(self) -> None: + ob = OrderBy(field='name', direction=OrderByDirections.DESC) + assert ob.direction == OrderByDirections.DESC + + def test_model_none_by_default(self) -> None: + ob = OrderBy(field='name') + assert ob.model is None + + def test_model_can_be_set(self) -> None: + class FakeModel: + pass + ob = OrderBy(field='name', model=FakeModel) + assert ob.model is FakeModel diff --git a/tests/v1/test_unit/test_utils.py b/tests/v1/test_unit/test_utils.py new file mode 100644 index 0000000..bd3f6e7 --- /dev/null +++ b/tests/v1/test_unit/test_utils.py @@ -0,0 +1,54 @@ +from datetime import UTC, datetime + +import pytest + +from notora.utils.time import now_without_tz +from notora.utils.validation import validate_exclusive_presence + + +class TestNowWithoutTz: + def test_returns_datetime_without_tzinfo(self) -> None: + result = now_without_tz() + assert isinstance(result, datetime) + assert result.tzinfo is None + + def test_is_close_to_utc_now(self) -> None: + before = datetime.now(UTC).replace(tzinfo=None) + result = now_without_tz() + after = datetime.now(UTC).replace(tzinfo=None) + assert before <= result <= after + + def test_called_twice_is_non_decreasing(self) -> None: + first = now_without_tz() + second = now_without_tz() + assert first <= second + + +class TestValidateExclusivePresence: + def test_first_only_does_not_raise(self) -> None: + validate_exclusive_presence('value', None) + + def test_second_only_does_not_raise(self) -> None: + validate_exclusive_presence(None, 'value') + + def test_both_provided_raises(self) -> None: + with pytest.raises(ValueError, match='Exactly one'): + validate_exclusive_presence('a', 'b') + + def test_neither_provided_raises(self) -> None: + with pytest.raises(ValueError, match='Exactly one'): + validate_exclusive_presence(None, None) + + def test_falsy_non_none_first_counts_as_provided(self) -> None: + # 0, '', [] are not None — should NOT raise + validate_exclusive_presence(0, None) + validate_exclusive_presence('', None) + validate_exclusive_presence([], None) + + def test_falsy_non_none_both_raises(self) -> None: + with pytest.raises(ValueError): + validate_exclusive_presence(0, 0) + + def test_non_string_values_accepted(self) -> None: + validate_exclusive_presence(42, None) + validate_exclusive_presence(None, {'key': 'val'}) diff --git a/tests/v2/test_unit/test_exceptions.py b/tests/v2/test_unit/test_exceptions.py new file mode 100644 index 0000000..e438241 --- /dev/null +++ b/tests/v2/test_unit/test_exceptions.py @@ -0,0 +1,88 @@ +import pytest + +from notora.v2.exceptions.common import AlreadyExistsError, FKNotFoundError, NotFoundError + + +class TestFKNotFoundError: + def test_stores_fk_name(self) -> None: + err = FKNotFoundError('msg', fk_name='profile_user_id_fkey', table_name='profile') + assert err.fk_name == 'profile_user_id_fkey' + + def test_stores_table_name(self) -> None: + err = FKNotFoundError('msg', fk_name='fk', table_name='orders') + assert err.table_name == 'orders' + + def test_message_is_accessible(self) -> None: + err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') + assert str(err) == 'Related object not found.' + + def test_is_exception(self) -> None: + err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') + assert isinstance(err, Exception) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(FKNotFoundError) as exc_info: + raise FKNotFoundError('err', fk_name='fk', table_name='tbl') + assert exc_info.value.fk_name == 'fk' + assert exc_info.value.table_name == 'tbl' + + +class TestAlreadyExistsError: + def test_default_message(self) -> None: + err = AlreadyExistsError() + assert str(err) == 'Entity already exists.' + + def test_custom_message(self) -> None: + err = AlreadyExistsError('Custom message.') + assert str(err) == 'Custom message.' + + def test_constraint_name_stored(self) -> None: + err = AlreadyExistsError(constraint_name='users_email_key') + assert err.constraint_name == 'users_email_key' + + def test_constraint_name_none_by_default(self) -> None: + err = AlreadyExistsError() + assert err.constraint_name is None + + def test_message_and_constraint_together(self) -> None: + err = AlreadyExistsError('Dup', constraint_name='my_constraint') + assert str(err) == 'Dup' + assert err.constraint_name == 'my_constraint' + + def test_is_exception(self) -> None: + assert isinstance(AlreadyExistsError(), Exception) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(AlreadyExistsError): + raise AlreadyExistsError('dup') + + +class TestNotFoundError: + def test_entity_id_none_by_default(self) -> None: + err = NotFoundError('not found') + assert err.entity_id is None + + def test_entity_id_integer(self) -> None: + err = NotFoundError('not found', entity_id=42) + assert err.entity_id == 42 + + def test_entity_id_uuid(self) -> None: + from uuid import uuid4 + uid = uuid4() + err = NotFoundError('not found', entity_id=uid) + assert err.entity_id == uid + + def test_message_preserved(self) -> None: + err = NotFoundError('Resource not found.') + assert str(err) == 'Resource not found.' + + def test_is_exception(self) -> None: + assert isinstance(NotFoundError('x'), Exception) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(NotFoundError): + raise NotFoundError('missing') + + def test_no_positional_args(self) -> None: + err = NotFoundError() + assert err.entity_id is None diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py new file mode 100644 index 0000000..765b108 --- /dev/null +++ b/tests/v2/test_unit/test_factory.py @@ -0,0 +1,125 @@ +"""Tests for build_repository, build_service, and build_service_for_repo factories.""" + +import pytest +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from notora.v2.models.base import GenericBaseModel +from notora.v2.repositories.base import Repository, SoftDeleteRepository +from notora.v2.repositories.config import RepoConfig +from notora.v2.repositories.factory import build_repository +from notora.v2.schemas.base import BaseResponseSchema +from notora.v2.services.base import RepositoryService, SoftDeleteRepositoryService +from notora.v2.services.factory import build_service, build_service_for_repo + + +class _Widget(GenericBaseModel): + name: Mapped[str] = mapped_column(String) + + +class _WidgetSchema(BaseResponseSchema): + pass + + +class TestBuildRepository: + def test_returns_standard_repo_by_default(self) -> None: + repo = build_repository(_Widget) + assert isinstance(repo, Repository) + assert not isinstance(repo, SoftDeleteRepository) + + def test_soft_delete_flag_returns_soft_delete_repo(self) -> None: + repo = build_repository(_Widget, soft_delete=True) + assert isinstance(repo, SoftDeleteRepository) + + def test_config_is_applied(self) -> None: + config = RepoConfig[_Widget](default_limit=7) + repo = build_repository(_Widget, config=config) + assert repo.default_limit == 7 + + def test_custom_repo_class_used(self) -> None: + class _CustomRepo(Repository[object, _Widget]): + pass + + repo = build_repository(_Widget, repo_cls=_CustomRepo) + assert isinstance(repo, _CustomRepo) + + def test_model_attribute_set(self) -> None: + repo = build_repository(_Widget) + assert repo.model is _Widget + + +class TestBuildService: + def test_returns_repository_service_by_default(self) -> None: + svc = build_service(_Widget) + assert isinstance(svc, RepositoryService) + + def test_soft_delete_flag_returns_soft_delete_service(self) -> None: + svc = build_service(_Widget, soft_delete=True) + assert isinstance(svc, SoftDeleteRepositoryService) + + def test_custom_repo_passed_directly(self) -> None: + repo = Repository[object, _Widget](_Widget) + svc = build_service(_Widget, repo=repo) + assert isinstance(svc, RepositoryService) + assert svc.repo is repo + + def test_soft_delete_repo_infers_soft_delete_service(self) -> None: + repo = SoftDeleteRepository[object, _Widget](_Widget) + svc = build_service(_Widget, repo=repo) + assert isinstance(svc, SoftDeleteRepositoryService) + + def test_soft_delete_service_class_with_non_soft_delete_repo_raises(self) -> None: + repo = Repository[object, _Widget](_Widget) + with pytest.raises(TypeError, match='Soft-delete service requires'): + build_service(_Widget, repo=repo, service_cls=SoftDeleteRepositoryService) + + def test_soft_delete_flag_with_standard_service_class_used(self) -> None: + svc = build_service( + _Widget, + soft_delete=True, + service_cls=SoftDeleteRepositoryService, + ) + assert isinstance(svc, SoftDeleteRepositoryService) + + def test_repo_config_applied(self) -> None: + repo_config = RepoConfig[_Widget](default_limit=3) + svc = build_service(_Widget, repo_config=repo_config) + assert svc.repo.default_limit == 3 + + def test_soft_delete_true_without_matching_service_cls_raises(self) -> None: + """Passing a plain Repository as repo + soft_delete=True internally should be fine + only if the repo is SoftDeleteRepository. When soft_delete=True is inferred + from the build_repository call, we get a SoftDeleteRepository automatically.""" + repo = SoftDeleteRepository[object, _Widget](_Widget) + svc = build_service(_Widget, soft_delete=True, repo=repo) + assert isinstance(svc, SoftDeleteRepositoryService) + + +class TestBuildServiceForRepo: + def test_standard_repo_returns_repository_service(self) -> None: + repo = Repository[object, _Widget](_Widget) + svc = build_service_for_repo(repo) + assert isinstance(svc, RepositoryService) + + def test_soft_delete_repo_returns_soft_delete_service(self) -> None: + repo = SoftDeleteRepository[object, _Widget](_Widget) + svc = build_service_for_repo(repo) + assert isinstance(svc, SoftDeleteRepositoryService) + + def test_custom_service_class_used(self) -> None: + class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): + pass + + repo = Repository[object, _Widget](_Widget) + svc = build_service_for_repo(repo, service_cls=_CustomService) + assert isinstance(svc, _CustomService) + + def test_soft_delete_service_cls_with_non_soft_delete_repo_raises(self) -> None: + repo = Repository[object, _Widget](_Widget) + with pytest.raises(TypeError, match='Soft-delete service requires'): + build_service_for_repo(repo, service_cls=SoftDeleteRepositoryService) + + def test_repo_is_wired_to_service(self) -> None: + repo = Repository[object, _Widget](_Widget) + svc = build_service_for_repo(repo) + assert svc.repo is repo diff --git a/tests/v2/test_unit/test_payload_mixin.py b/tests/v2/test_unit/test_payload_mixin.py new file mode 100644 index 0000000..5796613 --- /dev/null +++ b/tests/v2/test_unit/test_payload_mixin.py @@ -0,0 +1,50 @@ +"""Tests for PayloadMixin._dump_payload.""" + +from pydantic import BaseModel as PydanticModel + +from notora.v2.services.mixins.payload import PayloadMixin + + +class _SomeSchema(PydanticModel): + name: str + score: int = 0 + + +class _Mixin(PayloadMixin): + pass + + +class TestDumpPayload: + def test_dict_input_returned_as_copy(self) -> None: + original = {'name': 'Alice', 'score': 5} + result = _Mixin._dump_payload(original, exclude_unset=True) + assert result == original + # Ensure it's a copy, not the same object + result['name'] = 'Bob' + assert original['name'] == 'Alice' + + def test_pydantic_model_dump_with_exclude_unset_true(self) -> None: + schema = _SomeSchema(name='Alice') + result = _Mixin._dump_payload(schema, exclude_unset=True) + # 'score' was not explicitly set, so it should be excluded + assert 'name' in result + assert 'score' not in result + + def test_pydantic_model_dump_with_exclude_unset_false(self) -> None: + schema = _SomeSchema(name='Alice') + result = _Mixin._dump_payload(schema, exclude_unset=False) + assert result == {'name': 'Alice', 'score': 0} + + def test_pydantic_model_fully_set(self) -> None: + schema = _SomeSchema(name='Bob', score=10) + result = _Mixin._dump_payload(schema, exclude_unset=True) + assert result == {'name': 'Bob', 'score': 10} + + def test_empty_dict_returns_empty_dict(self) -> None: + result = _Mixin._dump_payload({}, exclude_unset=False) + assert result == {} + + def test_non_string_dict_values_preserved(self) -> None: + payload = {'count': 42, 'active': True, 'tags': ['a', 'b']} + result = _Mixin._dump_payload(payload, exclude_unset=True) + assert result == payload diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py new file mode 100644 index 0000000..2b23cdd --- /dev/null +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -0,0 +1,376 @@ +"""Tests for query_dsl token parsers, apply_filter_operator, build_filter_clauses, and build_sort_clauses.""" + +import pytest +from pydantic import ValidationError +from sqlalchemy import Integer, String +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import ColumnElement + +from notora.v2.models.base import GenericBaseModel +from notora.v2.repositories.query_dsl import ( + FilterField, + FilterToken, + QueryInput, + SortField, + SortToken, + apply_filter_operator, + build_filter_clauses, + build_query_params, + build_sort_clauses, + parse_filter_token, + parse_sort_token, + resolve_to_column, +) + + +def _render(clause: ColumnElement) -> str: # type: ignore[type-arg] + return str( + clause.compile( + dialect=postgresql.dialect(), # type: ignore[no-untyped-call] + compile_kwargs={'literal_binds': True}, + ) + ) + + +class SampleModel(GenericBaseModel): + name: Mapped[str] = mapped_column(String) + score: Mapped[int] = mapped_column(Integer) + + +# --------------------------------------------------------------------------- +# parse_filter_token +# --------------------------------------------------------------------------- + +class TestParseFilterToken: + def test_parses_field_op_value(self) -> None: + token = parse_filter_token('name:eq:alice') + assert token.field == 'name' + assert token.operator == 'eq' + assert token.raw_value == 'alice' + + def test_parses_operator_only_for_isnull(self) -> None: + token = parse_filter_token('name:isnull') + assert token.field == 'name' + assert token.operator == 'isnull' + assert token.raw_value is None + + def test_raises_for_missing_colon(self) -> None: + with pytest.raises(ValueError, match='"field:op:value"'): + parse_filter_token('nocolon') + + def test_raises_for_empty_field_name(self) -> None: + with pytest.raises(ValueError, match='field name cannot be empty'): + parse_filter_token(':eq:value') + + def test_raises_for_unsupported_operator(self) -> None: + with pytest.raises(ValueError, match='Unsupported filter operator'): + parse_filter_token('name:contains:hello') + + def test_value_with_colons_preserved(self) -> None: + token = parse_filter_token('name:eq:a:b:c') + assert token.raw_value == 'a:b:c' + + def test_whitespace_stripped_from_field_and_op(self) -> None: + token = parse_filter_token(' name : eq : alice ') + assert token.field == 'name' + assert token.operator == 'eq' + + def test_whitespace_only_value_becomes_none(self) -> None: + token = parse_filter_token('name:eq: ') + assert token.raw_value is None + + def test_all_operators_accepted(self) -> None: + valid_ops = ('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'ilike', 'isnull') + for op in valid_ops: + token = parse_filter_token(f'name:{op}:x') + assert token.operator == op + + def test_isnull_with_false_value(self) -> None: + token = parse_filter_token('name:isnull:false') + assert token.raw_value == 'false' + + def test_in_with_comma_separated_value(self) -> None: + token = parse_filter_token('score:in:1,2,3') + assert token.raw_value == '1,2,3' + + +# --------------------------------------------------------------------------- +# parse_sort_token +# --------------------------------------------------------------------------- + +class TestParseSortToken: + def test_plain_field_is_ascending(self) -> None: + token = parse_sort_token('name') + assert token.field == 'name' + assert token.direction == 'asc' + + def test_plus_prefix_is_ascending(self) -> None: + token = parse_sort_token('+name') + assert token.field == 'name' + assert token.direction == 'asc' + + def test_minus_prefix_is_descending(self) -> None: + token = parse_sort_token('-score') + assert token.field == 'score' + assert token.direction == 'desc' + + def test_empty_string_raises(self) -> None: + with pytest.raises(ValueError, match='cannot be empty'): + parse_sort_token('') + + def test_only_minus_raises(self) -> None: + with pytest.raises(ValueError, match='cannot be empty'): + parse_sort_token('-') + + def test_only_plus_raises(self) -> None: + with pytest.raises(ValueError, match='cannot be empty'): + parse_sort_token('+') + + def test_whitespace_stripped(self) -> None: + token = parse_sort_token(' name ') + assert token.field == 'name' + + def test_returns_sort_token_dataclass(self) -> None: + token = parse_sort_token('name') + assert isinstance(token, SortToken) + + +# --------------------------------------------------------------------------- +# apply_filter_operator +# --------------------------------------------------------------------------- + +class TestApplyFilterOperator: + def test_eq(self) -> None: + clause = apply_filter_operator(SampleModel.name, 'eq', 'alice') + assert "sample_model.name = 'alice'" in _render(clause) + + def test_ne(self) -> None: + clause = apply_filter_operator(SampleModel.name, 'ne', 'alice') + rendered = _render(clause) + assert 'sample_model.name' in rendered + assert '!=' in rendered or '<>' in rendered + + def test_lt(self) -> None: + clause = apply_filter_operator(SampleModel.score, 'lt', 5) + assert 'sample_model.score < 5' in _render(clause) + + def test_lte(self) -> None: + clause = apply_filter_operator(SampleModel.score, 'lte', 5) + assert 'sample_model.score <= 5' in _render(clause) + + def test_gt(self) -> None: + clause = apply_filter_operator(SampleModel.score, 'gt', 5) + assert 'sample_model.score > 5' in _render(clause) + + def test_gte(self) -> None: + clause = apply_filter_operator(SampleModel.score, 'gte', 5) + assert 'sample_model.score >= 5' in _render(clause) + + def test_in(self) -> None: + clause = apply_filter_operator(SampleModel.score, 'in', [1, 2, 3]) + assert 'IN' in _render(clause) + + def test_ilike(self) -> None: + clause = apply_filter_operator(SampleModel.name, 'ilike', '%alice%') + assert 'ILIKE' in _render(clause) + + def test_isnull_true(self) -> None: + clause = apply_filter_operator(SampleModel.name, 'isnull', True) + assert 'IS NULL' in _render(clause) + + def test_isnull_false(self) -> None: + clause = apply_filter_operator(SampleModel.name, 'isnull', False) + assert 'IS NOT NULL' in _render(clause) + + def test_unsupported_operator_raises(self) -> None: + with pytest.raises(ValueError, match='Unsupported filter operator'): + apply_filter_operator(SampleModel.name, 'contains', 'x') # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# resolve_to_column +# --------------------------------------------------------------------------- + +class TestResolveToColumn: + def test_direct_column_returned_unchanged(self) -> None: + col = resolve_to_column(SampleModel.name, SampleModel) + assert 'sample_model.name' in str(col) + + def test_callable_resolver_called_with_model(self) -> None: + col = resolve_to_column(lambda m: m.score, SampleModel) + assert 'sample_model.score' in str(col) + + +# --------------------------------------------------------------------------- +# build_filter_clauses +# --------------------------------------------------------------------------- + +class TestBuildFilterClauses: + def test_single_eq_clause(self) -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] + fields = {'name': FilterField(resolver=SampleModel.name, value_type=str)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert len(clauses) == 1 + assert "sample_model.name = 'alice'" in _render(clauses[0]) + + def test_unknown_field_raises(self) -> None: + tokens = [FilterToken(field='unknown', operator='eq', raw_value='x')] + with pytest.raises(ValueError, match='Unsupported filter field'): + build_filter_clauses(tokens, model=SampleModel, fields={}) + + def test_disallowed_operator_raises(self) -> None: + tokens = [FilterToken(field='name', operator='gt', raw_value='5')] + fields = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} + with pytest.raises(ValueError, match='Operator'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + def test_predicate_field(self) -> None: + def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: + return model.name.ilike(f'%{value}%') + + tokens = [FilterToken(field='q', operator='eq', raw_value='alice')] + fields = {'q': FilterField(predicate=pred)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert 'ILIKE' in _render(clauses[0]) + + def test_field_without_resolver_or_predicate_raises(self) -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value='x')] + fields = {'name': FilterField()} + with pytest.raises(ValueError, match='resolver or predicate'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + def test_empty_tokens_returns_empty(self) -> None: + clauses = build_filter_clauses([], model=SampleModel, fields={}) + assert clauses == [] + + def test_isnull_no_value(self) -> None: + tokens = [FilterToken(field='name', operator='isnull', raw_value=None)] + fields = {'name': FilterField(resolver=SampleModel.name)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert 'IS NULL' in _render(clauses[0]) + + def test_in_operator_requires_value(self) -> None: + tokens = [FilterToken(field='name', operator='in', raw_value=None)] + fields = {'name': FilterField(resolver=SampleModel.name)} + with pytest.raises(ValueError, match='requires a value'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + def test_non_isnull_without_value_raises(self) -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value=None)] + fields = {'name': FilterField(resolver=SampleModel.name)} + with pytest.raises(ValueError, match='requires a value'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + def test_callable_resolver_in_field(self) -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value='x')] + fields = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert "sample_model.name = 'x'" in _render(clauses[0]) + + +# --------------------------------------------------------------------------- +# build_sort_clauses +# --------------------------------------------------------------------------- + +class TestBuildSortClauses: + def test_ascending(self) -> None: + tokens = [SortToken(field='name', direction='asc')] + fields = {'name': SortField(resolver=SampleModel.name)} + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert len(clauses) == 1 + assert 'ASC' in _render(clauses[0]) + + def test_descending(self) -> None: + tokens = [SortToken(field='score', direction='desc')] + fields = {'score': SortField(resolver=SampleModel.score)} + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert 'DESC' in _render(clauses[0]) + + def test_unknown_field_raises(self) -> None: + tokens = [SortToken(field='unknown', direction='asc')] + with pytest.raises(ValueError, match='Unsupported sort field'): + build_sort_clauses(tokens, model=SampleModel, fields={}) + + def test_empty_tokens_returns_empty(self) -> None: + clauses = build_sort_clauses([], model=SampleModel, fields={}) + assert clauses == [] + + def test_callable_resolver(self) -> None: + tokens = [SortToken(field='name', direction='asc')] + fields = {'name': SortField(resolver=lambda m: m.name)} + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert 'sample_model.name' in _render(clauses[0]) + + def test_multiple_tokens(self) -> None: + tokens = [ + SortToken(field='name', direction='asc'), + SortToken(field='score', direction='desc'), + ] + fields = { + 'name': SortField(resolver=SampleModel.name), + 'score': SortField(resolver=SampleModel.score), + } + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert len(clauses) == 2 + + +# --------------------------------------------------------------------------- +# QueryInput validation +# --------------------------------------------------------------------------- + +class TestQueryInput: + def test_negative_offset_raises(self) -> None: + with pytest.raises(ValidationError, match='offset must be zero or a positive integer'): + QueryInput(offset=-1) + + def test_zero_offset_accepted(self) -> None: + q = QueryInput(offset=0) + assert q.offset == 0 + + def test_positive_offset_accepted(self) -> None: + q = QueryInput(offset=100) + assert q.offset == 100 + + def test_none_limit_accepted(self) -> None: + q = QueryInput(limit=None) + assert q.limit is None + + def test_positive_limit_accepted(self) -> None: + q = QueryInput(limit=50) + assert q.limit == 50 + + +# --------------------------------------------------------------------------- +# build_query_params edge cases +# --------------------------------------------------------------------------- + +class TestBuildQueryParamsEdgeCases: + def test_filters_present_without_filter_fields_raises(self) -> None: + query = QueryInput(filter=['name:eq:x']) + with pytest.raises(ValueError, match='Filter fields mapping is required'): + build_query_params(query, model=SampleModel, filter_fields={}) + + def test_sort_present_without_sort_fields_raises(self) -> None: + query = QueryInput(sort=['-score']) + with pytest.raises(ValueError, match='Sort fields mapping is required'): + build_query_params(query, model=SampleModel, sort_fields={}) + + def test_no_filter_no_sort_returns_none_filters_and_ordering(self) -> None: + query = QueryInput() + params = build_query_params(query, model=SampleModel) + assert params.filters is None + assert params.ordering is None + + def test_explicit_limit_is_passed_through(self) -> None: + query = QueryInput(limit=5, offset=10) + params = build_query_params(query, model=SampleModel) + assert params.limit == 5 + assert params.offset == 10 + + def test_base_query_forwarded(self) -> None: + from sqlalchemy import select + base = select(SampleModel) + query = QueryInput() + params = build_query_params(query, model=SampleModel, base_query=base) + assert params.base_query is base diff --git a/tests/v2/test_unit/test_schemas_base.py b/tests/v2/test_unit/test_schemas_base.py new file mode 100644 index 0000000..dc929c8 --- /dev/null +++ b/tests/v2/test_unit/test_schemas_base.py @@ -0,0 +1,45 @@ +"""Tests for v2 schemas.base not covered elsewhere — ClientMeta, PaginationMetaSchema edge cases.""" + +from ipaddress import IPv4Address, IPv6Address + +import pytest + +from notora.v2.schemas.base import ClientMeta, PaginationMetaSchema + + +class TestClientMeta: + def test_both_fields_none_by_default(self) -> None: + client = ClientMeta() + assert client.ip_address is None + assert client.user_agent is None + + def test_ipv4_address_accepted(self) -> None: + client = ClientMeta(ip_address=IPv4Address('192.168.1.1')) + assert isinstance(client.ip_address, IPv4Address) + + def test_ipv6_address_accepted(self) -> None: + client = ClientMeta(ip_address=IPv6Address('::1')) + assert isinstance(client.ip_address, IPv6Address) + + def test_user_agent_stored(self) -> None: + client = ClientMeta(user_agent='Mozilla/5.0') + assert client.user_agent == 'Mozilla/5.0' + + def test_ip_serialized_as_string_in_dict(self) -> None: + client = ClientMeta(ip_address=IPv4Address('10.0.0.1')) + dumped = client.model_dump() + assert dumped['ip_address'] == '10.0.0.1' + + +class TestPaginationMetaSchemaNegativeTotal: + def test_negative_total_clamped_to_zero(self) -> None: + meta = PaginationMetaSchema.calculate(total=-5, limit=10, offset=0) + assert meta.total == 0 + + def test_zero_total_preserved(self) -> None: + meta = PaginationMetaSchema.calculate(total=0, limit=10, offset=0) + assert meta.total == 0 + + def test_positive_total_preserved(self) -> None: + meta = PaginationMetaSchema.calculate(total=100, limit=10, offset=0) + assert meta.total == 100 diff --git a/tests/v2/test_unit/test_serializer_mixin.py b/tests/v2/test_unit/test_serializer_mixin.py new file mode 100644 index 0000000..186c46f --- /dev/null +++ b/tests/v2/test_unit/test_serializer_mixin.py @@ -0,0 +1,126 @@ +"""Tests for SerializerMixin — edge cases not covered by integration tests.""" + +import types + +import pytest +from pydantic import ConfigDict + +from notora.v2.models.base import GenericBaseModel +from notora.v2.schemas.base import BaseResponseSchema +from notora.v2.services.mixins.serializer import SerializerMixin + + +class _Item(GenericBaseModel): + pass + + +class _DetailSchema(BaseResponseSchema): + model_config = ConfigDict(from_attributes=True) + + +class _ListSchema(BaseResponseSchema): + model_config = ConfigDict(from_attributes=True) + + +# A plain namespace satisfies `from_attributes=True` schemas that have no required fields. +def _make_obj() -> types.SimpleNamespace: + return types.SimpleNamespace() + + +def _make_mixin() -> SerializerMixin[_Item, _DetailSchema, _ListSchema]: + mixin: SerializerMixin[_Item, _DetailSchema, _ListSchema] = SerializerMixin() + return mixin + + +class TestSerializeOne: + def test_uses_explicit_schema_arg(self) -> None: + mixin = _make_mixin() + item = _make_obj() + result = mixin.serialize_one(item, schema=_DetailSchema) + assert isinstance(result, _DetailSchema) + + def test_falls_back_to_detail_schema_attribute(self) -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + item = _make_obj() + result = mixin.serialize_one(item) + assert isinstance(result, _DetailSchema) + + def test_raises_when_no_schema_and_no_detail_schema(self) -> None: + mixin = _make_mixin() + item = _make_obj() + with pytest.raises(ValueError, match='schema is required'): + mixin.serialize_one(item) + + def test_explicit_schema_overrides_detail_schema(self) -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + + class _AltSchema(_DetailSchema): + pass + + item = _make_obj() + result = mixin.serialize_one(item, schema=_AltSchema) + assert isinstance(result, _AltSchema) + + +class TestSerializeMany: + def test_empty_list_returns_empty(self) -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + result = mixin.serialize_many([]) + assert result == [] + + def test_uses_list_schema_by_default(self) -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + item = _make_obj() + results = mixin.serialize_many([item]) + assert all(isinstance(r, _ListSchema) for r in results) + + def test_falls_back_to_detail_schema_when_list_schema_absent(self) -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + item = _make_obj() + results = mixin.serialize_many([item]) + assert all(isinstance(r, _DetailSchema) for r in results) + + def test_explicit_schema_arg_overrides_list_schema(self) -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + + class _AltSchema(_ListSchema): + pass + + item = _make_obj() + results = mixin.serialize_many([item], schema=_AltSchema) + assert all(isinstance(r, _AltSchema) for r in results) + + def test_prefer_list_schema_false_uses_explicit_schema_only(self) -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + mixin.list_schema = _ListSchema + item = _make_obj() + results = mixin.serialize_many([item], schema=_DetailSchema, prefer_list_schema=False) + assert all(isinstance(r, _DetailSchema) for r in results) + + def test_raises_when_no_schema_at_all(self) -> None: + mixin = _make_mixin() + item = _make_obj() + with pytest.raises(ValueError, match='schema is required'): + mixin.serialize_many([item]) + + def test_prefer_list_schema_false_and_no_explicit_schema_raises(self) -> None: + mixin = _make_mixin() + mixin.detail_schema = None + mixin.list_schema = None + item = _make_obj() + with pytest.raises(ValueError, match='schema is required'): + mixin.serialize_many([item], prefer_list_schema=False) + + def test_serializes_multiple_items(self) -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + items = [_make_obj() for _ in range(5)] + results = mixin.serialize_many(items) + assert len(results) == 5 diff --git a/tests/v2/test_unit/test_updated_by_mixin.py b/tests/v2/test_unit/test_updated_by_mixin.py new file mode 100644 index 0000000..a454006 --- /dev/null +++ b/tests/v2/test_unit/test_updated_by_mixin.py @@ -0,0 +1,79 @@ +"""Tests for UpdatedByServiceMixin.""" + +import pytest +from sqlalchemy import String, Uuid +from sqlalchemy.orm import Mapped, mapped_column + +from notora.v2.models.base import GenericBaseModel +from notora.v2.repositories.base import Repository +from notora.v2.services.mixins.updated_by import UpdatedByServiceMixin + + +class _WithUpdatedBy(GenericBaseModel): + name: Mapped[str] = mapped_column(String) + updated_by: Mapped[object] = mapped_column(Uuid, nullable=True) + + +class _WithoutUpdatedBy(GenericBaseModel): + name: Mapped[str] = mapped_column(String) + + +class _Mixin(UpdatedByServiceMixin[object, _WithUpdatedBy]): + def __init__(self) -> None: + self.repo = Repository[object, _WithUpdatedBy](_WithUpdatedBy) + + +class _MixinNoAttr(UpdatedByServiceMixin[object, _WithoutUpdatedBy]): + def __init__(self) -> None: + self.repo = Repository[object, _WithoutUpdatedBy](_WithoutUpdatedBy) + + +class TestApplyUpdatedBy: + def test_actor_id_none_returns_payload_unchanged(self) -> None: + mixin = _Mixin() + payload = {'name': 'Alice'} + result = mixin._apply_updated_by(payload, actor_id=None) + assert result == {'name': 'Alice'} + + def test_actor_id_set_injects_updated_by(self) -> None: + from uuid import uuid4 + mixin = _Mixin() + actor_id = uuid4() + payload: dict[str, object] = {'name': 'Alice'} + result = mixin._apply_updated_by(payload, actor_id=actor_id) + assert result['updated_by'] == actor_id + + def test_existing_updated_by_not_overwritten(self) -> None: + from uuid import uuid4 + mixin = _Mixin() + original_actor = uuid4() + new_actor = uuid4() + payload: dict[str, object] = {'name': 'Alice', 'updated_by': original_actor} + result = mixin._apply_updated_by(payload, actor_id=new_actor) + assert result['updated_by'] == original_actor + + def test_model_without_attribute_raises(self) -> None: + from uuid import uuid4 + mixin = _MixinNoAttr() + payload: dict[str, object] = {'name': 'Bob'} + with pytest.raises(ValueError, match='is not defined on'): + mixin._apply_updated_by(payload, actor_id=uuid4()) + + def test_custom_attribute_name_used(self) -> None: + from uuid import uuid4 + + class _WithCustomAttr(GenericBaseModel): + name: Mapped[str] = mapped_column(String) + modified_by: Mapped[object] = mapped_column(Uuid, nullable=True) + + class _CustomMixin(UpdatedByServiceMixin[object, _WithCustomAttr]): + updated_by_attribute = 'modified_by' + + def __init__(self) -> None: + self.repo = Repository[object, _WithCustomAttr](_WithCustomAttr) + + mixin = _CustomMixin() + actor_id = uuid4() + payload: dict[str, object] = {'name': 'Charlie'} + result = mixin._apply_updated_by(payload, actor_id=actor_id) + assert result['modified_by'] == actor_id From 173317f889ce75270f9b36b4b159257254f530ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:35:55 +0000 Subject: [PATCH 02/11] Plan: fix ruff lint errors in new test files Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/a8913a70-7492-4b98-b1f5-dbd935cdb637 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- tests/v1/test_unit/test_schemas_base.py | 7 ++----- tests/v2/test_unit/test_factory.py | 3 ++- tests/v2/test_unit/test_schemas_base.py | 2 -- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/v1/test_unit/test_schemas_base.py b/tests/v1/test_unit/test_schemas_base.py index de21163..fdd95dd 100644 --- a/tests/v1/test_unit/test_schemas_base.py +++ b/tests/v1/test_unit/test_schemas_base.py @@ -1,21 +1,18 @@ from datetime import UTC, datetime, timedelta, timezone from ipaddress import IPv4Address, IPv6Address -from uuid import uuid4 - -import pytest +from notora.v1.enums.base import OrderByDirections from notora.v1.schemas.base import ( AdminMeta, ClientMeta, Filter, - OrFilterGroup, OrderBy, + OrFilterGroup, PaginationMetaSchema, datetime_encoder, normalize_datetime_to_utc, utc_datetime_encoder, ) -from notora.v1.enums.base import OrderByDirections class TestNormalizeDatetimeToUtc: diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index 765b108..4bf43dc 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -89,7 +89,8 @@ def test_repo_config_applied(self) -> None: def test_soft_delete_true_without_matching_service_cls_raises(self) -> None: """Passing a plain Repository as repo + soft_delete=True internally should be fine only if the repo is SoftDeleteRepository. When soft_delete=True is inferred - from the build_repository call, we get a SoftDeleteRepository automatically.""" + from the build_repository call, we get a SoftDeleteRepository automatically. + """ repo = SoftDeleteRepository[object, _Widget](_Widget) svc = build_service(_Widget, soft_delete=True, repo=repo) assert isinstance(svc, SoftDeleteRepositoryService) diff --git a/tests/v2/test_unit/test_schemas_base.py b/tests/v2/test_unit/test_schemas_base.py index dc929c8..de8ebb5 100644 --- a/tests/v2/test_unit/test_schemas_base.py +++ b/tests/v2/test_unit/test_schemas_base.py @@ -2,8 +2,6 @@ from ipaddress import IPv4Address, IPv6Address -import pytest - from notora.v2.schemas.base import ClientMeta, PaginationMetaSchema From fe0f91dd52c273bdc054dc5800a259b7586f952a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:45:19 +0000 Subject: [PATCH 03/11] Fix all ruff lint errors in test files Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/a8913a70-7492-4b98-b1f5-dbd935cdb637 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- .../v1/test_unit/test_enums_and_exceptions.py | 183 +++--- tests/v1/test_unit/test_schemas_base.py | 489 ++++++++------- tests/v1/test_unit/test_utils.py | 98 +-- tests/v2/test_unit/test_exceptions.py | 157 +++-- tests/v2/test_unit/test_factory.py | 229 +++---- tests/v2/test_unit/test_payload_mixin.py | 77 +-- tests/v2/test_unit/test_query_dsl_tokens.py | 567 ++++++++++-------- tests/v2/test_unit/test_schemas_base.py | 73 ++- tests/v2/test_unit/test_serializer_mixin.py | 202 ++++--- tests/v2/test_unit/test_updated_by_mixin.py | 98 +-- 10 files changed, 1209 insertions(+), 964 deletions(-) diff --git a/tests/v1/test_unit/test_enums_and_exceptions.py b/tests/v1/test_unit/test_enums_and_exceptions.py index e7dd4be..9b277bb 100644 --- a/tests/v1/test_unit/test_enums_and_exceptions.py +++ b/tests/v1/test_unit/test_enums_and_exceptions.py @@ -1,104 +1,141 @@ +from uuid import uuid4 + import pytest from notora.v1.enums.base import OrderByDirections from notora.v1.exceptions.common import AlreadyExistsError, FKNotFoundError, NotFoundError +_ENTITY_ID_INT = 42 + + +# --------------------------------------------------------------------------- +# OrderByDirections +# --------------------------------------------------------------------------- + +def test_order_by_directions_asc_value() -> None: + assert OrderByDirections.ASC.value == 'asc' + + +def test_order_by_directions_desc_value() -> None: + assert OrderByDirections.DESC.value == 'desc' + + +def test_order_by_directions_is_str_enum() -> None: + assert isinstance(OrderByDirections.ASC, str) + assert isinstance(OrderByDirections.DESC, str) + + +def test_order_by_directions_can_be_used_as_string() -> None: + assert f'{OrderByDirections.ASC}' == 'asc' + assert f'{OrderByDirections.DESC}' == 'desc' + + +# --------------------------------------------------------------------------- +# FKNotFoundError +# --------------------------------------------------------------------------- + +def test_fk_not_found_error_stores_fk_name() -> None: + err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') + assert err.fk_name == 'user_id_fkey' + + +def test_fk_not_found_error_stores_table_name() -> None: + err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') + assert err.table_name == 'post' + + +def test_fk_not_found_error_message_is_accessible() -> None: + err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') + assert str(err) == 'Related object not found.' + + +def test_fk_not_found_error_is_exception() -> None: + err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') + assert isinstance(err, Exception) + + +def test_fk_not_found_error_can_be_raised_and_caught() -> None: + msg = 'err' + with pytest.raises(FKNotFoundError) as exc_info: + raise FKNotFoundError(msg, fk_name='fk', table_name='tbl') + assert exc_info.value.fk_name == 'fk' + + +# --------------------------------------------------------------------------- +# AlreadyExistsError +# --------------------------------------------------------------------------- + +def test_already_exists_error_default_message() -> None: + err = AlreadyExistsError() + assert str(err) == 'Entity already exists.' + -class TestOrderByDirections: - def test_asc_value(self) -> None: - assert OrderByDirections.ASC == 'asc' +def test_already_exists_error_custom_message() -> None: + err = AlreadyExistsError('Custom message.') + assert str(err) == 'Custom message.' - def test_desc_value(self) -> None: - assert OrderByDirections.DESC == 'desc' - def test_is_str_enum(self) -> None: - assert isinstance(OrderByDirections.ASC, str) - assert isinstance(OrderByDirections.DESC, str) +def test_already_exists_error_constraint_name_stored() -> None: + err = AlreadyExistsError(constraint_name='users_email_key') + assert err.constraint_name == 'users_email_key' - def test_can_be_used_as_string(self) -> None: - assert f'{OrderByDirections.ASC}' == 'asc' - assert f'{OrderByDirections.DESC}' == 'desc' +def test_already_exists_error_constraint_name_none_by_default() -> None: + err = AlreadyExistsError() + assert err.constraint_name is None -class TestFKNotFoundError: - def test_stores_fk_name(self) -> None: - err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') - assert err.fk_name == 'user_id_fkey' - def test_stores_table_name(self) -> None: - err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') - assert err.table_name == 'post' +def test_already_exists_error_message_and_constraint_together() -> None: + err = AlreadyExistsError('Dup', constraint_name='my_constraint') + assert str(err) == 'Dup' + assert err.constraint_name == 'my_constraint' - def test_message_is_accessible(self) -> None: - err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') - assert str(err) == 'Related object not found.' - def test_is_exception(self) -> None: - err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') - assert isinstance(err, Exception) +def test_already_exists_error_is_exception() -> None: + assert isinstance(AlreadyExistsError(), Exception) - def test_can_be_raised_and_caught(self) -> None: - with pytest.raises(FKNotFoundError) as exc_info: - raise FKNotFoundError('err', fk_name='fk', table_name='tbl') - assert exc_info.value.fk_name == 'fk' +def test_already_exists_error_can_be_raised_and_caught() -> None: + msg = 'dup' + with pytest.raises(AlreadyExistsError): + raise AlreadyExistsError(msg) -class TestAlreadyExistsError: - def test_default_message(self) -> None: - err = AlreadyExistsError() - assert str(err) == 'Entity already exists.' - def test_custom_message(self) -> None: - err = AlreadyExistsError('Custom message.') - assert str(err) == 'Custom message.' +# --------------------------------------------------------------------------- +# NotFoundError +# --------------------------------------------------------------------------- - def test_constraint_name_stored(self) -> None: - err = AlreadyExistsError(constraint_name='users_email_key') - assert err.constraint_name == 'users_email_key' +def test_not_found_error_entity_id_none_by_default() -> None: + err: NotFoundError[None] = NotFoundError('not found') + assert err.entity_id is None - def test_constraint_name_none_by_default(self) -> None: - err = AlreadyExistsError() - assert err.constraint_name is None - def test_message_and_constraint_together(self) -> None: - err = AlreadyExistsError('Dup', constraint_name='my_constraint') - assert str(err) == 'Dup' - assert err.constraint_name == 'my_constraint' +def test_not_found_error_entity_id_stored() -> None: + err = NotFoundError('not found', entity_id=_ENTITY_ID_INT) + assert err.entity_id == _ENTITY_ID_INT - def test_is_exception(self) -> None: - assert isinstance(AlreadyExistsError(), Exception) - def test_can_be_raised_and_caught(self) -> None: - with pytest.raises(AlreadyExistsError): - raise AlreadyExistsError('dup') +def test_not_found_error_entity_id_uuid() -> None: + uid = uuid4() + err = NotFoundError('not found', entity_id=uid) + assert err.entity_id == uid -class TestNotFoundError: - def test_entity_id_none_by_default(self) -> None: - err = NotFoundError('not found') - assert err.entity_id is None +def test_not_found_error_message_preserved() -> None: + err: NotFoundError[None] = NotFoundError('Resource not found.') + assert str(err) == 'Resource not found.' - def test_entity_id_stored(self) -> None: - err = NotFoundError('not found', entity_id=42) - assert err.entity_id == 42 - def test_entity_id_uuid(self) -> None: - from uuid import uuid4 - uid = uuid4() - err = NotFoundError('not found', entity_id=uid) - assert err.entity_id == uid +def test_not_found_error_is_exception() -> None: + assert isinstance(NotFoundError('x'), Exception) - def test_message_preserved(self) -> None: - err = NotFoundError('Resource not found.') - assert str(err) == 'Resource not found.' - def test_is_exception(self) -> None: - assert isinstance(NotFoundError('x'), Exception) +def test_not_found_error_can_be_raised_and_caught() -> None: + msg = 'missing' + with pytest.raises(NotFoundError): + raise NotFoundError(msg) - def test_can_be_raised_and_caught(self) -> None: - with pytest.raises(NotFoundError): - raise NotFoundError('missing') - def test_no_positional_args(self) -> None: - err = NotFoundError() - assert err.entity_id is None +def test_not_found_error_no_positional_args() -> None: + err: NotFoundError[None] = NotFoundError() + assert err.entity_id is None diff --git a/tests/v1/test_unit/test_schemas_base.py b/tests/v1/test_unit/test_schemas_base.py index fdd95dd..66feab0 100644 --- a/tests/v1/test_unit/test_schemas_base.py +++ b/tests/v1/test_unit/test_schemas_base.py @@ -14,215 +14,282 @@ utc_datetime_encoder, ) +_LIMIT = 10 +_TOTAL_100 = 100 +_TOTAL_25 = 25 +_TOTAL_20 = 20 +_TOTAL_5 = 5 +_LAST_PAGE_10 = 10 +_LAST_PAGE_3 = 3 +_LAST_PAGE_2 = 2 +_SECOND_PAGE = 2 +_FILTER_COUNT = 2 -class TestNormalizeDatetimeToUtc: - def test_naive_datetime_gets_utc_tzinfo(self) -> None: - naive = datetime(2024, 6, 15, 12, 0, 0) - result = normalize_datetime_to_utc(naive) - assert result.tzinfo == UTC - - def test_naive_datetime_value_unchanged(self) -> None: - naive = datetime(2024, 6, 15, 12, 0, 0) - result = normalize_datetime_to_utc(naive) - assert result.replace(tzinfo=None) == naive - - def test_utc_aware_datetime_unchanged(self) -> None: - aware = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) - result = normalize_datetime_to_utc(aware) - assert result == aware - - def test_offset_aware_datetime_converted_to_utc(self) -> None: - tz_plus2 = timezone(timedelta(hours=2)) - aware = datetime(2024, 6, 15, 14, 0, 0, tzinfo=tz_plus2) - result = normalize_datetime_to_utc(aware) - assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) - assert result.tzinfo == UTC - - def test_negative_offset_converted_to_utc(self) -> None: - tz_minus5 = timezone(timedelta(hours=-5)) - aware = datetime(2024, 6, 15, 7, 0, 0, tzinfo=tz_minus5) - result = normalize_datetime_to_utc(aware) - assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) - - -class TestUtcDatetimeEncoder: - def test_returns_iso_string_with_z(self) -> None: - dt = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC) - result = utc_datetime_encoder(dt) - assert result == '2024-01-20T09:15:30Z' - - def test_naive_datetime_treated_as_utc(self) -> None: - naive = datetime(2024, 1, 20, 9, 15, 30) - result = utc_datetime_encoder(naive) - assert result == '2024-01-20T09:15:30Z' - - def test_offset_aware_datetime_converted(self) -> None: - tz_plus3 = timezone(timedelta(hours=3)) - dt = datetime(2024, 1, 20, 12, 15, 30, tzinfo=tz_plus3) - result = utc_datetime_encoder(dt) - assert result == '2024-01-20T09:15:30Z' - - def test_does_not_contain_plus00_00(self) -> None: - dt = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) - result = utc_datetime_encoder(dt) - assert '+00:00' not in result - - -class TestDatetimeEncoder: - def test_returns_float_timestamp(self) -> None: - dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) - result = datetime_encoder(dt) - assert isinstance(result, float) - - def test_naive_datetime_treated_as_utc(self) -> None: - naive = datetime(2024, 1, 1, 0, 0, 0) - aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) - assert datetime_encoder(naive) == datetime_encoder(aware) - - def test_offset_datetime_normalized(self) -> None: - tz_plus2 = timezone(timedelta(hours=2)) - offset = datetime(2024, 1, 1, 2, 0, 0, tzinfo=tz_plus2) - utc = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) - assert datetime_encoder(offset) == datetime_encoder(utc) - - -class TestPaginationMetaSchema: - def test_first_page_full(self) -> None: - meta = PaginationMetaSchema.calculate(total=100, limit=10, offset=0) - assert meta.current_page == 1 - assert meta.last_page == 10 - assert meta.total == 100 - assert meta.limit == 10 - - def test_second_page(self) -> None: - meta = PaginationMetaSchema.calculate(total=100, limit=10, offset=10) - assert meta.current_page == 2 - - def test_last_page_calculated(self) -> None: - meta = PaginationMetaSchema.calculate(total=25, limit=10, offset=0) - assert meta.last_page == 3 - - def test_zero_total_gives_page_1(self) -> None: - meta = PaginationMetaSchema.calculate(total=0, limit=10, offset=0) - assert meta.current_page == 1 - assert meta.last_page == 1 - assert meta.total == 0 - - def test_exact_multiple_total(self) -> None: - meta = PaginationMetaSchema.calculate(total=20, limit=10, offset=0) - assert meta.last_page == 2 - - def test_total_less_than_limit_gives_page_1(self) -> None: - meta = PaginationMetaSchema.calculate(total=5, limit=10, offset=0) - assert meta.current_page == 1 - assert meta.last_page == 1 - - -class TestAdminMeta: - def test_deleted_at_is_none_by_default(self) -> None: - meta = AdminMeta( - created_at=datetime(2024, 1, 1, tzinfo=UTC), - updated_at=datetime(2024, 1, 1, tzinfo=UTC), - ) - assert meta.deleted_at is None - - def test_deleted_at_can_be_set(self) -> None: - dt = datetime(2024, 6, 1, 12, 0, tzinfo=UTC) - meta = AdminMeta( - created_at=datetime(2024, 1, 1, tzinfo=UTC), - updated_at=datetime(2024, 1, 1, tzinfo=UTC), - deleted_at=dt, - ) - assert meta.deleted_at == dt - - def test_timestamps_normalized_to_utc(self) -> None: - naive = datetime(2024, 1, 1, 10, 0, 0) - meta = AdminMeta(created_at=naive, updated_at=naive) - assert meta.created_at.tzinfo == UTC - assert meta.updated_at.tzinfo == UTC - - -class TestClientMeta: - def test_both_fields_none_by_default(self) -> None: - client = ClientMeta() - assert client.ip_address is None - assert client.user_agent is None - - def test_ipv4_address_accepted(self) -> None: - client = ClientMeta(ip_address=IPv4Address('127.0.0.1')) - assert isinstance(client.ip_address, IPv4Address) - - def test_ipv6_address_accepted(self) -> None: - client = ClientMeta(ip_address=IPv6Address('::1')) - assert isinstance(client.ip_address, IPv6Address) - - def test_user_agent_stored(self) -> None: - client = ClientMeta(user_agent='Mozilla/5.0') - assert client.user_agent == 'Mozilla/5.0' - - def test_ip_address_serialized_as_string(self) -> None: - client = ClientMeta(ip_address=IPv4Address('192.168.0.1')) - dumped = client.model_dump() - assert dumped['ip_address'] == '192.168.0.1' - - -class TestFilter: - def test_default_op_is_eq(self) -> None: - f = Filter(field='name', value='alice') - assert f.op == '=' - - def test_custom_op(self) -> None: - f = Filter(field='age', op='gt', value=18) - assert f.op == 'gt' - - def test_value_none_allowed(self) -> None: - f = Filter(field='deleted_at', op='is', value=None) - assert f.value is None - - def test_model_none_by_default(self) -> None: - f = Filter(field='name', value='x') - assert f.model is None - - def test_model_can_be_set(self) -> None: - class FakeModel: - pass - f = Filter(field='name', value='x', model=FakeModel) - assert f.model is FakeModel - - def test_all_ops_accepted(self) -> None: - valid_ops = ('eq', '=', 'ilike', '~=', 'is', 'is_not', 'in', 'gt', '>', 'ge', '>=', 'lt', '<', 'le', '<=') - for op in valid_ops: - f = Filter(field='x', op=op, value=1) # type: ignore[arg-type] - assert f.op == op - - -class TestOrFilterGroup: - def test_stores_filters(self) -> None: - f1 = Filter(field='name', value='a') - f2 = Filter(field='name', value='b') - group = OrFilterGroup(filters=[f1, f2]) - assert len(group.filters) == 2 - - def test_empty_filters_allowed(self) -> None: - group = OrFilterGroup(filters=[]) - assert group.filters == [] - - -class TestOrderBy: - def test_default_direction_is_asc(self) -> None: - ob = OrderBy(field='name') - assert ob.direction == OrderByDirections.ASC - - def test_desc_direction(self) -> None: - ob = OrderBy(field='name', direction=OrderByDirections.DESC) - assert ob.direction == OrderByDirections.DESC - - def test_model_none_by_default(self) -> None: - ob = OrderBy(field='name') - assert ob.model is None - - def test_model_can_be_set(self) -> None: - class FakeModel: - pass - ob = OrderBy(field='name', model=FakeModel) - assert ob.model is FakeModel + +# --------------------------------------------------------------------------- +# normalize_datetime_to_utc +# --------------------------------------------------------------------------- + +def test_normalize_datetime_naive_gets_utc_tzinfo() -> None: + naive = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) + result = normalize_datetime_to_utc(naive) + assert result.tzinfo == UTC + + +def test_normalize_datetime_naive_value_unchanged() -> None: + naive = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) + result = normalize_datetime_to_utc(naive) + assert result.replace(tzinfo=None) == naive + + +def test_normalize_datetime_utc_aware_unchanged() -> None: + aware = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + result = normalize_datetime_to_utc(aware) + assert result == aware + + +def test_normalize_datetime_offset_aware_converted_to_utc() -> None: + tz_plus2 = timezone(timedelta(hours=2)) + aware = datetime(2024, 6, 15, 14, 0, 0, tzinfo=tz_plus2) + result = normalize_datetime_to_utc(aware) + assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + assert result.tzinfo == UTC + + +def test_normalize_datetime_negative_offset_converted_to_utc() -> None: + tz_minus5 = timezone(timedelta(hours=-5)) + aware = datetime(2024, 6, 15, 7, 0, 0, tzinfo=tz_minus5) + result = normalize_datetime_to_utc(aware) + assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# utc_datetime_encoder +# --------------------------------------------------------------------------- + +def test_utc_datetime_encoder_returns_iso_string_with_z() -> None: + dt = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC) + result = utc_datetime_encoder(dt) + assert result == '2024-01-20T09:15:30Z' + + +def test_utc_datetime_encoder_naive_datetime_treated_as_utc() -> None: + naive = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC).replace(tzinfo=None) + result = utc_datetime_encoder(naive) + assert result == '2024-01-20T09:15:30Z' + + +def test_utc_datetime_encoder_offset_aware_converted() -> None: + tz_plus3 = timezone(timedelta(hours=3)) + dt = datetime(2024, 1, 20, 12, 15, 30, tzinfo=tz_plus3) + result = utc_datetime_encoder(dt) + assert result == '2024-01-20T09:15:30Z' + + +def test_utc_datetime_encoder_does_not_contain_plus00_00() -> None: + dt = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) + result = utc_datetime_encoder(dt) + assert '+00:00' not in result + + +# --------------------------------------------------------------------------- +# datetime_encoder +# --------------------------------------------------------------------------- + +def test_datetime_encoder_returns_float_timestamp() -> None: + dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + result = datetime_encoder(dt) + assert isinstance(result, float) + + +def test_datetime_encoder_naive_datetime_treated_as_utc() -> None: + naive = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None) + aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert datetime_encoder(naive) == datetime_encoder(aware) + + +def test_datetime_encoder_offset_datetime_normalized() -> None: + tz_plus2 = timezone(timedelta(hours=2)) + offset = datetime(2024, 1, 1, 2, 0, 0, tzinfo=tz_plus2) + utc = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert datetime_encoder(offset) == datetime_encoder(utc) + + +# --------------------------------------------------------------------------- +# PaginationMetaSchema +# --------------------------------------------------------------------------- + +def test_pagination_meta_first_page_full() -> None: + meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=0) + assert meta.current_page == 1 + assert meta.last_page == _LAST_PAGE_10 + assert meta.total == _TOTAL_100 + assert meta.limit == _LIMIT + + +def test_pagination_meta_second_page() -> None: + meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=_LIMIT) + assert meta.current_page == _SECOND_PAGE + + +def test_pagination_meta_last_page_calculated() -> None: + meta = PaginationMetaSchema.calculate(total=_TOTAL_25, limit=_LIMIT, offset=0) + assert meta.last_page == _LAST_PAGE_3 + + +def test_pagination_meta_zero_total_gives_page_1() -> None: + meta = PaginationMetaSchema.calculate(total=0, limit=_LIMIT, offset=0) + assert meta.current_page == 1 + assert meta.last_page == 1 + assert meta.total == 0 + + +def test_pagination_meta_exact_multiple_total() -> None: + meta = PaginationMetaSchema.calculate(total=_TOTAL_20, limit=_LIMIT, offset=0) + assert meta.last_page == _LAST_PAGE_2 + + +def test_pagination_meta_total_less_than_limit_gives_page_1() -> None: + meta = PaginationMetaSchema.calculate(total=_TOTAL_5, limit=_LIMIT, offset=0) + assert meta.current_page == 1 + assert meta.last_page == 1 + + +# --------------------------------------------------------------------------- +# AdminMeta +# --------------------------------------------------------------------------- + +def test_admin_meta_deleted_at_is_none_by_default() -> None: + meta = AdminMeta( + created_at=datetime(2024, 1, 1, tzinfo=UTC), + updated_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + assert meta.deleted_at is None + + +def test_admin_meta_deleted_at_can_be_set() -> None: + dt = datetime(2024, 6, 1, 12, 0, tzinfo=UTC) + meta = AdminMeta( + created_at=datetime(2024, 1, 1, tzinfo=UTC), + updated_at=datetime(2024, 1, 1, tzinfo=UTC), + deleted_at=dt, + ) + assert meta.deleted_at == dt + + +def test_admin_meta_timestamps_normalized_to_utc() -> None: + naive = datetime(2024, 1, 1, 10, 0, 0, tzinfo=UTC).replace(tzinfo=None) + meta = AdminMeta(created_at=naive, updated_at=naive) + assert meta.created_at.tzinfo == UTC + assert meta.updated_at.tzinfo == UTC + + +# --------------------------------------------------------------------------- +# ClientMeta +# --------------------------------------------------------------------------- + +def test_client_meta_both_fields_none_by_default() -> None: + client = ClientMeta() + assert client.ip_address is None + assert client.user_agent is None + + +def test_client_meta_ipv4_address_accepted() -> None: + client = ClientMeta(ip_address=IPv4Address('127.0.0.1')) + assert isinstance(client.ip_address, IPv4Address) + + +def test_client_meta_ipv6_address_accepted() -> None: + client = ClientMeta(ip_address=IPv6Address('::1')) + assert isinstance(client.ip_address, IPv6Address) + + +def test_client_meta_user_agent_stored() -> None: + client = ClientMeta(user_agent='Mozilla/5.0') + assert client.user_agent == 'Mozilla/5.0' + + +def test_client_meta_ip_address_serialized_as_string() -> None: + client = ClientMeta(ip_address=IPv4Address('192.168.0.1')) + dumped = client.model_dump() + assert dumped['ip_address'] == '192.168.0.1' + + +# --------------------------------------------------------------------------- +# Filter +# --------------------------------------------------------------------------- + +def test_filter_default_op_is_eq() -> None: + f = Filter(field='name', value='alice') + assert f.op == '=' + + +def test_filter_custom_op() -> None: + f = Filter(field='age', op='gt', value=18) + assert f.op == 'gt' + + +def test_filter_value_none_allowed() -> None: + f = Filter(field='deleted_at', op='is', value=None) + assert f.value is None + + +def test_filter_model_none_by_default() -> None: + f = Filter(field='name', value='x') + assert f.model is None + + +def test_filter_model_can_be_set() -> None: + class FakeModel: + pass + f = Filter(field='name', value='x', model=FakeModel) + assert f.model is FakeModel + + +def test_filter_all_ops_accepted() -> None: + valid_ops = ('eq', '=', 'ilike', '~=', 'is', 'is_not', 'in', 'gt', '>', 'ge', '>=', 'lt', '<', 'le', '<=') + for op in valid_ops: + f = Filter(field='x', op=op, value=1) + assert f.op == op + + +# --------------------------------------------------------------------------- +# OrFilterGroup +# --------------------------------------------------------------------------- + +def test_or_filter_group_stores_filters() -> None: + f1 = Filter(field='name', value='a') + f2 = Filter(field='name', value='b') + group = OrFilterGroup(filters=[f1, f2]) + assert len(group.filters) == _FILTER_COUNT + + +def test_or_filter_group_empty_filters_allowed() -> None: + group = OrFilterGroup(filters=[]) + assert group.filters == [] + + +# --------------------------------------------------------------------------- +# OrderBy +# --------------------------------------------------------------------------- + +def test_order_by_default_direction_is_asc() -> None: + ob = OrderBy(field='name') + assert ob.direction == OrderByDirections.ASC + + +def test_order_by_desc_direction() -> None: + ob = OrderBy(field='name', direction=OrderByDirections.DESC) + assert ob.direction == OrderByDirections.DESC + + +def test_order_by_model_none_by_default() -> None: + ob = OrderBy(field='name') + assert ob.model is None + + +def test_order_by_model_can_be_set() -> None: + class FakeModel: + pass + ob = OrderBy(field='name', model=FakeModel) + assert ob.model is FakeModel diff --git a/tests/v1/test_unit/test_utils.py b/tests/v1/test_unit/test_utils.py index bd3f6e7..1d205a5 100644 --- a/tests/v1/test_unit/test_utils.py +++ b/tests/v1/test_unit/test_utils.py @@ -6,49 +6,55 @@ from notora.utils.validation import validate_exclusive_presence -class TestNowWithoutTz: - def test_returns_datetime_without_tzinfo(self) -> None: - result = now_without_tz() - assert isinstance(result, datetime) - assert result.tzinfo is None - - def test_is_close_to_utc_now(self) -> None: - before = datetime.now(UTC).replace(tzinfo=None) - result = now_without_tz() - after = datetime.now(UTC).replace(tzinfo=None) - assert before <= result <= after - - def test_called_twice_is_non_decreasing(self) -> None: - first = now_without_tz() - second = now_without_tz() - assert first <= second - - -class TestValidateExclusivePresence: - def test_first_only_does_not_raise(self) -> None: - validate_exclusive_presence('value', None) - - def test_second_only_does_not_raise(self) -> None: - validate_exclusive_presence(None, 'value') - - def test_both_provided_raises(self) -> None: - with pytest.raises(ValueError, match='Exactly one'): - validate_exclusive_presence('a', 'b') - - def test_neither_provided_raises(self) -> None: - with pytest.raises(ValueError, match='Exactly one'): - validate_exclusive_presence(None, None) - - def test_falsy_non_none_first_counts_as_provided(self) -> None: - # 0, '', [] are not None — should NOT raise - validate_exclusive_presence(0, None) - validate_exclusive_presence('', None) - validate_exclusive_presence([], None) - - def test_falsy_non_none_both_raises(self) -> None: - with pytest.raises(ValueError): - validate_exclusive_presence(0, 0) - - def test_non_string_values_accepted(self) -> None: - validate_exclusive_presence(42, None) - validate_exclusive_presence(None, {'key': 'val'}) +def test_now_without_tz_returns_datetime_without_tzinfo() -> None: + result = now_without_tz() + assert isinstance(result, datetime) + assert result.tzinfo is None + + +def test_now_without_tz_is_close_to_utc_now() -> None: + before = datetime.now(UTC).replace(tzinfo=None) + result = now_without_tz() + after = datetime.now(UTC).replace(tzinfo=None) + assert before <= result <= after + + +def test_now_without_tz_called_twice_is_non_decreasing() -> None: + first = now_without_tz() + second = now_without_tz() + assert first <= second + + +def test_validate_exclusive_presence_first_only_does_not_raise() -> None: + validate_exclusive_presence('value', None) + + +def test_validate_exclusive_presence_second_only_does_not_raise() -> None: + validate_exclusive_presence(None, 'value') + + +def test_validate_exclusive_presence_both_provided_raises() -> None: + with pytest.raises(ValueError, match='Exactly one'): + validate_exclusive_presence('a', 'b') + + +def test_validate_exclusive_presence_neither_provided_raises() -> None: + with pytest.raises(ValueError, match='Exactly one'): + validate_exclusive_presence(None, None) + + +def test_validate_exclusive_presence_falsy_non_none_first_counts_as_provided() -> None: + # 0, '', [] are not None — should NOT raise + validate_exclusive_presence(0, None) + validate_exclusive_presence('', None) + validate_exclusive_presence([], None) + + +def test_validate_exclusive_presence_falsy_non_none_both_raises() -> None: + with pytest.raises(ValueError): + validate_exclusive_presence(0, 0) + + +def test_validate_exclusive_presence_non_string_values_accepted() -> None: + validate_exclusive_presence(42, None) + validate_exclusive_presence(None, {'key': 'val'}) diff --git a/tests/v2/test_unit/test_exceptions.py b/tests/v2/test_unit/test_exceptions.py index e438241..acc2a18 100644 --- a/tests/v2/test_unit/test_exceptions.py +++ b/tests/v2/test_unit/test_exceptions.py @@ -1,88 +1,119 @@ +from uuid import uuid4 + import pytest from notora.v2.exceptions.common import AlreadyExistsError, FKNotFoundError, NotFoundError +_ENTITY_ID_INT = 42 + + +# --------------------------------------------------------------------------- +# FKNotFoundError +# --------------------------------------------------------------------------- + +def test_fk_not_found_error_stores_fk_name() -> None: + err = FKNotFoundError('msg', fk_name='profile_user_id_fkey', table_name='profile') + assert err.fk_name == 'profile_user_id_fkey' + + +def test_fk_not_found_error_stores_table_name() -> None: + err = FKNotFoundError('msg', fk_name='fk', table_name='orders') + assert err.table_name == 'orders' + + +def test_fk_not_found_error_message_is_accessible() -> None: + err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') + assert str(err) == 'Related object not found.' + + +def test_fk_not_found_error_is_exception() -> None: + err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') + assert isinstance(err, Exception) + + +def test_fk_not_found_error_can_be_raised_and_caught() -> None: + msg = 'err' + with pytest.raises(FKNotFoundError) as exc_info: + raise FKNotFoundError(msg, fk_name='fk', table_name='tbl') + assert exc_info.value.fk_name == 'fk' + assert exc_info.value.table_name == 'tbl' + + +# --------------------------------------------------------------------------- +# AlreadyExistsError +# --------------------------------------------------------------------------- + +def test_already_exists_error_default_message() -> None: + err = AlreadyExistsError() + assert str(err) == 'Entity already exists.' + + +def test_already_exists_error_custom_message() -> None: + err = AlreadyExistsError('Custom message.') + assert str(err) == 'Custom message.' + + +def test_already_exists_error_constraint_name_stored() -> None: + err = AlreadyExistsError(constraint_name='users_email_key') + assert err.constraint_name == 'users_email_key' + + +def test_already_exists_error_constraint_name_none_by_default() -> None: + err = AlreadyExistsError() + assert err.constraint_name is None -class TestFKNotFoundError: - def test_stores_fk_name(self) -> None: - err = FKNotFoundError('msg', fk_name='profile_user_id_fkey', table_name='profile') - assert err.fk_name == 'profile_user_id_fkey' - def test_stores_table_name(self) -> None: - err = FKNotFoundError('msg', fk_name='fk', table_name='orders') - assert err.table_name == 'orders' +def test_already_exists_error_message_and_constraint_together() -> None: + err = AlreadyExistsError('Dup', constraint_name='my_constraint') + assert str(err) == 'Dup' + assert err.constraint_name == 'my_constraint' - def test_message_is_accessible(self) -> None: - err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') - assert str(err) == 'Related object not found.' - def test_is_exception(self) -> None: - err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') - assert isinstance(err, Exception) +def test_already_exists_error_is_exception() -> None: + assert isinstance(AlreadyExistsError(), Exception) - def test_can_be_raised_and_caught(self) -> None: - with pytest.raises(FKNotFoundError) as exc_info: - raise FKNotFoundError('err', fk_name='fk', table_name='tbl') - assert exc_info.value.fk_name == 'fk' - assert exc_info.value.table_name == 'tbl' +def test_already_exists_error_can_be_raised_and_caught() -> None: + msg = 'dup' + with pytest.raises(AlreadyExistsError): + raise AlreadyExistsError(msg) -class TestAlreadyExistsError: - def test_default_message(self) -> None: - err = AlreadyExistsError() - assert str(err) == 'Entity already exists.' - def test_custom_message(self) -> None: - err = AlreadyExistsError('Custom message.') - assert str(err) == 'Custom message.' +# --------------------------------------------------------------------------- +# NotFoundError +# --------------------------------------------------------------------------- - def test_constraint_name_stored(self) -> None: - err = AlreadyExistsError(constraint_name='users_email_key') - assert err.constraint_name == 'users_email_key' +def test_not_found_error_entity_id_none_by_default() -> None: + err = NotFoundError('not found') + assert err.entity_id is None - def test_constraint_name_none_by_default(self) -> None: - err = AlreadyExistsError() - assert err.constraint_name is None - def test_message_and_constraint_together(self) -> None: - err = AlreadyExistsError('Dup', constraint_name='my_constraint') - assert str(err) == 'Dup' - assert err.constraint_name == 'my_constraint' +def test_not_found_error_entity_id_stored() -> None: + err = NotFoundError('not found', entity_id=_ENTITY_ID_INT) + assert err.entity_id == _ENTITY_ID_INT - def test_is_exception(self) -> None: - assert isinstance(AlreadyExistsError(), Exception) - def test_can_be_raised_and_caught(self) -> None: - with pytest.raises(AlreadyExistsError): - raise AlreadyExistsError('dup') +def test_not_found_error_entity_id_uuid() -> None: + uid = uuid4() + err = NotFoundError('not found', entity_id=uid) + assert err.entity_id == uid -class TestNotFoundError: - def test_entity_id_none_by_default(self) -> None: - err = NotFoundError('not found') - assert err.entity_id is None +def test_not_found_error_message_preserved() -> None: + err = NotFoundError('Resource not found.') + assert str(err) == 'Resource not found.' - def test_entity_id_integer(self) -> None: - err = NotFoundError('not found', entity_id=42) - assert err.entity_id == 42 - def test_entity_id_uuid(self) -> None: - from uuid import uuid4 - uid = uuid4() - err = NotFoundError('not found', entity_id=uid) - assert err.entity_id == uid +def test_not_found_error_is_exception() -> None: + assert isinstance(NotFoundError('x'), Exception) - def test_message_preserved(self) -> None: - err = NotFoundError('Resource not found.') - assert str(err) == 'Resource not found.' - def test_is_exception(self) -> None: - assert isinstance(NotFoundError('x'), Exception) +def test_not_found_error_can_be_raised_and_caught() -> None: + msg = 'missing' + with pytest.raises(NotFoundError): + raise NotFoundError(msg) - def test_can_be_raised_and_caught(self) -> None: - with pytest.raises(NotFoundError): - raise NotFoundError('missing') - def test_no_positional_args(self) -> None: - err = NotFoundError() - assert err.entity_id is None +def test_not_found_error_no_positional_args() -> None: + err = NotFoundError() + assert err.entity_id is None diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index 4bf43dc..62e11f0 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -12,6 +12,9 @@ from notora.v2.services.base import RepositoryService, SoftDeleteRepositoryService from notora.v2.services.factory import build_service, build_service_for_repo +_DEFAULT_LIMIT = 7 +_REPO_CONFIG_LIMIT = 3 + class _Widget(GenericBaseModel): name: Mapped[str] = mapped_column(String) @@ -21,106 +24,126 @@ class _WidgetSchema(BaseResponseSchema): pass -class TestBuildRepository: - def test_returns_standard_repo_by_default(self) -> None: - repo = build_repository(_Widget) - assert isinstance(repo, Repository) - assert not isinstance(repo, SoftDeleteRepository) - - def test_soft_delete_flag_returns_soft_delete_repo(self) -> None: - repo = build_repository(_Widget, soft_delete=True) - assert isinstance(repo, SoftDeleteRepository) - - def test_config_is_applied(self) -> None: - config = RepoConfig[_Widget](default_limit=7) - repo = build_repository(_Widget, config=config) - assert repo.default_limit == 7 - - def test_custom_repo_class_used(self) -> None: - class _CustomRepo(Repository[object, _Widget]): - pass - - repo = build_repository(_Widget, repo_cls=_CustomRepo) - assert isinstance(repo, _CustomRepo) - - def test_model_attribute_set(self) -> None: - repo = build_repository(_Widget) - assert repo.model is _Widget - - -class TestBuildService: - def test_returns_repository_service_by_default(self) -> None: - svc = build_service(_Widget) - assert isinstance(svc, RepositoryService) - - def test_soft_delete_flag_returns_soft_delete_service(self) -> None: - svc = build_service(_Widget, soft_delete=True) - assert isinstance(svc, SoftDeleteRepositoryService) - - def test_custom_repo_passed_directly(self) -> None: - repo = Repository[object, _Widget](_Widget) - svc = build_service(_Widget, repo=repo) - assert isinstance(svc, RepositoryService) - assert svc.repo is repo - - def test_soft_delete_repo_infers_soft_delete_service(self) -> None: - repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service(_Widget, repo=repo) - assert isinstance(svc, SoftDeleteRepositoryService) - - def test_soft_delete_service_class_with_non_soft_delete_repo_raises(self) -> None: - repo = Repository[object, _Widget](_Widget) - with pytest.raises(TypeError, match='Soft-delete service requires'): - build_service(_Widget, repo=repo, service_cls=SoftDeleteRepositoryService) - - def test_soft_delete_flag_with_standard_service_class_used(self) -> None: - svc = build_service( - _Widget, - soft_delete=True, - service_cls=SoftDeleteRepositoryService, - ) - assert isinstance(svc, SoftDeleteRepositoryService) - - def test_repo_config_applied(self) -> None: - repo_config = RepoConfig[_Widget](default_limit=3) - svc = build_service(_Widget, repo_config=repo_config) - assert svc.repo.default_limit == 3 - - def test_soft_delete_true_without_matching_service_cls_raises(self) -> None: - """Passing a plain Repository as repo + soft_delete=True internally should be fine - only if the repo is SoftDeleteRepository. When soft_delete=True is inferred - from the build_repository call, we get a SoftDeleteRepository automatically. - """ - repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service(_Widget, soft_delete=True, repo=repo) - assert isinstance(svc, SoftDeleteRepositoryService) - - -class TestBuildServiceForRepo: - def test_standard_repo_returns_repository_service(self) -> None: - repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) - assert isinstance(svc, RepositoryService) - - def test_soft_delete_repo_returns_soft_delete_service(self) -> None: - repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) - assert isinstance(svc, SoftDeleteRepositoryService) - - def test_custom_service_class_used(self) -> None: - class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): - pass - - repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo, service_cls=_CustomService) - assert isinstance(svc, _CustomService) - - def test_soft_delete_service_cls_with_non_soft_delete_repo_raises(self) -> None: - repo = Repository[object, _Widget](_Widget) - with pytest.raises(TypeError, match='Soft-delete service requires'): - build_service_for_repo(repo, service_cls=SoftDeleteRepositoryService) - - def test_repo_is_wired_to_service(self) -> None: - repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) - assert svc.repo is repo +# --------------------------------------------------------------------------- +# build_repository +# --------------------------------------------------------------------------- + +def test_build_repository_returns_standard_repo_by_default() -> None: + repo = build_repository(_Widget) + assert isinstance(repo, Repository) + assert not isinstance(repo, SoftDeleteRepository) + + +def test_build_repository_soft_delete_flag_returns_soft_delete_repo() -> None: + repo = build_repository(_Widget, soft_delete=True) + assert isinstance(repo, SoftDeleteRepository) + + +def test_build_repository_config_is_applied() -> None: + config = RepoConfig[_Widget](default_limit=_DEFAULT_LIMIT) + repo = build_repository(_Widget, config=config) + assert repo.default_limit == _DEFAULT_LIMIT + + +def test_build_repository_custom_repo_class_used() -> None: + class _CustomRepo(Repository[object, _Widget]): + pass + + repo = build_repository(_Widget, repo_cls=_CustomRepo) + assert isinstance(repo, _CustomRepo) + + +def test_build_repository_model_attribute_set() -> None: + repo = build_repository(_Widget) + assert repo.model is _Widget + + +# --------------------------------------------------------------------------- +# build_service +# --------------------------------------------------------------------------- + +def test_build_service_returns_repository_service_by_default() -> None: + svc = build_service(_Widget) + assert isinstance(svc, RepositoryService) + + +def test_build_service_soft_delete_flag_returns_soft_delete_service() -> None: + svc = build_service(_Widget, soft_delete=True) + assert isinstance(svc, SoftDeleteRepositoryService) + + +def test_build_service_custom_repo_passed_directly() -> None: + repo = Repository[object, _Widget](_Widget) + svc = build_service(_Widget, repo=repo) + assert isinstance(svc, RepositoryService) + assert svc.repo is repo + + +def test_build_service_soft_delete_repo_infers_soft_delete_service() -> None: + repo = SoftDeleteRepository[object, _Widget](_Widget) + svc = build_service(_Widget, repo=repo) + assert isinstance(svc, SoftDeleteRepositoryService) + + +def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raises() -> None: + repo = Repository[object, _Widget](_Widget) + with pytest.raises(TypeError, match='Soft-delete service requires'): + build_service(_Widget, repo=repo, service_cls=SoftDeleteRepositoryService) + + +def test_build_service_soft_delete_flag_with_standard_service_class_used() -> None: + svc = build_service( + _Widget, + soft_delete=True, + service_cls=SoftDeleteRepositoryService, + ) + assert isinstance(svc, SoftDeleteRepositoryService) + + +def test_build_service_repo_config_applied() -> None: + repo_config = RepoConfig[_Widget](default_limit=_REPO_CONFIG_LIMIT) + svc = build_service(_Widget, repo_config=repo_config) + assert svc.repo.default_limit == _REPO_CONFIG_LIMIT + + +def test_build_service_soft_delete_repo_with_soft_delete_true() -> None: + repo = SoftDeleteRepository[object, _Widget](_Widget) + svc = build_service(_Widget, soft_delete=True, repo=repo) + assert isinstance(svc, SoftDeleteRepositoryService) + + +# --------------------------------------------------------------------------- +# build_service_for_repo +# --------------------------------------------------------------------------- + +def test_build_service_for_repo_standard_repo_returns_repository_service() -> None: + repo = Repository[object, _Widget](_Widget) + svc = build_service_for_repo(repo) + assert isinstance(svc, RepositoryService) + + +def test_build_service_for_repo_soft_delete_repo_returns_soft_delete_service() -> None: + repo = SoftDeleteRepository[object, _Widget](_Widget) + svc = build_service_for_repo(repo) + assert isinstance(svc, SoftDeleteRepositoryService) + + +def test_build_service_for_repo_custom_service_class_used() -> None: + class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): + pass + + repo = Repository[object, _Widget](_Widget) + svc = build_service_for_repo(repo, service_cls=_CustomService) + assert isinstance(svc, _CustomService) + + +def test_build_service_for_repo_soft_delete_service_cls_with_non_soft_delete_repo_raises() -> None: + repo = Repository[object, _Widget](_Widget) + with pytest.raises(TypeError, match='Soft-delete service requires'): + build_service_for_repo(repo, service_cls=SoftDeleteRepositoryService) + + +def test_build_service_for_repo_repo_is_wired_to_service() -> None: + repo = Repository[object, _Widget](_Widget) + svc = build_service_for_repo(repo) + assert svc.repo is repo diff --git a/tests/v2/test_unit/test_payload_mixin.py b/tests/v2/test_unit/test_payload_mixin.py index 5796613..8a0d3d4 100644 --- a/tests/v2/test_unit/test_payload_mixin.py +++ b/tests/v2/test_unit/test_payload_mixin.py @@ -10,41 +10,42 @@ class _SomeSchema(PydanticModel): score: int = 0 -class _Mixin(PayloadMixin): - pass - - -class TestDumpPayload: - def test_dict_input_returned_as_copy(self) -> None: - original = {'name': 'Alice', 'score': 5} - result = _Mixin._dump_payload(original, exclude_unset=True) - assert result == original - # Ensure it's a copy, not the same object - result['name'] = 'Bob' - assert original['name'] == 'Alice' - - def test_pydantic_model_dump_with_exclude_unset_true(self) -> None: - schema = _SomeSchema(name='Alice') - result = _Mixin._dump_payload(schema, exclude_unset=True) - # 'score' was not explicitly set, so it should be excluded - assert 'name' in result - assert 'score' not in result - - def test_pydantic_model_dump_with_exclude_unset_false(self) -> None: - schema = _SomeSchema(name='Alice') - result = _Mixin._dump_payload(schema, exclude_unset=False) - assert result == {'name': 'Alice', 'score': 0} - - def test_pydantic_model_fully_set(self) -> None: - schema = _SomeSchema(name='Bob', score=10) - result = _Mixin._dump_payload(schema, exclude_unset=True) - assert result == {'name': 'Bob', 'score': 10} - - def test_empty_dict_returns_empty_dict(self) -> None: - result = _Mixin._dump_payload({}, exclude_unset=False) - assert result == {} - - def test_non_string_dict_values_preserved(self) -> None: - payload = {'count': 42, 'active': True, 'tags': ['a', 'b']} - result = _Mixin._dump_payload(payload, exclude_unset=True) - assert result == payload +def test_payload_mixin_dict_input_returned_as_copy() -> None: + original = {'name': 'Alice', 'score': 5} + result = PayloadMixin._dump_payload(original, exclude_unset=True) + assert result == original + # Ensure it's a copy, not the same object + result['name'] = 'Bob' + assert original['name'] == 'Alice' + + +def test_payload_mixin_pydantic_model_dump_with_exclude_unset_true() -> None: + schema = _SomeSchema(name='Alice') + result = PayloadMixin._dump_payload(schema, exclude_unset=True) + # 'score' was not explicitly set, so it should be excluded + assert 'name' in result + assert 'score' not in result + + +def test_payload_mixin_pydantic_model_dump_with_exclude_unset_false() -> None: + schema = _SomeSchema(name='Alice') + result = PayloadMixin._dump_payload(schema, exclude_unset=False) + assert result == {'name': 'Alice', 'score': 0} + + +def test_payload_mixin_pydantic_model_fully_set() -> None: + score = 10 + schema = _SomeSchema(name='Bob', score=score) + result = PayloadMixin._dump_payload(schema, exclude_unset=True) + assert result == {'name': 'Bob', 'score': score} + + +def test_payload_mixin_empty_dict_returns_empty_dict() -> None: + result = PayloadMixin._dump_payload({}, exclude_unset=False) + assert result == {} + + +def test_payload_mixin_non_string_dict_values_preserved() -> None: + payload = {'count': 42, 'active': True, 'tags': ['a', 'b']} + result = PayloadMixin._dump_payload(payload, exclude_unset=True) + assert result == payload diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index 2b23cdd..d1350f4 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -1,8 +1,8 @@ -"""Tests for query_dsl token parsers, apply_filter_operator, build_filter_clauses, and build_sort_clauses.""" +"""Tests for query_dsl token parsers, filter/sort clause builders, and build_query_params.""" import pytest from pydantic import ValidationError -from sqlalchemy import Integer, String +from sqlalchemy import Integer, String, select from sqlalchemy.dialects import postgresql from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import ColumnElement @@ -23,6 +23,12 @@ resolve_to_column, ) +_MULTI_CLAUSE_COUNT = 2 +_POSITIVE_OFFSET = 100 +_POSITIVE_LIMIT = 50 +_LIMIT_SMALL = 5 +_OFFSET_SMALL = 10 + def _render(clause: ColumnElement) -> str: # type: ignore[type-arg] return str( @@ -42,335 +48,376 @@ class SampleModel(GenericBaseModel): # parse_filter_token # --------------------------------------------------------------------------- -class TestParseFilterToken: - def test_parses_field_op_value(self) -> None: - token = parse_filter_token('name:eq:alice') - assert token.field == 'name' - assert token.operator == 'eq' - assert token.raw_value == 'alice' +def test_parse_filter_token_parses_field_op_value() -> None: + token = parse_filter_token('name:eq:alice') + assert token.field == 'name' + assert token.operator == 'eq' + assert token.raw_value == 'alice' + + +def test_parse_filter_token_parses_operator_only_for_isnull() -> None: + token = parse_filter_token('name:isnull') + assert token.field == 'name' + assert token.operator == 'isnull' + assert token.raw_value is None + + +def test_parse_filter_token_raises_for_missing_colon() -> None: + with pytest.raises(ValueError, match='"field:op:value"'): + parse_filter_token('nocolon') + + +def test_parse_filter_token_raises_for_empty_field_name() -> None: + with pytest.raises(ValueError, match='field name cannot be empty'): + parse_filter_token(':eq:value') + + +def test_parse_filter_token_raises_for_unsupported_operator() -> None: + with pytest.raises(ValueError, match='Unsupported filter operator'): + parse_filter_token('name:contains:hello') - def test_parses_operator_only_for_isnull(self) -> None: - token = parse_filter_token('name:isnull') - assert token.field == 'name' - assert token.operator == 'isnull' - assert token.raw_value is None - def test_raises_for_missing_colon(self) -> None: - with pytest.raises(ValueError, match='"field:op:value"'): - parse_filter_token('nocolon') +def test_parse_filter_token_value_with_colons_preserved() -> None: + token = parse_filter_token('name:eq:a:b:c') + assert token.raw_value == 'a:b:c' - def test_raises_for_empty_field_name(self) -> None: - with pytest.raises(ValueError, match='field name cannot be empty'): - parse_filter_token(':eq:value') - def test_raises_for_unsupported_operator(self) -> None: - with pytest.raises(ValueError, match='Unsupported filter operator'): - parse_filter_token('name:contains:hello') +def test_parse_filter_token_whitespace_stripped_from_field_and_op() -> None: + token = parse_filter_token(' name : eq : alice ') + assert token.field == 'name' + assert token.operator == 'eq' - def test_value_with_colons_preserved(self) -> None: - token = parse_filter_token('name:eq:a:b:c') - assert token.raw_value == 'a:b:c' - def test_whitespace_stripped_from_field_and_op(self) -> None: - token = parse_filter_token(' name : eq : alice ') - assert token.field == 'name' - assert token.operator == 'eq' +def test_parse_filter_token_whitespace_only_value_becomes_none() -> None: + token = parse_filter_token('name:eq: ') + assert token.raw_value is None - def test_whitespace_only_value_becomes_none(self) -> None: - token = parse_filter_token('name:eq: ') - assert token.raw_value is None - def test_all_operators_accepted(self) -> None: - valid_ops = ('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'ilike', 'isnull') - for op in valid_ops: - token = parse_filter_token(f'name:{op}:x') - assert token.operator == op +def test_parse_filter_token_all_operators_accepted() -> None: + valid_ops = ('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'ilike', 'isnull') + for op in valid_ops: + token = parse_filter_token(f'name:{op}:x') + assert token.operator == op - def test_isnull_with_false_value(self) -> None: - token = parse_filter_token('name:isnull:false') - assert token.raw_value == 'false' - def test_in_with_comma_separated_value(self) -> None: - token = parse_filter_token('score:in:1,2,3') - assert token.raw_value == '1,2,3' +def test_parse_filter_token_isnull_with_false_value() -> None: + token = parse_filter_token('name:isnull:false') + assert token.raw_value == 'false' + + +def test_parse_filter_token_in_with_comma_separated_value() -> None: + token = parse_filter_token('score:in:1,2,3') + assert token.raw_value == '1,2,3' # --------------------------------------------------------------------------- # parse_sort_token # --------------------------------------------------------------------------- -class TestParseSortToken: - def test_plain_field_is_ascending(self) -> None: - token = parse_sort_token('name') - assert token.field == 'name' - assert token.direction == 'asc' +def test_parse_sort_token_plain_field_is_ascending() -> None: + token = parse_sort_token('name') + assert token.field == 'name' + assert token.direction == 'asc' + + +def test_parse_sort_token_plus_prefix_is_ascending() -> None: + token = parse_sort_token('+name') + assert token.field == 'name' + assert token.direction == 'asc' + + +def test_parse_sort_token_minus_prefix_is_descending() -> None: + token = parse_sort_token('-score') + assert token.field == 'score' + assert token.direction == 'desc' + + +def test_parse_sort_token_empty_string_raises() -> None: + with pytest.raises(ValueError, match='cannot be empty'): + parse_sort_token('') + - def test_plus_prefix_is_ascending(self) -> None: - token = parse_sort_token('+name') - assert token.field == 'name' - assert token.direction == 'asc' +def test_parse_sort_token_only_minus_raises() -> None: + with pytest.raises(ValueError, match='cannot be empty'): + parse_sort_token('-') - def test_minus_prefix_is_descending(self) -> None: - token = parse_sort_token('-score') - assert token.field == 'score' - assert token.direction == 'desc' - def test_empty_string_raises(self) -> None: - with pytest.raises(ValueError, match='cannot be empty'): - parse_sort_token('') +def test_parse_sort_token_only_plus_raises() -> None: + with pytest.raises(ValueError, match='cannot be empty'): + parse_sort_token('+') - def test_only_minus_raises(self) -> None: - with pytest.raises(ValueError, match='cannot be empty'): - parse_sort_token('-') - def test_only_plus_raises(self) -> None: - with pytest.raises(ValueError, match='cannot be empty'): - parse_sort_token('+') +def test_parse_sort_token_whitespace_stripped() -> None: + token = parse_sort_token(' name ') + assert token.field == 'name' - def test_whitespace_stripped(self) -> None: - token = parse_sort_token(' name ') - assert token.field == 'name' - def test_returns_sort_token_dataclass(self) -> None: - token = parse_sort_token('name') - assert isinstance(token, SortToken) +def test_parse_sort_token_returns_sort_token_dataclass() -> None: + token = parse_sort_token('name') + assert isinstance(token, SortToken) # --------------------------------------------------------------------------- # apply_filter_operator # --------------------------------------------------------------------------- -class TestApplyFilterOperator: - def test_eq(self) -> None: - clause = apply_filter_operator(SampleModel.name, 'eq', 'alice') - assert "sample_model.name = 'alice'" in _render(clause) +def test_apply_filter_operator_eq() -> None: + clause = apply_filter_operator(SampleModel.name, 'eq', 'alice') + assert "sample_model.name = 'alice'" in _render(clause) - def test_ne(self) -> None: - clause = apply_filter_operator(SampleModel.name, 'ne', 'alice') - rendered = _render(clause) - assert 'sample_model.name' in rendered - assert '!=' in rendered or '<>' in rendered - def test_lt(self) -> None: - clause = apply_filter_operator(SampleModel.score, 'lt', 5) - assert 'sample_model.score < 5' in _render(clause) +def test_apply_filter_operator_ne() -> None: + clause = apply_filter_operator(SampleModel.name, 'ne', 'alice') + rendered = _render(clause) + assert 'sample_model.name' in rendered + assert '!=' in rendered or '<>' in rendered - def test_lte(self) -> None: - clause = apply_filter_operator(SampleModel.score, 'lte', 5) - assert 'sample_model.score <= 5' in _render(clause) - def test_gt(self) -> None: - clause = apply_filter_operator(SampleModel.score, 'gt', 5) - assert 'sample_model.score > 5' in _render(clause) +def test_apply_filter_operator_lt() -> None: + clause = apply_filter_operator(SampleModel.score, 'lt', 5) + assert 'sample_model.score < 5' in _render(clause) - def test_gte(self) -> None: - clause = apply_filter_operator(SampleModel.score, 'gte', 5) - assert 'sample_model.score >= 5' in _render(clause) - def test_in(self) -> None: - clause = apply_filter_operator(SampleModel.score, 'in', [1, 2, 3]) - assert 'IN' in _render(clause) +def test_apply_filter_operator_lte() -> None: + clause = apply_filter_operator(SampleModel.score, 'lte', 5) + assert 'sample_model.score <= 5' in _render(clause) - def test_ilike(self) -> None: - clause = apply_filter_operator(SampleModel.name, 'ilike', '%alice%') - assert 'ILIKE' in _render(clause) - def test_isnull_true(self) -> None: - clause = apply_filter_operator(SampleModel.name, 'isnull', True) - assert 'IS NULL' in _render(clause) +def test_apply_filter_operator_gt() -> None: + clause = apply_filter_operator(SampleModel.score, 'gt', 5) + assert 'sample_model.score > 5' in _render(clause) - def test_isnull_false(self) -> None: - clause = apply_filter_operator(SampleModel.name, 'isnull', False) - assert 'IS NOT NULL' in _render(clause) - def test_unsupported_operator_raises(self) -> None: - with pytest.raises(ValueError, match='Unsupported filter operator'): - apply_filter_operator(SampleModel.name, 'contains', 'x') # type: ignore[arg-type] +def test_apply_filter_operator_gte() -> None: + clause = apply_filter_operator(SampleModel.score, 'gte', 5) + assert 'sample_model.score >= 5' in _render(clause) + + +def test_apply_filter_operator_in() -> None: + clause = apply_filter_operator(SampleModel.score, 'in', [1, 2, 3]) + assert 'IN' in _render(clause) + + +def test_apply_filter_operator_ilike() -> None: + clause = apply_filter_operator(SampleModel.name, 'ilike', '%alice%') + assert 'ILIKE' in _render(clause) + + +def test_apply_filter_operator_isnull_true() -> None: + clause = apply_filter_operator(SampleModel.name, 'isnull', value=True) + assert 'IS NULL' in _render(clause) + + +def test_apply_filter_operator_isnull_false() -> None: + clause = apply_filter_operator(SampleModel.name, 'isnull', value=False) + assert 'IS NOT NULL' in _render(clause) + + +def test_apply_filter_operator_unsupported_operator_raises() -> None: + with pytest.raises(ValueError, match='Unsupported filter operator'): + apply_filter_operator(SampleModel.name, 'contains', 'x') # type: ignore[arg-type] # --------------------------------------------------------------------------- # resolve_to_column # --------------------------------------------------------------------------- -class TestResolveToColumn: - def test_direct_column_returned_unchanged(self) -> None: - col = resolve_to_column(SampleModel.name, SampleModel) - assert 'sample_model.name' in str(col) +def test_resolve_to_column_direct_column_returned_unchanged() -> None: + col = resolve_to_column(SampleModel.name, SampleModel) + assert 'sample_model.name' in str(col) + - def test_callable_resolver_called_with_model(self) -> None: - col = resolve_to_column(lambda m: m.score, SampleModel) - assert 'sample_model.score' in str(col) +def test_resolve_to_column_callable_resolver_called_with_model() -> None: + col = resolve_to_column(lambda m: m.score, SampleModel) + assert 'sample_model.score' in str(col) # --------------------------------------------------------------------------- # build_filter_clauses # --------------------------------------------------------------------------- -class TestBuildFilterClauses: - def test_single_eq_clause(self) -> None: - tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] - fields = {'name': FilterField(resolver=SampleModel.name, value_type=str)} - clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) - assert len(clauses) == 1 - assert "sample_model.name = 'alice'" in _render(clauses[0]) - - def test_unknown_field_raises(self) -> None: - tokens = [FilterToken(field='unknown', operator='eq', raw_value='x')] - with pytest.raises(ValueError, match='Unsupported filter field'): - build_filter_clauses(tokens, model=SampleModel, fields={}) - - def test_disallowed_operator_raises(self) -> None: - tokens = [FilterToken(field='name', operator='gt', raw_value='5')] - fields = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} - with pytest.raises(ValueError, match='Operator'): - build_filter_clauses(tokens, model=SampleModel, fields=fields) - - def test_predicate_field(self) -> None: - def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: - return model.name.ilike(f'%{value}%') - - tokens = [FilterToken(field='q', operator='eq', raw_value='alice')] - fields = {'q': FilterField(predicate=pred)} - clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) - assert 'ILIKE' in _render(clauses[0]) - - def test_field_without_resolver_or_predicate_raises(self) -> None: - tokens = [FilterToken(field='name', operator='eq', raw_value='x')] - fields = {'name': FilterField()} - with pytest.raises(ValueError, match='resolver or predicate'): - build_filter_clauses(tokens, model=SampleModel, fields=fields) - - def test_empty_tokens_returns_empty(self) -> None: - clauses = build_filter_clauses([], model=SampleModel, fields={}) - assert clauses == [] - - def test_isnull_no_value(self) -> None: - tokens = [FilterToken(field='name', operator='isnull', raw_value=None)] - fields = {'name': FilterField(resolver=SampleModel.name)} - clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) - assert 'IS NULL' in _render(clauses[0]) - - def test_in_operator_requires_value(self) -> None: - tokens = [FilterToken(field='name', operator='in', raw_value=None)] - fields = {'name': FilterField(resolver=SampleModel.name)} - with pytest.raises(ValueError, match='requires a value'): - build_filter_clauses(tokens, model=SampleModel, fields=fields) - - def test_non_isnull_without_value_raises(self) -> None: - tokens = [FilterToken(field='name', operator='eq', raw_value=None)] - fields = {'name': FilterField(resolver=SampleModel.name)} - with pytest.raises(ValueError, match='requires a value'): - build_filter_clauses(tokens, model=SampleModel, fields=fields) - - def test_callable_resolver_in_field(self) -> None: - tokens = [FilterToken(field='name', operator='eq', raw_value='x')] - fields = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} - clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) - assert "sample_model.name = 'x'" in _render(clauses[0]) +def test_build_filter_clauses_single_eq_clause() -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] + fields = {'name': FilterField(resolver=SampleModel.name, value_type=str)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert len(clauses) == 1 + assert "sample_model.name = 'alice'" in _render(clauses[0]) + + +def test_build_filter_clauses_unknown_field_raises() -> None: + tokens = [FilterToken(field='unknown', operator='eq', raw_value='x')] + with pytest.raises(ValueError, match='Unsupported filter field'): + build_filter_clauses(tokens, model=SampleModel, fields={}) + + +def test_build_filter_clauses_disallowed_operator_raises() -> None: + tokens = [FilterToken(field='name', operator='gt', raw_value='5')] + fields = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} + with pytest.raises(ValueError, match='Operator'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + +def test_build_filter_clauses_predicate_field() -> None: + def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: + return model.name.ilike(f'%{value}%') + + tokens = [FilterToken(field='q', operator='eq', raw_value='alice')] + fields = {'q': FilterField(predicate=pred)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert 'ILIKE' in _render(clauses[0]) + + +def test_build_filter_clauses_field_without_resolver_or_predicate_raises() -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value='x')] + fields = {'name': FilterField()} + with pytest.raises(ValueError, match='resolver or predicate'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + +def test_build_filter_clauses_empty_tokens_returns_empty() -> None: + clauses = build_filter_clauses([], model=SampleModel, fields={}) + assert clauses == [] + + +def test_build_filter_clauses_isnull_no_value() -> None: + tokens = [FilterToken(field='name', operator='isnull', raw_value=None)] + fields = {'name': FilterField(resolver=SampleModel.name)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert 'IS NULL' in _render(clauses[0]) + + +def test_build_filter_clauses_in_operator_requires_value() -> None: + tokens = [FilterToken(field='name', operator='in', raw_value=None)] + fields = {'name': FilterField(resolver=SampleModel.name)} + with pytest.raises(ValueError, match='requires a value'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + +def test_build_filter_clauses_non_isnull_without_value_raises() -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value=None)] + fields = {'name': FilterField(resolver=SampleModel.name)} + with pytest.raises(ValueError, match='requires a value'): + build_filter_clauses(tokens, model=SampleModel, fields=fields) + + +def test_build_filter_clauses_callable_resolver_in_field() -> None: + tokens = [FilterToken(field='name', operator='eq', raw_value='x')] + fields = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} + clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) + assert "sample_model.name = 'x'" in _render(clauses[0]) # --------------------------------------------------------------------------- # build_sort_clauses # --------------------------------------------------------------------------- -class TestBuildSortClauses: - def test_ascending(self) -> None: - tokens = [SortToken(field='name', direction='asc')] - fields = {'name': SortField(resolver=SampleModel.name)} - clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) - assert len(clauses) == 1 - assert 'ASC' in _render(clauses[0]) - - def test_descending(self) -> None: - tokens = [SortToken(field='score', direction='desc')] - fields = {'score': SortField(resolver=SampleModel.score)} - clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) - assert 'DESC' in _render(clauses[0]) - - def test_unknown_field_raises(self) -> None: - tokens = [SortToken(field='unknown', direction='asc')] - with pytest.raises(ValueError, match='Unsupported sort field'): - build_sort_clauses(tokens, model=SampleModel, fields={}) - - def test_empty_tokens_returns_empty(self) -> None: - clauses = build_sort_clauses([], model=SampleModel, fields={}) - assert clauses == [] - - def test_callable_resolver(self) -> None: - tokens = [SortToken(field='name', direction='asc')] - fields = {'name': SortField(resolver=lambda m: m.name)} - clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) - assert 'sample_model.name' in _render(clauses[0]) - - def test_multiple_tokens(self) -> None: - tokens = [ - SortToken(field='name', direction='asc'), - SortToken(field='score', direction='desc'), - ] - fields = { - 'name': SortField(resolver=SampleModel.name), - 'score': SortField(resolver=SampleModel.score), - } - clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) - assert len(clauses) == 2 +def test_build_sort_clauses_ascending() -> None: + tokens = [SortToken(field='name', direction='asc')] + fields = {'name': SortField(resolver=SampleModel.name)} + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert len(clauses) == 1 + assert 'ASC' in _render(clauses[0]) + + +def test_build_sort_clauses_descending() -> None: + tokens = [SortToken(field='score', direction='desc')] + fields = {'score': SortField(resolver=SampleModel.score)} + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert 'DESC' in _render(clauses[0]) + + +def test_build_sort_clauses_unknown_field_raises() -> None: + tokens = [SortToken(field='unknown', direction='asc')] + with pytest.raises(ValueError, match='Unsupported sort field'): + build_sort_clauses(tokens, model=SampleModel, fields={}) + + +def test_build_sort_clauses_empty_tokens_returns_empty() -> None: + clauses = build_sort_clauses([], model=SampleModel, fields={}) + assert clauses == [] + + +def test_build_sort_clauses_callable_resolver() -> None: + tokens = [SortToken(field='name', direction='asc')] + fields = {'name': SortField(resolver=lambda m: m.name)} + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert 'sample_model.name' in _render(clauses[0]) + + +def test_build_sort_clauses_multiple_tokens() -> None: + tokens = [ + SortToken(field='name', direction='asc'), + SortToken(field='score', direction='desc'), + ] + fields = { + 'name': SortField(resolver=SampleModel.name), + 'score': SortField(resolver=SampleModel.score), + } + clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) + assert len(clauses) == _MULTI_CLAUSE_COUNT # --------------------------------------------------------------------------- # QueryInput validation # --------------------------------------------------------------------------- -class TestQueryInput: - def test_negative_offset_raises(self) -> None: - with pytest.raises(ValidationError, match='offset must be zero or a positive integer'): - QueryInput(offset=-1) +def test_query_input_negative_offset_raises() -> None: + with pytest.raises(ValidationError, match='offset must be zero or a positive integer'): + QueryInput(offset=-1) + + +def test_query_input_zero_offset_accepted() -> None: + q = QueryInput(offset=0) + assert q.offset == 0 + + +def test_query_input_positive_offset_accepted() -> None: + q = QueryInput(offset=_POSITIVE_OFFSET) + assert q.offset == _POSITIVE_OFFSET - def test_zero_offset_accepted(self) -> None: - q = QueryInput(offset=0) - assert q.offset == 0 - def test_positive_offset_accepted(self) -> None: - q = QueryInput(offset=100) - assert q.offset == 100 +def test_query_input_none_limit_accepted() -> None: + q = QueryInput(limit=None) + assert q.limit is None - def test_none_limit_accepted(self) -> None: - q = QueryInput(limit=None) - assert q.limit is None - def test_positive_limit_accepted(self) -> None: - q = QueryInput(limit=50) - assert q.limit == 50 +def test_query_input_positive_limit_accepted() -> None: + q = QueryInput(limit=_POSITIVE_LIMIT) + assert q.limit == _POSITIVE_LIMIT # --------------------------------------------------------------------------- # build_query_params edge cases # --------------------------------------------------------------------------- -class TestBuildQueryParamsEdgeCases: - def test_filters_present_without_filter_fields_raises(self) -> None: - query = QueryInput(filter=['name:eq:x']) - with pytest.raises(ValueError, match='Filter fields mapping is required'): - build_query_params(query, model=SampleModel, filter_fields={}) - - def test_sort_present_without_sort_fields_raises(self) -> None: - query = QueryInput(sort=['-score']) - with pytest.raises(ValueError, match='Sort fields mapping is required'): - build_query_params(query, model=SampleModel, sort_fields={}) - - def test_no_filter_no_sort_returns_none_filters_and_ordering(self) -> None: - query = QueryInput() - params = build_query_params(query, model=SampleModel) - assert params.filters is None - assert params.ordering is None - - def test_explicit_limit_is_passed_through(self) -> None: - query = QueryInput(limit=5, offset=10) - params = build_query_params(query, model=SampleModel) - assert params.limit == 5 - assert params.offset == 10 - - def test_base_query_forwarded(self) -> None: - from sqlalchemy import select - base = select(SampleModel) - query = QueryInput() - params = build_query_params(query, model=SampleModel, base_query=base) - assert params.base_query is base +def test_build_query_params_filters_without_filter_fields_raises() -> None: + query = QueryInput(filter=['name:eq:x']) + with pytest.raises(ValueError, match='Filter fields mapping is required'): + build_query_params(query, model=SampleModel, filter_fields={}) + + +def test_build_query_params_sort_without_sort_fields_raises() -> None: + query = QueryInput(sort=['-score']) + with pytest.raises(ValueError, match='Sort fields mapping is required'): + build_query_params(query, model=SampleModel, sort_fields={}) + + +def test_build_query_params_no_filter_no_sort_returns_none_for_both() -> None: + query = QueryInput() + params = build_query_params(query, model=SampleModel) + assert params.filters is None + assert params.ordering is None + + +def test_build_query_params_explicit_limit_and_offset_passed_through() -> None: + query = QueryInput(limit=_LIMIT_SMALL, offset=_OFFSET_SMALL) + params = build_query_params(query, model=SampleModel) + assert params.limit == _LIMIT_SMALL + assert params.offset == _OFFSET_SMALL + + +def test_build_query_params_base_query_forwarded() -> None: + base = select(SampleModel) + query = QueryInput() + params = build_query_params(query, model=SampleModel, base_query=base) + assert params.base_query is base diff --git a/tests/v2/test_unit/test_schemas_base.py b/tests/v2/test_unit/test_schemas_base.py index de8ebb5..52b2cf3 100644 --- a/tests/v2/test_unit/test_schemas_base.py +++ b/tests/v2/test_unit/test_schemas_base.py @@ -1,43 +1,58 @@ -"""Tests for v2 schemas.base not covered elsewhere — ClientMeta, PaginationMetaSchema edge cases.""" +"""Tests for v2 schemas.base — ClientMeta, PaginationMetaSchema.""" from ipaddress import IPv4Address, IPv6Address from notora.v2.schemas.base import ClientMeta, PaginationMetaSchema +_TOTAL_100 = 100 +_LIMIT = 10 -class TestClientMeta: - def test_both_fields_none_by_default(self) -> None: - client = ClientMeta() - assert client.ip_address is None - assert client.user_agent is None - def test_ipv4_address_accepted(self) -> None: - client = ClientMeta(ip_address=IPv4Address('192.168.1.1')) - assert isinstance(client.ip_address, IPv4Address) +# --------------------------------------------------------------------------- +# ClientMeta +# --------------------------------------------------------------------------- - def test_ipv6_address_accepted(self) -> None: - client = ClientMeta(ip_address=IPv6Address('::1')) - assert isinstance(client.ip_address, IPv6Address) +def test_client_meta_both_fields_none_by_default() -> None: + client = ClientMeta() + assert client.ip_address is None + assert client.user_agent is None - def test_user_agent_stored(self) -> None: - client = ClientMeta(user_agent='Mozilla/5.0') - assert client.user_agent == 'Mozilla/5.0' - def test_ip_serialized_as_string_in_dict(self) -> None: - client = ClientMeta(ip_address=IPv4Address('10.0.0.1')) - dumped = client.model_dump() - assert dumped['ip_address'] == '10.0.0.1' +def test_client_meta_ipv4_address_accepted() -> None: + client = ClientMeta(ip_address=IPv4Address('192.168.1.1')) + assert isinstance(client.ip_address, IPv4Address) -class TestPaginationMetaSchemaNegativeTotal: - def test_negative_total_clamped_to_zero(self) -> None: - meta = PaginationMetaSchema.calculate(total=-5, limit=10, offset=0) - assert meta.total == 0 +def test_client_meta_ipv6_address_accepted() -> None: + client = ClientMeta(ip_address=IPv6Address('::1')) + assert isinstance(client.ip_address, IPv6Address) - def test_zero_total_preserved(self) -> None: - meta = PaginationMetaSchema.calculate(total=0, limit=10, offset=0) - assert meta.total == 0 - def test_positive_total_preserved(self) -> None: - meta = PaginationMetaSchema.calculate(total=100, limit=10, offset=0) - assert meta.total == 100 +def test_client_meta_user_agent_stored() -> None: + client = ClientMeta(user_agent='Mozilla/5.0') + assert client.user_agent == 'Mozilla/5.0' + + +def test_client_meta_ip_serialized_as_string_in_dict() -> None: + client = ClientMeta(ip_address=IPv4Address('10.0.0.1')) + dumped = client.model_dump() + assert dumped['ip_address'] == '10.0.0.1' + + +# --------------------------------------------------------------------------- +# PaginationMetaSchema +# --------------------------------------------------------------------------- + +def test_pagination_meta_negative_total_clamped_to_zero() -> None: + meta = PaginationMetaSchema.calculate(total=-5, limit=_LIMIT, offset=0) + assert meta.total == 0 + + +def test_pagination_meta_zero_total_preserved() -> None: + meta = PaginationMetaSchema.calculate(total=0, limit=_LIMIT, offset=0) + assert meta.total == 0 + + +def test_pagination_meta_positive_total_preserved() -> None: + meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=0) + assert meta.total == _TOTAL_100 diff --git a/tests/v2/test_unit/test_serializer_mixin.py b/tests/v2/test_unit/test_serializer_mixin.py index 186c46f..c811e2e 100644 --- a/tests/v2/test_unit/test_serializer_mixin.py +++ b/tests/v2/test_unit/test_serializer_mixin.py @@ -9,6 +9,8 @@ from notora.v2.schemas.base import BaseResponseSchema from notora.v2.services.mixins.serializer import SerializerMixin +_ITEM_COUNT = 5 + class _Item(GenericBaseModel): pass @@ -32,95 +34,111 @@ def _make_mixin() -> SerializerMixin[_Item, _DetailSchema, _ListSchema]: return mixin -class TestSerializeOne: - def test_uses_explicit_schema_arg(self) -> None: - mixin = _make_mixin() - item = _make_obj() - result = mixin.serialize_one(item, schema=_DetailSchema) - assert isinstance(result, _DetailSchema) - - def test_falls_back_to_detail_schema_attribute(self) -> None: - mixin = _make_mixin() - mixin.detail_schema = _DetailSchema - item = _make_obj() - result = mixin.serialize_one(item) - assert isinstance(result, _DetailSchema) - - def test_raises_when_no_schema_and_no_detail_schema(self) -> None: - mixin = _make_mixin() - item = _make_obj() - with pytest.raises(ValueError, match='schema is required'): - mixin.serialize_one(item) - - def test_explicit_schema_overrides_detail_schema(self) -> None: - mixin = _make_mixin() - mixin.detail_schema = _DetailSchema - - class _AltSchema(_DetailSchema): - pass - - item = _make_obj() - result = mixin.serialize_one(item, schema=_AltSchema) - assert isinstance(result, _AltSchema) - - -class TestSerializeMany: - def test_empty_list_returns_empty(self) -> None: - mixin = _make_mixin() - mixin.list_schema = _ListSchema - result = mixin.serialize_many([]) - assert result == [] - - def test_uses_list_schema_by_default(self) -> None: - mixin = _make_mixin() - mixin.list_schema = _ListSchema - item = _make_obj() - results = mixin.serialize_many([item]) - assert all(isinstance(r, _ListSchema) for r in results) - - def test_falls_back_to_detail_schema_when_list_schema_absent(self) -> None: - mixin = _make_mixin() - mixin.detail_schema = _DetailSchema - item = _make_obj() - results = mixin.serialize_many([item]) - assert all(isinstance(r, _DetailSchema) for r in results) - - def test_explicit_schema_arg_overrides_list_schema(self) -> None: - mixin = _make_mixin() - mixin.list_schema = _ListSchema - - class _AltSchema(_ListSchema): - pass - - item = _make_obj() - results = mixin.serialize_many([item], schema=_AltSchema) - assert all(isinstance(r, _AltSchema) for r in results) - - def test_prefer_list_schema_false_uses_explicit_schema_only(self) -> None: - mixin = _make_mixin() - mixin.detail_schema = _DetailSchema - mixin.list_schema = _ListSchema - item = _make_obj() - results = mixin.serialize_many([item], schema=_DetailSchema, prefer_list_schema=False) - assert all(isinstance(r, _DetailSchema) for r in results) - - def test_raises_when_no_schema_at_all(self) -> None: - mixin = _make_mixin() - item = _make_obj() - with pytest.raises(ValueError, match='schema is required'): - mixin.serialize_many([item]) - - def test_prefer_list_schema_false_and_no_explicit_schema_raises(self) -> None: - mixin = _make_mixin() - mixin.detail_schema = None - mixin.list_schema = None - item = _make_obj() - with pytest.raises(ValueError, match='schema is required'): - mixin.serialize_many([item], prefer_list_schema=False) - - def test_serializes_multiple_items(self) -> None: - mixin = _make_mixin() - mixin.list_schema = _ListSchema - items = [_make_obj() for _ in range(5)] - results = mixin.serialize_many(items) - assert len(results) == 5 +# --------------------------------------------------------------------------- +# serialize_one +# --------------------------------------------------------------------------- + +def test_serialize_one_uses_explicit_schema_arg() -> None: + mixin = _make_mixin() + item = _make_obj() + result = mixin.serialize_one(item, schema=_DetailSchema) + assert isinstance(result, _DetailSchema) + + +def test_serialize_one_falls_back_to_detail_schema_attribute() -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + item = _make_obj() + result = mixin.serialize_one(item) + assert isinstance(result, _DetailSchema) + + +def test_serialize_one_raises_when_no_schema_and_no_detail_schema() -> None: + mixin = _make_mixin() + item = _make_obj() + with pytest.raises(ValueError, match='schema is required'): + mixin.serialize_one(item) + + +def test_serialize_one_explicit_schema_overrides_detail_schema() -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + + class _AltSchema(_DetailSchema): + pass + + item = _make_obj() + result = mixin.serialize_one(item, schema=_AltSchema) + assert isinstance(result, _AltSchema) + + +# --------------------------------------------------------------------------- +# serialize_many +# --------------------------------------------------------------------------- + +def test_serialize_many_empty_list_returns_empty() -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + result = mixin.serialize_many([]) + assert result == [] + + +def test_serialize_many_uses_list_schema_by_default() -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + item = _make_obj() + results = mixin.serialize_many([item]) + assert all(isinstance(r, _ListSchema) for r in results) + + +def test_serialize_many_falls_back_to_detail_schema_when_list_schema_absent() -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + item = _make_obj() + results = mixin.serialize_many([item]) + assert all(isinstance(r, _DetailSchema) for r in results) + + +def test_serialize_many_explicit_schema_arg_overrides_list_schema() -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + + class _AltSchema(_ListSchema): + pass + + item = _make_obj() + results = mixin.serialize_many([item], schema=_AltSchema) + assert all(isinstance(r, _AltSchema) for r in results) + + +def test_serialize_many_prefer_list_schema_false_uses_explicit_schema() -> None: + mixin = _make_mixin() + mixin.detail_schema = _DetailSchema + mixin.list_schema = _ListSchema + item = _make_obj() + results = mixin.serialize_many([item], schema=_DetailSchema, prefer_list_schema=False) + assert all(isinstance(r, _DetailSchema) for r in results) + + +def test_serialize_many_raises_when_no_schema_at_all() -> None: + mixin = _make_mixin() + item = _make_obj() + with pytest.raises(ValueError, match='schema is required'): + mixin.serialize_many([item]) + + +def test_serialize_many_prefer_list_schema_false_no_schema_raises() -> None: + mixin = _make_mixin() + mixin.detail_schema = None + mixin.list_schema = None + item = _make_obj() + with pytest.raises(ValueError, match='schema is required'): + mixin.serialize_many([item], prefer_list_schema=False) + + +def test_serialize_many_serializes_multiple_items() -> None: + mixin = _make_mixin() + mixin.list_schema = _ListSchema + items = [_make_obj() for _ in range(_ITEM_COUNT)] + results = mixin.serialize_many(items) + assert len(results) == _ITEM_COUNT diff --git a/tests/v2/test_unit/test_updated_by_mixin.py b/tests/v2/test_unit/test_updated_by_mixin.py index a454006..8fbad57 100644 --- a/tests/v2/test_unit/test_updated_by_mixin.py +++ b/tests/v2/test_unit/test_updated_by_mixin.py @@ -1,5 +1,7 @@ """Tests for UpdatedByServiceMixin.""" +from uuid import uuid4 + import pytest from sqlalchemy import String, Uuid from sqlalchemy.orm import Mapped, mapped_column @@ -28,52 +30,50 @@ def __init__(self) -> None: self.repo = Repository[object, _WithoutUpdatedBy](_WithoutUpdatedBy) -class TestApplyUpdatedBy: - def test_actor_id_none_returns_payload_unchanged(self) -> None: - mixin = _Mixin() - payload = {'name': 'Alice'} - result = mixin._apply_updated_by(payload, actor_id=None) - assert result == {'name': 'Alice'} - - def test_actor_id_set_injects_updated_by(self) -> None: - from uuid import uuid4 - mixin = _Mixin() - actor_id = uuid4() - payload: dict[str, object] = {'name': 'Alice'} - result = mixin._apply_updated_by(payload, actor_id=actor_id) - assert result['updated_by'] == actor_id - - def test_existing_updated_by_not_overwritten(self) -> None: - from uuid import uuid4 - mixin = _Mixin() - original_actor = uuid4() - new_actor = uuid4() - payload: dict[str, object] = {'name': 'Alice', 'updated_by': original_actor} - result = mixin._apply_updated_by(payload, actor_id=new_actor) - assert result['updated_by'] == original_actor - - def test_model_without_attribute_raises(self) -> None: - from uuid import uuid4 - mixin = _MixinNoAttr() - payload: dict[str, object] = {'name': 'Bob'} - with pytest.raises(ValueError, match='is not defined on'): - mixin._apply_updated_by(payload, actor_id=uuid4()) - - def test_custom_attribute_name_used(self) -> None: - from uuid import uuid4 - - class _WithCustomAttr(GenericBaseModel): - name: Mapped[str] = mapped_column(String) - modified_by: Mapped[object] = mapped_column(Uuid, nullable=True) - - class _CustomMixin(UpdatedByServiceMixin[object, _WithCustomAttr]): - updated_by_attribute = 'modified_by' - - def __init__(self) -> None: - self.repo = Repository[object, _WithCustomAttr](_WithCustomAttr) - - mixin = _CustomMixin() - actor_id = uuid4() - payload: dict[str, object] = {'name': 'Charlie'} - result = mixin._apply_updated_by(payload, actor_id=actor_id) - assert result['modified_by'] == actor_id +def test_apply_updated_by_actor_id_none_returns_payload_unchanged() -> None: + mixin = _Mixin() + payload = {'name': 'Alice'} + result = mixin._apply_updated_by(payload, actor_id=None) + assert result == {'name': 'Alice'} + + +def test_apply_updated_by_actor_id_set_injects_updated_by() -> None: + mixin = _Mixin() + actor_id = uuid4() + payload: dict[str, object] = {'name': 'Alice'} + result = mixin._apply_updated_by(payload, actor_id=actor_id) + assert result['updated_by'] == actor_id + + +def test_apply_updated_by_existing_value_not_overwritten() -> None: + mixin = _Mixin() + original_actor = uuid4() + new_actor = uuid4() + payload: dict[str, object] = {'name': 'Alice', 'updated_by': original_actor} + result = mixin._apply_updated_by(payload, actor_id=new_actor) + assert result['updated_by'] == original_actor + + +def test_apply_updated_by_model_without_attribute_raises() -> None: + mixin = _MixinNoAttr() + payload: dict[str, object] = {'name': 'Bob'} + with pytest.raises(ValueError, match='is not defined on'): + mixin._apply_updated_by(payload, actor_id=uuid4()) + + +def test_apply_updated_by_custom_attribute_name_used() -> None: + class _WithCustomAttr(GenericBaseModel): + name: Mapped[str] = mapped_column(String) + modified_by: Mapped[object] = mapped_column(Uuid, nullable=True) + + class _CustomMixin(UpdatedByServiceMixin[object, _WithCustomAttr]): + updated_by_attribute = 'modified_by' + + def __init__(self) -> None: + self.repo = Repository[object, _WithCustomAttr](_WithCustomAttr) + + mixin = _CustomMixin() + actor_id = uuid4() + payload: dict[str, object] = {'name': 'Charlie'} + result = mixin._apply_updated_by(payload, actor_id=actor_id) + assert result['modified_by'] == actor_id From 8e9772eb349edc477589be1a25dc56c4b80f353c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:48:47 +0000 Subject: [PATCH 04/11] Remove horizontal separator comments from test files Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/33a5b3b8-01cf-48a0-9764-e1b8b3f13e51 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- .../v1/test_unit/test_enums_and_exceptions.py | 39 -------- tests/v1/test_unit/test_schemas_base.py | 74 --------------- tests/v2/test_unit/test_exceptions.py | 31 ------- tests/v2/test_unit/test_factory.py | 32 ------- tests/v2/test_unit/test_query_dsl_tokens.py | 92 ------------------- tests/v2/test_unit/test_schemas_base.py | 16 ---- tests/v2/test_unit/test_serializer_mixin.py | 25 ----- 7 files changed, 309 deletions(-) diff --git a/tests/v1/test_unit/test_enums_and_exceptions.py b/tests/v1/test_unit/test_enums_and_exceptions.py index 9b277bb..a809bf7 100644 --- a/tests/v1/test_unit/test_enums_and_exceptions.py +++ b/tests/v1/test_unit/test_enums_and_exceptions.py @@ -7,135 +7,96 @@ _ENTITY_ID_INT = 42 - -# --------------------------------------------------------------------------- -# OrderByDirections -# --------------------------------------------------------------------------- - def test_order_by_directions_asc_value() -> None: assert OrderByDirections.ASC.value == 'asc' - def test_order_by_directions_desc_value() -> None: assert OrderByDirections.DESC.value == 'desc' - def test_order_by_directions_is_str_enum() -> None: assert isinstance(OrderByDirections.ASC, str) assert isinstance(OrderByDirections.DESC, str) - def test_order_by_directions_can_be_used_as_string() -> None: assert f'{OrderByDirections.ASC}' == 'asc' assert f'{OrderByDirections.DESC}' == 'desc' - -# --------------------------------------------------------------------------- -# FKNotFoundError -# --------------------------------------------------------------------------- - def test_fk_not_found_error_stores_fk_name() -> None: err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') assert err.fk_name == 'user_id_fkey' - def test_fk_not_found_error_stores_table_name() -> None: err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') assert err.table_name == 'post' - def test_fk_not_found_error_message_is_accessible() -> None: err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') assert str(err) == 'Related object not found.' - def test_fk_not_found_error_is_exception() -> None: err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') assert isinstance(err, Exception) - def test_fk_not_found_error_can_be_raised_and_caught() -> None: msg = 'err' with pytest.raises(FKNotFoundError) as exc_info: raise FKNotFoundError(msg, fk_name='fk', table_name='tbl') assert exc_info.value.fk_name == 'fk' - -# --------------------------------------------------------------------------- -# AlreadyExistsError -# --------------------------------------------------------------------------- - def test_already_exists_error_default_message() -> None: err = AlreadyExistsError() assert str(err) == 'Entity already exists.' - def test_already_exists_error_custom_message() -> None: err = AlreadyExistsError('Custom message.') assert str(err) == 'Custom message.' - def test_already_exists_error_constraint_name_stored() -> None: err = AlreadyExistsError(constraint_name='users_email_key') assert err.constraint_name == 'users_email_key' - def test_already_exists_error_constraint_name_none_by_default() -> None: err = AlreadyExistsError() assert err.constraint_name is None - def test_already_exists_error_message_and_constraint_together() -> None: err = AlreadyExistsError('Dup', constraint_name='my_constraint') assert str(err) == 'Dup' assert err.constraint_name == 'my_constraint' - def test_already_exists_error_is_exception() -> None: assert isinstance(AlreadyExistsError(), Exception) - def test_already_exists_error_can_be_raised_and_caught() -> None: msg = 'dup' with pytest.raises(AlreadyExistsError): raise AlreadyExistsError(msg) - -# --------------------------------------------------------------------------- -# NotFoundError -# --------------------------------------------------------------------------- - def test_not_found_error_entity_id_none_by_default() -> None: err: NotFoundError[None] = NotFoundError('not found') assert err.entity_id is None - def test_not_found_error_entity_id_stored() -> None: err = NotFoundError('not found', entity_id=_ENTITY_ID_INT) assert err.entity_id == _ENTITY_ID_INT - def test_not_found_error_entity_id_uuid() -> None: uid = uuid4() err = NotFoundError('not found', entity_id=uid) assert err.entity_id == uid - def test_not_found_error_message_preserved() -> None: err: NotFoundError[None] = NotFoundError('Resource not found.') assert str(err) == 'Resource not found.' - def test_not_found_error_is_exception() -> None: assert isinstance(NotFoundError('x'), Exception) - def test_not_found_error_can_be_raised_and_caught() -> None: msg = 'missing' with pytest.raises(NotFoundError): raise NotFoundError(msg) - def test_not_found_error_no_positional_args() -> None: err: NotFoundError[None] = NotFoundError() assert err.entity_id is None diff --git a/tests/v1/test_unit/test_schemas_base.py b/tests/v1/test_unit/test_schemas_base.py index 66feab0..6e29990 100644 --- a/tests/v1/test_unit/test_schemas_base.py +++ b/tests/v1/test_unit/test_schemas_base.py @@ -25,29 +25,21 @@ _SECOND_PAGE = 2 _FILTER_COUNT = 2 - -# --------------------------------------------------------------------------- -# normalize_datetime_to_utc -# --------------------------------------------------------------------------- - def test_normalize_datetime_naive_gets_utc_tzinfo() -> None: naive = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) result = normalize_datetime_to_utc(naive) assert result.tzinfo == UTC - def test_normalize_datetime_naive_value_unchanged() -> None: naive = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) result = normalize_datetime_to_utc(naive) assert result.replace(tzinfo=None) == naive - def test_normalize_datetime_utc_aware_unchanged() -> None: aware = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) result = normalize_datetime_to_utc(aware) assert result == aware - def test_normalize_datetime_offset_aware_converted_to_utc() -> None: tz_plus2 = timezone(timedelta(hours=2)) aware = datetime(2024, 6, 15, 14, 0, 0, tzinfo=tz_plus2) @@ -55,70 +47,49 @@ def test_normalize_datetime_offset_aware_converted_to_utc() -> None: assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) assert result.tzinfo == UTC - def test_normalize_datetime_negative_offset_converted_to_utc() -> None: tz_minus5 = timezone(timedelta(hours=-5)) aware = datetime(2024, 6, 15, 7, 0, 0, tzinfo=tz_minus5) result = normalize_datetime_to_utc(aware) assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) - -# --------------------------------------------------------------------------- -# utc_datetime_encoder -# --------------------------------------------------------------------------- - def test_utc_datetime_encoder_returns_iso_string_with_z() -> None: dt = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC) result = utc_datetime_encoder(dt) assert result == '2024-01-20T09:15:30Z' - def test_utc_datetime_encoder_naive_datetime_treated_as_utc() -> None: naive = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC).replace(tzinfo=None) result = utc_datetime_encoder(naive) assert result == '2024-01-20T09:15:30Z' - def test_utc_datetime_encoder_offset_aware_converted() -> None: tz_plus3 = timezone(timedelta(hours=3)) dt = datetime(2024, 1, 20, 12, 15, 30, tzinfo=tz_plus3) result = utc_datetime_encoder(dt) assert result == '2024-01-20T09:15:30Z' - def test_utc_datetime_encoder_does_not_contain_plus00_00() -> None: dt = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) result = utc_datetime_encoder(dt) assert '+00:00' not in result - -# --------------------------------------------------------------------------- -# datetime_encoder -# --------------------------------------------------------------------------- - def test_datetime_encoder_returns_float_timestamp() -> None: dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) result = datetime_encoder(dt) assert isinstance(result, float) - def test_datetime_encoder_naive_datetime_treated_as_utc() -> None: naive = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None) aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) assert datetime_encoder(naive) == datetime_encoder(aware) - def test_datetime_encoder_offset_datetime_normalized() -> None: tz_plus2 = timezone(timedelta(hours=2)) offset = datetime(2024, 1, 1, 2, 0, 0, tzinfo=tz_plus2) utc = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) assert datetime_encoder(offset) == datetime_encoder(utc) - -# --------------------------------------------------------------------------- -# PaginationMetaSchema -# --------------------------------------------------------------------------- - def test_pagination_meta_first_page_full() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=0) assert meta.current_page == 1 @@ -126,39 +97,29 @@ def test_pagination_meta_first_page_full() -> None: assert meta.total == _TOTAL_100 assert meta.limit == _LIMIT - def test_pagination_meta_second_page() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=_LIMIT) assert meta.current_page == _SECOND_PAGE - def test_pagination_meta_last_page_calculated() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_25, limit=_LIMIT, offset=0) assert meta.last_page == _LAST_PAGE_3 - def test_pagination_meta_zero_total_gives_page_1() -> None: meta = PaginationMetaSchema.calculate(total=0, limit=_LIMIT, offset=0) assert meta.current_page == 1 assert meta.last_page == 1 assert meta.total == 0 - def test_pagination_meta_exact_multiple_total() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_20, limit=_LIMIT, offset=0) assert meta.last_page == _LAST_PAGE_2 - def test_pagination_meta_total_less_than_limit_gives_page_1() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_5, limit=_LIMIT, offset=0) assert meta.current_page == 1 assert meta.last_page == 1 - -# --------------------------------------------------------------------------- -# AdminMeta -# --------------------------------------------------------------------------- - def test_admin_meta_deleted_at_is_none_by_default() -> None: meta = AdminMeta( created_at=datetime(2024, 1, 1, tzinfo=UTC), @@ -166,7 +127,6 @@ def test_admin_meta_deleted_at_is_none_by_default() -> None: ) assert meta.deleted_at is None - def test_admin_meta_deleted_at_can_be_set() -> None: dt = datetime(2024, 6, 1, 12, 0, tzinfo=UTC) meta = AdminMeta( @@ -176,118 +136,84 @@ def test_admin_meta_deleted_at_can_be_set() -> None: ) assert meta.deleted_at == dt - def test_admin_meta_timestamps_normalized_to_utc() -> None: naive = datetime(2024, 1, 1, 10, 0, 0, tzinfo=UTC).replace(tzinfo=None) meta = AdminMeta(created_at=naive, updated_at=naive) assert meta.created_at.tzinfo == UTC assert meta.updated_at.tzinfo == UTC - -# --------------------------------------------------------------------------- -# ClientMeta -# --------------------------------------------------------------------------- - def test_client_meta_both_fields_none_by_default() -> None: client = ClientMeta() assert client.ip_address is None assert client.user_agent is None - def test_client_meta_ipv4_address_accepted() -> None: client = ClientMeta(ip_address=IPv4Address('127.0.0.1')) assert isinstance(client.ip_address, IPv4Address) - def test_client_meta_ipv6_address_accepted() -> None: client = ClientMeta(ip_address=IPv6Address('::1')) assert isinstance(client.ip_address, IPv6Address) - def test_client_meta_user_agent_stored() -> None: client = ClientMeta(user_agent='Mozilla/5.0') assert client.user_agent == 'Mozilla/5.0' - def test_client_meta_ip_address_serialized_as_string() -> None: client = ClientMeta(ip_address=IPv4Address('192.168.0.1')) dumped = client.model_dump() assert dumped['ip_address'] == '192.168.0.1' - -# --------------------------------------------------------------------------- -# Filter -# --------------------------------------------------------------------------- - def test_filter_default_op_is_eq() -> None: f = Filter(field='name', value='alice') assert f.op == '=' - def test_filter_custom_op() -> None: f = Filter(field='age', op='gt', value=18) assert f.op == 'gt' - def test_filter_value_none_allowed() -> None: f = Filter(field='deleted_at', op='is', value=None) assert f.value is None - def test_filter_model_none_by_default() -> None: f = Filter(field='name', value='x') assert f.model is None - def test_filter_model_can_be_set() -> None: class FakeModel: pass f = Filter(field='name', value='x', model=FakeModel) assert f.model is FakeModel - def test_filter_all_ops_accepted() -> None: valid_ops = ('eq', '=', 'ilike', '~=', 'is', 'is_not', 'in', 'gt', '>', 'ge', '>=', 'lt', '<', 'le', '<=') for op in valid_ops: f = Filter(field='x', op=op, value=1) assert f.op == op - -# --------------------------------------------------------------------------- -# OrFilterGroup -# --------------------------------------------------------------------------- - def test_or_filter_group_stores_filters() -> None: f1 = Filter(field='name', value='a') f2 = Filter(field='name', value='b') group = OrFilterGroup(filters=[f1, f2]) assert len(group.filters) == _FILTER_COUNT - def test_or_filter_group_empty_filters_allowed() -> None: group = OrFilterGroup(filters=[]) assert group.filters == [] - -# --------------------------------------------------------------------------- -# OrderBy -# --------------------------------------------------------------------------- - def test_order_by_default_direction_is_asc() -> None: ob = OrderBy(field='name') assert ob.direction == OrderByDirections.ASC - def test_order_by_desc_direction() -> None: ob = OrderBy(field='name', direction=OrderByDirections.DESC) assert ob.direction == OrderByDirections.DESC - def test_order_by_model_none_by_default() -> None: ob = OrderBy(field='name') assert ob.model is None - def test_order_by_model_can_be_set() -> None: class FakeModel: pass diff --git a/tests/v2/test_unit/test_exceptions.py b/tests/v2/test_unit/test_exceptions.py index acc2a18..4deb936 100644 --- a/tests/v2/test_unit/test_exceptions.py +++ b/tests/v2/test_unit/test_exceptions.py @@ -6,31 +6,22 @@ _ENTITY_ID_INT = 42 - -# --------------------------------------------------------------------------- -# FKNotFoundError -# --------------------------------------------------------------------------- - def test_fk_not_found_error_stores_fk_name() -> None: err = FKNotFoundError('msg', fk_name='profile_user_id_fkey', table_name='profile') assert err.fk_name == 'profile_user_id_fkey' - def test_fk_not_found_error_stores_table_name() -> None: err = FKNotFoundError('msg', fk_name='fk', table_name='orders') assert err.table_name == 'orders' - def test_fk_not_found_error_message_is_accessible() -> None: err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') assert str(err) == 'Related object not found.' - def test_fk_not_found_error_is_exception() -> None: err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') assert isinstance(err, Exception) - def test_fk_not_found_error_can_be_raised_and_caught() -> None: msg = 'err' with pytest.raises(FKNotFoundError) as exc_info: @@ -38,82 +29,60 @@ def test_fk_not_found_error_can_be_raised_and_caught() -> None: assert exc_info.value.fk_name == 'fk' assert exc_info.value.table_name == 'tbl' - -# --------------------------------------------------------------------------- -# AlreadyExistsError -# --------------------------------------------------------------------------- - def test_already_exists_error_default_message() -> None: err = AlreadyExistsError() assert str(err) == 'Entity already exists.' - def test_already_exists_error_custom_message() -> None: err = AlreadyExistsError('Custom message.') assert str(err) == 'Custom message.' - def test_already_exists_error_constraint_name_stored() -> None: err = AlreadyExistsError(constraint_name='users_email_key') assert err.constraint_name == 'users_email_key' - def test_already_exists_error_constraint_name_none_by_default() -> None: err = AlreadyExistsError() assert err.constraint_name is None - def test_already_exists_error_message_and_constraint_together() -> None: err = AlreadyExistsError('Dup', constraint_name='my_constraint') assert str(err) == 'Dup' assert err.constraint_name == 'my_constraint' - def test_already_exists_error_is_exception() -> None: assert isinstance(AlreadyExistsError(), Exception) - def test_already_exists_error_can_be_raised_and_caught() -> None: msg = 'dup' with pytest.raises(AlreadyExistsError): raise AlreadyExistsError(msg) - -# --------------------------------------------------------------------------- -# NotFoundError -# --------------------------------------------------------------------------- - def test_not_found_error_entity_id_none_by_default() -> None: err = NotFoundError('not found') assert err.entity_id is None - def test_not_found_error_entity_id_stored() -> None: err = NotFoundError('not found', entity_id=_ENTITY_ID_INT) assert err.entity_id == _ENTITY_ID_INT - def test_not_found_error_entity_id_uuid() -> None: uid = uuid4() err = NotFoundError('not found', entity_id=uid) assert err.entity_id == uid - def test_not_found_error_message_preserved() -> None: err = NotFoundError('Resource not found.') assert str(err) == 'Resource not found.' - def test_not_found_error_is_exception() -> None: assert isinstance(NotFoundError('x'), Exception) - def test_not_found_error_can_be_raised_and_caught() -> None: msg = 'missing' with pytest.raises(NotFoundError): raise NotFoundError(msg) - def test_not_found_error_no_positional_args() -> None: err = NotFoundError() assert err.entity_id is None diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index 62e11f0..2c31b05 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -15,36 +15,26 @@ _DEFAULT_LIMIT = 7 _REPO_CONFIG_LIMIT = 3 - class _Widget(GenericBaseModel): name: Mapped[str] = mapped_column(String) - class _WidgetSchema(BaseResponseSchema): pass - -# --------------------------------------------------------------------------- -# build_repository -# --------------------------------------------------------------------------- - def test_build_repository_returns_standard_repo_by_default() -> None: repo = build_repository(_Widget) assert isinstance(repo, Repository) assert not isinstance(repo, SoftDeleteRepository) - def test_build_repository_soft_delete_flag_returns_soft_delete_repo() -> None: repo = build_repository(_Widget, soft_delete=True) assert isinstance(repo, SoftDeleteRepository) - def test_build_repository_config_is_applied() -> None: config = RepoConfig[_Widget](default_limit=_DEFAULT_LIMIT) repo = build_repository(_Widget, config=config) assert repo.default_limit == _DEFAULT_LIMIT - def test_build_repository_custom_repo_class_used() -> None: class _CustomRepo(Repository[object, _Widget]): pass @@ -52,45 +42,34 @@ class _CustomRepo(Repository[object, _Widget]): repo = build_repository(_Widget, repo_cls=_CustomRepo) assert isinstance(repo, _CustomRepo) - def test_build_repository_model_attribute_set() -> None: repo = build_repository(_Widget) assert repo.model is _Widget - -# --------------------------------------------------------------------------- -# build_service -# --------------------------------------------------------------------------- - def test_build_service_returns_repository_service_by_default() -> None: svc = build_service(_Widget) assert isinstance(svc, RepositoryService) - def test_build_service_soft_delete_flag_returns_soft_delete_service() -> None: svc = build_service(_Widget, soft_delete=True) assert isinstance(svc, SoftDeleteRepositoryService) - def test_build_service_custom_repo_passed_directly() -> None: repo = Repository[object, _Widget](_Widget) svc = build_service(_Widget, repo=repo) assert isinstance(svc, RepositoryService) assert svc.repo is repo - def test_build_service_soft_delete_repo_infers_soft_delete_service() -> None: repo = SoftDeleteRepository[object, _Widget](_Widget) svc = build_service(_Widget, repo=repo) assert isinstance(svc, SoftDeleteRepositoryService) - def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raises() -> None: repo = Repository[object, _Widget](_Widget) with pytest.raises(TypeError, match='Soft-delete service requires'): build_service(_Widget, repo=repo, service_cls=SoftDeleteRepositoryService) - def test_build_service_soft_delete_flag_with_standard_service_class_used() -> None: svc = build_service( _Widget, @@ -99,35 +78,26 @@ def test_build_service_soft_delete_flag_with_standard_service_class_used() -> No ) assert isinstance(svc, SoftDeleteRepositoryService) - def test_build_service_repo_config_applied() -> None: repo_config = RepoConfig[_Widget](default_limit=_REPO_CONFIG_LIMIT) svc = build_service(_Widget, repo_config=repo_config) assert svc.repo.default_limit == _REPO_CONFIG_LIMIT - def test_build_service_soft_delete_repo_with_soft_delete_true() -> None: repo = SoftDeleteRepository[object, _Widget](_Widget) svc = build_service(_Widget, soft_delete=True, repo=repo) assert isinstance(svc, SoftDeleteRepositoryService) - -# --------------------------------------------------------------------------- -# build_service_for_repo -# --------------------------------------------------------------------------- - def test_build_service_for_repo_standard_repo_returns_repository_service() -> None: repo = Repository[object, _Widget](_Widget) svc = build_service_for_repo(repo) assert isinstance(svc, RepositoryService) - def test_build_service_for_repo_soft_delete_repo_returns_soft_delete_service() -> None: repo = SoftDeleteRepository[object, _Widget](_Widget) svc = build_service_for_repo(repo) assert isinstance(svc, SoftDeleteRepositoryService) - def test_build_service_for_repo_custom_service_class_used() -> None: class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): pass @@ -136,13 +106,11 @@ class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): svc = build_service_for_repo(repo, service_cls=_CustomService) assert isinstance(svc, _CustomService) - def test_build_service_for_repo_soft_delete_service_cls_with_non_soft_delete_repo_raises() -> None: repo = Repository[object, _Widget](_Widget) with pytest.raises(TypeError, match='Soft-delete service requires'): build_service_for_repo(repo, service_cls=SoftDeleteRepositoryService) - def test_build_service_for_repo_repo_is_wired_to_service() -> None: repo = Repository[object, _Widget](_Widget) svc = build_service_for_repo(repo) diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index d1350f4..e13a198 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -29,7 +29,6 @@ _LIMIT_SMALL = 5 _OFFSET_SMALL = 10 - def _render(clause: ColumnElement) -> str: # type: ignore[type-arg] return str( clause.compile( @@ -38,204 +37,150 @@ def _render(clause: ColumnElement) -> str: # type: ignore[type-arg] ) ) - class SampleModel(GenericBaseModel): name: Mapped[str] = mapped_column(String) score: Mapped[int] = mapped_column(Integer) - -# --------------------------------------------------------------------------- -# parse_filter_token -# --------------------------------------------------------------------------- - def test_parse_filter_token_parses_field_op_value() -> None: token = parse_filter_token('name:eq:alice') assert token.field == 'name' assert token.operator == 'eq' assert token.raw_value == 'alice' - def test_parse_filter_token_parses_operator_only_for_isnull() -> None: token = parse_filter_token('name:isnull') assert token.field == 'name' assert token.operator == 'isnull' assert token.raw_value is None - def test_parse_filter_token_raises_for_missing_colon() -> None: with pytest.raises(ValueError, match='"field:op:value"'): parse_filter_token('nocolon') - def test_parse_filter_token_raises_for_empty_field_name() -> None: with pytest.raises(ValueError, match='field name cannot be empty'): parse_filter_token(':eq:value') - def test_parse_filter_token_raises_for_unsupported_operator() -> None: with pytest.raises(ValueError, match='Unsupported filter operator'): parse_filter_token('name:contains:hello') - def test_parse_filter_token_value_with_colons_preserved() -> None: token = parse_filter_token('name:eq:a:b:c') assert token.raw_value == 'a:b:c' - def test_parse_filter_token_whitespace_stripped_from_field_and_op() -> None: token = parse_filter_token(' name : eq : alice ') assert token.field == 'name' assert token.operator == 'eq' - def test_parse_filter_token_whitespace_only_value_becomes_none() -> None: token = parse_filter_token('name:eq: ') assert token.raw_value is None - def test_parse_filter_token_all_operators_accepted() -> None: valid_ops = ('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'ilike', 'isnull') for op in valid_ops: token = parse_filter_token(f'name:{op}:x') assert token.operator == op - def test_parse_filter_token_isnull_with_false_value() -> None: token = parse_filter_token('name:isnull:false') assert token.raw_value == 'false' - def test_parse_filter_token_in_with_comma_separated_value() -> None: token = parse_filter_token('score:in:1,2,3') assert token.raw_value == '1,2,3' - -# --------------------------------------------------------------------------- -# parse_sort_token -# --------------------------------------------------------------------------- - def test_parse_sort_token_plain_field_is_ascending() -> None: token = parse_sort_token('name') assert token.field == 'name' assert token.direction == 'asc' - def test_parse_sort_token_plus_prefix_is_ascending() -> None: token = parse_sort_token('+name') assert token.field == 'name' assert token.direction == 'asc' - def test_parse_sort_token_minus_prefix_is_descending() -> None: token = parse_sort_token('-score') assert token.field == 'score' assert token.direction == 'desc' - def test_parse_sort_token_empty_string_raises() -> None: with pytest.raises(ValueError, match='cannot be empty'): parse_sort_token('') - def test_parse_sort_token_only_minus_raises() -> None: with pytest.raises(ValueError, match='cannot be empty'): parse_sort_token('-') - def test_parse_sort_token_only_plus_raises() -> None: with pytest.raises(ValueError, match='cannot be empty'): parse_sort_token('+') - def test_parse_sort_token_whitespace_stripped() -> None: token = parse_sort_token(' name ') assert token.field == 'name' - def test_parse_sort_token_returns_sort_token_dataclass() -> None: token = parse_sort_token('name') assert isinstance(token, SortToken) - -# --------------------------------------------------------------------------- -# apply_filter_operator -# --------------------------------------------------------------------------- - def test_apply_filter_operator_eq() -> None: clause = apply_filter_operator(SampleModel.name, 'eq', 'alice') assert "sample_model.name = 'alice'" in _render(clause) - def test_apply_filter_operator_ne() -> None: clause = apply_filter_operator(SampleModel.name, 'ne', 'alice') rendered = _render(clause) assert 'sample_model.name' in rendered assert '!=' in rendered or '<>' in rendered - def test_apply_filter_operator_lt() -> None: clause = apply_filter_operator(SampleModel.score, 'lt', 5) assert 'sample_model.score < 5' in _render(clause) - def test_apply_filter_operator_lte() -> None: clause = apply_filter_operator(SampleModel.score, 'lte', 5) assert 'sample_model.score <= 5' in _render(clause) - def test_apply_filter_operator_gt() -> None: clause = apply_filter_operator(SampleModel.score, 'gt', 5) assert 'sample_model.score > 5' in _render(clause) - def test_apply_filter_operator_gte() -> None: clause = apply_filter_operator(SampleModel.score, 'gte', 5) assert 'sample_model.score >= 5' in _render(clause) - def test_apply_filter_operator_in() -> None: clause = apply_filter_operator(SampleModel.score, 'in', [1, 2, 3]) assert 'IN' in _render(clause) - def test_apply_filter_operator_ilike() -> None: clause = apply_filter_operator(SampleModel.name, 'ilike', '%alice%') assert 'ILIKE' in _render(clause) - def test_apply_filter_operator_isnull_true() -> None: clause = apply_filter_operator(SampleModel.name, 'isnull', value=True) assert 'IS NULL' in _render(clause) - def test_apply_filter_operator_isnull_false() -> None: clause = apply_filter_operator(SampleModel.name, 'isnull', value=False) assert 'IS NOT NULL' in _render(clause) - def test_apply_filter_operator_unsupported_operator_raises() -> None: with pytest.raises(ValueError, match='Unsupported filter operator'): apply_filter_operator(SampleModel.name, 'contains', 'x') # type: ignore[arg-type] - -# --------------------------------------------------------------------------- -# resolve_to_column -# --------------------------------------------------------------------------- - def test_resolve_to_column_direct_column_returned_unchanged() -> None: col = resolve_to_column(SampleModel.name, SampleModel) assert 'sample_model.name' in str(col) - def test_resolve_to_column_callable_resolver_called_with_model() -> None: col = resolve_to_column(lambda m: m.score, SampleModel) assert 'sample_model.score' in str(col) - -# --------------------------------------------------------------------------- -# build_filter_clauses -# --------------------------------------------------------------------------- - def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] fields = {'name': FilterField(resolver=SampleModel.name, value_type=str)} @@ -243,20 +188,17 @@ def test_build_filter_clauses_single_eq_clause() -> None: assert len(clauses) == 1 assert "sample_model.name = 'alice'" in _render(clauses[0]) - def test_build_filter_clauses_unknown_field_raises() -> None: tokens = [FilterToken(field='unknown', operator='eq', raw_value='x')] with pytest.raises(ValueError, match='Unsupported filter field'): build_filter_clauses(tokens, model=SampleModel, fields={}) - def test_build_filter_clauses_disallowed_operator_raises() -> None: tokens = [FilterToken(field='name', operator='gt', raw_value='5')] fields = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} with pytest.raises(ValueError, match='Operator'): build_filter_clauses(tokens, model=SampleModel, fields=fields) - def test_build_filter_clauses_predicate_field() -> None: def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: return model.name.ilike(f'%{value}%') @@ -266,51 +208,40 @@ def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert 'ILIKE' in _render(clauses[0]) - def test_build_filter_clauses_field_without_resolver_or_predicate_raises() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='x')] fields = {'name': FilterField()} with pytest.raises(ValueError, match='resolver or predicate'): build_filter_clauses(tokens, model=SampleModel, fields=fields) - def test_build_filter_clauses_empty_tokens_returns_empty() -> None: clauses = build_filter_clauses([], model=SampleModel, fields={}) assert clauses == [] - def test_build_filter_clauses_isnull_no_value() -> None: tokens = [FilterToken(field='name', operator='isnull', raw_value=None)] fields = {'name': FilterField(resolver=SampleModel.name)} clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert 'IS NULL' in _render(clauses[0]) - def test_build_filter_clauses_in_operator_requires_value() -> None: tokens = [FilterToken(field='name', operator='in', raw_value=None)] fields = {'name': FilterField(resolver=SampleModel.name)} with pytest.raises(ValueError, match='requires a value'): build_filter_clauses(tokens, model=SampleModel, fields=fields) - def test_build_filter_clauses_non_isnull_without_value_raises() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value=None)] fields = {'name': FilterField(resolver=SampleModel.name)} with pytest.raises(ValueError, match='requires a value'): build_filter_clauses(tokens, model=SampleModel, fields=fields) - def test_build_filter_clauses_callable_resolver_in_field() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='x')] fields = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert "sample_model.name = 'x'" in _render(clauses[0]) - -# --------------------------------------------------------------------------- -# build_sort_clauses -# --------------------------------------------------------------------------- - def test_build_sort_clauses_ascending() -> None: tokens = [SortToken(field='name', direction='asc')] fields = {'name': SortField(resolver=SampleModel.name)} @@ -318,32 +249,27 @@ def test_build_sort_clauses_ascending() -> None: assert len(clauses) == 1 assert 'ASC' in _render(clauses[0]) - def test_build_sort_clauses_descending() -> None: tokens = [SortToken(field='score', direction='desc')] fields = {'score': SortField(resolver=SampleModel.score)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert 'DESC' in _render(clauses[0]) - def test_build_sort_clauses_unknown_field_raises() -> None: tokens = [SortToken(field='unknown', direction='asc')] with pytest.raises(ValueError, match='Unsupported sort field'): build_sort_clauses(tokens, model=SampleModel, fields={}) - def test_build_sort_clauses_empty_tokens_returns_empty() -> None: clauses = build_sort_clauses([], model=SampleModel, fields={}) assert clauses == [] - def test_build_sort_clauses_callable_resolver() -> None: tokens = [SortToken(field='name', direction='asc')] fields = {'name': SortField(resolver=lambda m: m.name)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert 'sample_model.name' in _render(clauses[0]) - def test_build_sort_clauses_multiple_tokens() -> None: tokens = [ SortToken(field='name', direction='asc'), @@ -356,66 +282,48 @@ def test_build_sort_clauses_multiple_tokens() -> None: clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert len(clauses) == _MULTI_CLAUSE_COUNT - -# --------------------------------------------------------------------------- -# QueryInput validation -# --------------------------------------------------------------------------- - def test_query_input_negative_offset_raises() -> None: with pytest.raises(ValidationError, match='offset must be zero or a positive integer'): QueryInput(offset=-1) - def test_query_input_zero_offset_accepted() -> None: q = QueryInput(offset=0) assert q.offset == 0 - def test_query_input_positive_offset_accepted() -> None: q = QueryInput(offset=_POSITIVE_OFFSET) assert q.offset == _POSITIVE_OFFSET - def test_query_input_none_limit_accepted() -> None: q = QueryInput(limit=None) assert q.limit is None - def test_query_input_positive_limit_accepted() -> None: q = QueryInput(limit=_POSITIVE_LIMIT) assert q.limit == _POSITIVE_LIMIT - -# --------------------------------------------------------------------------- -# build_query_params edge cases -# --------------------------------------------------------------------------- - def test_build_query_params_filters_without_filter_fields_raises() -> None: query = QueryInput(filter=['name:eq:x']) with pytest.raises(ValueError, match='Filter fields mapping is required'): build_query_params(query, model=SampleModel, filter_fields={}) - def test_build_query_params_sort_without_sort_fields_raises() -> None: query = QueryInput(sort=['-score']) with pytest.raises(ValueError, match='Sort fields mapping is required'): build_query_params(query, model=SampleModel, sort_fields={}) - def test_build_query_params_no_filter_no_sort_returns_none_for_both() -> None: query = QueryInput() params = build_query_params(query, model=SampleModel) assert params.filters is None assert params.ordering is None - def test_build_query_params_explicit_limit_and_offset_passed_through() -> None: query = QueryInput(limit=_LIMIT_SMALL, offset=_OFFSET_SMALL) params = build_query_params(query, model=SampleModel) assert params.limit == _LIMIT_SMALL assert params.offset == _OFFSET_SMALL - def test_build_query_params_base_query_forwarded() -> None: base = select(SampleModel) query = QueryInput() diff --git a/tests/v2/test_unit/test_schemas_base.py b/tests/v2/test_unit/test_schemas_base.py index 52b2cf3..116c1bd 100644 --- a/tests/v2/test_unit/test_schemas_base.py +++ b/tests/v2/test_unit/test_schemas_base.py @@ -7,52 +7,36 @@ _TOTAL_100 = 100 _LIMIT = 10 - -# --------------------------------------------------------------------------- -# ClientMeta -# --------------------------------------------------------------------------- - def test_client_meta_both_fields_none_by_default() -> None: client = ClientMeta() assert client.ip_address is None assert client.user_agent is None - def test_client_meta_ipv4_address_accepted() -> None: client = ClientMeta(ip_address=IPv4Address('192.168.1.1')) assert isinstance(client.ip_address, IPv4Address) - def test_client_meta_ipv6_address_accepted() -> None: client = ClientMeta(ip_address=IPv6Address('::1')) assert isinstance(client.ip_address, IPv6Address) - def test_client_meta_user_agent_stored() -> None: client = ClientMeta(user_agent='Mozilla/5.0') assert client.user_agent == 'Mozilla/5.0' - def test_client_meta_ip_serialized_as_string_in_dict() -> None: client = ClientMeta(ip_address=IPv4Address('10.0.0.1')) dumped = client.model_dump() assert dumped['ip_address'] == '10.0.0.1' - -# --------------------------------------------------------------------------- -# PaginationMetaSchema -# --------------------------------------------------------------------------- - def test_pagination_meta_negative_total_clamped_to_zero() -> None: meta = PaginationMetaSchema.calculate(total=-5, limit=_LIMIT, offset=0) assert meta.total == 0 - def test_pagination_meta_zero_total_preserved() -> None: meta = PaginationMetaSchema.calculate(total=0, limit=_LIMIT, offset=0) assert meta.total == 0 - def test_pagination_meta_positive_total_preserved() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=0) assert meta.total == _TOTAL_100 diff --git a/tests/v2/test_unit/test_serializer_mixin.py b/tests/v2/test_unit/test_serializer_mixin.py index c811e2e..3b4878e 100644 --- a/tests/v2/test_unit/test_serializer_mixin.py +++ b/tests/v2/test_unit/test_serializer_mixin.py @@ -11,40 +11,29 @@ _ITEM_COUNT = 5 - class _Item(GenericBaseModel): pass - class _DetailSchema(BaseResponseSchema): model_config = ConfigDict(from_attributes=True) - class _ListSchema(BaseResponseSchema): model_config = ConfigDict(from_attributes=True) - # A plain namespace satisfies `from_attributes=True` schemas that have no required fields. def _make_obj() -> types.SimpleNamespace: return types.SimpleNamespace() - def _make_mixin() -> SerializerMixin[_Item, _DetailSchema, _ListSchema]: mixin: SerializerMixin[_Item, _DetailSchema, _ListSchema] = SerializerMixin() return mixin - -# --------------------------------------------------------------------------- -# serialize_one -# --------------------------------------------------------------------------- - def test_serialize_one_uses_explicit_schema_arg() -> None: mixin = _make_mixin() item = _make_obj() result = mixin.serialize_one(item, schema=_DetailSchema) assert isinstance(result, _DetailSchema) - def test_serialize_one_falls_back_to_detail_schema_attribute() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -52,14 +41,12 @@ def test_serialize_one_falls_back_to_detail_schema_attribute() -> None: result = mixin.serialize_one(item) assert isinstance(result, _DetailSchema) - def test_serialize_one_raises_when_no_schema_and_no_detail_schema() -> None: mixin = _make_mixin() item = _make_obj() with pytest.raises(ValueError, match='schema is required'): mixin.serialize_one(item) - def test_serialize_one_explicit_schema_overrides_detail_schema() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -71,18 +58,12 @@ class _AltSchema(_DetailSchema): result = mixin.serialize_one(item, schema=_AltSchema) assert isinstance(result, _AltSchema) - -# --------------------------------------------------------------------------- -# serialize_many -# --------------------------------------------------------------------------- - def test_serialize_many_empty_list_returns_empty() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema result = mixin.serialize_many([]) assert result == [] - def test_serialize_many_uses_list_schema_by_default() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema @@ -90,7 +71,6 @@ def test_serialize_many_uses_list_schema_by_default() -> None: results = mixin.serialize_many([item]) assert all(isinstance(r, _ListSchema) for r in results) - def test_serialize_many_falls_back_to_detail_schema_when_list_schema_absent() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -98,7 +78,6 @@ def test_serialize_many_falls_back_to_detail_schema_when_list_schema_absent() -> results = mixin.serialize_many([item]) assert all(isinstance(r, _DetailSchema) for r in results) - def test_serialize_many_explicit_schema_arg_overrides_list_schema() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema @@ -110,7 +89,6 @@ class _AltSchema(_ListSchema): results = mixin.serialize_many([item], schema=_AltSchema) assert all(isinstance(r, _AltSchema) for r in results) - def test_serialize_many_prefer_list_schema_false_uses_explicit_schema() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -119,14 +97,12 @@ def test_serialize_many_prefer_list_schema_false_uses_explicit_schema() -> None: results = mixin.serialize_many([item], schema=_DetailSchema, prefer_list_schema=False) assert all(isinstance(r, _DetailSchema) for r in results) - def test_serialize_many_raises_when_no_schema_at_all() -> None: mixin = _make_mixin() item = _make_obj() with pytest.raises(ValueError, match='schema is required'): mixin.serialize_many([item]) - def test_serialize_many_prefer_list_schema_false_no_schema_raises() -> None: mixin = _make_mixin() mixin.detail_schema = None @@ -135,7 +111,6 @@ def test_serialize_many_prefer_list_schema_false_no_schema_raises() -> None: with pytest.raises(ValueError, match='schema is required'): mixin.serialize_many([item], prefer_list_schema=False) - def test_serialize_many_serializes_multiple_items() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema From 9237bb955994d040ec2a6280d35b25d634457984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:00:10 +0000 Subject: [PATCH 05/11] Fix mypy type errors in v2 test files Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/0c9226ce-dcec-448f-9dec-dd4ef561c525 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- tests/v2/test_unit/test_exceptions.py | 6 ++--- tests/v2/test_unit/test_factory.py | 26 ++++++++++----------- tests/v2/test_unit/test_query_dsl_tokens.py | 22 ++++++++--------- tests/v2/test_unit/test_serializer_mixin.py | 5 ++-- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/tests/v2/test_unit/test_exceptions.py b/tests/v2/test_unit/test_exceptions.py index 4deb936..36be95e 100644 --- a/tests/v2/test_unit/test_exceptions.py +++ b/tests/v2/test_unit/test_exceptions.py @@ -59,7 +59,7 @@ def test_already_exists_error_can_be_raised_and_caught() -> None: raise AlreadyExistsError(msg) def test_not_found_error_entity_id_none_by_default() -> None: - err = NotFoundError('not found') + err: NotFoundError[None] = NotFoundError('not found') assert err.entity_id is None def test_not_found_error_entity_id_stored() -> None: @@ -72,7 +72,7 @@ def test_not_found_error_entity_id_uuid() -> None: assert err.entity_id == uid def test_not_found_error_message_preserved() -> None: - err = NotFoundError('Resource not found.') + err: NotFoundError[None] = NotFoundError('Resource not found.') assert str(err) == 'Resource not found.' def test_not_found_error_is_exception() -> None: @@ -84,5 +84,5 @@ def test_not_found_error_can_be_raised_and_caught() -> None: raise NotFoundError(msg) def test_not_found_error_no_positional_args() -> None: - err = NotFoundError() + err: NotFoundError[None] = NotFoundError() assert err.entity_id is None diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index 2c31b05..2cd288c 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -22,17 +22,17 @@ class _WidgetSchema(BaseResponseSchema): pass def test_build_repository_returns_standard_repo_by_default() -> None: - repo = build_repository(_Widget) + repo = build_repository(_Widget) # type: ignore[var-annotated] assert isinstance(repo, Repository) assert not isinstance(repo, SoftDeleteRepository) def test_build_repository_soft_delete_flag_returns_soft_delete_repo() -> None: - repo = build_repository(_Widget, soft_delete=True) + repo = build_repository(_Widget, soft_delete=True) # type: ignore[var-annotated] assert isinstance(repo, SoftDeleteRepository) def test_build_repository_config_is_applied() -> None: config = RepoConfig[_Widget](default_limit=_DEFAULT_LIMIT) - repo = build_repository(_Widget, config=config) + repo = build_repository(_Widget, config=config) # type: ignore[var-annotated] assert repo.default_limit == _DEFAULT_LIMIT def test_build_repository_custom_repo_class_used() -> None: @@ -43,26 +43,26 @@ class _CustomRepo(Repository[object, _Widget]): assert isinstance(repo, _CustomRepo) def test_build_repository_model_attribute_set() -> None: - repo = build_repository(_Widget) + repo = build_repository(_Widget) # type: ignore[var-annotated] assert repo.model is _Widget def test_build_service_returns_repository_service_by_default() -> None: - svc = build_service(_Widget) + svc = build_service(_Widget) # type: ignore[var-annotated] assert isinstance(svc, RepositoryService) def test_build_service_soft_delete_flag_returns_soft_delete_service() -> None: - svc = build_service(_Widget, soft_delete=True) + svc = build_service(_Widget, soft_delete=True) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_custom_repo_passed_directly() -> None: repo = Repository[object, _Widget](_Widget) - svc = build_service(_Widget, repo=repo) + svc = build_service(_Widget, repo=repo) # type: ignore[var-annotated] assert isinstance(svc, RepositoryService) assert svc.repo is repo def test_build_service_soft_delete_repo_infers_soft_delete_service() -> None: repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service(_Widget, repo=repo) + svc = build_service(_Widget, repo=repo) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raises() -> None: @@ -80,22 +80,22 @@ def test_build_service_soft_delete_flag_with_standard_service_class_used() -> No def test_build_service_repo_config_applied() -> None: repo_config = RepoConfig[_Widget](default_limit=_REPO_CONFIG_LIMIT) - svc = build_service(_Widget, repo_config=repo_config) + svc = build_service(_Widget, repo_config=repo_config) # type: ignore[var-annotated] assert svc.repo.default_limit == _REPO_CONFIG_LIMIT def test_build_service_soft_delete_repo_with_soft_delete_true() -> None: repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service(_Widget, soft_delete=True, repo=repo) + svc = build_service(_Widget, soft_delete=True, repo=repo) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_for_repo_standard_repo_returns_repository_service() -> None: repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) + svc = build_service_for_repo(repo) # type: ignore[var-annotated] assert isinstance(svc, RepositoryService) def test_build_service_for_repo_soft_delete_repo_returns_soft_delete_service() -> None: repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) + svc = build_service_for_repo(repo) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_for_repo_custom_service_class_used() -> None: @@ -113,5 +113,5 @@ def test_build_service_for_repo_soft_delete_service_cls_with_non_soft_delete_rep def test_build_service_for_repo_repo_is_wired_to_service() -> None: repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) + svc = build_service_for_repo(repo) # type: ignore[var-annotated] assert svc.repo is repo diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index e13a198..da1c676 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -183,7 +183,7 @@ def test_resolve_to_column_callable_resolver_called_with_model() -> None: def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] - fields = {'name': FilterField(resolver=SampleModel.name, value_type=str)} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name, value_type=str)} clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert len(clauses) == 1 assert "sample_model.name = 'alice'" in _render(clauses[0]) @@ -195,7 +195,7 @@ def test_build_filter_clauses_unknown_field_raises() -> None: def test_build_filter_clauses_disallowed_operator_raises() -> None: tokens = [FilterToken(field='name', operator='gt', raw_value='5')] - fields = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} with pytest.raises(ValueError, match='Operator'): build_filter_clauses(tokens, model=SampleModel, fields=fields) @@ -210,7 +210,7 @@ def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: def test_build_filter_clauses_field_without_resolver_or_predicate_raises() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='x')] - fields = {'name': FilterField()} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField()} with pytest.raises(ValueError, match='resolver or predicate'): build_filter_clauses(tokens, model=SampleModel, fields=fields) @@ -220,38 +220,38 @@ def test_build_filter_clauses_empty_tokens_returns_empty() -> None: def test_build_filter_clauses_isnull_no_value() -> None: tokens = [FilterToken(field='name', operator='isnull', raw_value=None)] - fields = {'name': FilterField(resolver=SampleModel.name)} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name)} clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert 'IS NULL' in _render(clauses[0]) def test_build_filter_clauses_in_operator_requires_value() -> None: tokens = [FilterToken(field='name', operator='in', raw_value=None)] - fields = {'name': FilterField(resolver=SampleModel.name)} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name)} with pytest.raises(ValueError, match='requires a value'): build_filter_clauses(tokens, model=SampleModel, fields=fields) def test_build_filter_clauses_non_isnull_without_value_raises() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value=None)] - fields = {'name': FilterField(resolver=SampleModel.name)} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name)} with pytest.raises(ValueError, match='requires a value'): build_filter_clauses(tokens, model=SampleModel, fields=fields) def test_build_filter_clauses_callable_resolver_in_field() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='x')] - fields = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} + fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert "sample_model.name = 'x'" in _render(clauses[0]) def test_build_sort_clauses_ascending() -> None: tokens = [SortToken(field='name', direction='asc')] - fields = {'name': SortField(resolver=SampleModel.name)} + fields: dict[str, SortField[SampleModel]] = {'name': SortField(resolver=SampleModel.name)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert len(clauses) == 1 assert 'ASC' in _render(clauses[0]) def test_build_sort_clauses_descending() -> None: tokens = [SortToken(field='score', direction='desc')] - fields = {'score': SortField(resolver=SampleModel.score)} + fields: dict[str, SortField[SampleModel]] = {'score': SortField(resolver=SampleModel.score)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert 'DESC' in _render(clauses[0]) @@ -266,7 +266,7 @@ def test_build_sort_clauses_empty_tokens_returns_empty() -> None: def test_build_sort_clauses_callable_resolver() -> None: tokens = [SortToken(field='name', direction='asc')] - fields = {'name': SortField(resolver=lambda m: m.name)} + fields: dict[str, SortField[SampleModel]] = {'name': SortField(resolver=lambda m: m.name)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert 'sample_model.name' in _render(clauses[0]) @@ -275,7 +275,7 @@ def test_build_sort_clauses_multiple_tokens() -> None: SortToken(field='name', direction='asc'), SortToken(field='score', direction='desc'), ] - fields = { + fields: dict[str, SortField[SampleModel]] = { 'name': SortField(resolver=SampleModel.name), 'score': SortField(resolver=SampleModel.score), } diff --git a/tests/v2/test_unit/test_serializer_mixin.py b/tests/v2/test_unit/test_serializer_mixin.py index 3b4878e..30fa1b4 100644 --- a/tests/v2/test_unit/test_serializer_mixin.py +++ b/tests/v2/test_unit/test_serializer_mixin.py @@ -1,6 +1,7 @@ """Tests for SerializerMixin — edge cases not covered by integration tests.""" import types +from typing import cast import pytest from pydantic import ConfigDict @@ -21,8 +22,8 @@ class _ListSchema(BaseResponseSchema): model_config = ConfigDict(from_attributes=True) # A plain namespace satisfies `from_attributes=True` schemas that have no required fields. -def _make_obj() -> types.SimpleNamespace: - return types.SimpleNamespace() +def _make_obj() -> _Item: + return cast(_Item, types.SimpleNamespace()) def _make_mixin() -> SerializerMixin[_Item, _DetailSchema, _ListSchema]: mixin: SerializerMixin[_Item, _DetailSchema, _ListSchema] = SerializerMixin() From 1970fc01e3c9216029466cb389f060a7395e24fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:14:11 +0000 Subject: [PATCH 06/11] Fix 7 failing tests: add _SoftWidget for soft-delete tests, fix resolve_to_column assertions Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/92bb63bf-07cf-4012-bba6-1fb6dda66289 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- tests/v2/test_unit/test_factory.py | 21 ++++++++++++--------- tests/v2/test_unit/test_query_dsl_tokens.py | 4 ++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index 2cd288c..bea7e5b 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -4,7 +4,7 @@ from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column -from notora.v2.models.base import GenericBaseModel +from notora.v2.models.base import GenericBaseModel, SoftDeletableModel from notora.v2.repositories.base import Repository, SoftDeleteRepository from notora.v2.repositories.config import RepoConfig from notora.v2.repositories.factory import build_repository @@ -18,6 +18,9 @@ class _Widget(GenericBaseModel): name: Mapped[str] = mapped_column(String) +class _SoftWidget(SoftDeletableModel): + name: Mapped[str] = mapped_column(String) + class _WidgetSchema(BaseResponseSchema): pass @@ -27,7 +30,7 @@ def test_build_repository_returns_standard_repo_by_default() -> None: assert not isinstance(repo, SoftDeleteRepository) def test_build_repository_soft_delete_flag_returns_soft_delete_repo() -> None: - repo = build_repository(_Widget, soft_delete=True) # type: ignore[var-annotated] + repo = build_repository(_SoftWidget, soft_delete=True) # type: ignore[var-annotated] assert isinstance(repo, SoftDeleteRepository) def test_build_repository_config_is_applied() -> None: @@ -51,7 +54,7 @@ def test_build_service_returns_repository_service_by_default() -> None: assert isinstance(svc, RepositoryService) def test_build_service_soft_delete_flag_returns_soft_delete_service() -> None: - svc = build_service(_Widget, soft_delete=True) # type: ignore[var-annotated] + svc = build_service(_SoftWidget, soft_delete=True) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_custom_repo_passed_directly() -> None: @@ -61,8 +64,8 @@ def test_build_service_custom_repo_passed_directly() -> None: assert svc.repo is repo def test_build_service_soft_delete_repo_infers_soft_delete_service() -> None: - repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service(_Widget, repo=repo) # type: ignore[var-annotated] + repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) + svc = build_service(_SoftWidget, repo=repo) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raises() -> None: @@ -72,7 +75,7 @@ def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raise def test_build_service_soft_delete_flag_with_standard_service_class_used() -> None: svc = build_service( - _Widget, + _SoftWidget, soft_delete=True, service_cls=SoftDeleteRepositoryService, ) @@ -84,8 +87,8 @@ def test_build_service_repo_config_applied() -> None: assert svc.repo.default_limit == _REPO_CONFIG_LIMIT def test_build_service_soft_delete_repo_with_soft_delete_true() -> None: - repo = SoftDeleteRepository[object, _Widget](_Widget) - svc = build_service(_Widget, soft_delete=True, repo=repo) # type: ignore[var-annotated] + repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) + svc = build_service(_SoftWidget, soft_delete=True, repo=repo) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) def test_build_service_for_repo_standard_repo_returns_repository_service() -> None: @@ -94,7 +97,7 @@ def test_build_service_for_repo_standard_repo_returns_repository_service() -> No assert isinstance(svc, RepositoryService) def test_build_service_for_repo_soft_delete_repo_returns_soft_delete_service() -> None: - repo = SoftDeleteRepository[object, _Widget](_Widget) + repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) svc = build_service_for_repo(repo) # type: ignore[var-annotated] assert isinstance(svc, SoftDeleteRepositoryService) diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index da1c676..3206a7a 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -175,11 +175,11 @@ def test_apply_filter_operator_unsupported_operator_raises() -> None: def test_resolve_to_column_direct_column_returned_unchanged() -> None: col = resolve_to_column(SampleModel.name, SampleModel) - assert 'sample_model.name' in str(col) + assert 'sample_model.name' in _render(col) def test_resolve_to_column_callable_resolver_called_with_model() -> None: col = resolve_to_column(lambda m: m.score, SampleModel) - assert 'sample_model.score' in str(col) + assert 'sample_model.score' in _render(col) def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] From d7b709615eec7a39775cc0b5c59d6f2bc913c6e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:17:38 +0000 Subject: [PATCH 07/11] Fix mypy errors: cast resolve_to_column result to ColumnElement before passing to _render Agent-Logs-Url: https://github.com/AldanDev/Notora/sessions/8d88a3ea-029a-49eb-9e7a-7eb4399e3242 Co-authored-by: Pentusha <1904496+Pentusha@users.noreply.github.com> --- tests/v2/test_unit/test_query_dsl_tokens.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index 3206a7a..dc9c84a 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -1,5 +1,7 @@ """Tests for query_dsl token parsers, filter/sort clause builders, and build_query_params.""" +from typing import cast + import pytest from pydantic import ValidationError from sqlalchemy import Integer, String, select @@ -175,11 +177,11 @@ def test_apply_filter_operator_unsupported_operator_raises() -> None: def test_resolve_to_column_direct_column_returned_unchanged() -> None: col = resolve_to_column(SampleModel.name, SampleModel) - assert 'sample_model.name' in _render(col) + assert 'sample_model.name' in _render(cast(ColumnElement, col)) def test_resolve_to_column_callable_resolver_called_with_model() -> None: col = resolve_to_column(lambda m: m.score, SampleModel) - assert 'sample_model.score' in _render(col) + assert 'sample_model.score' in _render(cast(ColumnElement, col)) def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] From 8996fa707bd58dc3ba6df6a2a0b54f55cedd83c1 Mon Sep 17 00:00:00 2001 From: Ivan Larin Date: Mon, 4 May 2026 15:23:47 +0300 Subject: [PATCH 08/11] fix(tests): add missing Any type parameter to ColumnElement casts --- tests/v2/test_unit/test_query_dsl_tokens.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index dc9c84a..9c34579 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -1,6 +1,6 @@ """Tests for query_dsl token parsers, filter/sort clause builders, and build_query_params.""" -from typing import cast +from typing import Any, cast import pytest from pydantic import ValidationError @@ -177,11 +177,11 @@ def test_apply_filter_operator_unsupported_operator_raises() -> None: def test_resolve_to_column_direct_column_returned_unchanged() -> None: col = resolve_to_column(SampleModel.name, SampleModel) - assert 'sample_model.name' in _render(cast(ColumnElement, col)) + assert 'sample_model.name' in _render(cast(ColumnElement[Any], col)) def test_resolve_to_column_callable_resolver_called_with_model() -> None: col = resolve_to_column(lambda m: m.score, SampleModel) - assert 'sample_model.score' in _render(cast(ColumnElement, col)) + assert 'sample_model.score' in _render(cast(ColumnElement[Any], col)) def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] From ceb30fc6ffb3a044fd68906540af3387cc1e1ddb Mon Sep 17 00:00:00 2001 From: Ivan Larin Date: Mon, 4 May 2026 15:40:05 +0300 Subject: [PATCH 09/11] fix(tests): replace all type ignores and typing.cast with proper types --- tests/conftest.py | 5 +- tests/v2/conftest.py | 2 +- tests/v2/test_unit/test_factory.py | 59 +++++++++++++------ .../test_unit/test_pydantic_query_schemas.py | 14 +++-- tests/v2/test_unit/test_query_dsl_tokens.py | 21 ++++--- tests/v2/test_unit/test_serializer_mixin.py | 7 +-- 6 files changed, 69 insertions(+), 39 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e724d77..6187b28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,2 +1,5 @@ -def pytest_addoption(parser) -> None: # type: ignore[no-untyped-def] +from _pytest.config.argparsing import Parser + + +def pytest_addoption(parser: Parser) -> None: parser.addoption('--postgres-version', action='store', default='latest') diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index b638fde..01c937b 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -26,7 +26,7 @@ @pytest.fixture(scope='session') -def postgres_db(request) -> Iterator[PostgresContainer]: # type: ignore[no-untyped-def] +def postgres_db(request: pytest.FixtureRequest) -> Iterator[PostgresContainer]: postgres_version = request.config.getoption('--postgres-version') with PostgresContainer(f'postgres:{postgres_version}') as db: yield db diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index bea7e5b..f8e2b8c 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -1,5 +1,7 @@ """Tests for build_repository, build_service, and build_service_for_repo factories.""" +from typing import Any + import pytest from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column @@ -7,114 +9,135 @@ from notora.v2.models.base import GenericBaseModel, SoftDeletableModel from notora.v2.repositories.base import Repository, SoftDeleteRepository from notora.v2.repositories.config import RepoConfig -from notora.v2.repositories.factory import build_repository +from notora.v2.repositories.factory import AnyRepository, build_repository from notora.v2.schemas.base import BaseResponseSchema from notora.v2.services.base import RepositoryService, SoftDeleteRepositoryService -from notora.v2.services.factory import build_service, build_service_for_repo +from notora.v2.services.factory import AnyService, build_service, build_service_for_repo _DEFAULT_LIMIT = 7 _REPO_CONFIG_LIMIT = 3 + class _Widget(GenericBaseModel): name: Mapped[str] = mapped_column(String) + class _SoftWidget(SoftDeletableModel): name: Mapped[str] = mapped_column(String) + class _WidgetSchema(BaseResponseSchema): pass + def test_build_repository_returns_standard_repo_by_default() -> None: - repo = build_repository(_Widget) # type: ignore[var-annotated] + repo: AnyRepository[object, _Widget] = build_repository(_Widget) assert isinstance(repo, Repository) assert not isinstance(repo, SoftDeleteRepository) + def test_build_repository_soft_delete_flag_returns_soft_delete_repo() -> None: - repo = build_repository(_SoftWidget, soft_delete=True) # type: ignore[var-annotated] + repo: AnyRepository[object, _SoftWidget] = build_repository(_SoftWidget, soft_delete=True) assert isinstance(repo, SoftDeleteRepository) + def test_build_repository_config_is_applied() -> None: config = RepoConfig[_Widget](default_limit=_DEFAULT_LIMIT) - repo = build_repository(_Widget, config=config) # type: ignore[var-annotated] + repo: AnyRepository[object, _Widget] = build_repository(_Widget, config=config) assert repo.default_limit == _DEFAULT_LIMIT + def test_build_repository_custom_repo_class_used() -> None: class _CustomRepo(Repository[object, _Widget]): pass - repo = build_repository(_Widget, repo_cls=_CustomRepo) + repo: AnyRepository[object, _Widget] = build_repository(_Widget, repo_cls=_CustomRepo) assert isinstance(repo, _CustomRepo) + def test_build_repository_model_attribute_set() -> None: - repo = build_repository(_Widget) # type: ignore[var-annotated] + repo: AnyRepository[object, _Widget] = build_repository(_Widget) assert repo.model is _Widget + def test_build_service_returns_repository_service_by_default() -> None: - svc = build_service(_Widget) # type: ignore[var-annotated] + svc: AnyService[object, _Widget, Any, Any] = build_service(_Widget) assert isinstance(svc, RepositoryService) + def test_build_service_soft_delete_flag_returns_soft_delete_service() -> None: - svc = build_service(_SoftWidget, soft_delete=True) # type: ignore[var-annotated] + svc: AnyService[object, _SoftWidget, Any, Any] = build_service(_SoftWidget, soft_delete=True) assert isinstance(svc, SoftDeleteRepositoryService) + def test_build_service_custom_repo_passed_directly() -> None: repo = Repository[object, _Widget](_Widget) - svc = build_service(_Widget, repo=repo) # type: ignore[var-annotated] + svc: AnyService[object, _Widget, Any, Any] = build_service(_Widget, repo=repo) assert isinstance(svc, RepositoryService) assert svc.repo is repo + def test_build_service_soft_delete_repo_infers_soft_delete_service() -> None: repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) - svc = build_service(_SoftWidget, repo=repo) # type: ignore[var-annotated] + svc: AnyService[object, _SoftWidget, Any, Any] = build_service(_SoftWidget, repo=repo) assert isinstance(svc, SoftDeleteRepositoryService) + def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raises() -> None: repo = Repository[object, _Widget](_Widget) with pytest.raises(TypeError, match='Soft-delete service requires'): build_service(_Widget, repo=repo, service_cls=SoftDeleteRepositoryService) + def test_build_service_soft_delete_flag_with_standard_service_class_used() -> None: - svc = build_service( + svc: AnyService[object, _SoftWidget, Any, Any] = build_service( _SoftWidget, soft_delete=True, service_cls=SoftDeleteRepositoryService, ) assert isinstance(svc, SoftDeleteRepositoryService) + def test_build_service_repo_config_applied() -> None: repo_config = RepoConfig[_Widget](default_limit=_REPO_CONFIG_LIMIT) - svc = build_service(_Widget, repo_config=repo_config) # type: ignore[var-annotated] + svc: AnyService[object, _Widget, Any, Any] = build_service(_Widget, repo_config=repo_config) assert svc.repo.default_limit == _REPO_CONFIG_LIMIT + def test_build_service_soft_delete_repo_with_soft_delete_true() -> None: repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) - svc = build_service(_SoftWidget, soft_delete=True, repo=repo) # type: ignore[var-annotated] + svc: AnyService[object, _SoftWidget, Any, Any] = build_service(_SoftWidget, soft_delete=True, repo=repo) assert isinstance(svc, SoftDeleteRepositoryService) + def test_build_service_for_repo_standard_repo_returns_repository_service() -> None: repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) # type: ignore[var-annotated] + svc: AnyService[object, _Widget, Any, Any] = build_service_for_repo(repo) assert isinstance(svc, RepositoryService) + def test_build_service_for_repo_soft_delete_repo_returns_soft_delete_service() -> None: repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) - svc = build_service_for_repo(repo) # type: ignore[var-annotated] + svc: AnyService[object, _SoftWidget, Any, Any] = build_service_for_repo(repo) assert isinstance(svc, SoftDeleteRepositoryService) + def test_build_service_for_repo_custom_service_class_used() -> None: class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): pass repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo, service_cls=_CustomService) + svc: AnyService[object, _Widget, Any, Any] = build_service_for_repo(repo, service_cls=_CustomService) assert isinstance(svc, _CustomService) + def test_build_service_for_repo_soft_delete_service_cls_with_non_soft_delete_repo_raises() -> None: repo = Repository[object, _Widget](_Widget) with pytest.raises(TypeError, match='Soft-delete service requires'): build_service_for_repo(repo, service_cls=SoftDeleteRepositoryService) + def test_build_service_for_repo_repo_is_wired_to_service() -> None: repo = Repository[object, _Widget](_Widget) - svc = build_service_for_repo(repo) # type: ignore[var-annotated] + svc: AnyService[object, _Widget, Any, Any] = build_service_for_repo(repo) assert svc.repo is repo diff --git a/tests/v2/test_unit/test_pydantic_query_schemas.py b/tests/v2/test_unit/test_pydantic_query_schemas.py index 50aff41..ecab4df 100644 --- a/tests/v2/test_unit/test_pydantic_query_schemas.py +++ b/tests/v2/test_unit/test_pydantic_query_schemas.py @@ -1,11 +1,11 @@ -from typing import Annotated, Any, ClassVar, Literal, cast +from typing import Annotated, Any, ClassVar, Literal from uuid import UUID, uuid4 import pytest from pydantic import BaseModel, Field -from sqlalchemy import Boolean, Integer, String -from sqlalchemy.dialects import postgresql +from sqlalchemy import Boolean, Integer, String, create_engine from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import ColumnElement, or_ @@ -59,12 +59,14 @@ class ThingOrdering(PydanticOrderBySchema[Thing]): } +_PG_DIALECT: Dialect = create_engine('postgresql+asyncpg://').dialect + + def _render(spec: FilterSpec[Any] | OrderSpec[Any]) -> str: assert not callable(spec) - clause = cast(ColumnElement[Any], spec) return str( - clause.compile( - dialect=postgresql.dialect(), # type: ignore[no-untyped-call] + spec.compile( + dialect=_PG_DIALECT, compile_kwargs={'literal_binds': True}, ), ) diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index 9c34579..26fe317 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -1,12 +1,13 @@ """Tests for query_dsl token parsers, filter/sort clause builders, and build_query_params.""" -from typing import Any, cast +from typing import Any import pytest from pydantic import ValidationError -from sqlalchemy import Integer, String, select -from sqlalchemy.dialects import postgresql +from sqlalchemy import Integer, String, create_engine, select +from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.sql import ColumnElement from notora.v2.models.base import GenericBaseModel @@ -31,10 +32,13 @@ _LIMIT_SMALL = 5 _OFFSET_SMALL = 10 -def _render(clause: ColumnElement) -> str: # type: ignore[type-arg] +_PG_DIALECT: Dialect = create_engine('postgresql+asyncpg://').dialect + + +def _render(clause: ColumnElement[Any] | InstrumentedAttribute[Any]) -> str: return str( clause.compile( - dialect=postgresql.dialect(), # type: ignore[no-untyped-call] + dialect=_PG_DIALECT, compile_kwargs={'literal_binds': True}, ) ) @@ -173,15 +177,16 @@ def test_apply_filter_operator_isnull_false() -> None: def test_apply_filter_operator_unsupported_operator_raises() -> None: with pytest.raises(ValueError, match='Unsupported filter operator'): - apply_filter_operator(SampleModel.name, 'contains', 'x') # type: ignore[arg-type] + bad_op: Any = 'contains' + apply_filter_operator(SampleModel.name, bad_op, 'x') def test_resolve_to_column_direct_column_returned_unchanged() -> None: col = resolve_to_column(SampleModel.name, SampleModel) - assert 'sample_model.name' in _render(cast(ColumnElement[Any], col)) + assert 'sample_model.name' in _render(col) def test_resolve_to_column_callable_resolver_called_with_model() -> None: col = resolve_to_column(lambda m: m.score, SampleModel) - assert 'sample_model.score' in _render(cast(ColumnElement[Any], col)) + assert 'sample_model.score' in _render(col) def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] diff --git a/tests/v2/test_unit/test_serializer_mixin.py b/tests/v2/test_unit/test_serializer_mixin.py index 30fa1b4..4de2927 100644 --- a/tests/v2/test_unit/test_serializer_mixin.py +++ b/tests/v2/test_unit/test_serializer_mixin.py @@ -1,8 +1,5 @@ """Tests for SerializerMixin — edge cases not covered by integration tests.""" -import types -from typing import cast - import pytest from pydantic import ConfigDict @@ -21,9 +18,9 @@ class _DetailSchema(BaseResponseSchema): class _ListSchema(BaseResponseSchema): model_config = ConfigDict(from_attributes=True) -# A plain namespace satisfies `from_attributes=True` schemas that have no required fields. + def _make_obj() -> _Item: - return cast(_Item, types.SimpleNamespace()) + return _Item() def _make_mixin() -> SerializerMixin[_Item, _DetailSchema, _ListSchema]: mixin: SerializerMixin[_Item, _DetailSchema, _ListSchema] = SerializerMixin() From 47346f25f5c0ab92b82fc17a797501a2c91b6999 Mon Sep 17 00:00:00 2001 From: Ivan Larin Date: Mon, 4 May 2026 15:49:49 +0300 Subject: [PATCH 10/11] fix(tests): address PR review comments - Use pytest.Parser instead of _pytest private import - Rename test to match actual behavior (soft-delete flag + explicit service class) - Rename filter default op test to reflect '=' not 'eq' - Use Mapped[UUID | None] instead of Mapped[object] in updated_by test doubles --- tests/conftest.py | 4 ++-- tests/v1/test_unit/test_schemas_base.py | 2 +- tests/v2/test_unit/test_factory.py | 2 +- tests/v2/test_unit/test_updated_by_mixin.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6187b28..68097fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ -from _pytest.config.argparsing import Parser +import pytest -def pytest_addoption(parser: Parser) -> None: +def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption('--postgres-version', action='store', default='latest') diff --git a/tests/v1/test_unit/test_schemas_base.py b/tests/v1/test_unit/test_schemas_base.py index 6e29990..d46e5e5 100644 --- a/tests/v1/test_unit/test_schemas_base.py +++ b/tests/v1/test_unit/test_schemas_base.py @@ -164,7 +164,7 @@ def test_client_meta_ip_address_serialized_as_string() -> None: dumped = client.model_dump() assert dumped['ip_address'] == '192.168.0.1' -def test_filter_default_op_is_eq() -> None: +def test_filter_default_op_is_equals_sign() -> None: f = Filter(field='name', value='alice') assert f.op == '=' diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index f8e2b8c..d7f5d5d 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -89,7 +89,7 @@ def test_build_service_soft_delete_service_class_with_non_soft_delete_repo_raise build_service(_Widget, repo=repo, service_cls=SoftDeleteRepositoryService) -def test_build_service_soft_delete_flag_with_standard_service_class_used() -> None: +def test_build_service_soft_delete_flag_with_explicit_service_class() -> None: svc: AnyService[object, _SoftWidget, Any, Any] = build_service( _SoftWidget, soft_delete=True, diff --git a/tests/v2/test_unit/test_updated_by_mixin.py b/tests/v2/test_unit/test_updated_by_mixin.py index 8fbad57..b2748ef 100644 --- a/tests/v2/test_unit/test_updated_by_mixin.py +++ b/tests/v2/test_unit/test_updated_by_mixin.py @@ -1,6 +1,6 @@ """Tests for UpdatedByServiceMixin.""" -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from sqlalchemy import String, Uuid @@ -13,7 +13,7 @@ class _WithUpdatedBy(GenericBaseModel): name: Mapped[str] = mapped_column(String) - updated_by: Mapped[object] = mapped_column(Uuid, nullable=True) + updated_by: Mapped[UUID | None] = mapped_column(Uuid, nullable=True) class _WithoutUpdatedBy(GenericBaseModel): @@ -64,7 +64,7 @@ def test_apply_updated_by_model_without_attribute_raises() -> None: def test_apply_updated_by_custom_attribute_name_used() -> None: class _WithCustomAttr(GenericBaseModel): name: Mapped[str] = mapped_column(String) - modified_by: Mapped[object] = mapped_column(Uuid, nullable=True) + modified_by: Mapped[UUID | None] = mapped_column(Uuid, nullable=True) class _CustomMixin(UpdatedByServiceMixin[object, _WithCustomAttr]): updated_by_attribute = 'modified_by' From 62cf847363c3dea7cf798bcb4297905024ff20aa Mon Sep 17 00:00:00 2001 From: Ivan Larin Date: Mon, 4 May 2026 15:56:37 +0300 Subject: [PATCH 11/11] style(tests): run ruff format on PR files --- .../v1/test_unit/test_enums_and_exceptions.py | 23 ++++++ tests/v1/test_unit/test_schemas_base.py | 58 ++++++++++++++- tests/v2/test_unit/test_exceptions.py | 19 +++++ tests/v2/test_unit/test_factory.py | 8 ++- .../test_unit/test_pydantic_query_schemas.py | 16 +++-- tests/v2/test_unit/test_query_dsl_tokens.py | 71 ++++++++++++++++++- tests/v2/test_unit/test_schemas_base.py | 8 +++ tests/v2/test_unit/test_serializer_mixin.py | 16 +++++ 8 files changed, 208 insertions(+), 11 deletions(-) diff --git a/tests/v1/test_unit/test_enums_and_exceptions.py b/tests/v1/test_unit/test_enums_and_exceptions.py index a809bf7..785e25e 100644 --- a/tests/v1/test_unit/test_enums_and_exceptions.py +++ b/tests/v1/test_unit/test_enums_and_exceptions.py @@ -7,96 +7,119 @@ _ENTITY_ID_INT = 42 + def test_order_by_directions_asc_value() -> None: assert OrderByDirections.ASC.value == 'asc' + def test_order_by_directions_desc_value() -> None: assert OrderByDirections.DESC.value == 'desc' + def test_order_by_directions_is_str_enum() -> None: assert isinstance(OrderByDirections.ASC, str) assert isinstance(OrderByDirections.DESC, str) + def test_order_by_directions_can_be_used_as_string() -> None: assert f'{OrderByDirections.ASC}' == 'asc' assert f'{OrderByDirections.DESC}' == 'desc' + def test_fk_not_found_error_stores_fk_name() -> None: err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') assert err.fk_name == 'user_id_fkey' + def test_fk_not_found_error_stores_table_name() -> None: err = FKNotFoundError('msg', fk_name='user_id_fkey', table_name='post') assert err.table_name == 'post' + def test_fk_not_found_error_message_is_accessible() -> None: err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') assert str(err) == 'Related object not found.' + def test_fk_not_found_error_is_exception() -> None: err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') assert isinstance(err, Exception) + def test_fk_not_found_error_can_be_raised_and_caught() -> None: msg = 'err' with pytest.raises(FKNotFoundError) as exc_info: raise FKNotFoundError(msg, fk_name='fk', table_name='tbl') assert exc_info.value.fk_name == 'fk' + def test_already_exists_error_default_message() -> None: err = AlreadyExistsError() assert str(err) == 'Entity already exists.' + def test_already_exists_error_custom_message() -> None: err = AlreadyExistsError('Custom message.') assert str(err) == 'Custom message.' + def test_already_exists_error_constraint_name_stored() -> None: err = AlreadyExistsError(constraint_name='users_email_key') assert err.constraint_name == 'users_email_key' + def test_already_exists_error_constraint_name_none_by_default() -> None: err = AlreadyExistsError() assert err.constraint_name is None + def test_already_exists_error_message_and_constraint_together() -> None: err = AlreadyExistsError('Dup', constraint_name='my_constraint') assert str(err) == 'Dup' assert err.constraint_name == 'my_constraint' + def test_already_exists_error_is_exception() -> None: assert isinstance(AlreadyExistsError(), Exception) + def test_already_exists_error_can_be_raised_and_caught() -> None: msg = 'dup' with pytest.raises(AlreadyExistsError): raise AlreadyExistsError(msg) + def test_not_found_error_entity_id_none_by_default() -> None: err: NotFoundError[None] = NotFoundError('not found') assert err.entity_id is None + def test_not_found_error_entity_id_stored() -> None: err = NotFoundError('not found', entity_id=_ENTITY_ID_INT) assert err.entity_id == _ENTITY_ID_INT + def test_not_found_error_entity_id_uuid() -> None: uid = uuid4() err = NotFoundError('not found', entity_id=uid) assert err.entity_id == uid + def test_not_found_error_message_preserved() -> None: err: NotFoundError[None] = NotFoundError('Resource not found.') assert str(err) == 'Resource not found.' + def test_not_found_error_is_exception() -> None: assert isinstance(NotFoundError('x'), Exception) + def test_not_found_error_can_be_raised_and_caught() -> None: msg = 'missing' with pytest.raises(NotFoundError): raise NotFoundError(msg) + def test_not_found_error_no_positional_args() -> None: err: NotFoundError[None] = NotFoundError() assert err.entity_id is None diff --git a/tests/v1/test_unit/test_schemas_base.py b/tests/v1/test_unit/test_schemas_base.py index d46e5e5..d7ecb13 100644 --- a/tests/v1/test_unit/test_schemas_base.py +++ b/tests/v1/test_unit/test_schemas_base.py @@ -25,21 +25,25 @@ _SECOND_PAGE = 2 _FILTER_COUNT = 2 + def test_normalize_datetime_naive_gets_utc_tzinfo() -> None: naive = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) result = normalize_datetime_to_utc(naive) assert result.tzinfo == UTC + def test_normalize_datetime_naive_value_unchanged() -> None: naive = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) result = normalize_datetime_to_utc(naive) assert result.replace(tzinfo=None) == naive + def test_normalize_datetime_utc_aware_unchanged() -> None: aware = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) result = normalize_datetime_to_utc(aware) assert result == aware + def test_normalize_datetime_offset_aware_converted_to_utc() -> None: tz_plus2 = timezone(timedelta(hours=2)) aware = datetime(2024, 6, 15, 14, 0, 0, tzinfo=tz_plus2) @@ -47,49 +51,58 @@ def test_normalize_datetime_offset_aware_converted_to_utc() -> None: assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) assert result.tzinfo == UTC + def test_normalize_datetime_negative_offset_converted_to_utc() -> None: tz_minus5 = timezone(timedelta(hours=-5)) aware = datetime(2024, 6, 15, 7, 0, 0, tzinfo=tz_minus5) result = normalize_datetime_to_utc(aware) assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + def test_utc_datetime_encoder_returns_iso_string_with_z() -> None: dt = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC) result = utc_datetime_encoder(dt) assert result == '2024-01-20T09:15:30Z' + def test_utc_datetime_encoder_naive_datetime_treated_as_utc() -> None: naive = datetime(2024, 1, 20, 9, 15, 30, tzinfo=UTC).replace(tzinfo=None) result = utc_datetime_encoder(naive) assert result == '2024-01-20T09:15:30Z' + def test_utc_datetime_encoder_offset_aware_converted() -> None: tz_plus3 = timezone(timedelta(hours=3)) dt = datetime(2024, 1, 20, 12, 15, 30, tzinfo=tz_plus3) result = utc_datetime_encoder(dt) assert result == '2024-01-20T09:15:30Z' + def test_utc_datetime_encoder_does_not_contain_plus00_00() -> None: dt = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) result = utc_datetime_encoder(dt) assert '+00:00' not in result + def test_datetime_encoder_returns_float_timestamp() -> None: dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) result = datetime_encoder(dt) assert isinstance(result, float) + def test_datetime_encoder_naive_datetime_treated_as_utc() -> None: naive = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None) aware = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) assert datetime_encoder(naive) == datetime_encoder(aware) + def test_datetime_encoder_offset_datetime_normalized() -> None: tz_plus2 = timezone(timedelta(hours=2)) offset = datetime(2024, 1, 1, 2, 0, 0, tzinfo=tz_plus2) utc = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) assert datetime_encoder(offset) == datetime_encoder(utc) + def test_pagination_meta_first_page_full() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=0) assert meta.current_page == 1 @@ -97,29 +110,35 @@ def test_pagination_meta_first_page_full() -> None: assert meta.total == _TOTAL_100 assert meta.limit == _LIMIT + def test_pagination_meta_second_page() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=_LIMIT) assert meta.current_page == _SECOND_PAGE + def test_pagination_meta_last_page_calculated() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_25, limit=_LIMIT, offset=0) assert meta.last_page == _LAST_PAGE_3 + def test_pagination_meta_zero_total_gives_page_1() -> None: meta = PaginationMetaSchema.calculate(total=0, limit=_LIMIT, offset=0) assert meta.current_page == 1 assert meta.last_page == 1 assert meta.total == 0 + def test_pagination_meta_exact_multiple_total() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_20, limit=_LIMIT, offset=0) assert meta.last_page == _LAST_PAGE_2 + def test_pagination_meta_total_less_than_limit_gives_page_1() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_5, limit=_LIMIT, offset=0) assert meta.current_page == 1 assert meta.last_page == 1 + def test_admin_meta_deleted_at_is_none_by_default() -> None: meta = AdminMeta( created_at=datetime(2024, 1, 1, tzinfo=UTC), @@ -127,6 +146,7 @@ def test_admin_meta_deleted_at_is_none_by_default() -> None: ) assert meta.deleted_at is None + def test_admin_meta_deleted_at_can_be_set() -> None: dt = datetime(2024, 6, 1, 12, 0, tzinfo=UTC) meta = AdminMeta( @@ -136,86 +156,122 @@ def test_admin_meta_deleted_at_can_be_set() -> None: ) assert meta.deleted_at == dt + def test_admin_meta_timestamps_normalized_to_utc() -> None: naive = datetime(2024, 1, 1, 10, 0, 0, tzinfo=UTC).replace(tzinfo=None) meta = AdminMeta(created_at=naive, updated_at=naive) assert meta.created_at.tzinfo == UTC assert meta.updated_at.tzinfo == UTC + def test_client_meta_both_fields_none_by_default() -> None: client = ClientMeta() assert client.ip_address is None assert client.user_agent is None + def test_client_meta_ipv4_address_accepted() -> None: client = ClientMeta(ip_address=IPv4Address('127.0.0.1')) assert isinstance(client.ip_address, IPv4Address) + def test_client_meta_ipv6_address_accepted() -> None: client = ClientMeta(ip_address=IPv6Address('::1')) assert isinstance(client.ip_address, IPv6Address) + def test_client_meta_user_agent_stored() -> None: client = ClientMeta(user_agent='Mozilla/5.0') assert client.user_agent == 'Mozilla/5.0' + def test_client_meta_ip_address_serialized_as_string() -> None: client = ClientMeta(ip_address=IPv4Address('192.168.0.1')) dumped = client.model_dump() assert dumped['ip_address'] == '192.168.0.1' + def test_filter_default_op_is_equals_sign() -> None: f = Filter(field='name', value='alice') assert f.op == '=' + def test_filter_custom_op() -> None: f = Filter(field='age', op='gt', value=18) assert f.op == 'gt' + def test_filter_value_none_allowed() -> None: f = Filter(field='deleted_at', op='is', value=None) assert f.value is None + def test_filter_model_none_by_default() -> None: f = Filter(field='name', value='x') assert f.model is None + def test_filter_model_can_be_set() -> None: class FakeModel: pass + f = Filter(field='name', value='x', model=FakeModel) assert f.model is FakeModel + def test_filter_all_ops_accepted() -> None: - valid_ops = ('eq', '=', 'ilike', '~=', 'is', 'is_not', 'in', 'gt', '>', 'ge', '>=', 'lt', '<', 'le', '<=') + valid_ops = ( + 'eq', + '=', + 'ilike', + '~=', + 'is', + 'is_not', + 'in', + 'gt', + '>', + 'ge', + '>=', + 'lt', + '<', + 'le', + '<=', + ) for op in valid_ops: f = Filter(field='x', op=op, value=1) assert f.op == op + def test_or_filter_group_stores_filters() -> None: f1 = Filter(field='name', value='a') f2 = Filter(field='name', value='b') group = OrFilterGroup(filters=[f1, f2]) assert len(group.filters) == _FILTER_COUNT + def test_or_filter_group_empty_filters_allowed() -> None: group = OrFilterGroup(filters=[]) assert group.filters == [] + def test_order_by_default_direction_is_asc() -> None: ob = OrderBy(field='name') assert ob.direction == OrderByDirections.ASC + def test_order_by_desc_direction() -> None: ob = OrderBy(field='name', direction=OrderByDirections.DESC) assert ob.direction == OrderByDirections.DESC + def test_order_by_model_none_by_default() -> None: ob = OrderBy(field='name') assert ob.model is None + def test_order_by_model_can_be_set() -> None: class FakeModel: pass + ob = OrderBy(field='name', model=FakeModel) assert ob.model is FakeModel diff --git a/tests/v2/test_unit/test_exceptions.py b/tests/v2/test_unit/test_exceptions.py index 36be95e..85ec359 100644 --- a/tests/v2/test_unit/test_exceptions.py +++ b/tests/v2/test_unit/test_exceptions.py @@ -6,22 +6,27 @@ _ENTITY_ID_INT = 42 + def test_fk_not_found_error_stores_fk_name() -> None: err = FKNotFoundError('msg', fk_name='profile_user_id_fkey', table_name='profile') assert err.fk_name == 'profile_user_id_fkey' + def test_fk_not_found_error_stores_table_name() -> None: err = FKNotFoundError('msg', fk_name='fk', table_name='orders') assert err.table_name == 'orders' + def test_fk_not_found_error_message_is_accessible() -> None: err = FKNotFoundError('Related object not found.', fk_name='fk', table_name='tbl') assert str(err) == 'Related object not found.' + def test_fk_not_found_error_is_exception() -> None: err = FKNotFoundError('msg', fk_name='fk', table_name='tbl') assert isinstance(err, Exception) + def test_fk_not_found_error_can_be_raised_and_caught() -> None: msg = 'err' with pytest.raises(FKNotFoundError) as exc_info: @@ -29,60 +34,74 @@ def test_fk_not_found_error_can_be_raised_and_caught() -> None: assert exc_info.value.fk_name == 'fk' assert exc_info.value.table_name == 'tbl' + def test_already_exists_error_default_message() -> None: err = AlreadyExistsError() assert str(err) == 'Entity already exists.' + def test_already_exists_error_custom_message() -> None: err = AlreadyExistsError('Custom message.') assert str(err) == 'Custom message.' + def test_already_exists_error_constraint_name_stored() -> None: err = AlreadyExistsError(constraint_name='users_email_key') assert err.constraint_name == 'users_email_key' + def test_already_exists_error_constraint_name_none_by_default() -> None: err = AlreadyExistsError() assert err.constraint_name is None + def test_already_exists_error_message_and_constraint_together() -> None: err = AlreadyExistsError('Dup', constraint_name='my_constraint') assert str(err) == 'Dup' assert err.constraint_name == 'my_constraint' + def test_already_exists_error_is_exception() -> None: assert isinstance(AlreadyExistsError(), Exception) + def test_already_exists_error_can_be_raised_and_caught() -> None: msg = 'dup' with pytest.raises(AlreadyExistsError): raise AlreadyExistsError(msg) + def test_not_found_error_entity_id_none_by_default() -> None: err: NotFoundError[None] = NotFoundError('not found') assert err.entity_id is None + def test_not_found_error_entity_id_stored() -> None: err = NotFoundError('not found', entity_id=_ENTITY_ID_INT) assert err.entity_id == _ENTITY_ID_INT + def test_not_found_error_entity_id_uuid() -> None: uid = uuid4() err = NotFoundError('not found', entity_id=uid) assert err.entity_id == uid + def test_not_found_error_message_preserved() -> None: err: NotFoundError[None] = NotFoundError('Resource not found.') assert str(err) == 'Resource not found.' + def test_not_found_error_is_exception() -> None: assert isinstance(NotFoundError('x'), Exception) + def test_not_found_error_can_be_raised_and_caught() -> None: msg = 'missing' with pytest.raises(NotFoundError): raise NotFoundError(msg) + def test_not_found_error_no_positional_args() -> None: err: NotFoundError[None] = NotFoundError() assert err.entity_id is None diff --git a/tests/v2/test_unit/test_factory.py b/tests/v2/test_unit/test_factory.py index d7f5d5d..8ad0073 100644 --- a/tests/v2/test_unit/test_factory.py +++ b/tests/v2/test_unit/test_factory.py @@ -106,7 +106,9 @@ def test_build_service_repo_config_applied() -> None: def test_build_service_soft_delete_repo_with_soft_delete_true() -> None: repo = SoftDeleteRepository[object, _SoftWidget](_SoftWidget) - svc: AnyService[object, _SoftWidget, Any, Any] = build_service(_SoftWidget, soft_delete=True, repo=repo) + svc: AnyService[object, _SoftWidget, Any, Any] = build_service( + _SoftWidget, soft_delete=True, repo=repo + ) assert isinstance(svc, SoftDeleteRepositoryService) @@ -127,7 +129,9 @@ class _CustomService(RepositoryService[object, _Widget, _WidgetSchema]): pass repo = Repository[object, _Widget](_Widget) - svc: AnyService[object, _Widget, Any, Any] = build_service_for_repo(repo, service_cls=_CustomService) + svc: AnyService[object, _Widget, Any, Any] = build_service_for_repo( + repo, service_cls=_CustomService + ) assert isinstance(svc, _CustomService) diff --git a/tests/v2/test_unit/test_pydantic_query_schemas.py b/tests/v2/test_unit/test_pydantic_query_schemas.py index ecab4df..ef0e677 100644 --- a/tests/v2/test_unit/test_pydantic_query_schemas.py +++ b/tests/v2/test_unit/test_pydantic_query_schemas.py @@ -243,7 +243,9 @@ class WithFilter(BaseModel): def test_extract_annotated_filters_skips_non_filter_metadata() -> None: class Mixed(BaseModel): - name: Annotated[str | None, Filter(resolver=Thing.name)] = Field(default=None, description='X') + name: Annotated[str | None, Filter(resolver=Thing.name)] = Field( + default=None, description='X' + ) plain: str | None = None out = _extract_annotated_filters(Mixed) @@ -252,6 +254,7 @@ class Mixed(BaseModel): def test_extract_annotated_filters_raises_on_multiple_filters_in_one_field() -> None: with pytest.raises(TypeError, match='multiple Filter'): + class Conflict(BaseModel): name: Annotated[ str | None, @@ -321,6 +324,7 @@ class LegacyParent(PydanticFiltersSchema[Thing]): } with pytest.raises(TypeError, match='mixes legacy `filter_fields` ClassVar'): + class AnnotatedChild(LegacyParent): age: Annotated[int | None, Filter(resolver=Thing.age)] = None @@ -357,10 +361,12 @@ class ThingFiltersAnnotated(PydanticFiltersSchema[Thing]): is_active: Annotated[bool | None, Filter(resolver=Thing.is_active)] = None q: Annotated[ str | None, - Filter(predicate=lambda m, _op, v: or_( - m.name.ilike(f'%{v}%'), - m.owner_id.cast(String).ilike(f'%{v}%'), - )), + Filter( + predicate=lambda m, _op, v: or_( + m.name.ilike(f'%{v}%'), + m.owner_id.cast(String).ilike(f'%{v}%'), + ) + ), ] = None diff --git a/tests/v2/test_unit/test_query_dsl_tokens.py b/tests/v2/test_unit/test_query_dsl_tokens.py index 26fe317..e28a7c0 100644 --- a/tests/v2/test_unit/test_query_dsl_tokens.py +++ b/tests/v2/test_unit/test_query_dsl_tokens.py @@ -43,169 +43,210 @@ def _render(clause: ColumnElement[Any] | InstrumentedAttribute[Any]) -> str: ) ) + class SampleModel(GenericBaseModel): name: Mapped[str] = mapped_column(String) score: Mapped[int] = mapped_column(Integer) + def test_parse_filter_token_parses_field_op_value() -> None: token = parse_filter_token('name:eq:alice') assert token.field == 'name' assert token.operator == 'eq' assert token.raw_value == 'alice' + def test_parse_filter_token_parses_operator_only_for_isnull() -> None: token = parse_filter_token('name:isnull') assert token.field == 'name' assert token.operator == 'isnull' assert token.raw_value is None + def test_parse_filter_token_raises_for_missing_colon() -> None: with pytest.raises(ValueError, match='"field:op:value"'): parse_filter_token('nocolon') + def test_parse_filter_token_raises_for_empty_field_name() -> None: with pytest.raises(ValueError, match='field name cannot be empty'): parse_filter_token(':eq:value') + def test_parse_filter_token_raises_for_unsupported_operator() -> None: with pytest.raises(ValueError, match='Unsupported filter operator'): parse_filter_token('name:contains:hello') + def test_parse_filter_token_value_with_colons_preserved() -> None: token = parse_filter_token('name:eq:a:b:c') assert token.raw_value == 'a:b:c' + def test_parse_filter_token_whitespace_stripped_from_field_and_op() -> None: token = parse_filter_token(' name : eq : alice ') assert token.field == 'name' assert token.operator == 'eq' + def test_parse_filter_token_whitespace_only_value_becomes_none() -> None: token = parse_filter_token('name:eq: ') assert token.raw_value is None + def test_parse_filter_token_all_operators_accepted() -> None: valid_ops = ('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'ilike', 'isnull') for op in valid_ops: token = parse_filter_token(f'name:{op}:x') assert token.operator == op + def test_parse_filter_token_isnull_with_false_value() -> None: token = parse_filter_token('name:isnull:false') assert token.raw_value == 'false' + def test_parse_filter_token_in_with_comma_separated_value() -> None: token = parse_filter_token('score:in:1,2,3') assert token.raw_value == '1,2,3' + def test_parse_sort_token_plain_field_is_ascending() -> None: token = parse_sort_token('name') assert token.field == 'name' assert token.direction == 'asc' + def test_parse_sort_token_plus_prefix_is_ascending() -> None: token = parse_sort_token('+name') assert token.field == 'name' assert token.direction == 'asc' + def test_parse_sort_token_minus_prefix_is_descending() -> None: token = parse_sort_token('-score') assert token.field == 'score' assert token.direction == 'desc' + def test_parse_sort_token_empty_string_raises() -> None: with pytest.raises(ValueError, match='cannot be empty'): parse_sort_token('') + def test_parse_sort_token_only_minus_raises() -> None: with pytest.raises(ValueError, match='cannot be empty'): parse_sort_token('-') + def test_parse_sort_token_only_plus_raises() -> None: with pytest.raises(ValueError, match='cannot be empty'): parse_sort_token('+') + def test_parse_sort_token_whitespace_stripped() -> None: token = parse_sort_token(' name ') assert token.field == 'name' + def test_parse_sort_token_returns_sort_token_dataclass() -> None: token = parse_sort_token('name') assert isinstance(token, SortToken) + def test_apply_filter_operator_eq() -> None: clause = apply_filter_operator(SampleModel.name, 'eq', 'alice') assert "sample_model.name = 'alice'" in _render(clause) + def test_apply_filter_operator_ne() -> None: clause = apply_filter_operator(SampleModel.name, 'ne', 'alice') rendered = _render(clause) assert 'sample_model.name' in rendered assert '!=' in rendered or '<>' in rendered + def test_apply_filter_operator_lt() -> None: clause = apply_filter_operator(SampleModel.score, 'lt', 5) assert 'sample_model.score < 5' in _render(clause) + def test_apply_filter_operator_lte() -> None: clause = apply_filter_operator(SampleModel.score, 'lte', 5) assert 'sample_model.score <= 5' in _render(clause) + def test_apply_filter_operator_gt() -> None: clause = apply_filter_operator(SampleModel.score, 'gt', 5) assert 'sample_model.score > 5' in _render(clause) + def test_apply_filter_operator_gte() -> None: clause = apply_filter_operator(SampleModel.score, 'gte', 5) assert 'sample_model.score >= 5' in _render(clause) + def test_apply_filter_operator_in() -> None: clause = apply_filter_operator(SampleModel.score, 'in', [1, 2, 3]) assert 'IN' in _render(clause) + def test_apply_filter_operator_ilike() -> None: clause = apply_filter_operator(SampleModel.name, 'ilike', '%alice%') assert 'ILIKE' in _render(clause) + def test_apply_filter_operator_isnull_true() -> None: clause = apply_filter_operator(SampleModel.name, 'isnull', value=True) assert 'IS NULL' in _render(clause) + def test_apply_filter_operator_isnull_false() -> None: clause = apply_filter_operator(SampleModel.name, 'isnull', value=False) assert 'IS NOT NULL' in _render(clause) + def test_apply_filter_operator_unsupported_operator_raises() -> None: with pytest.raises(ValueError, match='Unsupported filter operator'): bad_op: Any = 'contains' apply_filter_operator(SampleModel.name, bad_op, 'x') + def test_resolve_to_column_direct_column_returned_unchanged() -> None: col = resolve_to_column(SampleModel.name, SampleModel) assert 'sample_model.name' in _render(col) + def test_resolve_to_column_callable_resolver_called_with_model() -> None: col = resolve_to_column(lambda m: m.score, SampleModel) assert 'sample_model.score' in _render(col) + def test_build_filter_clauses_single_eq_clause() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='alice')] - fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name, value_type=str)} + fields: dict[str, FilterField[SampleModel]] = { + 'name': FilterField(resolver=SampleModel.name, value_type=str) + } clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert len(clauses) == 1 assert "sample_model.name = 'alice'" in _render(clauses[0]) + def test_build_filter_clauses_unknown_field_raises() -> None: tokens = [FilterToken(field='unknown', operator='eq', raw_value='x')] with pytest.raises(ValueError, match='Unsupported filter field'): build_filter_clauses(tokens, model=SampleModel, fields={}) + def test_build_filter_clauses_disallowed_operator_raises() -> None: tokens = [FilterToken(field='name', operator='gt', raw_value='5')] - fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'}))} + fields: dict[str, FilterField[SampleModel]] = { + 'name': FilterField(resolver=SampleModel.name, operators=frozenset({'eq'})) + } with pytest.raises(ValueError, match='Operator'): build_filter_clauses(tokens, model=SampleModel, fields=fields) + def test_build_filter_clauses_predicate_field() -> None: def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: return model.name.ilike(f'%{value}%') @@ -215,40 +256,49 @@ def pred(model: type[SampleModel], _op: str, value: str) -> ColumnElement[bool]: clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert 'ILIKE' in _render(clauses[0]) + def test_build_filter_clauses_field_without_resolver_or_predicate_raises() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='x')] fields: dict[str, FilterField[SampleModel]] = {'name': FilterField()} with pytest.raises(ValueError, match='resolver or predicate'): build_filter_clauses(tokens, model=SampleModel, fields=fields) + def test_build_filter_clauses_empty_tokens_returns_empty() -> None: clauses = build_filter_clauses([], model=SampleModel, fields={}) assert clauses == [] + def test_build_filter_clauses_isnull_no_value() -> None: tokens = [FilterToken(field='name', operator='isnull', raw_value=None)] fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name)} clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert 'IS NULL' in _render(clauses[0]) + def test_build_filter_clauses_in_operator_requires_value() -> None: tokens = [FilterToken(field='name', operator='in', raw_value=None)] fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name)} with pytest.raises(ValueError, match='requires a value'): build_filter_clauses(tokens, model=SampleModel, fields=fields) + def test_build_filter_clauses_non_isnull_without_value_raises() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value=None)] fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=SampleModel.name)} with pytest.raises(ValueError, match='requires a value'): build_filter_clauses(tokens, model=SampleModel, fields=fields) + def test_build_filter_clauses_callable_resolver_in_field() -> None: tokens = [FilterToken(field='name', operator='eq', raw_value='x')] - fields: dict[str, FilterField[SampleModel]] = {'name': FilterField(resolver=lambda m: m.name, value_type=str)} + fields: dict[str, FilterField[SampleModel]] = { + 'name': FilterField(resolver=lambda m: m.name, value_type=str) + } clauses = build_filter_clauses(tokens, model=SampleModel, fields=fields) assert "sample_model.name = 'x'" in _render(clauses[0]) + def test_build_sort_clauses_ascending() -> None: tokens = [SortToken(field='name', direction='asc')] fields: dict[str, SortField[SampleModel]] = {'name': SortField(resolver=SampleModel.name)} @@ -256,27 +306,32 @@ def test_build_sort_clauses_ascending() -> None: assert len(clauses) == 1 assert 'ASC' in _render(clauses[0]) + def test_build_sort_clauses_descending() -> None: tokens = [SortToken(field='score', direction='desc')] fields: dict[str, SortField[SampleModel]] = {'score': SortField(resolver=SampleModel.score)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert 'DESC' in _render(clauses[0]) + def test_build_sort_clauses_unknown_field_raises() -> None: tokens = [SortToken(field='unknown', direction='asc')] with pytest.raises(ValueError, match='Unsupported sort field'): build_sort_clauses(tokens, model=SampleModel, fields={}) + def test_build_sort_clauses_empty_tokens_returns_empty() -> None: clauses = build_sort_clauses([], model=SampleModel, fields={}) assert clauses == [] + def test_build_sort_clauses_callable_resolver() -> None: tokens = [SortToken(field='name', direction='asc')] fields: dict[str, SortField[SampleModel]] = {'name': SortField(resolver=lambda m: m.name)} clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert 'sample_model.name' in _render(clauses[0]) + def test_build_sort_clauses_multiple_tokens() -> None: tokens = [ SortToken(field='name', direction='asc'), @@ -289,48 +344,58 @@ def test_build_sort_clauses_multiple_tokens() -> None: clauses = build_sort_clauses(tokens, model=SampleModel, fields=fields) assert len(clauses) == _MULTI_CLAUSE_COUNT + def test_query_input_negative_offset_raises() -> None: with pytest.raises(ValidationError, match='offset must be zero or a positive integer'): QueryInput(offset=-1) + def test_query_input_zero_offset_accepted() -> None: q = QueryInput(offset=0) assert q.offset == 0 + def test_query_input_positive_offset_accepted() -> None: q = QueryInput(offset=_POSITIVE_OFFSET) assert q.offset == _POSITIVE_OFFSET + def test_query_input_none_limit_accepted() -> None: q = QueryInput(limit=None) assert q.limit is None + def test_query_input_positive_limit_accepted() -> None: q = QueryInput(limit=_POSITIVE_LIMIT) assert q.limit == _POSITIVE_LIMIT + def test_build_query_params_filters_without_filter_fields_raises() -> None: query = QueryInput(filter=['name:eq:x']) with pytest.raises(ValueError, match='Filter fields mapping is required'): build_query_params(query, model=SampleModel, filter_fields={}) + def test_build_query_params_sort_without_sort_fields_raises() -> None: query = QueryInput(sort=['-score']) with pytest.raises(ValueError, match='Sort fields mapping is required'): build_query_params(query, model=SampleModel, sort_fields={}) + def test_build_query_params_no_filter_no_sort_returns_none_for_both() -> None: query = QueryInput() params = build_query_params(query, model=SampleModel) assert params.filters is None assert params.ordering is None + def test_build_query_params_explicit_limit_and_offset_passed_through() -> None: query = QueryInput(limit=_LIMIT_SMALL, offset=_OFFSET_SMALL) params = build_query_params(query, model=SampleModel) assert params.limit == _LIMIT_SMALL assert params.offset == _OFFSET_SMALL + def test_build_query_params_base_query_forwarded() -> None: base = select(SampleModel) query = QueryInput() diff --git a/tests/v2/test_unit/test_schemas_base.py b/tests/v2/test_unit/test_schemas_base.py index 116c1bd..8b3af37 100644 --- a/tests/v2/test_unit/test_schemas_base.py +++ b/tests/v2/test_unit/test_schemas_base.py @@ -7,36 +7,44 @@ _TOTAL_100 = 100 _LIMIT = 10 + def test_client_meta_both_fields_none_by_default() -> None: client = ClientMeta() assert client.ip_address is None assert client.user_agent is None + def test_client_meta_ipv4_address_accepted() -> None: client = ClientMeta(ip_address=IPv4Address('192.168.1.1')) assert isinstance(client.ip_address, IPv4Address) + def test_client_meta_ipv6_address_accepted() -> None: client = ClientMeta(ip_address=IPv6Address('::1')) assert isinstance(client.ip_address, IPv6Address) + def test_client_meta_user_agent_stored() -> None: client = ClientMeta(user_agent='Mozilla/5.0') assert client.user_agent == 'Mozilla/5.0' + def test_client_meta_ip_serialized_as_string_in_dict() -> None: client = ClientMeta(ip_address=IPv4Address('10.0.0.1')) dumped = client.model_dump() assert dumped['ip_address'] == '10.0.0.1' + def test_pagination_meta_negative_total_clamped_to_zero() -> None: meta = PaginationMetaSchema.calculate(total=-5, limit=_LIMIT, offset=0) assert meta.total == 0 + def test_pagination_meta_zero_total_preserved() -> None: meta = PaginationMetaSchema.calculate(total=0, limit=_LIMIT, offset=0) assert meta.total == 0 + def test_pagination_meta_positive_total_preserved() -> None: meta = PaginationMetaSchema.calculate(total=_TOTAL_100, limit=_LIMIT, offset=0) assert meta.total == _TOTAL_100 diff --git a/tests/v2/test_unit/test_serializer_mixin.py b/tests/v2/test_unit/test_serializer_mixin.py index 4de2927..890b2ba 100644 --- a/tests/v2/test_unit/test_serializer_mixin.py +++ b/tests/v2/test_unit/test_serializer_mixin.py @@ -9,12 +9,15 @@ _ITEM_COUNT = 5 + class _Item(GenericBaseModel): pass + class _DetailSchema(BaseResponseSchema): model_config = ConfigDict(from_attributes=True) + class _ListSchema(BaseResponseSchema): model_config = ConfigDict(from_attributes=True) @@ -22,16 +25,19 @@ class _ListSchema(BaseResponseSchema): def _make_obj() -> _Item: return _Item() + def _make_mixin() -> SerializerMixin[_Item, _DetailSchema, _ListSchema]: mixin: SerializerMixin[_Item, _DetailSchema, _ListSchema] = SerializerMixin() return mixin + def test_serialize_one_uses_explicit_schema_arg() -> None: mixin = _make_mixin() item = _make_obj() result = mixin.serialize_one(item, schema=_DetailSchema) assert isinstance(result, _DetailSchema) + def test_serialize_one_falls_back_to_detail_schema_attribute() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -39,12 +45,14 @@ def test_serialize_one_falls_back_to_detail_schema_attribute() -> None: result = mixin.serialize_one(item) assert isinstance(result, _DetailSchema) + def test_serialize_one_raises_when_no_schema_and_no_detail_schema() -> None: mixin = _make_mixin() item = _make_obj() with pytest.raises(ValueError, match='schema is required'): mixin.serialize_one(item) + def test_serialize_one_explicit_schema_overrides_detail_schema() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -56,12 +64,14 @@ class _AltSchema(_DetailSchema): result = mixin.serialize_one(item, schema=_AltSchema) assert isinstance(result, _AltSchema) + def test_serialize_many_empty_list_returns_empty() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema result = mixin.serialize_many([]) assert result == [] + def test_serialize_many_uses_list_schema_by_default() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema @@ -69,6 +79,7 @@ def test_serialize_many_uses_list_schema_by_default() -> None: results = mixin.serialize_many([item]) assert all(isinstance(r, _ListSchema) for r in results) + def test_serialize_many_falls_back_to_detail_schema_when_list_schema_absent() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -76,6 +87,7 @@ def test_serialize_many_falls_back_to_detail_schema_when_list_schema_absent() -> results = mixin.serialize_many([item]) assert all(isinstance(r, _DetailSchema) for r in results) + def test_serialize_many_explicit_schema_arg_overrides_list_schema() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema @@ -87,6 +99,7 @@ class _AltSchema(_ListSchema): results = mixin.serialize_many([item], schema=_AltSchema) assert all(isinstance(r, _AltSchema) for r in results) + def test_serialize_many_prefer_list_schema_false_uses_explicit_schema() -> None: mixin = _make_mixin() mixin.detail_schema = _DetailSchema @@ -95,12 +108,14 @@ def test_serialize_many_prefer_list_schema_false_uses_explicit_schema() -> None: results = mixin.serialize_many([item], schema=_DetailSchema, prefer_list_schema=False) assert all(isinstance(r, _DetailSchema) for r in results) + def test_serialize_many_raises_when_no_schema_at_all() -> None: mixin = _make_mixin() item = _make_obj() with pytest.raises(ValueError, match='schema is required'): mixin.serialize_many([item]) + def test_serialize_many_prefer_list_schema_false_no_schema_raises() -> None: mixin = _make_mixin() mixin.detail_schema = None @@ -109,6 +124,7 @@ def test_serialize_many_prefer_list_schema_false_no_schema_raises() -> None: with pytest.raises(ValueError, match='schema is required'): mixin.serialize_many([item], prefer_list_schema=False) + def test_serialize_many_serializes_multiple_items() -> None: mixin = _make_mixin() mixin.list_schema = _ListSchema