diff --git a/analytics_service/Dockerfile b/analytics_service/Dockerfile new file mode 100644 index 0000000..f2d094a --- /dev/null +++ b/analytics_service/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.11-slim AS base + +WORKDIR /app/analytics_service + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +COPY docker-entrypoint.sh . +RUN chmod +x docker-entrypoint.sh + +ENV PYTHONPATH=/app + +ENTRYPOINT ["./docker-entrypoint.sh"] + +FROM base AS dev + +COPY requirements-dev.txt . +RUN pip install --no-cache-dir -r requirements-dev.txt + +ENV ENV=DEV + +# TEST +FROM dev AS test + +# COPY pytest.ini . +COPY tests ./tests/ + +RUN pip install --no-cache-dir pytest-cov +ENV ENV=TEST + +# ---------- production ---------- +FROM base AS prod + +ENV ENV=PROD diff --git a/analytics_service/app/__init__.py b/analytics_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/api/__init__.py b/analytics_service/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/api/v1/__init__.py b/analytics_service/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/api/v1/health.py b/analytics_service/app/api/v1/health.py new file mode 100644 index 0000000..cf81a9b --- /dev/null +++ b/analytics_service/app/api/v1/health.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + + +router = APIRouter( + prefix="/health", + tags=["health"], +) + +@router.get("/") +def get_health(): + return {"status": "OK"} diff --git a/analytics_service/app/api/v1/stats.py b/analytics_service/app/api/v1/stats.py new file mode 100644 index 0000000..d5cc3f1 --- /dev/null +++ b/analytics_service/app/api/v1/stats.py @@ -0,0 +1,37 @@ +from datetime import date +from fastapi import APIRouter +from analytics_service.app.service import analytics_service +from analytics_service.app.schemas.analytics import ( + SummaryResponse, RevenueResponse, OrderStatsResponse, + DistributionResponse, DashboardResponse, +) + +router = APIRouter( + prefix="/analytics", + tags=["stats"], +) + + +@router.get("/summary", response_model=SummaryResponse) +def get_summary(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_summary(start_date, end_date) + + +@router.get("/orders", response_model=OrderStatsResponse) +def get_order_stats(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_order_stats(start_date, end_date) + + +@router.get("/revenue", response_model=RevenueResponse) +def get_revenue_stats(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_revenue_stats(start_date, end_date) + + +@router.get("/distribution", response_model=DistributionResponse) +def get_distribution(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_distribution(start_date, end_date) + + +@router.get("/dashboard", response_model=DashboardResponse) +def get_dashboard(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_dashboard(start_date, end_date) diff --git a/analytics_service/app/clients/__init__.py b/analytics_service/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/clients/orders_client.py b/analytics_service/app/clients/orders_client.py new file mode 100644 index 0000000..4b882b6 --- /dev/null +++ b/analytics_service/app/clients/orders_client.py @@ -0,0 +1,35 @@ +from cachetools import TTLCache +from fastapi import HTTPException +import requests as r + +cache = TTLCache(maxsize=1, ttl=300) + +ORDERS_URL = "http://orders_service/orders" +PAGE_SIZE = 100 + +def get_orders() -> dict: + if "orders" in cache: + return cache["orders"] + + print("Fetching orders from Orders Service...") + + all_items = [] + offset = 0 + + try: + while True: + response = r.get(ORDERS_URL, params={"limit": PAGE_SIZE, "offset": offset}) + response.raise_for_status() + data = response.json() + + all_items.extend(data["items"]) + offset += len(data["items"]) + + if offset >= data["total"] or not data["items"]: + break + except r.exceptions.RequestException: + raise HTTPException(status_code=503, detail="Orders service unavailable") + + result = {"items": all_items, "total": len(all_items)} + cache["orders"] = result + return result diff --git a/analytics_service/app/main.py b/analytics_service/app/main.py new file mode 100644 index 0000000..203e640 --- /dev/null +++ b/analytics_service/app/main.py @@ -0,0 +1,20 @@ +from fastapi import FastAPI +from .api.v1 import health, stats + + +def create_app(): + fastapi_app = FastAPI( + title="Analytics Service", + description="Microservice 2", + version="1.0.0", + ) + @fastapi_app.get("/") + async def root(): + return {"message": "analytics_service"} + + fastapi_app.include_router(stats.router) + fastapi_app.include_router(health.router) + + return fastapi_app + +app = create_app() diff --git a/analytics_service/app/schemas/__init__.py b/analytics_service/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/schemas/analytics.py b/analytics_service/app/schemas/analytics.py new file mode 100644 index 0000000..6dc96f2 --- /dev/null +++ b/analytics_service/app/schemas/analytics.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, field_serializer + + +class SummaryResponse(BaseModel): + total_orders: int + total_revenue: float + avg_order_value: float + + @field_serializer('total_revenue', 'avg_order_value') + def round_currency(self, v: float) -> float: + return round(v, 2) + + +class RevenueResponse(BaseModel): + total_revenue: float + per_day: dict[str, float] + per_week: dict[str, float] + per_month: dict[str, float] + growth_rate: float | None + + +class OrderStatsResponse(BaseModel): + total_orders: int + per_day: dict[str, int] + per_week: dict[str, int] + per_month: dict[str, int] + growth_rate: float | None + + +class DistributionResponse(BaseModel): + largest: float + smallest: float + median: float + avg_order_value: float + + +class DashboardResponse(BaseModel): + summary: SummaryResponse + revenue: RevenueResponse + orders: OrderStatsResponse + distribution: DistributionResponse + \ No newline at end of file diff --git a/analytics_service/app/service/__init__.py b/analytics_service/app/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/service/analytics_service.py b/analytics_service/app/service/analytics_service.py new file mode 100644 index 0000000..1676930 --- /dev/null +++ b/analytics_service/app/service/analytics_service.py @@ -0,0 +1,105 @@ +from datetime import date, datetime +from analytics_service.app.clients import orders_client +from analytics_service.app.service import metrics_util +from analytics_service.app.schemas.analytics import ( + SummaryResponse, RevenueResponse, OrderStatsResponse, + DistributionResponse, DashboardResponse, +) + + +def _filter_orders(orders: dict, start_date: date | None, end_date: date | None) -> dict: + if not start_date and not end_date: + return orders + filtered = [ + o for o in orders['items'] + if (start_date is None or datetime.fromisoformat(o['created_at']).date() >= start_date) + and (end_date is None or datetime.fromisoformat(o['created_at']).date() <= end_date) + ] + return {'items': filtered, 'total': len(filtered)} + + +def get_summary(start_date: date | None = None, end_date: date | None = None) -> SummaryResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + total = metrics_util.calc_total_order_count(orders) + revenue = metrics_util.calc_total_revenue(orders) + return SummaryResponse( + total_orders=total, + total_revenue=revenue, + avg_order_value=revenue / total if total > 0 else 0.0, + ) + + +def get_revenue_stats(start_date: date | None = None, end_date: date | None = None) -> RevenueResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + per_month = metrics_util.calc_revenue_per_month(orders) + return RevenueResponse( + total_revenue=metrics_util.calc_total_revenue(orders), + per_day=metrics_util.calc_revenue_per_day(orders), + per_week=metrics_util.calc_revenue_per_week(orders), + per_month=per_month, + growth_rate=metrics_util.calc_growth_rate(per_month), + ) + + +def get_order_stats(start_date: date | None = None, end_date: date | None = None) -> OrderStatsResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + per_month = metrics_util.calc_orders_per_month(orders) + return OrderStatsResponse( + total_orders=metrics_util.calc_total_order_count(orders), + per_day=metrics_util.calc_orders_per_day(orders), + per_week=metrics_util.calc_orders_per_week(orders), + per_month=per_month, + growth_rate=metrics_util.calc_growth_rate(per_month), + ) + + +def get_distribution(start_date: date | None = None, end_date: date | None = None) -> DistributionResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + largest = metrics_util.largest_order_by_revenue(orders) + smallest = metrics_util.smallest_order_by_revenue(orders) + return DistributionResponse( + largest=largest['total'] if largest else 0.0, + smallest=smallest['total'] if smallest else 0.0, + median=metrics_util.median_order_total(orders), + avg_order_value=metrics_util.avg_order_value(orders), + ) + + +def get_dashboard(start_date: date | None = None, end_date: date | None = None) -> DashboardResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + + total_orders = metrics_util.calc_total_order_count(orders) + total_revenue = metrics_util.calc_total_revenue(orders) + aov = metrics_util.avg_order_value(orders) + per_revenue_month = metrics_util.calc_revenue_per_month(orders) + per_orders_month = metrics_util.calc_orders_per_month(orders) + largest = metrics_util.largest_order_by_revenue(orders) + smallest = metrics_util.smallest_order_by_revenue(orders) + + return DashboardResponse( + summary=SummaryResponse( + total_orders=total_orders, + total_revenue=total_revenue, + avg_order_value=aov, + ), + revenue=RevenueResponse( + total_revenue=total_revenue, + per_day=metrics_util.calc_revenue_per_day(orders), + per_week=metrics_util.calc_revenue_per_week(orders), + per_month=per_revenue_month, + growth_rate=metrics_util.calc_growth_rate(per_revenue_month), + ), + orders=OrderStatsResponse( + total_orders=total_orders, + per_day=metrics_util.calc_orders_per_day(orders), + per_week=metrics_util.calc_orders_per_week(orders), + per_month=per_orders_month, + growth_rate=metrics_util.calc_growth_rate(per_orders_month), + ), + distribution=DistributionResponse( + largest=largest['total'] if largest else 0.0, + smallest=smallest['total'] if smallest else 0.0, + median=metrics_util.median_order_total(orders), + avg_order_value=aov, + ), + ) diff --git a/analytics_service/app/service/metrics_util.py b/analytics_service/app/service/metrics_util.py new file mode 100644 index 0000000..424debb --- /dev/null +++ b/analytics_service/app/service/metrics_util.py @@ -0,0 +1,119 @@ +import statistics +from datetime import datetime +from collections import defaultdict + + +### ORDER COUNTS ### + +def calc_total_order_count(orders: dict) -> int: + return len(orders['items']) + +def calc_orders_per_day(orders: dict) -> dict[str, int]: + day_totals = defaultdict(int) + for order in orders['items']: + date = datetime.fromisoformat(order['created_at']).date().isoformat() + day_totals[date] += 1 + return dict(sorted(day_totals.items())) + +def calc_orders_per_week(orders: dict) -> dict[str, int]: + week_totals = defaultdict(int) + for order in orders['items']: + dt = datetime.fromisoformat(order['created_at']) + iso = dt.isocalendar() + week_key = f"{iso.year}-W{iso.week:02d}" + week_totals[week_key] += 1 + return dict(sorted(week_totals.items())) + +def calc_orders_per_month(orders: dict) -> dict[str, int]: + month_totals = defaultdict(int) + for order in orders['items']: + month = datetime.fromisoformat(order['created_at']).strftime("%Y-%m") + month_totals[month] += 1 + return dict(sorted(month_totals.items())) + +def calc_orders_per_product(orders: dict) -> dict[str, int]: + product_totals = defaultdict(int) + for order in orders['items']: + product_totals[order['product']] += 1 + return dict(sorted(product_totals.items(), key=lambda x: x[1], reverse=True)) + + +### REVENUE ### + +def calc_total_revenue(orders: dict) -> float: + return sum(order['total'] for order in orders['items']) + +def calc_revenue_per_day(orders: dict) -> dict[str, float]: + day_totals = defaultdict(float) + for order in orders['items']: + date = datetime.fromisoformat(order['created_at']).date().isoformat() + day_totals[date] += order['total'] + return dict(sorted(day_totals.items())) + +def calc_revenue_per_week(orders: dict) -> dict[str, float]: + week_totals = defaultdict(float) + for order in orders['items']: + dt = datetime.fromisoformat(order['created_at']) + iso = dt.isocalendar() + week_key = f"{iso.year}-W{iso.week:02d}" + week_totals[week_key] += order['total'] + return dict(sorted(week_totals.items())) + +def calc_revenue_per_month(orders: dict) -> dict[str, float]: + month_totals = defaultdict(float) + for order in orders['items']: + month = datetime.fromisoformat(order['created_at']).strftime("%Y-%m") + month_totals[month] += order['total'] + return dict(sorted(month_totals.items())) + +def calc_revenue_per_product(orders: dict) -> dict[str, float]: + product_totals = defaultdict(float) + for order in orders['items']: + product_totals[order['product']] += order['total'] + return dict(sorted(product_totals.items(), key=lambda x: x[1], reverse=True)) + + +### GROWTH ### + +def calc_growth_rate(period_totals: dict[str, float | int]) -> float | None: + if len(period_totals) < 2: + return None + values = list(period_totals.values()) + previous, current = values[-2], values[-1] + if previous == 0: + return None + return round(((current - previous) / previous) * 100, 2) + + +### DISTRIBUTION ### + +def avg_order_value(orders: dict) -> float: + total = calc_total_order_count(orders) + if total == 0: + return 0.0 + return calc_total_revenue(orders=orders) / total + +def largest_order_by_revenue(orders: dict) -> dict | None: + if not orders['items']: + return None + return max(orders['items'], key=lambda order: order['total']) + +def largest_order_by_size(orders: dict) -> dict | None: + if not orders['items']: + return None + return max(orders['items'], key=lambda order: order['count']) + +def smallest_order_by_revenue(orders: dict) -> dict | None: + if not orders['items']: + return None + return min(orders['items'], key=lambda order: order['total']) + +def median_order_total(orders: dict) -> float: + totals = [float(order['total']) for order in orders.get('items', []) if 'total' in order] + if not totals: + return 0.0 + return statistics.median(totals) + +# calc_order_value_percentiles — p50/p75/p90 via statistics.quantiles + + diff --git a/analytics_service/docker-entrypoint.sh b/analytics_service/docker-entrypoint.sh new file mode 100644 index 0000000..20ba604 --- /dev/null +++ b/analytics_service/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +echo "ENV = $ENV" + +if [ "$ENV" = "DEV" ]; then + echo "Running dev" + exec uvicorn analytics_service.app.main:app \ + --host 0.0.0.0 \ + --port 8001 \ + --reload +elif [ "$ENV" = "TEST" ]; then + echo "Running tests" + exec pytest /app/analytics_service --rootdir=/app + # exec pytest -v --cov=orders_service +elif [ "$ENV" = "PROD" ]; then + echo "PROD ENVIRONMENT" + exec uvicorn analytics_service.app.main:app \ + --host 0.0.0.0 \ + --port 8001 +else + echo "No env provided - stopped" + exit 1 +fi \ No newline at end of file diff --git a/analytics_service/requirements-dev.txt b/analytics_service/requirements-dev.txt new file mode 100644 index 0000000..c5b87b7 --- /dev/null +++ b/analytics_service/requirements-dev.txt @@ -0,0 +1,7 @@ +cachetools==7.0.5 +faker==37.1.0 +fastapi==0.128.0 +httpx==0.28.1 +pytest==9.0.2 +requests==2.32.5 +uvicorn==0.40.0 diff --git a/analytics_service/requirements.txt b/analytics_service/requirements.txt new file mode 100644 index 0000000..06343eb --- /dev/null +++ b/analytics_service/requirements.txt @@ -0,0 +1,4 @@ +cachetools==7.0.5 +fastapi==0.128.0 +requests==2.32.5 +uvicorn==0.40.0 diff --git a/analytics_service/tests/__init__.py b/analytics_service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/conftest.py b/analytics_service/tests/conftest.py new file mode 100644 index 0000000..38378a6 --- /dev/null +++ b/analytics_service/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +from analytics_service.tests.factories.order_dict_factory import make_order + + +@pytest.fixture +def empty_orders(): + return {"items": [], "total": 0} + + +@pytest.fixture +def sample_orders(): + return { + "items": [ + make_order(product="Widget", count=2, price=10.0, total=20.0, created_at="2025-01-15T10:00:00"), + make_order(product="Widget", count=1, price=5.0, total=5.0, created_at="2025-01-15T14:00:00"), + make_order(product="Gadget", count=3, price=15.0, total=45.0, created_at="2025-02-10T09:00:00"), + make_order(product="Gadget", count=1, price=100.0, total=100.0, created_at="2025-03-01T08:00:00"), + ], + "total": 4, + } diff --git a/analytics_service/tests/factories/__init__.py b/analytics_service/tests/factories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/factories/order_dict_factory.py b/analytics_service/tests/factories/order_dict_factory.py new file mode 100644 index 0000000..cfa4478 --- /dev/null +++ b/analytics_service/tests/factories/order_dict_factory.py @@ -0,0 +1,22 @@ +from faker import Faker + +fake = Faker() + +def make_order(**overrides) -> dict: + count = overrides.pop("count", fake.random_int(min=1, max=10)) + price = overrides.pop("price", round(fake.pyfloat(min_value=1, max_value=200, right_digits=2), 2)) + total = overrides.pop("total", round(count * price, 2)) + return { + "id": overrides.pop("id", fake.random_int(min=1, max=9999)), + "name": overrides.pop("name", fake.name()), + "product": overrides.pop("product", fake.word()), + "count": count, + "price": price, + "total": total, + "created_at": overrides.pop("created_at", fake.date_time_this_year().isoformat()), + **overrides, + } + +def make_orders(n: int = 3, **overrides) -> dict: + items = [make_order(**overrides) for _ in range(n)] + return {"items": items, "total": len(items)} diff --git a/analytics_service/tests/integration_tests/__init__.py b/analytics_service/tests/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/integration_tests/test_analytics_endpoints.py b/analytics_service/tests/integration_tests/test_analytics_endpoints.py new file mode 100644 index 0000000..379e117 --- /dev/null +++ b/analytics_service/tests/integration_tests/test_analytics_endpoints.py @@ -0,0 +1,252 @@ +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from analytics_service.app.main import app +from analytics_service.app.clients import orders_client + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def mock_orders(monkeypatch, sample_orders): + monkeypatch.setattr(orders_client, "get_orders", lambda: sample_orders) + + +@pytest.fixture +def mock_empty(monkeypatch, empty_orders): + monkeypatch.setattr(orders_client, "get_orders", lambda: empty_orders) + + +# --------------------------------------------------------------------------- +# /analytics/summary +# --------------------------------------------------------------------------- + +def test_summary_200(client, mock_orders): + assert client.get("/analytics/summary").status_code == 200 + + +def test_summary_totals(client, mock_orders): + # sample_orders: 4 orders, totals 20+5+45+100 = 170, avg = 42.5 + data = client.get("/analytics/summary").json() + assert data["total_orders"] == 4 + assert data["total_revenue"] == 170.0 + assert data["avg_order_value"] == 42.5 + + +def test_summary_empty_orders(client, mock_empty): + data = client.get("/analytics/summary").json() + assert data["total_orders"] == 0 + assert data["total_revenue"] == 0.0 + assert data["avg_order_value"] == 0.0 + + +def test_summary_start_date_filter(client, mock_orders): + # Feb + Mar only: 2 orders, 45 + 100 = 145 + data = client.get("/analytics/summary?start_date=2025-02-01").json() + assert data["total_orders"] == 2 + assert data["total_revenue"] == 145.0 + + +def test_summary_end_date_filter(client, mock_orders): + # Jan only: 2 orders, 20 + 5 = 25 + data = client.get("/analytics/summary?end_date=2025-01-31").json() + assert data["total_orders"] == 2 + assert data["total_revenue"] == 25.0 + + +def test_summary_date_range_filter(client, mock_orders): + # Jan + Feb: 3 orders, 20 + 5 + 45 = 70 + data = client.get("/analytics/summary?start_date=2025-01-01&end_date=2025-02-28").json() + assert data["total_orders"] == 3 + assert data["total_revenue"] == 70.0 + + +def test_summary_date_range_no_match(client, mock_orders): + data = client.get("/analytics/summary?start_date=2024-01-01&end_date=2024-12-31").json() + assert data["total_orders"] == 0 + assert data["total_revenue"] == 0.0 + + +def test_summary_invalid_date_422(client, mock_orders): + assert client.get("/analytics/summary?start_date=not-a-date").status_code == 422 + + +# --------------------------------------------------------------------------- +# /analytics/orders +# --------------------------------------------------------------------------- + +def test_orders_200(client, mock_orders): + assert client.get("/analytics/orders").status_code == 200 + + +def test_orders_response_shape(client, mock_orders): + data = client.get("/analytics/orders").json() + assert {"total_orders", "per_day", "per_week", "per_month", "growth_rate"} <= data.keys() + + +def test_orders_total(client, mock_orders): + assert client.get("/analytics/orders").json()["total_orders"] == 4 + + +def test_orders_per_month(client, mock_orders): + data = client.get("/analytics/orders").json() + assert data["per_month"] == {"2025-01": 2, "2025-02": 1, "2025-03": 1} + + +def test_orders_growth_rate_multiple_months(client, mock_orders): + # last two months: Feb=1, Mar=1 → 0% growth + assert client.get("/analytics/orders").json()["growth_rate"] == 0.0 + + +def test_orders_growth_rate_null_single_month(client, monkeypatch): + monkeypatch.setattr(orders_client, "get_orders", lambda: { + "items": [{"id": 1, "name": "A", "product": "X", "count": 1, + "price": 10.0, "total": 10.0, "created_at": "2025-01-15T00:00:00"}], + "total": 1, + }) + assert client.get("/analytics/orders").json()["growth_rate"] is None + + +def test_orders_date_filter(client, mock_orders): + # Feb onward: 2 orders + data = client.get("/analytics/orders?start_date=2025-02-01").json() + assert data["total_orders"] == 2 + assert "2025-01" not in data["per_month"] + + +def test_orders_invalid_date_422(client, mock_orders): + assert client.get("/analytics/orders?end_date=baddate").status_code == 422 + + +# --------------------------------------------------------------------------- +# /analytics/revenue +# --------------------------------------------------------------------------- + +def test_revenue_200(client, mock_orders): + assert client.get("/analytics/revenue").status_code == 200 + + +def test_revenue_response_shape(client, mock_orders): + data = client.get("/analytics/revenue").json() + assert {"total_revenue", "per_day", "per_week", "per_month", "growth_rate"} <= data.keys() + + +def test_revenue_total(client, mock_orders): + assert client.get("/analytics/revenue").json()["total_revenue"] == 170.0 + + +def test_revenue_per_month(client, mock_orders): + data = client.get("/analytics/revenue").json() + assert data["per_month"]["2025-01"] == 25.0 + assert data["per_month"]["2025-02"] == 45.0 + assert data["per_month"]["2025-03"] == 100.0 + + +def test_revenue_growth_rate(client, mock_orders): + # last two months: Feb=45, Mar=100 → ((100-45)/45)*100 = 122.22 + assert client.get("/analytics/revenue").json()["growth_rate"] == 122.22 + + +def test_revenue_date_filter(client, mock_orders): + data = client.get("/analytics/revenue?start_date=2025-01-01&end_date=2025-01-31").json() + assert data["total_revenue"] == 25.0 + assert list(data["per_month"].keys()) == ["2025-01"] + + +def test_revenue_invalid_date_422(client, mock_orders): + assert client.get("/analytics/revenue?start_date=2025-13-01").status_code == 422 + + +# --------------------------------------------------------------------------- +# /analytics/distribution +# --------------------------------------------------------------------------- + +def test_distribution_200(client, mock_orders): + assert client.get("/analytics/distribution").status_code == 200 + + +def test_distribution_values(client, mock_orders): + # totals: [5, 20, 45, 100] — sorted median = (20+45)/2 = 32.5 + data = client.get("/analytics/distribution").json() + assert data["largest"] == 100.0 + assert data["smallest"] == 5.0 + assert data["median"] == 32.5 + assert data["avg_order_value"] == 42.5 + + +def test_distribution_empty_orders(client, mock_empty): + data = client.get("/analytics/distribution").json() + assert data["largest"] == 0.0 + assert data["smallest"] == 0.0 + assert data["median"] == 0.0 + assert data["avg_order_value"] == 0.0 + + +def test_distribution_date_filter(client, mock_orders): + # Jan only: totals [5, 20] + data = client.get("/analytics/distribution?start_date=2025-01-01&end_date=2025-01-31").json() + assert data["largest"] == 20.0 + assert data["smallest"] == 5.0 + assert data["median"] == 12.5 + assert data["avg_order_value"] == 12.5 + + +# --------------------------------------------------------------------------- +# /analytics/dashboard +# --------------------------------------------------------------------------- + +def test_dashboard_200(client, mock_orders): + assert client.get("/analytics/dashboard").status_code == 200 + + +def test_dashboard_top_level_keys(client, mock_orders): + data = client.get("/analytics/dashboard").json() + assert set(data.keys()) == {"summary", "revenue", "orders", "distribution"} + + +def test_dashboard_summary_consistent(client, mock_orders): + # dashboard summary must match the dedicated summary endpoint + dashboard = client.get("/analytics/dashboard").json() + summary = client.get("/analytics/summary").json() + assert dashboard["summary"] == summary + + +def test_dashboard_revenue_consistent(client, mock_orders): + dashboard = client.get("/analytics/dashboard").json() + revenue = client.get("/analytics/revenue").json() + assert dashboard["revenue"] == revenue + + +def test_dashboard_date_filter(client, mock_orders): + data = client.get("/analytics/dashboard?start_date=2025-01-01&end_date=2025-01-31").json() + assert data["summary"]["total_orders"] == 2 + assert data["summary"]["total_revenue"] == 25.0 + assert data["distribution"]["largest"] == 20.0 + + +def test_dashboard_invalid_date_422(client, mock_orders): + assert client.get("/analytics/dashboard?start_date=2025-99-99").status_code == 422 + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + +def test_orders_service_unavailable_503(client, monkeypatch): + def raise_503(): + raise HTTPException(status_code=503, detail="Orders service unavailable") + monkeypatch.setattr(orders_client, "get_orders", raise_503) + assert client.get("/analytics/summary").status_code == 503 + + +def test_orders_service_unavailable_all_endpoints(client, monkeypatch): + def raise_503(): + raise HTTPException(status_code=503, detail="Orders service unavailable") + monkeypatch.setattr(orders_client, "get_orders", raise_503) + for endpoint in ["/analytics/summary", "/analytics/orders", + "/analytics/revenue", "/analytics/distribution", "/analytics/dashboard"]: + assert client.get(endpoint).status_code == 503, f"Expected 503 for {endpoint}" diff --git a/analytics_service/tests/unit_tests/__init__.py b/analytics_service/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/unit_tests/schemas/__init__.py b/analytics_service/tests/unit_tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py b/analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py new file mode 100644 index 0000000..6fd6c33 --- /dev/null +++ b/analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py @@ -0,0 +1,196 @@ +import pytest +from pydantic import ValidationError +from analytics_service.app.schemas import analytics + + +### SummaryResponse ### + +def test_valid_summary(): + summary = analytics.SummaryResponse( + total_orders=5, + total_revenue=12.7, + avg_order_value=12.7 / 5, + ) + assert summary.total_orders == 5 + assert summary.total_revenue == 12.7 + assert summary.avg_order_value == 2.54 + +def test_zero_order_summary(): + summary = analytics.SummaryResponse( + total_orders=0, + total_revenue=0, + avg_order_value=0.0, + ) + assert summary.total_orders == 0 + assert summary.total_revenue == 0.0 + assert summary.avg_order_value == 0.0 + +def test_summary_serializer(): + summary = analytics.SummaryResponse( + total_orders=2, + total_revenue=5.998, + avg_order_value=5.998 / 2, + ) + dumped = summary.model_dump() + assert dumped["total_revenue"] == 6.00 + assert dumped["avg_order_value"] == 3.00 + +def test_summary_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.SummaryResponse(total_orders=1, total_revenue=10.0) + +def test_summary_wrong_type_raises(): + with pytest.raises(ValidationError): + analytics.SummaryResponse( + total_orders="not-a-number", + total_revenue=10.0, + avg_order_value=10.0, + ) + + +### RevenueResponse ### + +def test_valid_revenue_response(): + revenue = analytics.RevenueResponse( + total_revenue=150.0, + per_day={"2025-01-15": 50.0, "2025-01-16": 100.0}, + per_week={"2025-W03": 150.0}, + per_month={"2025-01": 150.0}, + growth_rate=25.0, + ) + assert revenue.total_revenue == 150.0 + assert revenue.per_day["2025-01-15"] == 50.0 + +def test_revenue_response_empty(): + revenue = analytics.RevenueResponse( + total_revenue=0.0, + per_day={}, + per_week={}, + per_month={}, + growth_rate=None, + ) + assert revenue.growth_rate is None + +def test_revenue_response_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.RevenueResponse( + total_revenue=100.0, + per_day={}, + per_week={}, + ) + + +### OrderStatsResponse ### + +def test_valid_order_stats_response(): + stats = analytics.OrderStatsResponse( + total_orders=10, + per_day={"2025-01-15": 4, "2025-01-16": 6}, + per_week={"2025-W03": 10}, + per_month={"2025-01": 10}, + growth_rate=-10.0, + ) + assert stats.total_orders == 10 + assert stats.per_month["2025-01"] == 10 + +def test_order_stats_null_growth_rate(): + stats = analytics.OrderStatsResponse( + total_orders=0, + per_day={}, + per_week={}, + per_month={}, + growth_rate=None, + ) + assert stats.growth_rate is None + +def test_order_stats_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.OrderStatsResponse( + total_orders=5, + per_day={}, + ) + + +### DistributionResponse ### + +def test_valid_distribution_response(): + dist = analytics.DistributionResponse( + largest=100.0, + smallest=5.0, + median=20.0, + avg_order_value=42.5, + ) + assert dist.largest == 100.0 + assert dist.smallest == 5.0 + assert dist.median == 20.0 + +def test_distribution_zeros(): + dist = analytics.DistributionResponse( + largest=0.0, + smallest=0.0, + median=0.0, + avg_order_value=0.0, + ) + assert dist.largest == 0.0 + +def test_distribution_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.DistributionResponse(largest=100.0, smallest=5.0, median=20.0) + + +### DashboardResponse ### + +def _make_summary(**overrides): + defaults = {"total_orders": 4, "total_revenue": 170.0, "avg_order_value": 42.5} + return analytics.SummaryResponse(**{**defaults, **overrides}) + +def _make_revenue(**overrides): + defaults = { + "total_revenue": 170.0, + "per_day": {}, + "per_week": {}, + "per_month": {"2025-01": 25.0}, + "growth_rate": None, + } + return analytics.RevenueResponse(**{**defaults, **overrides}) + +def _make_order_stats(**overrides): + defaults = { + "total_orders": 4, + "per_day": {}, + "per_week": {}, + "per_month": {"2025-01": 2}, + "growth_rate": None, + } + return analytics.OrderStatsResponse(**{**defaults, **overrides}) + +def _make_distribution(**overrides): + defaults = {"largest": 100.0, "smallest": 5.0, "median": 32.5, "avg_order_value": 42.5} + return analytics.DistributionResponse(**{**defaults, **overrides}) + +def test_valid_dashboard_response(): + dashboard = analytics.DashboardResponse( + summary=_make_summary(), + revenue=_make_revenue(), + orders=_make_order_stats(), + distribution=_make_distribution(), + ) + assert dashboard.summary.total_orders == 4 + assert dashboard.revenue.total_revenue == 170.0 + assert dashboard.distribution.largest == 100.0 + +def test_dashboard_missing_nested_field_raises(): + with pytest.raises(ValidationError): + analytics.DashboardResponse( + summary=_make_summary(), + revenue=_make_revenue(), + ) + +def test_dashboard_wrong_nested_type_raises(): + with pytest.raises(ValidationError): + analytics.DashboardResponse( + summary={"not": "a SummaryResponse"}, + revenue=_make_revenue(), + orders=_make_order_stats(), + distribution=_make_distribution(), + ) \ No newline at end of file diff --git a/analytics_service/tests/unit_tests/services/__init__.py b/analytics_service/tests/unit_tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/unit_tests/services/metrics_util_test.py b/analytics_service/tests/unit_tests/services/metrics_util_test.py new file mode 100644 index 0000000..6d91810 --- /dev/null +++ b/analytics_service/tests/unit_tests/services/metrics_util_test.py @@ -0,0 +1,277 @@ +from analytics_service.app.service.metrics_util import ( + avg_order_value, + largest_order_by_revenue, + largest_order_by_size, + smallest_order_by_revenue, + median_order_total, + calc_total_order_count, + calc_orders_per_day, + calc_orders_per_week, + calc_orders_per_month, + calc_orders_per_product, + calc_total_revenue, + calc_revenue_per_day, + calc_revenue_per_week, + calc_revenue_per_month, + calc_revenue_per_product, + calc_growth_rate, +) +from analytics_service.tests.factories.order_dict_factory import make_order, make_orders + + +### Order counts ### + +def test_calc_total_order_count(): + assert calc_total_order_count(make_orders(7)) == 7 + +def test_calc_total_order_count_empty(empty_orders): + assert calc_total_order_count(empty_orders) == 0 + + +def test_calc_orders_per_day(): + orders = {"items": [ + make_order(created_at="2025-01-15T10:06:00"), + make_order(created_at="2025-01-15T11:59:59"), + make_order(created_at="2025-01-16T09:00:00"), + make_order(created_at="2026-01-14T09:00:00"), + + ], "total": 4} + result = calc_orders_per_day(orders) + assert result == {"2025-01-15": 2, "2025-01-16": 1, "2026-01-14": 1} + keys = list(result.keys()) + assert keys == sorted(keys) + +def test_calc_orders_per_day_empty(): + orders = {"items": [], "total": 0} + result = calc_orders_per_day(orders) + assert not result + + +def test_calc_orders_per_week_count(): + orders = {"items": [ + make_order(created_at="2025-01-04T00:00:00"), # week 1 - Jan. 4 is always week 1 + make_order(created_at="2025-01-06T00:00:00"), # week 2 + make_order(created_at="2025-01-07T00:00:00"), # week 2 + make_order(created_at="2025-01-13T00:00:00"), # week 3 + ], "total": 4} + result = calc_orders_per_week(orders) + assert result["2025-W01"] == 1 + assert result["2025-W02"] == 2 + assert result["2025-W03"] == 1 + key = list(result.keys())[0] + assert key == "2025-W01" + +def test_calc_orders_per_week_empty(): + orders = {"items": [], "total": 0} + result = calc_orders_per_week(orders) + assert not result + +def test_calc_orders_per_week_year_boundary(): + # 2024-12-30 is ISO week 2025-W01 (Monday of the week containing Jan 2 Thu) + # using dt.year would incorrectly produce "2024-W01" + orders = {"items": [make_order(created_at="2024-12-30T00:00:00")], "total": 1} + result = calc_orders_per_week(orders) + assert "2025-W01" in result + + +def test_calc_orders_per_month(): + orders = {"items": [ + make_order(created_at="2025-01-15T00:00:00"), + make_order(created_at="2025-01-20T00:00:00"), + make_order(created_at="2025-02-05T00:00:00"), + ], "total": 3} + result = calc_orders_per_month(orders) + assert result == {"2025-01": 2, "2025-02": 1} + keys = list(result.keys()) + assert keys == sorted(keys) + +def test_calc_orders_per_month_empty(): + orders = {"items": [], "total": 0} + assert not calc_orders_per_month(orders) + + +def test_calc_orders_per_product_counts(): + orders = {"items": [ + make_order(product="Widget"), + make_order(product="Widget"), + make_order(product="Gadget"), + ], "total": 3} + result = calc_orders_per_product(orders) + assert result["Widget"] == 2 + assert result["Gadget"] == 1 + keys = list(result.keys()) + assert keys[0] == "Widget" + +def test_calc_orders_per_product_empty(empty_orders): + assert not calc_orders_per_product(empty_orders) + + +### Revenue ### + +def test_calc_total_revenue(sample_orders): + assert calc_total_revenue(sample_orders) == 170.0 + +def test_calc_total_revenue_single(): + orders = {"items": [make_order(total=99.99)], "total": 1} + assert calc_total_revenue(orders) == 99.99 + +def test_calc_total_revenue_empty(empty_orders): + assert calc_total_revenue(empty_orders) == 0.0 + + +def test_calc_revenue_per_day(): + orders = {"items": [ + make_order(created_at="2025-01-15T10:00:00", total=10.0), + make_order(created_at="2025-01-15T14:00:00", total=20.0), + make_order(created_at="2025-01-16T09:00:00", total=5.0), + ], "total": 3} + assert calc_revenue_per_day(orders) == {"2025-01-15": 30.0, "2025-01-16": 5.0} + +def test_calc_revenue_per_day_is_sorted(): + orders = {"items": [ + make_order(created_at="2025-01-16T00:00:00", total=10.0), + make_order(created_at="2025-01-15T00:00:00", total=10.0), + ], "total": 2} + keys = list(calc_revenue_per_day(orders).keys()) + assert keys == sorted(keys) + + +def test_calc_revenue_per_week(): + orders = {"items": [ + make_order(created_at="2025-01-06T00:00:00", total=40.0), # week 2 + make_order(created_at="2025-01-07T00:00:00", total=60.0), # week 2 + make_order(created_at="2025-01-13T00:00:00", total=25.0), # week 3 + ], "total": 3} + result = calc_revenue_per_week(orders) + assert result["2025-W02"] == 100.0 + assert result["2025-W03"] == 25.0 + +def test_calc_revenue_per_week_year_boundary(): + # 2024-12-30 is ISO week 2025-W01 — year from dt.year would be wrong + orders = {"items": [make_order(created_at="2024-12-30T00:00:00", total=50.0)], "total": 1} + result = calc_revenue_per_week(orders) + assert "2025-W01" in result + assert result["2025-W01"] == 50.0 + +def test_calc_revenue_per_week_is_sorted(): + orders = {"items": [ + make_order(created_at="2025-01-13T00:00:00", total=10.0), # week 3 + make_order(created_at="2025-01-06T00:00:00", total=10.0), # week 2 + ], "total": 2} + keys = list(calc_revenue_per_week(orders).keys()) + assert keys == sorted(keys) + + +def test_calc_revenue_per_month(sample_orders): + result = calc_revenue_per_month(sample_orders) + assert result["2025-01"] == 25.0 # 20 + 5 + assert result["2025-02"] == 45.0 + assert result["2025-03"] == 100.0 + +def test_calc_revenue_per_month_is_sorted(): + orders = {"items": [ + make_order(created_at="2025-03-01T00:00:00", total=10.0), + make_order(created_at="2025-01-01T00:00:00", total=10.0), + make_order(created_at="2025-02-01T00:00:00", total=10.0), + ], "total": 3} + keys = list(calc_revenue_per_month(orders).keys()) + assert keys == sorted(keys) + + +def test_calc_revenue_per_product(sample_orders): + result = calc_revenue_per_product(sample_orders) + assert result["Widget"] == 25.0 + assert result["Gadget"] == 145.0 + +def test_calc_revenue_per_product_sorted_descending(sample_orders): + keys = list(calc_revenue_per_product(sample_orders).keys()) + assert keys[0] == "Gadget" # 145 > 25 + +def test_calc_revenue_per_product_empty(empty_orders): + assert calc_revenue_per_product(empty_orders) == {} + + +# --- Growth --- + +def test_calc_growth_rate_positive(): + assert calc_growth_rate({"2025-01": 100.0, "2025-02": 120.0}) == 20.0 + +def test_calc_growth_rate_negative(): + assert calc_growth_rate({"2025-01": 100.0, "2025-02": 80.0}) == -20.0 + +def test_calc_growth_rate_single_period(): + assert calc_growth_rate({"2025-01": 100.0}) is None + +def test_calc_growth_rate_empty(): + assert calc_growth_rate({}) is None + +def test_calc_growth_rate_zero_previous(): + assert calc_growth_rate({"2025-01": 0.0, "2025-02": 100.0}) is None + +def test_calc_growth_rate_no_change(): + assert calc_growth_rate({"2025-01": 100.0, "2025-02": 100.0}) == 0.0 + +def test_calc_growth_rate_rounding(): + assert calc_growth_rate({"2025-01": 3.0, "2025-02": 4.0}) == 33.33 + +def test_calc_growth_rate_uses_last_two_periods(): + # only the last two values should be compared, regardless of earlier history + result = calc_growth_rate({"2024-11": 999.0, "2024-12": 999.0, "2025-01": 100.0, "2025-02": 200.0}) + assert result == 100.0 + + + + +# --- Distribution --- + +def test_avg_order_value(sample_orders): + # totals: 20 + 5 + 45 + 100 = 170, count = 4, avg = 42.5 + assert avg_order_value(sample_orders) == 42.5 + +def test_avg_order_value_single(): + orders = {"items": [make_order(total=37.5)], "total": 1} + assert avg_order_value(orders) == 37.5 + +def test_avg_order_value_empty(empty_orders): + assert avg_order_value(empty_orders) == 0.0 + + +def test_largest_order_by_revenue(): + orders = {"items": [make_order(total=5.0), make_order(total=100.0), make_order(total=20.0)], "total": 3} + assert largest_order_by_revenue(orders)["total"] == 100.0 + +def test_largest_order_by_revenue_empty(empty_orders): + assert largest_order_by_revenue(empty_orders) is None + + +def test_largest_order_by_size(): + orders = {"items": [make_order(count=1), make_order(count=10), make_order(count=5)], "total": 3} + assert largest_order_by_size(orders)["count"] == 10 + +def test_largest_order_by_size_empty(empty_orders): + assert largest_order_by_size(empty_orders) is None + + +def test_smallest_order_by_revenue(): + orders = {"items": [make_order(total=5.0), make_order(total=100.0), make_order(total=20.0)], "total": 3} + assert smallest_order_by_revenue(orders)["total"] == 5.0 + +def test_smallest_order_by_revenue_empty(empty_orders): + assert smallest_order_by_revenue(empty_orders) is None + + +def test_median_order_total(): + orders = {"items": [make_order(total=10.0), make_order(total=30.0), make_order(total=20.0)], "total": 3} + assert median_order_total(orders) == 20.0 + +def test_median_order_total_even_count(): + orders = {"items": [make_order(total=10.0), make_order(total=20.0)], "total": 2} + assert median_order_total(orders) == 15.0 + +def test_median_order_total_single(): + orders = {"items": [make_order(total=42.0)], "total": 1} + assert median_order_total(orders) == 42.0 + +def test_median_order_total_empty(empty_orders): + assert median_order_total(empty_orders) == 0.0 + diff --git a/docker-compose.yml b/docker-compose.yml index f2c0ec5..705619d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: POSTGRES_PASSWORD: ${DATABASE_PSWD} POSTGRES_DB: ${DATABASE_DB} ports: - - "5432:5432" + - "15432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -32,6 +32,24 @@ services: db: condition: service_healthy + analytics_service: + build: + context: analytics_service/ + target: dev + dockerfile: ./Dockerfile + restart: on-failure + ports: + - "8001:8001" + environment: + ENV: DEV + env_file: + - analytics_service/.env + volumes: + - ./analytics_service:/app/analytics_service + depends_on: + orders_service: + condition: service_started + # test: test-db: image: postgres:16-bookworm @@ -50,7 +68,6 @@ services: timeout: 5s retries: 5 - orders_service_test: profiles: ["test"] build: @@ -64,11 +81,19 @@ services: test-db: condition: service_healthy + analytics_service_test: + profiles: ["test"] + build: + context: analytics_service/ + target: test + dockerfile: ./Dockerfile + environment: + ENV: TEST + env_file: analytics_service/.env + depends_on: + orders_service_test: + condition: service_started + volumes: postgres_data: - - # analytics_service: - # build: ./analytics_service - # ports: - # - "8002:8002" diff --git a/orders_service/app/api/v1/orders.py b/orders_service/app/api/v1/orders.py index eeb5ecd..5661e90 100644 --- a/orders_service/app/api/v1/orders.py +++ b/orders_service/app/api/v1/orders.py @@ -5,6 +5,7 @@ from ...schemas.order import OrderCreate, OrderResponse, OrderUpdate, OrderListResponse from ...dependencies.db import get_db from ...db_logic import crud + router = APIRouter( prefix="/orders", tags=["orders"],