Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ TARGET_ENVIRONMENT_NAME=local
ANALYTICS_MONGODB_URI=mongodb+srv://<USERNAME>:<PASSWORD>@<CLUSTER>/?retryWrites=true&w=majority&appName=CompassAnalytics-Dev
ANALYTICS_DATABASE_NAME=compass-analytics-dev

# ---- Auth (Firebase) ----
# Required in non-local environments. In local mode the JWT signature is not verified.
FIREBASE_PROJECT_ID=

# ---- Per-service API keys (outbound, backend → external services) ----
# Add a new variable here and a new ExternalService enum value when onboarding a new service.
COMPASS_API_KEY=change-me-before-deploying
COMPASS_BASE_URL=https://dev.compass.tabiya.tech/api

# ---- Observability ----
BACKEND_ENABLE_SENTRY=False
BACKEND_SENTRY_DSN=
Expand Down
Empty file.
34 changes: 34 additions & 0 deletions backend/app/analytics/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import asyncio
import logging

from app.analytics.repositories import CompassAnalyticsRepository
from app.analytics.services import AnalyticsService, IAnalyticsService
from app.app_config import get_application_config
from app.auth.api_key import ExternalService
from common_libs.http_client.base import AsyncHttpClient

logger = logging.getLogger(__name__)

_lock = asyncio.Lock()
_singleton: IAnalyticsService | None = None


async def get_analytics_service() -> IAnalyticsService:
global _singleton
if _singleton is None:
async with _lock:
if _singleton is None:
config = get_application_config()
api_key = config.service_api_keys[ExternalService.COMPASS]
http_client = AsyncHttpClient(
base_url=config.compass_base_url,
headers={"X-API-Key": api_key},
)
_singleton = AnalyticsService(repository=CompassAnalyticsRepository(http_client))
return _singleton


def clear_analytics_service_cache() -> None:
"""Test-only: reset the singleton so the next request gets a fresh instance."""
global _singleton
_singleton = None
256 changes: 256 additions & 0 deletions backend/app/analytics/reach_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
"""
End-to-end tests for GET /api/reach.

Uses httpx.AsyncClient with ASGI transport so the Motor DB fixture and the
route handler share the same asyncio event loop (TestClient uses a sync thread
bridge which causes a different-loop error with Motor).

Authentication uses local mode (TARGET_ENVIRONMENT_TYPE=local), so any
HS256-signed JWT is accepted without signature verification — same behaviour
as running the server locally.

The Compass API is not available in tests, so we use httpx.MockTransport to
control what the repository's http client receives. Tests that expect empty
data simply let the transport raise a connection error (API unavailable).
"""
import json

import httpx
import jwt as pyjwt
import pytest
from fastapi import FastAPI

from app.analytics.dependencies import get_analytics_service
from app.analytics.repositories import CompassAnalyticsRepository
from app.analytics.routes import add_analytics_routes
from app.analytics.services import AnalyticsService
from app.analytics.types import ReachResponse, ReachSummary, TimeSeriesPoint
from app.auth.firebase import Authentication
from common_libs.http_client.base import AsyncHttpClient

_TEST_SECRET = "test-secret-key-long-enough-for-hs256" # nosec B105 — HS256 signing key for forged test JWTs, not a credential


def _make_firebase_token(user_id: str = "u1", email: str = "user@example.com") -> str:
claims = {
"sub": user_id,
"email": email,
"name": "Test User",
"firebase": {"sign_in_provider": "password"},
}
return pyjwt.encode(claims, key=_TEST_SECRET, algorithm="HS256")


_VALID_TOKEN = _make_firebase_token()
_AUTH_HEADER = {"Authorization": f"Bearer {_VALID_TOKEN}"}

_STUB_REACH_PAYLOAD = {
"summary": {
"total_users": 5000,
"active_users_30d": 1200,
"total_logins": 20000,
"avg_logins_per_user": 4.0,
"avg_session_minutes": 18,
},
"series": [
{"label": "Jan", "cumulative": 5000, "added": 500, "new_users": 400, "returning": 100, "logins": 800},
],
}


def _make_mock_transport(payload: dict | None = None, status_code: int = 200):
"""Returns an httpx transport that responds with the given payload, or raises ConnectError if payload is None."""
if payload is None:
def handler(_request):
raise httpx.ConnectError("Compass API not available")
return httpx.MockTransport(handler)

body = json.dumps(payload).encode()
def handler(_request):
return httpx.Response(status_code, content=body, headers={"content-type": "application/json"})
return httpx.MockTransport(handler)


def _make_service(transport) -> AnalyticsService:
http_client = AsyncHttpClient.__new__(AsyncHttpClient)
http_client._client = httpx.AsyncClient(transport=transport, base_url="http://compass-mock")
return AnalyticsService(repository=CompassAnalyticsRepository(http_client))


@pytest.fixture()
async def client_with_data(monkeypatch):
# GIVEN the server runs in local mode (no Firebase signature verification)
monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "local")

app = FastAPI()
auth = Authentication()
add_analytics_routes(app, auth)

service = _make_service(_make_mock_transport(_STUB_REACH_PAYLOAD))
app.dependency_overrides[get_analytics_service] = lambda: service

async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c


@pytest.fixture()
async def client_no_api(monkeypatch):
# GIVEN the server runs in local mode and the Compass API is unreachable
monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "local")

app = FastAPI()
auth = Authentication()
add_analytics_routes(app, auth)

service = _make_service(_make_mock_transport(None))
app.dependency_overrides[get_analytics_service] = lambda: service

async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c


def _reach_url(start: str, end: str, granularity: str = "month", **kwargs) -> str:
params = f"start_date={start}&end_date={end}&granularity={granularity}"
for k, v in kwargs.items():
params += f"&{k}={v}"
return f"/api/reach?{params}"


class TestReachAuth:
async def test_should_reject_request_with_no_auth_header(self, client_with_data):
# GIVEN no Authorization header is sent

# WHEN the reach endpoint is called without a token
actual_response = await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"))

# THEN expect the request to be rejected with 401
assert actual_response.status_code == 401

async def test_should_reject_request_with_an_invalid_token(self, client_with_data):
# GIVEN an invalid (non-JWT) bearer token
given_headers = {"Authorization": "Bearer not-a-jwt"}

# WHEN the reach endpoint is called with the invalid token
actual_response = await client_with_data.get(
_reach_url("2026-01-01", "2026-06-30"),
headers=given_headers,
)

# THEN expect the request to be rejected with 401
assert actual_response.status_code == 401


class TestReachResponse:
async def test_should_return_200_with_valid_token_and_params(self, client_with_data):
# GIVEN a valid Firebase token and valid date range params

# WHEN the reach endpoint is called
actual_response = await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)

# THEN expect a successful response
assert actual_response.status_code == 200

async def test_should_include_summary_and_series_in_response(self, client_with_data):
# GIVEN a valid request

# WHEN the reach endpoint is called
actual_body = (await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)).json()

# THEN expect the response to contain both summary and series sections
assert "summary" in actual_body
assert "series" in actual_body

async def test_should_include_all_required_summary_fields(self, client_with_data):
# GIVEN a valid request

# WHEN the reach endpoint is called
actual_summary = (
await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)
).json()["summary"]

# THEN expect all required summary fields to be present
assert all(
k in actual_summary
for k in ("total_users", "active_users_30d", "total_logins", "avg_logins_per_user", "avg_session_minutes")
)

async def test_should_return_data_from_compass_api(self, client_with_data):
# GIVEN the Compass API returns stub data

# WHEN the reach endpoint is called
actual_summary = (
await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)
).json()["summary"]

# THEN expect the summary values to match what the Compass API returned
assert actual_summary["total_users"] == 5000
assert actual_summary["active_users_30d"] == 1200

async def test_should_include_all_required_fields_in_each_series_point(self, client_with_data):
# GIVEN a valid request

# WHEN the reach endpoint is called
actual_series = (
await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)
).json()["series"]

# THEN expect at least one point in the series
assert len(actual_series) > 0
# AND each point to have all required fields
for point in actual_series:
assert all(k in point for k in ("label", "cumulative", "added", "new_users", "returning", "logins"))


class TestReachWhenApiUnavailable:
async def test_should_return_200_with_empty_data_when_compass_api_is_unreachable(self, client_no_api):
# GIVEN the Compass API is unreachable

# WHEN the reach endpoint is called
actual_response = await client_no_api.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)

# THEN expect a successful response (not an error)
assert actual_response.status_code == 200

async def test_should_return_zero_summary_when_compass_api_is_unreachable(self, client_no_api):
# GIVEN the Compass API is unreachable

# WHEN the reach endpoint is called
actual_summary = (
await client_no_api.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)
).json()["summary"]

# THEN expect all summary values to be zero
assert actual_summary["total_users"] == 0
assert actual_summary["total_logins"] == 0

async def test_should_return_empty_series_when_compass_api_is_unreachable(self, client_no_api):
# GIVEN the Compass API is unreachable

# WHEN the reach endpoint is called
actual_series = (
await client_no_api.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)
).json()["series"]

# THEN expect an empty series
assert actual_series == []


class TestReachValidation:
async def test_should_return_422_when_required_params_are_missing(self, client_with_data):
# GIVEN no query parameters

# WHEN the reach endpoint is called without required params
actual_response = await client_with_data.get("/api/reach", headers=_AUTH_HEADER)

# THEN expect a validation error
assert actual_response.status_code == 422

async def test_should_return_422_for_invalid_granularity_value(self, client_with_data):
# GIVEN an unsupported granularity value
given_url = _reach_url("2026-01-01", "2026-06-30", granularity="quarter")

# WHEN the reach endpoint is called with the invalid value
actual_response = await client_with_data.get(given_url, headers=_AUTH_HEADER)

# THEN expect a validation error
assert actual_response.status_code == 422
86 changes: 86 additions & 0 deletions backend/app/analytics/repositories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import logging
from abc import ABC, abstractmethod

from app.analytics.types import (
AnalyticsFilters,
ReachResponse,
ReachSummary,
TimeSeriesPoint,
)
from common_libs.http_client.base import AsyncHttpClient, HttpClientError

logger = logging.getLogger(__name__)


class IAnalyticsRepository(ABC):
@abstractmethod
async def get_reach(self, institution_ids: list[str] | None, filters: AnalyticsFilters) -> ReachResponse: ...


class CompassAnalyticsRepository(IAnalyticsRepository):
"""
Fetches analytics data from the Compass API.
Returns empty/zero data if the API is unavailable or returns nothing —
the Compass API endpoints don't exist yet and will be added incrementally.
"""

def __init__(self, http_client: AsyncHttpClient):
self._client = http_client

async def get_reach(self, institution_ids: list[str] | None, filters: AnalyticsFilters) -> ReachResponse:
params: dict = {
"start_date": filters.start_date.isoformat(),
"end_date": filters.end_date.isoformat(),
"granularity": filters.granularity,
}
if institution_ids:
params["institution_ids"] = ",".join(institution_ids)
if filters.audience_segment:
params["audience_segment"] = filters.audience_segment
if filters.login_method:
params["login_method"] = filters.login_method

try:
data = await self._client.get("/analytics/reach", params=params)
except HttpClientError as exc:
logger.warning("Compass API reach request failed (%s): %s", exc.status_code, exc)
data = None
except Exception as exc: # pylint: disable=broad-except
logger.warning("Compass API reach request error: %s", exc)
data = None

if not data:
return ReachResponse(
summary=ReachSummary(
total_users=0,
active_users_30d=0,
total_logins=0,
avg_logins_per_user=0.0,
avg_session_minutes=0,
),
series=[],
)

summary_raw = data.get("summary", {})
series_raw = data.get("series", [])

return ReachResponse(
summary=ReachSummary(
total_users=summary_raw.get("total_users", 0),
active_users_30d=summary_raw.get("active_users_30d", 0),
total_logins=summary_raw.get("total_logins", 0),
avg_logins_per_user=summary_raw.get("avg_logins_per_user", 0.0),
avg_session_minutes=summary_raw.get("avg_session_minutes", 0),
),
series=[
TimeSeriesPoint(
label=pt.get("label", ""),
cumulative=pt.get("cumulative", 0),
added=pt.get("added", 0),
new_users=pt.get("new_users", 0),
returning=pt.get("returning", 0),
logins=pt.get("logins", 0),
)
for pt in series_raw
],
)
Loading
Loading