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
3 changes: 3 additions & 0 deletions backend/tests/_fake_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
(actual hash increments, actual set membership) instead of asserting on
mock call counts, without adding a new dependency (no `fakeredis` package
exists anywhere in this repo's dependency tree).

Developer:
Manish Kumar <manish@omnibioai.org>
"""
from __future__ import annotations

Expand Down
97 changes: 97 additions & 0 deletions backend/tests/test_analytics_aggregator.py

Large diffs are not rendered by default.

31 changes: 30 additions & 1 deletion backend/tests/test_analytics_billing_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"""
tests/test_analytics_billing_client.py

Unit tests for control_center.analytics.billing_client.
Unit tests for control_center.analytics.billing_client: get_usage()
distinguishes "billing service unreachable" (available=False) from
"billing service reachable but has no data for this org / gave us an
unparseable body" (available=True, body=None) so callers can render the
right degraded-state message; get_subscription()/get_usage_limits() hit
their expected billing-service paths.

Developer:
Manish Kumar <manish@omnibioai.org>
"""
from __future__ import annotations

Expand All @@ -14,13 +22,16 @@


def _resp(status_code: int, json_body=None) -> MagicMock:
"""A MagicMock httpx.Response stand-in with a fixed status/JSON body."""
r = MagicMock()
r.status_code = status_code
r.json.return_value = json_body
return r


def _mock_client(response: MagicMock):
"""An async-context-manager mock of httpx.AsyncClient whose .get()
always returns `response`."""
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=response)
mock_ctx = MagicMock()
Expand All @@ -30,19 +41,26 @@ def _mock_client(response: MagicMock):


class GetUsageTestCase(unittest.IsolatedAsyncioTestCase):
"""get_usage()'s (available, body) contract distinguishing a reachable
billing service (even with no/unparseable data) from an unreachable one."""

async def test_success_returns_available_and_body(self) -> None:
"""A 200 response returns (True, <parsed body>)."""
with patch("control_center.analytics.billing_client.httpx.AsyncClient", return_value=_mock_client(_resp(200, {"services": []}))):
available, body = await billing_client.get_usage(1, "Bearer tok")
self.assertTrue(available)
self.assertEqual(body, {"services": []})

async def test_404_is_available_but_no_body(self) -> None:
"""A 404 (billing service reachable, no data for this org) is
available=True with body=None, not available=False."""
with patch("control_center.analytics.billing_client.httpx.AsyncClient", return_value=_mock_client(_resp(404))):
available, body = await billing_client.get_usage(1, "Bearer tok")
self.assertTrue(available)
self.assertIsNone(body)

async def test_unreachable_is_not_available(self) -> None:
"""A connection failure to the billing service returns (False, None)."""
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
mock_ctx = MagicMock()
Expand All @@ -54,6 +72,8 @@ async def test_unreachable_is_not_available(self) -> None:
self.assertIsNone(body)

async def test_malformed_json_returns_available_with_none_body(self) -> None:
"""A 200 response with an unparseable body is still available=True
(the service was reachable), just with body=None."""
resp = MagicMock()
resp.status_code = 200
resp.json.side_effect = ValueError("bad json")
Expand All @@ -63,6 +83,8 @@ async def test_malformed_json_returns_available_with_none_body(self) -> None:
self.assertIsNone(body)

async def test_no_authorization_sends_no_header(self) -> None:
"""Calling get_usage(..., None) sends an empty headers dict -- no
Authorization key at all."""
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=_resp(200, {}))
mock_ctx = MagicMock()
Expand All @@ -75,7 +97,12 @@ async def test_no_authorization_sends_no_header(self) -> None:


class GetSubscriptionAndUsageLimitsTestCase(unittest.IsolatedAsyncioTestCase):
"""get_subscription()/get_usage_limits() hit their expected
billing-service URL paths for a given organization id."""

async def test_get_subscription_hits_expected_path(self) -> None:
"""get_subscription() requests
/billing/organizations/{org_id}/subscription and returns the body."""
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=_resp(200, {"plan_name": "pro"}))
mock_ctx = MagicMock()
Expand All @@ -89,6 +116,8 @@ async def test_get_subscription_hits_expected_path(self) -> None:
self.assertIn("/billing/organizations/7/subscription", called_url)

async def test_get_usage_limits_hits_expected_path(self) -> None:
"""get_usage_limits() requests
/billing/organizations/{org_id}/subscription/usage-limits."""
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=_resp(200, {"limit": 100}))
mock_ctx = MagicMock()
Expand Down
38 changes: 37 additions & 1 deletion backend/tests/test_analytics_cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
"""
tests/test_analytics_cache.py

Unit tests for control_center.analytics.cache.
Unit tests for control_center.analytics.cache: get_or_set()/
get_or_set_async() compute-and-cache with graceful degradation on a
corrupted cache entry or a Redis GET/SETEX failure (always falls back to
calling `compute`, never raises), and invalidate()'s best-effort delete.
Uses the repo's own FakeRedis (tests/_fake_redis.py) rather than mocking
Redis calls individually, since real get/setex/delete round-trip
semantics (including its `raise_on` failure-injection) are what these
tests need.

Developer:
Manish Kumar <manish@omnibioai.org>
"""
from __future__ import annotations

Expand All @@ -13,13 +23,17 @@


class GetOrSetAsyncTestCase(unittest.IsolatedAsyncioTestCase):
"""get_or_set_async()'s compute-and-cache behavior against a FakeRedis,
including graceful fallback on a corrupted entry or a Redis failure."""

def setUp(self) -> None:
self.fake = FakeRedis()
self._patcher = patch.object(cache, "_redis", self.fake)
self._patcher.start()
self.addCleanup(self._patcher.stop)

async def test_cache_miss_awaits_compute_and_stores(self) -> None:
"""A cache miss awaits `compute` exactly once and returns its result."""
calls = []

async def compute():
Expand All @@ -31,6 +45,8 @@ async def compute():
self.assertEqual(len(calls), 1)

async def test_cache_hit_skips_compute(self) -> None:
"""A cache hit returns the stored value without ever awaiting
`compute` again."""
async def compute():
return {"total": 1}

Expand All @@ -43,6 +59,8 @@ async def should_not_run():
self.assertEqual(result, {"total": 1})

async def test_corrupted_cache_entry_falls_back_to_compute(self) -> None:
"""A cache value that isn't valid JSON is treated as a miss --
`compute` runs and its result is returned."""
self.fake._strings["ak3"] = "not-json{"

async def compute():
Expand All @@ -52,6 +70,8 @@ async def compute():
self.assertEqual(result, {"total": 9})

async def test_redis_get_failure_falls_back_to_compute(self) -> None:
"""A Redis GET failure is treated as a miss -- `compute` still
runs and its result is returned."""
self.fake.raise_on = {"get"}

async def compute():
Expand All @@ -61,6 +81,8 @@ async def compute():
self.assertEqual(result, {"total": 2})

async def test_redis_setex_failure_still_returns_value(self) -> None:
"""A Redis SETEX failure after a successful compute still returns
the computed value -- caching is best-effort."""
self.fake.raise_on = {"setex"}

async def compute():
Expand All @@ -71,13 +93,16 @@ async def compute():


class GetOrSetTestCase(unittest.TestCase):
"""get_or_set()'s synchronous equivalent of the async cache tests above."""

def setUp(self) -> None:
self.fake = FakeRedis()
self._patcher = patch.object(cache, "_redis", self.fake)
self._patcher.start()
self.addCleanup(self._patcher.stop)

def test_cache_miss_computes_and_stores(self) -> None:
"""A cache miss calls `compute` exactly once and returns its result."""
calls = []

def compute():
Expand All @@ -89,38 +114,49 @@ def compute():
self.assertEqual(len(calls), 1)

def test_cache_hit_skips_compute(self) -> None:
"""A cache hit returns the stored value without calling `compute`
again."""
cache.get_or_set("k2", "overview", lambda: {"total": 1})
result = cache.get_or_set("k2", "overview", lambda: (_ for _ in ()).throw(AssertionError("should not compute")))
self.assertEqual(result, {"total": 1})

def test_corrupted_cache_entry_falls_back_to_compute(self) -> None:
"""A cache value that isn't valid JSON is treated as a miss."""
self.fake._strings["k3"] = "not-json{"
result = cache.get_or_set("k3", "overview", lambda: {"total": 9})
self.assertEqual(result, {"total": 9})

def test_redis_get_failure_falls_back_to_compute(self) -> None:
"""A Redis GET failure is treated as a miss."""
self.fake.raise_on = {"get"}
result = cache.get_or_set("k4", "overview", lambda: {"total": 2})
self.assertEqual(result, {"total": 2})

def test_redis_setex_failure_still_returns_value(self) -> None:
"""A Redis SETEX failure still returns the computed value."""
self.fake.raise_on = {"setex"}
result = cache.get_or_set("k5", "overview", lambda: {"total": 3})
self.assertEqual(result, {"total": 3})


class InvalidateTestCase(unittest.TestCase):
"""invalidate()'s delete behavior, including failing silently on a
Redis error."""

def setUp(self) -> None:
self.fake = FakeRedis()
self._patcher = patch.object(cache, "_redis", self.fake)
self._patcher.start()
self.addCleanup(self._patcher.stop)

def test_invalidate_deletes_key(self) -> None:
"""invalidate() removes the cached key -- a subsequent get()
returns nothing."""
cache.get_or_set("k6", "overview", lambda: {"total": 1})
cache.invalidate("k6")
self.assertIsNone(self.fake.get("k6"))

def test_invalidate_swallows_redis_error(self) -> None:
"""A Redis DELETE failure is swallowed rather than raised."""
self.fake.raise_on = {"delete"}
cache.invalidate("k7") # must not raise
Loading
Loading