linnet/tests/test_tiers.py
pyr0ball b92285b4b3 feat: tier-aware sessions, session pinning, translation, audio settings, voice status
Backend:
- tiers.py: real license validation via Heimdall (self-hosted key or cloud user_id),
  30-min cache, BYOK exception for translation
- session_store.py: create_session() accepts tier/user_id/window_ms/transcribe_lang/
  num_speakers; tier-aware _should_reap() (Free: reap on subscriber leave, Paid: activity TTL);
  auto-snapshot paid sessions with history on reap; voice-ready probe on session start
- services/translator.py: DeepL-backed label translation, CF-managed and BYOK key paths,
  per-session label cache
- models/session.py, scene_event.py, service_event.py: new event types and session fields
- api/snapshots.py: GET /sessions/history + POST /session/resume (Paid tier only)
- api/dev.py: dev-only /dev/voice/stop|start|restart proxy to cf-orch agent
- migrations/002_session_snapshots.sql: session_snapshots table

Frontend:
- ComposeBar.vue: pre-session settings panel (window, language, speakers, Elcor toggle)
  with slide transition; passes options to startSession()
- stores/settings.ts: persistent user preferences (localStorage), survives page reload
- stores/session.ts: voiceReady/voiceError/voiceWarning states; SceneEvent/AccentEvent/
  ServiceEvent types; TTL timers for environ/queue badges; startSession() accepts opts
- composables/useToneStream.ts: handlers for scene-event, accent-event, service-event SSE
- NowPanel.vue: diarization speaker identity badge, acoustic scene badge, privacy risk indicator
- HistoryStrip.vue: per-speaker colour chips in history strip
- components/DevPanel.vue: dev-only cf-voice stop/restart controls + thread view toggle
- App.vue: DevPanel in footer (DEV only), threadView flag switches v0.1.x/v0.2.x preview

Tests:
- tests/test_pinning.py: 20 tests covering tier-aware reaping, snapshot creation,
  and translator behaviour
- tests/test_tiers.py: 17 tests covering key validation, cloud resolution,
  caching, require_paid() gate, BYOK exception
- All 72 tests passing
2026-06-24 15:25:46 -07:00

156 lines
6.2 KiB
Python

# tests/test_tiers.py — tier gate and Heimdall validation logic
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from app import tiers
@pytest.fixture(autouse=True)
def clear_tier_cache():
"""Wipe the in-process tier cache between tests."""
tiers._cache.clear()
yield
tiers._cache.clear()
# ── Key-based resolution ──────────────────────────────────────────────────────
def _mock_verify(tier: str, valid: bool = True):
"""Return a mock requests.Response for /v1/licenses/verify."""
resp = MagicMock()
resp.ok = valid
resp.json.return_value = {"valid": valid, "tier": tier, "user_id": "u123"}
return resp
def test_get_tier_free_when_no_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
assert tiers.get_tier() == "free"
def test_get_tier_paid_via_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
assert result == "paid"
mock_post.assert_called_once()
assert "/v1/licenses/verify" in mock_post.call_args[0][0]
def test_get_tier_free_on_invalid_key(monkeypatch):
with patch("app.tiers.requests.post", return_value=_mock_verify("free", valid=False)):
result = tiers.get_tier(license_key="CFG-LNNT-DEAD-BEEF-0000")
assert result == "free"
def test_get_tier_free_on_heimdall_error(monkeypatch):
with patch("app.tiers.requests.post", side_effect=Exception("timeout")):
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
assert result == "free"
def test_get_tier_uses_env_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "CFG-LNNT-ENV0-ENV0-ENV0")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
result = tiers.get_tier()
assert result == "paid"
assert mock_post.call_args[1]["json"]["key"] == "CFG-LNNT-ENV0-ENV0-ENV0"
def test_key_result_is_cached(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
# Second call must hit cache, not Heimdall
assert mock_post.call_count == 1
def test_network_error_does_not_cache(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", side_effect=Exception("timeout")) as mock_post:
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
# Network errors are NOT cached — both calls hit Heimdall
assert mock_post.call_count == 2
# ── Cloud user resolution ─────────────────────────────────────────────────────
def _mock_cloud_resolve(tier: str):
resp = MagicMock()
resp.ok = True
resp.json.return_value = {"tier": tier, "user_id": "u456"}
return resp
def test_get_tier_cloud_paid(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
result = tiers.get_tier(user_id="user-uuid-123")
assert result == "paid"
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
def test_get_tier_cloud_free_when_no_admin_token(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "")
result = tiers.get_tier(user_id="user-uuid-123")
assert result == "free"
def test_user_id_takes_precedence_over_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("premium")) as mock_post:
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC", user_id="u999")
assert result == "premium"
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
def test_cloud_result_is_cached(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
tiers.get_tier(user_id="u-cached")
tiers.get_tier(user_id="u-cached")
assert mock_post.call_count == 1
# ── is_paid() and require_paid() ─────────────────────────────────────────────
def test_is_paid_true_for_paid_tier(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")):
assert tiers.is_paid(license_key="CFG-LNNT-AAAA-BBBB-CCCC") is True
def test_is_paid_true_for_premium_tier(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("premium")):
assert tiers.is_paid(license_key="CFG-LNNT-PREM-PREM-PREM") is True
def test_is_paid_false_for_free(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
assert tiers.is_paid() is False
def test_require_paid_raises_402_for_free():
with patch("app.tiers.get_tier", return_value="free"):
with pytest.raises(HTTPException) as exc_info:
tiers.require_paid()
assert exc_info.value.status_code == 402
def test_require_paid_passes_for_paid():
with patch("app.tiers.get_tier", return_value="paid"):
tiers.require_paid() # must not raise
def test_require_paid_byok_skips_check():
# byok_deepl=True bypasses the tier gate regardless of tier
with patch("app.tiers.get_tier", return_value="free") as mock_get:
tiers.require_paid(byok_deepl=True) # must not raise
mock_get.assert_not_called()