From ffdaead686b8008ef2ab9ee0fbdb5d7dc61ad3fa Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Fri, 20 Mar 2026 19:12:07 +0200 Subject: [PATCH 1/4] Add tests for uncovered happy paths and edge cases --- tests/test_api_unit.py | 616 +++++++++++++++++++++++++++++++++ tests/test_category_service.py | 138 ++++++++ 2 files changed, 754 insertions(+) create mode 100644 tests/test_api_unit.py create mode 100644 tests/test_category_service.py diff --git a/tests/test_api_unit.py b/tests/test_api_unit.py new file mode 100644 index 0000000..38f3664 --- /dev/null +++ b/tests/test_api_unit.py @@ -0,0 +1,616 @@ +"""Unit tests for API handler branch and error behavior.""" + +from datetime import datetime, timezone +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from starlette.requests import Request + +from app.api import categories as categories_api +from app.api import products as products_api +from app.api import search as search_api +from app.schemas.category import CategoryCreate, CategoryUpdate +from app.schemas.product import ProductCreate, ProductUpdate + + +def make_request() -> Request: + """Build a minimal Starlette request for direct endpoint invocation.""" + return Request({"type": "http", "method": "GET", "path": "/"}) + + +@pytest.mark.asyncio +async def test_create_category_parent_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + payload = CategoryCreate(name="Child", parent_id=999) + + monkeypatch.setattr( + categories_api, + "validate_category_parent", + AsyncMock(side_effect=categories_api.CategoryParentNotFoundError(999)), + ) + + with pytest.raises(HTTPException) as exc: + await categories_api.create_category(payload=payload, session=session) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Parent category not found" + + +@pytest.mark.asyncio +async def test_create_category_depth_exceeded_returns_422(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + payload = CategoryCreate(name="Child", parent_id=1) + + monkeypatch.setattr( + categories_api, + "validate_category_parent", + AsyncMock(side_effect=categories_api.CategoryDepthError("too deep")), + ) + + with pytest.raises(HTTPException) as exc: + await categories_api.create_category(payload=payload, session=session) + + assert exc.value.status_code == 422 + assert "Category depth cannot exceed" in exc.value.detail + + +@pytest.mark.asyncio +async def test_create_category_conflict_rolls_back(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock(side_effect=IntegrityError("stmt", {}, Exception("dup"))) + session.rollback = AsyncMock() + + payload = CategoryCreate(name="Duplicate", parent_id=None) + + with pytest.raises(HTTPException) as exc: + await categories_api.create_category(payload=payload, session=session) + + assert exc.value.status_code == 409 + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_category_success_returns_model(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock() + + captured = {} + + async def refresh_side_effect(category): + category.id = 55 + category.created_at = datetime.now(timezone.utc) + category.updated_at = datetime.now(timezone.utc) + captured["category"] = category + + session.refresh = AsyncMock(side_effect=refresh_side_effect) + payload = CategoryCreate(name="Root", parent_id=None) + + result = await categories_api.create_category(payload=payload, session=session) + + assert result.id == 55 + assert result.name == "Root" + assert captured["category"].name == "Root" + + +@pytest.mark.asyncio +async def test_get_category_success_returns_category(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + category = SimpleNamespace( + id=7, + name="Books", + parent_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=category)) + + result = await categories_api.get_category(category_id=7, session=session) + + assert result.id == 7 + assert result.name == "Books" + + +@pytest.mark.asyncio +async def test_list_categories_uses_fast_total_path(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + records = [ + SimpleNamespace( + id=1, + name="One", + parent_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + scalar_one = AsyncMock(return_value=999) + + monkeypatch.setattr(categories_api, "timed_execute_scalars_all", AsyncMock(return_value=records)) + monkeypatch.setattr(categories_api, "timed_execute_scalar_one", scalar_one) + + result = await categories_api.list_categories(limit=10, offset=0, session=session) + + assert result.total == 1 + assert len(result.items) == 1 + scalar_one.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_list_categories_uses_count_query_when_needed(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + records = [ + SimpleNamespace( + id=1, + name="One", + parent_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + scalar_one = AsyncMock(return_value=42) + + monkeypatch.setattr(categories_api, "timed_execute_scalars_all", AsyncMock(return_value=records)) + monkeypatch.setattr(categories_api, "timed_execute_scalar_one", scalar_one) + + result = await categories_api.list_categories(limit=1, offset=5, session=session) + + assert result.total == 42 + scalar_one.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_category_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + payload = CategoryUpdate(name="Renamed") + + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=None)) + + with pytest.raises(HTTPException) as exc: + await categories_api.update_category(category_id=123, payload=payload, session=session) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Category not found" + + +@pytest.mark.asyncio +async def test_update_category_cycle_detected_returns_422(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + category = SimpleNamespace(id=10, name="Node", parent_id=None, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc)) + payload = CategoryUpdate(parent_id=11) + + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=category)) + monkeypatch.setattr( + categories_api, + "validate_category_reparent", + AsyncMock(side_effect=categories_api.CategoryCycleError("Category cycle detected")), + ) + + with pytest.raises(HTTPException) as exc: + await categories_api.update_category(category_id=10, payload=payload, session=session) + + assert exc.value.status_code == 422 + assert exc.value.detail == "Category cycle detected" + + +@pytest.mark.asyncio +async def test_update_category_parent_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + category = SimpleNamespace( + id=10, + name="Node", + parent_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + payload = CategoryUpdate(parent_id=999) + + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=category)) + monkeypatch.setattr( + categories_api, + "validate_category_reparent", + AsyncMock(side_effect=categories_api.CategoryParentNotFoundError(999)), + ) + + with pytest.raises(HTTPException) as exc: + await categories_api.update_category(category_id=10, payload=payload, session=session) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Parent category not found" + + +@pytest.mark.asyncio +async def test_update_category_depth_exceeded_returns_422(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + category = SimpleNamespace( + id=11, + name="Node", + parent_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + payload = CategoryUpdate(parent_id=12) + + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=category)) + monkeypatch.setattr( + categories_api, + "validate_category_reparent", + AsyncMock(side_effect=categories_api.CategoryDepthError("too deep")), + ) + + with pytest.raises(HTTPException) as exc: + await categories_api.update_category(category_id=11, payload=payload, session=session) + + assert exc.value.status_code == 422 + assert "Category depth cannot exceed" in exc.value.detail + + +@pytest.mark.asyncio +async def test_update_category_success_sets_fields(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + session.commit = AsyncMock() + session.refresh = AsyncMock() + category = SimpleNamespace( + id=20, + name="Old", + parent_id=1, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + payload = CategoryUpdate(name="New", parent_id=2) + + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=category)) + monkeypatch.setattr(categories_api, "validate_category_reparent", AsyncMock(return_value=None)) + + result = await categories_api.update_category(category_id=20, payload=payload, session=session) + + assert result.name == "New" + assert result.parent_id == 2 + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_delete_category_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=None)) + + with pytest.raises(HTTPException) as exc: + await categories_api.delete_category(category_id=1, session=session) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Category not found" + + +@pytest.mark.asyncio +async def test_delete_category_success_commits(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + category = SimpleNamespace(id=1, name="DeleteMe") + monkeypatch.setattr(categories_api, "get_category_or_none", AsyncMock(return_value=category)) + + await categories_api.delete_category(category_id=1, session=session) + + session.delete.assert_awaited_once_with(category) + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_product_conflict_rolls_back(): + session = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock(side_effect=IntegrityError("stmt", {}, Exception("dup"))) + session.rollback = AsyncMock() + payload = ProductCreate( + title="Prod", + description="Desc", + sku="DUP-001", + price=Decimal("10.00"), + category_id=None, + ) + + with pytest.raises(HTTPException) as exc: + await products_api.create_product(payload=payload, session=session) + + assert exc.value.status_code == 409 + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_product_success_returns_model(): + session = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock() + + async def refresh_side_effect(product): + product.id = 99 + product.created_at = datetime.now(timezone.utc) + product.updated_at = datetime.now(timezone.utc) + + session.refresh = AsyncMock(side_effect=refresh_side_effect) + + payload = ProductCreate( + title="Prod", + description="Desc", + sku="OK-001", + price=Decimal("10.00"), + category_id=None, + ) + + result = await products_api.create_product(payload=payload, session=session) + + assert result.id == 99 + assert result.sku == "OK-001" + + +@pytest.mark.asyncio +async def test_get_product_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=None)) + + with pytest.raises(HTTPException) as exc: + await products_api.get_product(product_id=3, session=session) + + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_product_success_returns_product(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + product = SimpleNamespace( + id=3, + title="Prod", + description="Desc", + image_url=None, + sku="GET-001", + price=Decimal("11.00"), + category_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=product)) + + result = await products_api.get_product(product_id=3, session=session) + + assert result.id == 3 + assert result.sku == "GET-001" + + +@pytest.mark.asyncio +async def test_list_products_fast_total_path(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + records = [ + SimpleNamespace( + id=1, + title="A", + description="B", + image_url=None, + sku="A-001", + price=Decimal("1.00"), + category_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + scalar_one = AsyncMock(return_value=100) + monkeypatch.setattr(products_api, "timed_execute_scalars_all", AsyncMock(return_value=records)) + monkeypatch.setattr(products_api, "timed_execute_scalar_one", scalar_one) + + result = await products_api.list_products(limit=10, offset=0, session=session) + + assert result.total == 1 + scalar_one.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_list_products_count_query_path(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + records = [ + SimpleNamespace( + id=1, + title="A", + description="B", + image_url=None, + sku="A-001", + price=Decimal("1.00"), + category_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + scalar_one = AsyncMock(return_value=5) + monkeypatch.setattr(products_api, "timed_execute_scalars_all", AsyncMock(return_value=records)) + monkeypatch.setattr(products_api, "timed_execute_scalar_one", scalar_one) + + result = await products_api.list_products(limit=1, offset=2, session=session) + + assert result.total == 5 + scalar_one.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_product_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + payload = ProductUpdate(title="Renamed") + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=None)) + + with pytest.raises(HTTPException) as exc: + await products_api.update_product(product_id=10, payload=payload, session=session) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Product not found" + + +@pytest.mark.asyncio +async def test_update_product_conflict_returns_409(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + product = SimpleNamespace( + id=5, + title="Prod", + description="Desc", + image_url=None, + sku="ORIG-001", + price=Decimal("10.00"), + category_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + payload = ProductUpdate(sku="NEW-001") + + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=product)) + session.commit = AsyncMock(side_effect=IntegrityError("stmt", {}, Exception("dup"))) + session.rollback = AsyncMock() + + with pytest.raises(HTTPException) as exc: + await products_api.update_product(product_id=5, payload=payload, session=session) + + assert exc.value.status_code == 409 + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_product_success_updates_fields(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + session.commit = AsyncMock() + session.refresh = AsyncMock() + product = SimpleNamespace( + id=5, + title="Old", + description="Desc", + image_url=None, + sku="OLD-001", + price=Decimal("10.00"), + category_id=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + payload = ProductUpdate(title="New", sku="NEW-001") + + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=product)) + + result = await products_api.update_product(product_id=5, payload=payload, session=session) + + assert result.title == "New" + assert result.sku == "NEW-001" + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_delete_product_not_found_returns_404(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=None)) + + with pytest.raises(HTTPException) as exc: + await products_api.delete_product(product_id=404, session=session) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Product not found" + + +@pytest.mark.asyncio +async def test_delete_product_success_commits(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + product = SimpleNamespace(id=8, sku="DEL-001") + monkeypatch.setattr(products_api, "timed_get", AsyncMock(return_value=product)) + + await products_api.delete_product(product_id=8, session=session) + + session.delete.assert_awaited_once_with(product) + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_search_invalid_price_range_returns_422(): + request = make_request() + session = AsyncMock() + + with pytest.raises(HTTPException) as exc: + await search_api.search_products_endpoint( + request=request, + q=None, + min_price=Decimal("10"), + max_price=Decimal("1"), + category_id=None, + limit=20, + offset=0, + session=session, + ) + + assert exc.value.status_code == 422 + assert exc.value.detail == "min_price cannot be greater than max_price" + + +@pytest.mark.asyncio +async def test_search_sets_request_state_and_records_zero_results(monkeypatch: pytest.MonkeyPatch): + request = make_request() + request.state.request_observability_state = SimpleNamespace( + search_phase_ms=None, + search_filters_applied=None, + ) + + async def fake_search_products(**kwargs): + kwargs["timing_context"]["query_ms"] = 3.2 + return [], 0 + + monkeypatch.setattr(search_api, "search_products", fake_search_products) + + response = await search_api.search_products_endpoint( + request=request, + q="gaming", + min_price=Decimal("100"), + max_price=None, + category_id=1, + limit=10, + offset=0, + session=AsyncMock(), + ) + + assert response.total == 0 + assert response.items == [] + assert request.state.request_observability_state.search_phase_ms == {"query_ms": 3.2} + assert request.state.request_observability_state.search_filters_applied == [ + "q", + "min_price", + "category_id", + ] + + +@pytest.mark.asyncio +async def test_search_returns_product_response_items(monkeypatch: pytest.MonkeyPatch): + request = make_request() + product = SimpleNamespace( + id=1, + title="Gaming Laptop", + description="High-end", + image_url=None, + sku="GAME-001", + price=Decimal("1999.99"), + category_id=2, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + async def fake_search_products(**kwargs): + kwargs["timing_context"]["db_ms"] = 1.1 + return [product], 1 + + monkeypatch.setattr(search_api, "search_products", fake_search_products) + + response = await search_api.search_products_endpoint( + request=request, + q="game", + min_price=None, + max_price=None, + category_id=None, + limit=20, + offset=0, + session=AsyncMock(), + ) + + assert response.total == 1 + assert len(response.items) == 1 + assert response.items[0].sku == "GAME-001" \ No newline at end of file diff --git a/tests/test_category_service.py b/tests/test_category_service.py new file mode 100644 index 0000000..4171901 --- /dev/null +++ b/tests/test_category_service.py @@ -0,0 +1,138 @@ +"""Unit tests for category service helper functions.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.services import category_service + + +@pytest.mark.asyncio +async def test_category_depth_stops_when_parent_missing(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + + async def fake_timed_get(_session, _model, category_id): + if category_id == 1: + return SimpleNamespace(parent_id=2) + return None + + monkeypatch.setattr(category_service, "timed_get", fake_timed_get) + + depth = await category_service.category_depth(session, parent_id=1) + assert depth == 2 + + +@pytest.mark.asyncio +async def test_validate_no_cycles_raises_for_cycle(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + + async def fake_timed_get(_session, _model, category_id): + if category_id == 2: + return SimpleNamespace(parent_id=1) + return None + + monkeypatch.setattr(category_service, "timed_get", fake_timed_get) + + with pytest.raises(ValueError, match="Category cycle detected"): + await category_service.validate_no_cycles(session, category_id=1, new_parent_id=2) + + +@pytest.mark.asyncio +async def test_validate_category_parent_raises_not_found(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr(category_service, "get_category_or_none", AsyncMock(return_value=None)) + + with pytest.raises(category_service.CategoryParentNotFoundError): + await category_service.validate_category_parent(session, parent_id=99) + + +@pytest.mark.asyncio +async def test_validate_category_parent_raises_depth_error(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr( + category_service, + "get_category_or_none", + AsyncMock(return_value=SimpleNamespace(id=1, parent_id=None)), + ) + monkeypatch.setattr( + category_service, + "category_depth", + AsyncMock(return_value=category_service.MAX_CATEGORY_DEPTH), + ) + + with pytest.raises(category_service.CategoryDepthError): + await category_service.validate_category_parent(session, parent_id=1) + + +@pytest.mark.asyncio +async def test_validate_category_reparent_returns_for_none_parent(): + session = AsyncMock() + await category_service.validate_category_reparent(session, category_id=1, new_parent_id=None) + + +@pytest.mark.asyncio +async def test_validate_category_reparent_raises_parent_not_found(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr(category_service, "get_category_or_none", AsyncMock(return_value=None)) + + with pytest.raises(category_service.CategoryParentNotFoundError): + await category_service.validate_category_reparent(session, category_id=1, new_parent_id=77) + + +@pytest.mark.asyncio +async def test_validate_category_reparent_raises_cycle_error(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr( + category_service, + "get_category_or_none", + AsyncMock(return_value=SimpleNamespace(id=2, parent_id=None)), + ) + monkeypatch.setattr( + category_service, + "validate_no_cycles", + AsyncMock(side_effect=ValueError("Category cycle detected")), + ) + + with pytest.raises(category_service.CategoryCycleError, match="Category cycle detected"): + await category_service.validate_category_reparent(session, category_id=1, new_parent_id=2) + + +@pytest.mark.asyncio +async def test_validate_category_reparent_raises_depth_error(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr( + category_service, + "get_category_or_none", + AsyncMock(return_value=SimpleNamespace(id=2, parent_id=None)), + ) + monkeypatch.setattr(category_service, "validate_no_cycles", AsyncMock(return_value=None)) + monkeypatch.setattr( + category_service, + "category_depth", + AsyncMock(return_value=category_service.MAX_CATEGORY_DEPTH), + ) + + with pytest.raises(category_service.CategoryDepthError): + await category_service.validate_category_reparent(session, category_id=1, new_parent_id=2) + + +@pytest.mark.asyncio +async def test_category_depth_returns_when_exceeding_max(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + + async def fake_timed_get(_session, _model, category_id): + return SimpleNamespace(parent_id=category_id + 1) + + monkeypatch.setattr(category_service, "timed_get", fake_timed_get) + + depth = await category_service.category_depth(session, parent_id=1) + assert depth == category_service.MAX_CATEGORY_DEPTH + 1 + + +@pytest.mark.asyncio +async def test_validate_no_cycles_breaks_on_missing_candidate(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + monkeypatch.setattr(category_service, "timed_get", AsyncMock(return_value=None)) + + await category_service.validate_no_cycles(session, category_id=1, new_parent_id=2) \ No newline at end of file From 534ad626f8de2de6f848b8c7d080bc227dc85198 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Fri, 20 Mar 2026 19:19:19 +0200 Subject: [PATCH 2/4] Add observability tests --- tests/test_observability.py | 329 ++++++++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 tests/test_observability.py diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..8c0288c --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,329 @@ +"""Unit tests for observability middleware and metric emission paths.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import ANY, AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from starlette.requests import Request +from starlette.responses import Response + +from app.api import categories as categories_api +from app.api import products as products_api +from app.api import search as search_api +from app.observability import middleware as middleware_mod +from app.schemas.category import CategoryCreate +from app.schemas.product import ProductCreate + + +def _build_request(path: str = "/x", method: str = "GET") -> Request: + scope = { + "type": "http", + "http_version": "1.1", + "scheme": "http", + "method": method, + "path": path, + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("testserver", 80), + } + return Request(scope) + + +class _FakeLoop: + def __init__(self): + self.calls: list[tuple] = [] + + def run_in_executor(self, executor, func, *args): + self.calls.append((executor, func, args)) + func(*args) + + +def _metric_pair(): + return SimpleNamespace(add=MagicMock()), SimpleNamespace(record=MagicMock()) + + +def test_record_http_metrics_success_path(monkeypatch: pytest.MonkeyPatch): + http_errors_total, _ = _metric_pair() + http_exceptions_total, _ = _metric_pair() + http_requests_total, _ = _metric_pair() + _, http_request_duration_seconds = _metric_pair() + _, http_response_payload_size_bytes = _metric_pair() + + monkeypatch.setattr(middleware_mod, "http_errors_total", http_errors_total) + monkeypatch.setattr(middleware_mod, "http_exceptions_total", http_exceptions_total) + monkeypatch.setattr(middleware_mod, "http_requests_total", http_requests_total) + monkeypatch.setattr(middleware_mod, "http_request_duration_seconds", http_request_duration_seconds) + monkeypatch.setattr(middleware_mod, "http_response_payload_size_bytes", http_response_payload_size_bytes) + + middleware_mod._record_http_metrics( + request_duration=0.123, + payload_size=345, + status_code=200, + exception_class=None, + method="GET", + route_path="/health", + ) + + http_request_duration_seconds.record.assert_called_once() + http_response_payload_size_bytes.record.assert_called_once() + http_requests_total.add.assert_called_once() + http_errors_total.add.assert_not_called() + http_exceptions_total.add.assert_not_called() + + +def test_record_http_metrics_error_and_exception(monkeypatch: pytest.MonkeyPatch): + http_errors_total, _ = _metric_pair() + http_exceptions_total, _ = _metric_pair() + http_requests_total, _ = _metric_pair() + _, http_request_duration_seconds = _metric_pair() + _, http_response_payload_size_bytes = _metric_pair() + + monkeypatch.setattr(middleware_mod, "http_errors_total", http_errors_total) + monkeypatch.setattr(middleware_mod, "http_exceptions_total", http_exceptions_total) + monkeypatch.setattr(middleware_mod, "http_requests_total", http_requests_total) + monkeypatch.setattr(middleware_mod, "http_request_duration_seconds", http_request_duration_seconds) + monkeypatch.setattr(middleware_mod, "http_response_payload_size_bytes", http_response_payload_size_bytes) + + middleware_mod._record_http_metrics( + request_duration=0.2, + payload_size=10, + status_code=404, + exception_class="RuntimeError", + method="GET", + route_path="/missing", + ) + + http_errors_total.add.assert_called_once() + _, kwargs = http_errors_total.add.call_args + assert kwargs == {} + error_attrs = http_errors_total.add.call_args.args[1] + assert error_attrs["error_type"] == "not_found" + assert error_attrs["http.status_class"] == "4xx" + + http_exceptions_total.add.assert_called_once() + exc_attrs = http_exceptions_total.add.call_args.args[1] + assert exc_attrs["exception_class"] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_middleware_dispatch_success_records_metrics(monkeypatch: pytest.MonkeyPatch): + middleware_mod._IN_FLIGHT_REQUESTS = 0 + fake_loop = _FakeLoop() + request_logger = MagicMock() + + http_requests_in_flight = SimpleNamespace(add=MagicMock()) + record_http_metrics = MagicMock() + + monkeypatch.setattr(middleware_mod, "http_requests_in_flight", http_requests_in_flight) + monkeypatch.setattr(middleware_mod, "_record_http_metrics", record_http_metrics) + monkeypatch.setattr(middleware_mod.asyncio, "get_running_loop", lambda: fake_loop) + monkeypatch.setattr(middleware_mod, "get_pool_in_use_connections", lambda: 2) + monkeypatch.setattr(middleware_mod.logging, "getLogger", lambda _name=None: request_logger) + monkeypatch.setattr(middleware_mod, "uuid4", lambda: SimpleNamespace(hex="req-123")) + monkeypatch.setattr(middleware_mod, "perf_counter", MagicMock(side_effect=[1.0, 1.1])) + + async def call_next(_request: Request): + return Response(content=b"ok", status_code=200) + + request = _build_request(path="/health") + middleware = middleware_mod.ObservabilityMetricsMiddleware(app=AsyncMock()) + response = await middleware.dispatch(request, call_next) + + assert response.status_code == 200 + assert response.headers["X-Request-ID"] == "req-123" + assert middleware_mod._get_in_flight() == 0 + http_requests_in_flight.add.assert_any_call(1, {"http.method": "GET"}) + http_requests_in_flight.add.assert_any_call(-1, {"http.method": "GET"}) + assert fake_loop.calls + record_http_metrics.assert_called_once() + request_logger.log.assert_called_once() + + +@pytest.mark.asyncio +async def test_middleware_dispatch_exception_records_exception_class(monkeypatch: pytest.MonkeyPatch): + middleware_mod._IN_FLIGHT_REQUESTS = 0 + fake_loop = _FakeLoop() + request_logger = MagicMock() + + http_requests_in_flight = SimpleNamespace(add=MagicMock()) + record_http_metrics = MagicMock() + + monkeypatch.setattr(middleware_mod, "http_requests_in_flight", http_requests_in_flight) + monkeypatch.setattr(middleware_mod, "_record_http_metrics", record_http_metrics) + monkeypatch.setattr(middleware_mod.asyncio, "get_running_loop", lambda: fake_loop) + monkeypatch.setattr(middleware_mod, "get_pool_in_use_connections", lambda: 0) + monkeypatch.setattr(middleware_mod.logging, "getLogger", lambda _name=None: request_logger) + monkeypatch.setattr(middleware_mod, "uuid4", lambda: SimpleNamespace(hex="req-err")) + monkeypatch.setattr(middleware_mod, "perf_counter", MagicMock(side_effect=[5.0, 5.05])) + + async def call_next(_request: Request): + raise RuntimeError("boom") + + middleware = middleware_mod.ObservabilityMetricsMiddleware(app=AsyncMock()) + with pytest.raises(RuntimeError, match="boom"): + await middleware.dispatch(_build_request(path="/err"), call_next) + + assert middleware_mod._get_in_flight() == 0 + record_http_metrics.assert_called_once() + args = record_http_metrics.call_args.args + assert args[2] == 500 + assert args[3] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_middleware_dispatch_slow_request_payload_body_fallback(monkeypatch: pytest.MonkeyPatch): + middleware_mod._IN_FLIGHT_REQUESTS = 0 + fake_loop = _FakeLoop() + request_logger = MagicMock() + + monkeypatch.setattr(middleware_mod, "http_requests_in_flight", SimpleNamespace(add=MagicMock())) + monkeypatch.setattr(middleware_mod, "_record_http_metrics", MagicMock()) + monkeypatch.setattr(middleware_mod.asyncio, "get_running_loop", lambda: fake_loop) + monkeypatch.setattr(middleware_mod, "get_pool_in_use_connections", lambda: 1) + monkeypatch.setattr(middleware_mod.logging, "getLogger", lambda _name=None: request_logger) + monkeypatch.setattr(middleware_mod, "uuid4", lambda: SimpleNamespace(hex="req-slow")) + monkeypatch.setattr(middleware_mod, "perf_counter", MagicMock(side_effect=[10.0, 10.25])) + + async def call_next(_request: Request): + response = Response(content=b"hello", status_code=404) + del response.headers["content-length"] + return response + + middleware = middleware_mod.ObservabilityMetricsMiddleware(app=AsyncMock()) + response = await middleware.dispatch(_build_request(path="/slow"), call_next) + + assert response.status_code == 404 + request_logger.warning.assert_called_once() + warning_event = request_logger.warning.call_args.args[0] + assert warning_event == "request_slow" + + +def test_classify_error_type_mapping(): + assert middleware_mod._classify_error_type(404) == "not_found" + assert middleware_mod._classify_error_type(409) == "conflict" + assert middleware_mod._classify_error_type(422) == "validation" + assert middleware_mod._classify_error_type(500) == "server_error" + assert middleware_mod._classify_error_type(401) == "client_error" + + +@pytest.mark.asyncio +async def test_search_endpoint_emits_search_metrics(monkeypatch: pytest.MonkeyPatch): + add_requests = MagicMock() + record_results = MagicMock() + add_zero_results = MagicMock() + + monkeypatch.setattr(search_api, "search_requests_total", SimpleNamespace(add=add_requests)) + monkeypatch.setattr(search_api, "search_result_count", SimpleNamespace(record=record_results)) + monkeypatch.setattr(search_api, "search_zero_results_total", SimpleNamespace(add=add_zero_results)) + + async def fake_search_products(**kwargs): + kwargs["timing_context"]["db_ms"] = 1.5 + return [], 0 + + monkeypatch.setattr(search_api, "search_products", fake_search_products) + + request = _build_request(path="/api/v1/products/search") + response = await search_api.search_products_endpoint( + request=request, + q="abc", + min_price=None, + max_price=None, + category_id=None, + limit=10, + offset=0, + session=AsyncMock(), + ) + + assert response.total == 0 + add_requests.assert_called_once() + record_results.assert_called_once_with(0, ANY) + add_zero_results.assert_called_once() + + +@pytest.mark.asyncio +async def test_product_create_emits_success_metric(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock() + + async def refresh_side_effect(product): + product.id = 100 + product.created_at = datetime.now(timezone.utc) + product.updated_at = datetime.now(timezone.utc) + + session.refresh = AsyncMock(side_effect=refresh_side_effect) + + add_mutation = MagicMock() + monkeypatch.setattr(products_api, "product_mutations_total", SimpleNamespace(add=add_mutation)) + + payload = ProductCreate( + title="Laptop", + description="desc", + sku="LTP-001", + price=Decimal("12.00"), + category_id=None, + ) + + result = await products_api.create_product(payload=payload, session=session) + + assert result.id == 100 + add_mutation.assert_called_once_with(1, {"operation": "create", "result": "success"}) + + +@pytest.mark.asyncio +async def test_product_create_emits_conflict_metric(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock(side_effect=IntegrityError("stmt", {}, Exception("dup"))) + session.rollback = AsyncMock() + + add_mutation = MagicMock() + monkeypatch.setattr(products_api, "product_mutations_total", SimpleNamespace(add=add_mutation)) + + payload = ProductCreate( + title="Laptop", + description="desc", + sku="LTP-001", + price=Decimal("12.00"), + category_id=None, + ) + + with pytest.raises(HTTPException) as exc: + await products_api.create_product(payload=payload, session=session) + + assert exc.value.status_code == 409 + add_mutation.assert_called_once_with(1, {"operation": "create", "result": "conflict"}) + + +@pytest.mark.asyncio +async def test_category_create_parent_not_found_emits_validation_metrics(monkeypatch: pytest.MonkeyPatch): + session = AsyncMock() + payload = CategoryCreate(name="Child", parent_id=321) + + validation_add = MagicMock() + mutation_add = MagicMock() + monkeypatch.setattr( + categories_api, + "category_validation_failures_total", + SimpleNamespace(add=validation_add), + ) + monkeypatch.setattr(categories_api, "category_mutations_total", SimpleNamespace(add=mutation_add)) + monkeypatch.setattr( + categories_api, + "validate_category_parent", + AsyncMock(side_effect=categories_api.CategoryParentNotFoundError(321)), + ) + + with pytest.raises(HTTPException) as exc: + await categories_api.create_category(payload=payload, session=session) + + assert exc.value.status_code == 404 + validation_add.assert_called_once_with(1, {"reason": "parent_not_found"}) + mutation_add.assert_called_once_with(1, {"operation": "create", "result": "parent_not_found"}) \ No newline at end of file From 411743f7124b5fd49820177ebd6c4d5af9e96e69 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Fri, 20 Mar 2026 19:23:07 +0200 Subject: [PATCH 3/4] Add concurrency access test --- tests/test_concurrency.py | 137 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/test_concurrency.py diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..ef444bf --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,137 @@ +"""Concurrent access integration tests.""" + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.session import get_session, get_session_factory +from app.main import create_app + + +@pytest.fixture +async def concurrent_client(db_session): + """Create a test client that gives each request an independent DB session.""" + app = create_app() + session_factory = get_session_factory() + + async def override_get_session(): + async with session_factory() as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_concurrent_product_create_same_sku_one_conflicts(concurrent_client: AsyncClient): + """Two concurrent creates with the same SKU should produce one conflict.""" + + async def create_product(): + return await concurrent_client.post( + "/api/v1/products", + json={ + "title": "Concurrent Product", + "description": "Concurrent create test", + "sku": "RACE-SKU-001", + "price": "99.99", + }, + ) + + response_a, response_b = await asyncio.gather(create_product(), create_product()) + + status_codes = sorted([response_a.status_code, response_b.status_code]) + assert status_codes == [201, 409] + + +@pytest.mark.asyncio +async def test_concurrent_sibling_category_create_one_conflicts(concurrent_client: AsyncClient): + """Two concurrent sibling category creates should produce one conflict.""" + + parent_response = await concurrent_client.post("/api/v1/categories", json={"name": "Parent For Race"}) + assert parent_response.status_code == 201 + parent_id = parent_response.json()["id"] + + async def create_sibling(): + return await concurrent_client.post( + "/api/v1/categories", + json={"name": "Duplicate Child", "parent_id": parent_id}, + ) + + response_a, response_b = await asyncio.gather(create_sibling(), create_sibling()) + + status_codes = sorted([response_a.status_code, response_b.status_code]) + assert status_codes == [201, 409] + + +@pytest.mark.asyncio +async def test_concurrent_product_update_to_same_sku_one_conflicts(concurrent_client: AsyncClient): + """Two concurrent updates to the same target SKU should produce one conflict.""" + + create_a = await concurrent_client.post( + "/api/v1/products", + json={ + "title": "Product A", + "description": "A", + "sku": "RACE-UPD-A", + "price": "10.00", + }, + ) + create_b = await concurrent_client.post( + "/api/v1/products", + json={ + "title": "Product B", + "description": "B", + "sku": "RACE-UPD-B", + "price": "20.00", + }, + ) + assert create_a.status_code == 201 + assert create_b.status_code == 201 + + product_a_id = create_a.json()["id"] + product_b_id = create_b.json()["id"] + + async def update_to_shared_sku(product_id: int): + return await concurrent_client.patch( + f"/api/v1/products/{product_id}", + json={"sku": "RACE-UPD-TARGET"}, + ) + + response_a, response_b = await asyncio.gather( + update_to_shared_sku(product_a_id), + update_to_shared_sku(product_b_id), + ) + + status_codes = sorted([response_a.status_code, response_b.status_code]) + assert status_codes == [200, 409] + + +@pytest.mark.asyncio +async def test_concurrent_delete_same_product_one_not_found(concurrent_client: AsyncClient): + """Two concurrent deletes may return 204/404 or 204/204 depending on timing.""" + + created = await concurrent_client.post( + "/api/v1/products", + json={ + "title": "Delete Race", + "description": "Delete me", + "sku": "RACE-DEL-001", + "price": "30.00", + }, + ) + assert created.status_code == 201 + product_id = created.json()["id"] + + async def delete_once(): + return await concurrent_client.delete(f"/api/v1/products/{product_id}") + + response_a, response_b = await asyncio.gather(delete_once(), delete_once()) + + status_codes = sorted([response_a.status_code, response_b.status_code]) + assert status_codes in ([204, 204], [204, 404]) \ No newline at end of file From dda009a4ed8c113231eef31d39882df41533c0a1 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Fri, 20 Mar 2026 19:34:23 +0200 Subject: [PATCH 4/4] chore: release version 0.1.2 --- AGENTS.md | 6 +++--- CHANGELOG.md | 9 +++++++++ app/main.py | 2 +- app/observability/metrics.py | 2 +- app/observability/setup.py | 2 +- pyproject.toml | 2 +- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cbe219..215ad3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Guide for AI coding agents working on the Commerce System Demo project. Commerce System Demo is a FastAPI-based commerce service that provides RESTful APIs for managing products, categories, and implementing search functionality. The project includes built-in observability with OpenTelemetry metrics, logging, and distributed tracing. -**Current Version**: 0.1.1 (following [Semantic Versioning](https://semver.org/)) +**Current Version**: 0.1.2 (following [Semantic Versioning](https://semver.org/)) ## Setup Commands @@ -101,7 +101,7 @@ app/ ### Docker -- **Build image**: `docker build -t commerce-system-demo:0.1.1 .` +- **Build image**: `docker build -t commerce-system-demo:0.1.2 .` - **View Dockerfile**: Includes Python dependencies, migration scripts, and app code - **Build context**: Includes `scripts/`, `app/`, and `observability/` directories @@ -184,7 +184,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/): - **MINOR**: Backward-compatible new features - **PATCH**: Backward-compatible bug fixes -Current version is **0.1.1** (initial development). Version is defined in: +Current version is **0.1.2** (initial development). Version is defined in: - `pyproject.toml` (project metadata) - `app/main.py` (FastAPI version) - `app/observability/metrics.py` (meter version) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa58029..9e916ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Advanced filtering options for product search - Product images storage optimization +## [0.1.2] - 2026-03-20 + +### Added + +- Unit tests for API handler branches covering success paths and edge cases (test_api_unit.py) +- Service-level tests for category depth limit and cycle detection logic (test_category_service.py) +- Unit tests for observability middleware and metrics emission paths (test_observability.py) +- Concurrent access integration tests for race conditions on SKU and category constraints (test_concurrency.py) + ## [0.1.1] - 2026-03-19 ### Fixed diff --git a/app/main.py b/app/main.py index 5ec00e4..05d3dd5 100644 --- a/app/main.py +++ b/app/main.py @@ -77,7 +77,7 @@ def create_app() -> FastAPI: app = FastAPI( title="Commerce System Demo", - version="0.1.1", + version="0.1.2", lifespan=lifespan, ) app.router.route_class = ObservabilityRoute diff --git a/app/observability/metrics.py b/app/observability/metrics.py index 79bee6a..1b54f8f 100644 --- a/app/observability/metrics.py +++ b/app/observability/metrics.py @@ -3,7 +3,7 @@ from opentelemetry import metrics from opentelemetry.metrics import Counter, Histogram, UpDownCounter -_meter = metrics.get_meter("commerce-system-demo-observability", version="0.1.1") +_meter = metrics.get_meter("commerce-system-demo-observability", version="0.1.2") http_request_duration_seconds: Histogram = _meter.create_histogram( name="commerce_http_request_duration_seconds", diff --git a/app/observability/setup.py b/app/observability/setup.py index 15050c5..47311e2 100644 --- a/app/observability/setup.py +++ b/app/observability/setup.py @@ -76,7 +76,7 @@ def _build_resource(settings: Settings) -> Resource: """Build OpenTelemetry resource attributes from runtime settings.""" attributes = { "service.name": settings.otel_service_name, - "service.version": "0.1.1", + "service.version": "0.1.2", "deployment.environment": settings.otel_environment, } diff --git a/pyproject.toml b/pyproject.toml index a454389..09d2c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "commerce-system-demo" -version = "0.1.1" +version = "0.1.2" description = "FastAPI commerce service demo" readme = "README.md" requires-python = ">=3.11"