""" Tests for RunningJobsLedger lockless ledger behavior. """ # To run just these tests (without loading pytest third-party plugins): # PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -q studio_api/tests/test_running_jobs_ledger.py import logging import sys import types # Provide a minimal structlog stub so tests don't require structlog installed if "structlog" not in sys.modules: # pragma: no cover sys.modules["structlog"] = types.SimpleNamespace(get_logger=lambda name: logging.getLogger(name)) # Stub minimal Django and django_redis modules to allow importing cache.py without Django if "django" not in sys.modules: # pragma: no cover sys.modules["django"] = types.ModuleType("django") if "django.conf" not in sys.modules: # pragma: no cover conf_mod = types.ModuleType("django.conf") conf_mod.settings = types.SimpleNamespace(RUNNING_JOBS_CACHE="default") sys.modules["django.conf"] = conf_mod if "django.contrib" not in sys.modules: # pragma: no cover sys.modules["django.contrib"] = types.ModuleType("django.contrib") if "django.contrib.auth" not in sys.modules: # pragma: no cover sys.modules["django.contrib.auth"] = types.ModuleType("django.contrib.auth") if "django.contrib.auth.models" not in sys.modules: # pragma: no cover models_mod = types.ModuleType("django.contrib.auth.models") class _DummyManager: def get(self, **kwargs): class _DummyUser: pass return _DummyUser() class User: # type: ignore objects = _DummyManager() models_mod.User = User # type: ignore sys.modules["django.contrib.auth.models"] = models_mod if "django_redis" not in sys.modules: # pragma: no cover sys.modules["django_redis"] = types.SimpleNamespace(get_redis_connection=lambda alias: None) # Stub studio_api package to avoid importing app/celery/redis during tests if "studio_api" not in sys.modules: # pragma: no cover studio_api_pkg = types.ModuleType("studio_api") bots_pkg = types.ModuleType("studio_api.bots") cache_pkg = types.ModuleType("studio_api.bots.cache") caches_mod = types.ModuleType("studio_api.bots.cache.caches") caches_mod.CACHE_TIMEOUT_5_MINUTE = 300 utils_mod = types.ModuleType("studio_api.bots.utils") def _check_running_jobs_cost(_user): return 0 utils_mod.check_running_jobs_cost = _check_running_jobs_cost utils_mod.check_DB_running_jobs_cost = _check_running_jobs_cost sys.modules["studio_api"] = studio_api_pkg sys.modules["studio_api.bots"] = bots_pkg sys.modules["studio_api.bots.cache"] = cache_pkg sys.modules["studio_api.bots.cache.caches"] = caches_mod sys.modules["studio_api.bots.utils"] = utils_mod import importlib.util import pathlib import sys import time import types from unittest.mock import patch if "structlog" not in sys.modules: sys.modules["structlog"] = types.SimpleNamespace(get_logger=lambda name: logging.getLogger(name)) # Dynamically load the ledger module by file path to avoid importing the Django package MODULE_PATH = ( pathlib.Path(__file__).resolve().parents[1] / "studio_api" / "bots" / "billing" / "cache.py" ) spec = importlib.util.spec_from_file_location("running_jobs_ledger_module", str(MODULE_PATH)) assert spec is not None cache_module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(cache_module) # type: ignore RunningJobsLedger = cache_module.RunningJobsLedger class DummyUser: def __init__(self, user_id): self.id = user_id class FakeRedis: def __init__(self): self._store = {} self._exp = {} def _cleanup(self, key): exp = self._exp.get(key) if exp is not None and time.time() >= exp: self._store.pop(key, None) self._exp.pop(key, None) def exists(self, key): self._cleanup(key) return 1 if key in self._store else 0 def hgetall(self, key): self._cleanup(key) data = self._store.get(key) or {} # Return string keys/values to mirror decode_responses=True behavior return {str(k): str(v) for k, v in data.items()} def hset(self, key, field, value): self._cleanup(key) self._store.setdefault(key, {})[field] = value return 1 def hsetnx(self, key, field, value): self._cleanup(key) bucket = self._store.setdefault(key, {}) if field in bucket: return 0 bucket[field] = value return 1 def hexists(self, key, field): self._cleanup(key) return 1 if field in (self._store.get(key) or {}) else 0 def incr(self, key): self._cleanup(key) cur = self._store.get(key) try: cur = int(cur) if cur is not None else 0 except Exception: cur = 0 cur += 1 self._store[key] = cur return cur def expire(self, key, seconds): self._cleanup(key) self._exp[key] = time.time() + int(seconds) return True def delete(self, *keys): for key in keys: self._store.pop(key, None) self._exp.pop(key, None) return True def make_handler(): # Avoid calling __init__ (which imports Django); manually construct and set attrs h = object.__new__(RunningJobsLedger) h.redis = FakeRedis() h.ttl_seconds = 1 return h @patch.object(RunningJobsLedger, "_get_db_cost", return_value=60) def test_init_from_db_on_first_read(mock_cost): h = make_handler() user = DummyUser(1) assert h.get_running_jobs_cost(str(user.id)) == 60 mock_cost.assert_called_once_with(str(user.id)) def test_increment_and_decrement_updates_total(): h = make_handler() user = DummyUser(2) assert h.set_ledger_base(str(user.id), 100) == 100 assert h.increment_ledger_cost(str(user.id), 10, gen_request_id="req1") == 110 assert h.decrement_ledger_cost(str(user.id), 5, clip_id="clip1") == 105 assert h.get_running_jobs_cost(str(user.id)) == 105 def test_never_goes_negative(): h = make_handler() user = DummyUser(3) assert h.set_ledger_base(str(user.id), 5) == 5 assert h.decrement_ledger_cost(str(user.id), 20, clip_id="clip2") == 0 assert h.get_running_jobs_cost(str(user.id)) == 0 @patch.object(RunningJobsLedger, "_get_db_cost", return_value=35) def test_missing_ledger_on_write_initializes_base(mock_cost): h = make_handler() user = DummyUser(4) assert h.decrement_ledger_cost(str(user.id), 20, clip_id="clip3") == 35 assert h.get_running_jobs_cost(str(user.id)) == 35 mock_cost.assert_called_once_with(str(user.id)) def test_zero_deltas_no_change(): h = make_handler() user = DummyUser(5) assert h.set_ledger_base(str(user.id), 30) == 30 assert h.increment_ledger_cost(str(user.id), 0, gen_request_id="req0") == 30 assert h.decrement_ledger_cost(str(user.id), 0, clip_id="clip0") == 30 assert h.get_running_jobs_cost(str(user.id)) == 30 @patch.object(RunningJobsLedger, "_get_db_cost", return_value=100) def test_ttl_expiry_reinitializes_base(_): h = make_handler() user = DummyUser(6) assert h.get_running_jobs_cost(str(user.id)) == 100 time.sleep(1.2) assert h.get_running_jobs_cost(str(user.id)) == 100 def test_spam_creation_idempotent_and_multiple(): h = make_handler() user = DummyUser(7) # Warm ledger with a known base to avoid first-increment init short-circuit assert h.set_ledger_base(str(user.id), 0) == 0 # First unique gen assert h.increment_ledger_cost(str(user.id), 10, gen_request_id="reqA") == 10 # Second unique gen accumulates assert h.increment_ledger_cost(str(user.id), 20, gen_request_id="reqB") == 30 # Repeating same gen_request_id is idempotent (overwrite same value, no double count) assert h.increment_ledger_cost(str(user.id), 10, gen_request_id="reqA") == 30 # Another unique gen stacks on top assert h.increment_ledger_cost(str(user.id), 5, gen_request_id="reqC") == 35 # Final check via reader assert h.get_running_jobs_cost(str(user.id)) == 35 @patch.object(RunningJobsLedger, "_get_db_cost", return_value=50) def test_cache_deleted_then_calls_still_work(_): h = make_handler() user = DummyUser(8) # Start from a known state: base 10 and one delta +5 assert h.set_ledger_base(str(user.id), 10) == 10 assert h.increment_ledger_cost(str(user.id), 5, gen_request_id="reqX") == 15 # Simulate cache wipe h.redis.delete(h._ledger_key(str(user.id))) # Reader should re-initialize from DB (50) assert h.get_running_jobs_cost(str(user.id)) == 50 # Writer after re-init should append delta assert h.increment_ledger_cost(str(user.id), 5, gen_request_id="reqY") == 55 # Simulate cache wipe again, then decrement path h.redis.delete(h._ledger_key(str(user.id))) # First decrement after missing ledger initializes base and returns it (no delta) assert h.decrement_ledger_cost(str(user.id), 5, clip_id="clipZ") == 50 # Second decrement now appends the delta assert h.decrement_ledger_cost(str(user.id), 5, clip_id="clipZ") == 45