From 2bd884c8d5a932f282d7f424d9ef717466d0af8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Mon, 9 Mar 2026 16:14:49 +0300 Subject: [PATCH] 013 add price multiplier --- app/config.py | 2 + app/controllers/v1/delivery.py | 1 + app/domain/price.py | 59 +++++++++++++++++-- app/services/aggregator.py | 24 ++++++-- config.example.yaml | 1 + config.test.yaml | 1 + spec/index.md | 7 ++- spec/overview.md | 3 + ..._configurable_provider_price_multiplier.md | 47 +++++++++++++++ .../delivery_providers/cdek/test_client.py | 19 +++++- tests/config/fixtures/config.default.yaml | 1 + .../config.invalid_price_multiplier.yaml | 24 ++++++++ .../fixtures/config.missing_adapter.yaml | 1 + .../config.missing_price_multiplier.yaml | 23 ++++++++ .../config/fixtures/config.test.override.yaml | 1 + tests/config/test_config_sections.py | 42 +++++++++++++ tests/controllers/v1/test_delivery.py | 4 +- tests/domain/test_price.py | 29 ++++++++- tests/repositories/cache/test_redis_cache.py | 21 +++++++ tests/services/test_aggregator.py | 27 ++++++--- 20 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 spec/tasks/013_add_configurable_provider_price_multiplier.md create mode 100644 tests/config/fixtures/config.invalid_price_multiplier.yaml create mode 100644 tests/config/fixtures/config.missing_price_multiplier.yaml diff --git a/app/config.py b/app/config.py index c4a4098..9266b7c 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ """Application configuration split by architecture component.""" +from decimal import Decimal from functools import lru_cache from pathlib import Path import sys @@ -29,6 +30,7 @@ class ServiceConfig(BaseModel): class BusinessLogicConfig(BaseModel): weight_round_scale: int = 2 + provider_price_multiplier: Decimal = Field(..., gt=0) class RepositoryConfig(BaseModel): diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 4cdc886..58f84c4 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -35,6 +35,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService: providers=providers, cache=cache, weight_round_scale=settings.business_logic.weight_round_scale, + provider_price_multiplier=settings.business_logic.provider_price_multiplier, ) return service diff --git a/app/domain/price.py b/app/domain/price.py index 8f96572..c66c7eb 100644 --- a/app/domain/price.py +++ b/app/domain/price.py @@ -6,10 +6,12 @@ from decimal import ROUND_HALF_UP, Decimal from typing import Protocol DEFAULT_WEIGHT_ROUND_SCALE = 2 +DEFAULT_PROVIDER_PRICE_MULTIPLIER = Decimal("1") MAX_ROUND_SCALE = 6 DIMENSIONS_ROUND_SCALE = 1 MIN_WEIGHT_KG = Decimal("0.01") MIN_DIMENSION_CM = Decimal("0.1") +INTEGER_PRICE_QUANTIZER = Decimal("1") _MISSING = object() @@ -77,12 +79,20 @@ def normalize_delivery_request( ) -def filter_valid_prices(prices: Iterable[object]) -> list[ProviderPrice]: +def filter_valid_prices( + prices: Iterable[object], + *, + price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, +) -> list[ProviderPrice]: """Normalize price records and keep only domain-valid entries.""" + normalized_multiplier = _normalize_price_multiplier(price_multiplier) valid_prices: list[ProviderPrice] = [] for price_entry in prices: - normalized_price = _normalize_price(price_entry) + normalized_price = _normalize_price( + price_entry, + price_multiplier=normalized_multiplier, + ) if normalized_price is not None: valid_prices.append(normalized_price) return valid_prices @@ -94,13 +104,23 @@ def sort_prices_by_price(prices: Iterable[ProviderPrice]) -> list[ProviderPrice] return sorted(prices, key=lambda price_entry: price_entry.price) -def filter_and_sort_prices(prices: Iterable[object]) -> list[ProviderPrice]: +def filter_and_sort_prices( + prices: Iterable[object], + *, + price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, +) -> list[ProviderPrice]: """Apply domain filtering and return prices ordered by value.""" - return sort_prices_by_price(filter_valid_prices(prices)) + return sort_prices_by_price( + filter_valid_prices(prices, price_multiplier=price_multiplier) + ) -def _normalize_price(candidate: object) -> ProviderPrice | None: +def _normalize_price( + candidate: object, + *, + price_multiplier: Decimal, +) -> ProviderPrice | None: provider = _normalize_text(_get_optional_attr(candidate, "provider")) service_name = _normalize_text(_get_optional_attr(candidate, "service_name")) currency = _normalize_text(_get_optional_attr(candidate, "currency")).upper() @@ -117,6 +137,12 @@ def _normalize_price(candidate: object) -> ProviderPrice | None: price = _try_to_decimal(price_raw) if price is None or not price.is_finite() or price <= 0: return None + adjusted_price = _apply_price_multiplier_and_round( + price, + price_multiplier=price_multiplier, + ) + if adjusted_price is None: + return None min_days = _try_to_int(min_days_raw) max_days = _try_to_int(max_days_raw) @@ -129,7 +155,7 @@ def _normalize_price(candidate: object) -> ProviderPrice | None: return ProviderPrice( provider=provider, service_name=service_name, - price=price, + price=adjusted_price, currency=currency, delivery_days_min=min_days, delivery_days_max=max_days, @@ -192,6 +218,27 @@ def _round_half_up(value: Decimal, scale: int) -> Decimal: return value.quantize(quantizer, rounding=ROUND_HALF_UP) +def _normalize_price_multiplier(value: Decimal) -> Decimal: + multiplier = _try_to_decimal(value) + if multiplier is None or not multiplier.is_finite() or multiplier <= 0: + raise ValueError(f"Price multiplier must be a positive finite Decimal: {value!r}") + return multiplier + + +def _apply_price_multiplier_and_round( + price: Decimal, + *, + price_multiplier: Decimal, +) -> Decimal | None: + adjusted_price = (price * price_multiplier).quantize( + INTEGER_PRICE_QUANTIZER, + rounding=ROUND_HALF_UP, + ) + if not adjusted_price.is_finite() or adjusted_price <= 0: + return None + return adjusted_price + + def _to_decimal(value: object) -> Decimal: converted = _try_to_decimal(value) if converted is None: diff --git a/app/services/aggregator.py b/app/services/aggregator.py index b0886a2..93bd41a 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -3,11 +3,13 @@ import asyncio import hashlib import json -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Iterable, Sequence +from decimal import Decimal from typing import Protocol from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.domain.price import ( + DEFAULT_PROVIDER_PRICE_MULTIPLIER, DEFAULT_WEIGHT_ROUND_SCALE, NormalizedDeliveryRequest, filter_and_sort_prices, @@ -31,6 +33,15 @@ class PriceCacheProtocol(Protocol): async def set(self, key: str, value: object, ttl: int | None = None) -> None: ... +class FilterAndSortPricesFn(Protocol): + def __call__( + self, + prices: Iterable[object], + *, + price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, + ) -> list[object]: ... + + class AggregatorService: def __init__( self, @@ -38,13 +49,13 @@ class AggregatorService: cache: PriceCacheProtocol | None = None, *, weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE, - filter_and_sort_prices_fn: Callable[[Iterable[object]], list[object]] = ( - filter_and_sort_prices - ), + provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, + filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices, ) -> None: self._providers = tuple(providers) self._cache = cache self._weight_round_scale = weight_round_scale + self._provider_price_multiplier = provider_price_multiplier self._filter_and_sort_prices = filter_and_sort_prices_fn async def get_all_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: @@ -86,7 +97,10 @@ class AggregatorService: raise InvalidDeliveryRequestError( "Delivery request is invalid for configured providers." ) - filtered_and_sorted = self._filter_and_sort_prices(successful_results) + filtered_and_sorted = self._filter_and_sort_prices( + successful_results, + price_multiplier=self._provider_price_multiplier, + ) return [self._coerce_delivery_price(price) for price in filtered_and_sorted] async def _get_provider_price( diff --git a/config.example.yaml b/config.example.yaml index 48a1dba..017b996 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,6 +8,7 @@ service: business_logic: weight_round_scale: 2 + provider_price_multiplier: 1.0 repository: redis_dsn: "redis://localhost:6379/0" diff --git a/config.test.yaml b/config.test.yaml index ff37899..06525c4 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -8,6 +8,7 @@ service: business_logic: weight_round_scale: 2 + provider_price_multiplier: 1.0 repository: redis_dsn: "redis://localhost:6379/0" diff --git a/spec/index.md b/spec/index.md index 5219a49..484526f 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-08T19:58:23+00:00` +> Generated at (UTC): `2026-03-09T13:13:56+00:00` ## Tasks @@ -20,9 +20,10 @@ | 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` | | 011 | DONE | 2026-03-08 | Migrate Redis repository client to aioredis | `spec/tasks/011_migrate_redis_repository_to_aioredis.md` | | 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` | +| 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` | ## Summary -- Total: **13** +- Total: **14** - TODO: **0** -- DONE: **13** +- DONE: **14** diff --git a/spec/overview.md b/spec/overview.md index b25169e..1226801 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -20,6 +20,7 @@ - Возвращать унифицированный список тарифов, отсортированных по цене - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком - Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг +- Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения --- @@ -54,6 +55,8 @@ ### Business Logic (`app/domain/`) - Правила сортировки и фильтрации тарифов - Логика сравнения цен +- Правила применения конфигурируемого мультипликатора к ценам провайдеров +- Округление цены после применения мультипликатора до целого значения по детерминированному правилу - Нормализация входных данных (например, правила округления веса) - Чистые функции, без IO, без зависимостей от фреймворков diff --git a/spec/tasks/013_add_configurable_provider_price_multiplier.md b/spec/tasks/013_add_configurable_provider_price_multiplier.md new file mode 100644 index 0000000..ebd0a29 --- /dev/null +++ b/spec/tasks/013_add_configurable_provider_price_multiplier.md @@ -0,0 +1,47 @@ +--- +id: 013 +title: Add configurable provider price multiplier in domain logic +status: DONE +created: 2026-03-09 +--- + +## Context +Сейчас `app/domain/price.py` валидирует и сортирует тарифы провайдеров без дополнительной постобработки цены. Появилось новое требование: перед возвратом результата применять к цене провайдера конфигурируемый multiplier из YAML-конфига и округлять итог до целого числа. + +## Goal +Добавить в Business Logic детерминированное правило пересчёта `DeliveryPrice.price` через multiplier из секции `business_logic` и провести это значение через конфигурацию и service orchestration без переноса формулы в Service или Adapter. + +## Constraints +- Соблюдать layered architecture из `AGENTS.md`. +- Формула умножения и округления должна жить только в `app/domain/`; Service может только передавать нужный параметр и делегировать вызов. +- Значение multiplier должно читаться из YAML-конфига через секцию `business_logic`. +- После применения multiplier цена должна оставаться `Decimal`, но иметь целочисленное значение. +- Правило округления должно быть явным и детерминированным: до `Decimal("1")` c `ROUND_HALF_UP`. +- Не изменять provider adapters, внешние HTTP-контракты и формат ответа кроме нового значения `price`. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- В `business_logic` configuration section добавлено обязательное поле для multiplier цены провайдера с валидацией положительного значения. +- Domain functions применяют multiplier к каждой валидной цене провайдера до сортировки результата. +- Итоговая цена после умножения округляется до целого значения по правилу `ROUND_HALF_UP`. +- Service не содержит формулу расчёта и по-прежнему делегирует обработку цен в domain logic. +- Поведение одинаково для свежих ответов провайдера и для cache hit: в обоих случаях наружу возвращается уже пересчитанная цена. + +## Definition of Done +- [ ] Обновлена конфигурация `business_logic` и YAML fixtures/example configs для multiplier. +- [ ] Реализована pure domain logic для пересчёта и округления цены. +- [ ] Service wiring передаёт multiplier в domain logic без добавления business rules в Service. +- [ ] Unit/service/config tests покрывают стандартные и edge-case сценарии. + +## Tests +- Обновить `tests/domain/test_price.py` для проверки multiplier, округления `ROUND_HALF_UP`, пустого input и невалидных цен. +- Обновить `tests/services/test_aggregator.py` для проверки делегирования multiplier и одинакового поведения fresh/cache hit. +- Обновить `tests/config/test_config_sections.py` для проверки чтения multiplier из YAML и валидации невалидных значений. +- При необходимости обновить `tests/smoke/test_app_import.py` под обязательное новое поле конфигурации. + +## Commands +- `poetry run pytest tests/domain/test_price.py -q` +- `poetry run pytest tests/services/test_aggregator.py -q` +- `poetry run pytest tests/config/test_config_sections.py -q` +- `poetry run pytest tests/smoke/test_app_import.py -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 3caf7dd..86daa06 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -10,6 +10,7 @@ from app.adapters.delivery_providers.cdek.client import ( CDEKProvider, CDEKRequestError, ) +from app import config as config_module from app.config import AdapterConfig, Settings from app.schemas.request import DeliveryEntity, DeliveryRequest @@ -299,6 +300,22 @@ def test_provider_uses_adapter_yaml_config_for_timeout_and_cache_ttl( config_file = tmp_path / "config.yaml" config_file.write_text( """ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 1.0 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + adapter: cdek_base_url: "https://api.cdek.test/v2" cdek_client_id: "yaml-id" @@ -310,7 +327,7 @@ adapter: """.strip(), encoding="utf-8", ) - monkeypatch.setenv("G2S_CONFIG_FILE", str(config_file)) + monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file)) settings = Settings() http_client = RecordingHTTPClient() provider = CDEKProvider.from_adapter_config( diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index 97478e6..53d9751 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -6,6 +6,7 @@ service: business_logic: weight_round_scale: 2 + provider_price_multiplier: 1.0 repository: redis_dsn: "redis://localhost:6379/0" diff --git a/tests/config/fixtures/config.invalid_price_multiplier.yaml b/tests/config/fixtures/config.invalid_price_multiplier.yaml new file mode 100644 index 0000000..50088f7 --- /dev/null +++ b/tests/config/fixtures/config.invalid_price_multiplier.yaml @@ -0,0 +1,24 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 0 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 diff --git a/tests/config/fixtures/config.missing_adapter.yaml b/tests/config/fixtures/config.missing_adapter.yaml index 11fd79c..609fc1e 100644 --- a/tests/config/fixtures/config.missing_adapter.yaml +++ b/tests/config/fixtures/config.missing_adapter.yaml @@ -6,6 +6,7 @@ service: business_logic: weight_round_scale: 2 + provider_price_multiplier: 1.0 repository: redis_dsn: "redis://localhost:6379/0" diff --git a/tests/config/fixtures/config.missing_price_multiplier.yaml b/tests/config/fixtures/config.missing_price_multiplier.yaml new file mode 100644 index 0000000..ff37899 --- /dev/null +++ b/tests/config/fixtures/config.missing_price_multiplier.yaml @@ -0,0 +1,23 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 8243cf2..390e9fd 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -6,6 +6,7 @@ service: business_logic: weight_round_scale: 2 + provider_price_multiplier: 1.25 repository: redis_dsn: "redis://localhost:6379/0" diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 90d0a2b..3e3e0a7 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -1,3 +1,4 @@ +from decimal import Decimal from pathlib import Path import pytest @@ -17,6 +18,12 @@ DEFAULT_CONFIG_FILE_FIXTURE = ( TEST_OVERRIDE_CONFIG_FILE_FIXTURE = ( PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.test.override.yaml" ) +INVALID_PRICE_MULTIPLIER_CONFIG_FILE = ( + PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.invalid_price_multiplier.yaml" +) +MISSING_PRICE_MULTIPLIER_CONFIG_FILE = ( + PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_price_multiplier.yaml" +) def _use_runtime_config_files( @@ -47,6 +54,7 @@ def test_configuration_sections_are_loaded_from_yaml_file( assert settings.service.provider_timeout_seconds == 10.0 assert settings.service.max_parallel_providers == 8 assert settings.business_logic.weight_round_scale == 2 + assert settings.business_logic.provider_price_multiplier == Decimal("1.0") assert settings.repository.redis_dsn == "redis://localhost:6379/0" assert settings.repository.price_cache_ttl_seconds == 900 assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2" @@ -82,6 +90,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) - assert first is second assert first.service.provider_timeout_seconds == 10.0 + assert first.business_logic.provider_price_multiplier == Decimal("1.0") get_settings.cache_clear() @@ -98,5 +107,38 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.service.provider_timeout_seconds == 17.0 assert settings.controller.api_prefix == "/from-config-test-yaml" + assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.adapter.cdek_client_id == "test-id" get_settings.cache_clear() + + +def test_get_settings_fails_when_provider_price_multiplier_is_invalid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files( + monkeypatch, + test_config_file=INVALID_PRICE_MULTIPLIER_CONFIG_FILE, + ) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert ("business_logic", "provider_price_multiplier") in locations + get_settings.cache_clear() + + +def test_get_settings_fails_when_provider_price_multiplier_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files( + monkeypatch, + test_config_file=MISSING_PRICE_MULTIPLIER_CONFIG_FILE, + ) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert ("business_logic", "provider_price_multiplier") in locations + get_settings.cache_clear() diff --git a/tests/controllers/v1/test_delivery.py b/tests/controllers/v1/test_delivery.py index 47bcd2d..9ed71fb 100644 --- a/tests/controllers/v1/test_delivery.py +++ b/tests/controllers/v1/test_delivery.py @@ -69,7 +69,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( return DeliveryPrice( provider=self.name, service_name="stub-service", - price=Decimal("123.45"), + price=Decimal("123.50"), currency="RUB", delivery_days_min=2, delivery_days_max=3, @@ -136,7 +136,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( { "provider": "stub-provider", "service_name": "stub-service", - "price": "123.45", + "price": "124", "currency": "RUB", "delivery_days_min": 2, "delivery_days_max": 3, diff --git a/tests/domain/test_price.py b/tests/domain/test_price.py index 1ed1794..c4751e0 100644 --- a/tests/domain/test_price.py +++ b/tests/domain/test_price.py @@ -80,6 +80,18 @@ def test_filter_valid_prices_handles_empty_input() -> None: assert filter_and_sort_prices([]) == [] +def test_filter_valid_prices_applies_multiplier_and_rounds_half_up() -> None: + low = _make_price(provider="cdek", price=Decimal("100.40")) + high = _make_price(provider="boxberry", price=Decimal("100.50")) + + result = filter_valid_prices( + [low, high], + price_multiplier=Decimal("1.1"), + ) + + assert [price.price for price in result] == [Decimal("110"), Decimal("111")] + + def test_filter_valid_prices_excludes_invalid_records() -> None: valid = _make_price(provider=" cdek ", service_name=" express ", currency="rub") invalid_provider = _make_price(provider=" ") @@ -128,6 +140,17 @@ def test_filter_valid_prices_excludes_none_provider_and_service() -> None: assert result[0].service_name == "express" +def test_filter_valid_prices_excludes_prices_rounded_to_zero_after_multiplier() -> None: + low_price = _make_price(provider="cdek", price=Decimal("0.40")) + + result = filter_valid_prices( + [low_price], + price_multiplier=Decimal("0.5"), + ) + + assert result == [] + + def test_sort_prices_by_price_keeps_equal_price_order_stable() -> None: price_a = _make_price(provider="a", price=Decimal("150.00")) price_b = _make_price(provider="b", price=Decimal("150.00")) @@ -143,6 +166,10 @@ def test_filter_and_sort_prices_combines_domain_steps() -> None: price_b = _make_price(provider="b", price=Decimal("100.00")) invalid = _make_price(provider="", price=Decimal("50.00")) - result = filter_and_sort_prices([price_a, invalid, price_b]) + result = filter_and_sort_prices( + [price_a, invalid, price_b], + price_multiplier=Decimal("1.3"), + ) assert [price.provider for price in result] == ["b", "a"] + assert [price.price for price in result] == [Decimal("130"), Decimal("286")] diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py index 01f8050..ff9e13b 100644 --- a/tests/repositories/cache/test_redis_cache.py +++ b/tests/repositories/cache/test_redis_cache.py @@ -154,9 +154,30 @@ def test_repository_yaml_config_sets_redis_connection_and_ttl( config_file = tmp_path / "config.yaml" config_file.write_text( """ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 1.0 + repository: redis_dsn: "redis://redis.internal:6380/5" price_cache_ttl_seconds: 123 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 """.strip(), encoding="utf-8", ) diff --git a/tests/services/test_aggregator.py b/tests/services/test_aggregator.py index 6ea0cf2..2987507 100644 --- a/tests/services/test_aggregator.py +++ b/tests/services/test_aggregator.py @@ -85,14 +85,19 @@ def _make_price(provider: str, price: str) -> DeliveryPrice: def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None: - provider_a = StubProvider(name="a", response=_make_price("a", "300.00"), cache_ttl_seconds=111) - provider_b = StubProvider(name="b", response=_make_price("b", "100.00"), cache_ttl_seconds=222) + provider_a = StubProvider(name="a", response=_make_price("a", "300.49"), cache_ttl_seconds=111) + provider_b = StubProvider(name="b", response=_make_price("b", "100.40"), cache_ttl_seconds=222) cache = StubCache() - service = AggregatorService([provider_a, provider_b], cache=cache) + service = AggregatorService( + [provider_a, provider_b], + cache=cache, + provider_price_multiplier=Decimal("1.1"), + ) result = asyncio.run(service.get_all_prices(_make_request())) assert [price.provider for price in result] == ["b", "a"] + assert [price.price for price in result] == [Decimal("110"), Decimal("331")] assert len(provider_a.calls) == 1 assert len(provider_b.calls) == 1 assert len(cache.get_calls) == 2 @@ -148,15 +153,19 @@ def test_get_all_prices_raises_invalid_request_for_provider_request_errors() -> def test_get_all_prices_cache_hit_skips_provider_call() -> None: - cached_payload = _make_price("cdek", "99.00").model_dump(mode="json") + cached_payload = _make_price("cdek", "100.40").model_dump(mode="json") cache = StubCache(forced_get_value=cached_payload) provider = StubProvider(name="cdek", response=_make_price("cdek", "150.00")) - service = AggregatorService([provider], cache=cache) + service = AggregatorService( + [provider], + cache=cache, + provider_price_multiplier=Decimal("1.1"), + ) result = asyncio.run(service.get_all_prices(_make_request())) assert [price.provider for price in result] == ["cdek"] - assert result[0].price == Decimal("99.00") + assert result[0].price == Decimal("110") assert provider.calls == [] assert len(cache.get_calls) == 1 assert cache.set_calls == [] @@ -166,19 +175,23 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non provider_a = StubProvider(name="a", response=_make_price("a", "300.00")) provider_b = StubProvider(name="b", response=_make_price("b", "100.00")) delegated_inputs: list[list[DeliveryPrice]] = [] + delegated_multipliers: list[Decimal] = [] - def fake_filter_and_sort(prices): + def fake_filter_and_sort(prices, *, price_multiplier): price_list = list(prices) delegated_inputs.append(price_list) + delegated_multipliers.append(price_multiplier) return [price_list[0]] service = AggregatorService( [provider_a, provider_b], cache=StubCache(), + provider_price_multiplier=Decimal("1.23"), filter_and_sort_prices_fn=fake_filter_and_sort, ) result = asyncio.run(service.get_all_prices(_make_request())) assert [price.provider for price in delegated_inputs[0]] == ["a", "b"] + assert delegated_multipliers == [Decimal("1.23")] assert [price.provider for price in result] == ["a"]