Spaces:
Configuration error
Configuration error
| """ | |
| Unit tests for Analytics Reports module — tasks 12.2 through 12.13. | |
| Test coverage map: | |
| 12.2 test_run_returns_401_without_token | |
| 12.3 test_run_returns_403_without_permission | |
| 12.4 test_run_returns_500_on_permission_lookup_failure | |
| 12.5 test_custom_period_without_dates_returns_422 | |
| 12.6 test_customer_purchase_history_without_customer_id_returns_422 | |
| 12.7 test_export_invalid_format_returns_400_or_422 | |
| 12.8 test_export_exceeds_limit_returns_400 | |
| 12.9 test_export_csv_content_disposition_header | |
| 12.10 test_export_xlsx_content_type_header | |
| 12.11 test_run_returns_503_on_db_error | |
| 12.12 test_all_22_run_routes_exist | |
| 12.13 test_all_22_export_routes_exist | |
| """ | |
| import re | |
| from datetime import datetime, timezone, timedelta | |
| from unittest.mock import AsyncMock, MagicMock, patch | |
| import httpx | |
| import pytest | |
| from fastapi import HTTPException | |
| from jose import jwt | |
| from app.core.config import settings | |
| from app.dependencies.auth import TokenUser, get_current_user | |
| from app.main import app | |
| from app.postgres import get_postgres_pool | |
| from app.reports.base.permissions import require_reports_view | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _make_token( | |
| user_id: str = "test-user-id", | |
| role_id: str = "test-role", | |
| merchant_id: str = "test-merchant", | |
| ) -> str: | |
| """Generate a valid JWT signed with the app's SECRET_KEY.""" | |
| payload = { | |
| "user_id": user_id, | |
| "username": "testuser", | |
| "role_id": role_id, | |
| "merchant_id": merchant_id, | |
| "exp": datetime.now(tz=timezone.utc) + timedelta(hours=1), | |
| } | |
| return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) | |
| def _mock_token_user() -> TokenUser: | |
| return TokenUser( | |
| user_id="test-user-id", | |
| username="testuser", | |
| role_id="test-role", | |
| merchant_id="test-merchant", | |
| ) | |
| def _async_client() -> httpx.AsyncClient: | |
| return httpx.AsyncClient( | |
| transport=httpx.ASGITransport(app=app), | |
| base_url="http://test", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # 12.2 — run returns 401 with an invalid (bad) JWT token | |
| # _Requirements: 1.2, 25.1_ | |
| # --------------------------------------------------------------------------- | |
| async def test_run_returns_401_without_token(): | |
| """ | |
| Sending a request with a malformed JWT causes get_current_user to raise 401. | |
| """ | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/run", | |
| json={"period": "mtd"}, | |
| headers={"Authorization": "Bearer bad.token.here"}, | |
| ) | |
| assert response.status_code == 401 | |
| # --------------------------------------------------------------------------- | |
| # 12.3 — run returns 403 when role has no reports.view permission | |
| # _Requirements: 25.2_ | |
| # --------------------------------------------------------------------------- | |
| async def test_run_returns_403_without_permission(): | |
| """ | |
| Override get_current_user to return a valid TokenUser. | |
| Mock MongoDB so the role doc has no 'reports' permission. | |
| Expect HTTP 403. | |
| """ | |
| mock_db = MagicMock() | |
| mock_collection = MagicMock() | |
| mock_db.__getitem__ = MagicMock(return_value=mock_collection) | |
| mock_collection.find_one = AsyncMock(return_value={ | |
| "role_id": "test-role", | |
| "is_active": True, | |
| "permissions": {"dashboard": ["view"]}, | |
| }) | |
| app.dependency_overrides[get_current_user] = lambda: _mock_token_user() | |
| try: | |
| with patch("app.reports.base.permissions.get_database", return_value=mock_db): | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/run", | |
| json={"period": "mtd"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(get_current_user, None) | |
| assert response.status_code == 403 | |
| # --------------------------------------------------------------------------- | |
| # 12.4 — run returns 500 when MongoDB permission lookup raises an exception | |
| # _Requirements: 25.4_ | |
| # --------------------------------------------------------------------------- | |
| async def test_run_returns_500_on_permission_lookup_failure(): | |
| """ | |
| Override get_current_user to return a valid TokenUser. | |
| Mock MongoDB find_one to raise an unexpected Exception. | |
| Expect HTTP 500. | |
| """ | |
| mock_db = MagicMock() | |
| mock_collection = MagicMock() | |
| mock_db.__getitem__ = MagicMock(return_value=mock_collection) | |
| mock_collection.find_one = AsyncMock(side_effect=Exception("Mongo is down")) | |
| app.dependency_overrides[get_current_user] = lambda: _mock_token_user() | |
| try: | |
| with patch("app.reports.base.permissions.get_database", return_value=mock_db): | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/run", | |
| json={"period": "mtd"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(get_current_user, None) | |
| assert response.status_code == 500 | |
| # --------------------------------------------------------------------------- | |
| # 12.5 — custom period without date_from/date_to returns 422 | |
| # _Requirements: 1.12_ | |
| # --------------------------------------------------------------------------- | |
| async def test_custom_period_without_dates_returns_422(): | |
| """ | |
| Bypass auth via dependency override. | |
| Send period='custom' with no date_from/date_to. | |
| resolve_period raises HTTPException(422) before any DB call. | |
| """ | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/run", | |
| json={"period": "custom"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| assert response.status_code == 422 | |
| # --------------------------------------------------------------------------- | |
| # 12.6 — customer-purchase-history without customer_id returns 422 | |
| # _Requirements: 15.1_ | |
| # --------------------------------------------------------------------------- | |
| async def test_customer_purchase_history_without_customer_id_returns_422(): | |
| """ | |
| customer_id is a required field on CustomerPurchaseHistoryRunRequest. | |
| Pydantic validation returns 422 when it is absent. | |
| """ | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/customers/customer-purchase-history/run", | |
| json={"period": "mtd"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| assert response.status_code == 422 | |
| # --------------------------------------------------------------------------- | |
| # 12.7 — export with invalid format returns 422 (Pydantic Literal validation) | |
| # _Requirements: 1.10_ | |
| # Note: BaseExportRequest uses Literal["csv", "xlsx"], so Pydantic returns 422 | |
| # before the engine's else-branch (which would return 400) is ever reached. | |
| # --------------------------------------------------------------------------- | |
| async def test_export_invalid_format_returns_400_or_422(): | |
| """ | |
| Send export_format='pdf' (not in Literal["csv","xlsx"]). | |
| Pydantic rejects it with 422 before the engine is called. | |
| """ | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/export", | |
| json={"period": "mtd", "export_format": "pdf"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| # Pydantic Literal validation returns 422 | |
| assert response.status_code == 422 | |
| # --------------------------------------------------------------------------- | |
| # 12.8 — export exceeds 100 000 rows returns 400 | |
| # _Requirements: 24.2_ | |
| # --------------------------------------------------------------------------- | |
| async def test_export_exceeds_limit_returns_400(): | |
| """ | |
| Mock run_sales_summary to return 100_001 rows. | |
| stream_export raises HTTPException(400, "Export limit exceeded"). | |
| """ | |
| big_rows = [{"col": i} for i in range(100_001)] | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| with patch( | |
| "app.reports.sales.router.run_sales_summary", | |
| new=AsyncMock(return_value=(big_rows, 100_001)), | |
| ): | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/export", | |
| json={"period": "mtd", "export_format": "csv"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| assert response.status_code == 400 | |
| assert "Export limit exceeded" in response.text | |
| # --------------------------------------------------------------------------- | |
| # 12.9 — export CSV Content-Disposition header | |
| # _Requirements: 24.5_ | |
| # --------------------------------------------------------------------------- | |
| async def test_export_csv_content_disposition_header(): | |
| """ | |
| Mock service to return a small list of dicts. | |
| Assert Content-Disposition matches: attachment; filename="sales-summary_{YYYYMMDD}.csv" | |
| """ | |
| sample_rows = [{"period_label": "2024-01-01", "total_revenue": 1000}] | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| with patch( | |
| "app.reports.sales.router.run_sales_summary", | |
| new=AsyncMock(return_value=(sample_rows, 1)), | |
| ): | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/export", | |
| json={"period": "mtd", "export_format": "csv"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| assert response.status_code == 200 | |
| content_disposition = response.headers.get("content-disposition", "") | |
| # Matches: attachment; filename="sales-summary_YYYYMMDD.csv" | |
| assert re.match( | |
| r'attachment; filename="sales-summary_\d{8}\.csv"', | |
| content_disposition, | |
| ), f"Unexpected Content-Disposition: {content_disposition}" | |
| # --------------------------------------------------------------------------- | |
| # 12.10 — export XLSX Content-Type header | |
| # _Requirements: 24.6_ | |
| # --------------------------------------------------------------------------- | |
| async def test_export_xlsx_content_type_header(): | |
| """ | |
| Mock service to return a small list of dicts. | |
| Assert Content-Type is the OOXML spreadsheet MIME type. | |
| """ | |
| sample_rows = [{"period_label": "2024-01-01", "total_revenue": 1000}] | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| with patch( | |
| "app.reports.sales.router.run_sales_summary", | |
| new=AsyncMock(return_value=(sample_rows, 1)), | |
| ): | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/export", | |
| json={"period": "mtd", "export_format": "xlsx"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| assert response.status_code == 200 | |
| content_type = response.headers.get("content-type", "") | |
| assert "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" in content_type | |
| # --------------------------------------------------------------------------- | |
| # 12.11 — run returns 503 on DB error | |
| # _Requirements: 1.13_ | |
| # --------------------------------------------------------------------------- | |
| async def test_run_returns_503_on_db_error(): | |
| """ | |
| Mock run_sales_summary to raise HTTPException(503). | |
| The router propagates it; assert HTTP 503. | |
| """ | |
| mock_pool = MagicMock() | |
| app.dependency_overrides[require_reports_view] = lambda: _mock_token_user() | |
| app.dependency_overrides[get_postgres_pool] = lambda: mock_pool | |
| try: | |
| with patch( | |
| "app.reports.sales.router.run_sales_summary", | |
| new=AsyncMock(side_effect=HTTPException(status_code=503, detail="Database error")), | |
| ): | |
| async with _async_client() as client: | |
| response = await client.post( | |
| "/reports/reports/sales/sales-summary/run", | |
| json={"period": "mtd"}, | |
| ) | |
| finally: | |
| app.dependency_overrides.pop(require_reports_view, None) | |
| app.dependency_overrides.pop(get_postgres_pool, None) | |
| assert response.status_code == 503 | |
| # --------------------------------------------------------------------------- | |
| # 12.12 — all 22 run routes are registered on the app | |
| # _Requirements: 1.3_ | |
| # --------------------------------------------------------------------------- | |
| EXPECTED_RUN_ROUTES = [ | |
| "/reports/reports/sales/sales-summary/run", | |
| "/reports/reports/sales/sales-by-product/run", | |
| "/reports/reports/sales/sales-by-customer/run", | |
| "/reports/reports/sales/trade-vs-retail/run", | |
| "/reports/reports/purchases/purchase-orders/run", | |
| "/reports/reports/purchases/goods-receipt/run", | |
| "/reports/reports/purchases/purchase-returns/run", | |
| "/reports/reports/purchases/outstanding-pos/run", | |
| "/reports/reports/inventory/stock-levels/run", | |
| "/reports/reports/inventory/low-stock/run", | |
| "/reports/reports/inventory/stock-movement/run", | |
| "/reports/reports/inventory/dead-stock/run", | |
| "/reports/reports/customers/new-vs-returning/run", | |
| "/reports/reports/customers/customer-purchase-history/run", | |
| "/reports/reports/customers/customer-aging/run", | |
| "/reports/reports/employees/employee-list/run", | |
| "/reports/reports/employees/service-professional-performance/run", | |
| "/reports/reports/spa-bookings/bookings-by-date/run", | |
| "/reports/reports/spa-bookings/cancellations-no-shows/run", | |
| "/reports/reports/spa-bookings/revenue-per-service/run", | |
| "/reports/reports/financial/accounts-payable-aging/run", | |
| "/reports/reports/financial/accounts-receivable-aging/run", | |
| ] | |
| EXPECTED_EXPORT_ROUTES = [p.replace("/run", "/export") for p in EXPECTED_RUN_ROUTES] | |
| def _registered_post_paths() -> set[str]: | |
| """Return the set of all POST route paths registered on the app.""" | |
| paths = set() | |
| for route in app.routes: | |
| if hasattr(route, "path") and hasattr(route, "methods"): | |
| if route.methods and "POST" in route.methods: | |
| paths.add(route.path) | |
| return paths | |
| def test_all_22_run_routes_exist(): | |
| """ | |
| Assert all 22 POST /reports/{category}/{slug}/run paths are registered. | |
| _Requirements: 1.3_ | |
| """ | |
| registered = _registered_post_paths() | |
| missing = [r for r in EXPECTED_RUN_ROUTES if r not in registered] | |
| assert not missing, f"Missing run routes: {missing}" | |
| # --------------------------------------------------------------------------- | |
| # 12.13 — all 22 export routes are registered on the app | |
| # _Requirements: 1.3_ | |
| # --------------------------------------------------------------------------- | |
| def test_all_22_export_routes_exist(): | |
| """ | |
| Assert all 22 POST /reports/{category}/{slug}/export paths are registered. | |
| _Requirements: 1.3_ | |
| """ | |
| registered = _registered_post_paths() | |
| missing = [r for r in EXPECTED_EXPORT_ROUTES if r not in registered] | |
| assert not missing, f"Missing export routes: {missing}" | |