"""M5: ai_calls Verbrauch + Hard-Block (CLUB_FEATURE_ENFORCE).""" import pytest from fastapi import HTTPException from club_features import ( club_feature_enforcement_enabled, consume_club_feature, consume_club_feature_with_usage, merge_feature_usage_into_response, probe_club_feature_access, ) def _fake_cur(): class C: def execute(self, *a, **k): pass def fetchone(self): return None return C() def test_probe_blocks_when_enforce_and_limit_exceeded(monkeypatch): monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1") monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) monkeypatch.setattr( "club_quota_bypass.is_club_feature_quota_bypassed", lambda *a, **k: False, ) monkeypatch.setattr( "club_features.check_club_feature_access", lambda club_id, feature_id, conn=None: { "allowed": False, "limit": 0, "used": 0, "remaining": 0, "reason": "feature_disabled", "plan_id": "free", }, ) monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None) with pytest.raises(HTTPException) as exc: probe_club_feature_access( feature_id="ai_calls", action="suggest", club_id=12, profile_id=3, endpoint="POST /exercises/ai/suggest", conn=object(), ) assert exc.value.status_code == 403 assert "ai_calls" in str(exc.value.detail) def test_probe_allows_when_enforce_off(monkeypatch): monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "0") monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) monkeypatch.setattr( "club_quota_bypass.is_club_feature_quota_bypassed", lambda *a, **k: False, ) monkeypatch.setattr( "club_features.check_club_feature_access", lambda club_id, feature_id, conn=None: { "allowed": False, "limit": 0, "used": 0, "remaining": 0, "reason": "feature_disabled", "plan_id": "free", }, ) monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None) access = probe_club_feature_access( feature_id="ai_calls", action="suggest", club_id=12, profile_id=3, conn=object(), ) assert access["allowed"] is False def test_consume_skips_without_club_id(monkeypatch): calls = [] def _inc(*args, **kwargs): calls.append(1) monkeypatch.setattr("club_features.increment_club_feature_usage", _inc) consume_club_feature(feature_id="ai_calls", club_id=None, profile_id=1) assert calls == [] def test_consume_logs_usage_after_increment(monkeypatch): logs = [] monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) monkeypatch.setattr( "club_quota_bypass.is_club_feature_quota_bypassed", lambda *a, **k: False, ) monkeypatch.setattr("club_features.increment_club_feature_usage", lambda *a, **k: None) monkeypatch.setattr( "club_features.check_club_feature_access", lambda club_id, feature_id, conn=None: { "allowed": True, "used": 1, "limit": 30, "remaining": 29, "plan_id": "club", "reason": "within_limit", }, ) monkeypatch.setattr( "club_feature_logger.log_club_feature_usage", lambda **kwargs: logs.append(kwargs), ) consume_club_feature( feature_id="ai_calls", club_id=5, profile_id=9, portal_role="trainer", action="suggest", conn=object(), ) assert len(logs) == 1 assert logs[0]["phase"] == "consume" assert logs[0]["feature_id"] == "ai_calls" assert logs[0]["club_id"] == 5 def test_consume_increments_once_per_call(monkeypatch): calls = [] def _inc(club_id, feature_id, **kwargs): calls.append((club_id, feature_id, kwargs.get("action"))) monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) monkeypatch.setattr( "club_quota_bypass.is_club_feature_quota_bypassed", lambda *a, **k: False, ) monkeypatch.setattr("club_features.increment_club_feature_usage", _inc) monkeypatch.setattr( "club_features.check_club_feature_access", lambda club_id, feature_id, conn=None: { "allowed": True, "used": 1, "limit": 30, "remaining": 29, "plan_id": "club", "reason": "within_limit", }, ) monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None) consume_club_feature( feature_id="ai_calls", club_id=5, profile_id=9, portal_role="trainer", action="suggest", conn=object(), ) assert calls == [(5, "ai_calls", "suggest")] def test_merge_feature_usage_into_response(): out = merge_feature_usage_into_response( {"ok": True}, {"ai_calls": {"used": 3, "limit": 30}}, ) assert out["ok"] is True assert out["feature_usage"]["ai_calls"]["used"] == 3 assert merge_feature_usage_into_response({"x": 1}, None) == {"x": 1} def test_consume_with_usage_returns_snapshot(monkeypatch): monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) monkeypatch.setattr( "club_quota_bypass.is_club_feature_quota_bypassed", lambda *a, **k: False, ) monkeypatch.setattr("club_features.consume_club_feature", lambda **kwargs: None) monkeypatch.setattr( "club_features.club_feature_usage_for_api", lambda cur, **kwargs: {"used": 4, "limit": 30, "allowed": True}, ) usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=7, profile_id=1, portal_role="trainer", action="suggest", cur=_fake_cur(), conn=object(), ) assert usage["ai_calls"]["used"] == 4 def test_probe_blocks_no_club_context_when_enforce(monkeypatch): monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1") monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None) with pytest.raises(HTTPException) as exc: probe_club_feature_access( feature_id="ai_calls", action="suggest", club_id=None, profile_id=3, endpoint="POST /exercises/ai/suggest", ) assert exc.value.status_code == 403 assert "Vereinskontext" in str(exc.value.detail) def test_club_feature_enforcement_env_default_off(monkeypatch): monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False) assert club_feature_enforcement_enabled() is False