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
214 lines
7.7 KiB
Python
214 lines
7.7 KiB
Python
# tests/test_pinning.py — session pinning (Paid tier TTL) and translator
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.models.session import Session
|
|
from app.services import session_store
|
|
from app.services.translator import Translator
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_sessions():
|
|
session_store._sessions.clear()
|
|
session_store._tasks.clear()
|
|
session_store._audio_buffers.clear()
|
|
yield
|
|
session_store._sessions.clear()
|
|
session_store._tasks.clear()
|
|
session_store._audio_buffers.clear()
|
|
|
|
|
|
# ── Tier-aware reaper (_should_reap) ─────────────────────────────────────────
|
|
|
|
def test_free_session_reaped_after_subscriber_leaves(monkeypatch):
|
|
monkeypatch.setattr("app.services.session_store.settings.session_idle_ttl_s", 0)
|
|
s = Session(tier="free")
|
|
s.state = "running"
|
|
q = s.subscribe()
|
|
s.unsubscribe(q) # triggers last_subscriber_left_at
|
|
now = time.monotonic() + 1
|
|
assert session_store._should_reap(s, now) is True
|
|
|
|
|
|
def test_free_session_spared_while_subscriber_active(monkeypatch):
|
|
monkeypatch.setattr("app.services.session_store.settings.session_idle_ttl_s", 0)
|
|
s = Session(tier="free")
|
|
s.state = "running"
|
|
s.subscribe()
|
|
now = time.monotonic() + 9999
|
|
assert session_store._should_reap(s, now) is False
|
|
|
|
|
|
def test_paid_session_reaped_on_activity_timeout(monkeypatch):
|
|
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 0)
|
|
s = Session(tier="paid")
|
|
s.state = "running"
|
|
now = time.monotonic() + 1
|
|
assert session_store._should_reap(s, now) is True
|
|
|
|
|
|
def test_paid_session_spared_within_activity_window(monkeypatch):
|
|
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 9999)
|
|
s = Session(tier="paid")
|
|
s.state = "running"
|
|
now = time.monotonic() + 1
|
|
assert session_store._should_reap(s, now) is False
|
|
|
|
|
|
def test_paid_session_not_reaped_despite_no_subscribers(monkeypatch):
|
|
"""Paid tier ignores subscriber state — activity window is what matters."""
|
|
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 9999)
|
|
s = Session(tier="paid")
|
|
s.state = "running"
|
|
q = s.subscribe()
|
|
s.unsubscribe(q) # no subscribers now
|
|
now = time.monotonic() + 200 # past free-tier TTL but within paid TTL
|
|
assert session_store._should_reap(s, now) is False
|
|
|
|
|
|
def test_stopped_session_never_reaped():
|
|
s = Session(tier="free")
|
|
s.state = "stopped"
|
|
assert session_store._should_reap(s, time.monotonic() + 9999) is False
|
|
|
|
|
|
def test_broadcast_updates_last_activity_at():
|
|
from app.models.tone_event import ToneEvent
|
|
s = Session(tier="paid")
|
|
before = s.last_activity_at
|
|
time.sleep(0.01)
|
|
tone = ToneEvent(
|
|
session_id=s.session_id, label="Neutral", confidence=0.9,
|
|
speaker_id="speaker_a", shift_magnitude=0.0, timestamp=0.0,
|
|
subtext="", affect="neutral",
|
|
)
|
|
s.broadcast(tone)
|
|
assert s.last_activity_at > before
|
|
|
|
|
|
# ── Snapshot save on end ──────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_saved_for_paid_session_with_history():
|
|
from app.models.tone_event import ToneEvent
|
|
session = await session_store.create_session(tier="paid", user_id="u1")
|
|
tone = ToneEvent(
|
|
session_id=session.session_id, label="Stressed", confidence=0.85,
|
|
speaker_id="Speaker A", shift_magnitude=0.1, timestamp=1.0,
|
|
subtext="", affect="stressed",
|
|
)
|
|
session.history.append(tone)
|
|
|
|
with patch("app.services.session_store._save_snapshot") as mock_save:
|
|
await session_store.end_session(session.session_id)
|
|
mock_save.assert_called_once_with(session)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_not_saved_for_free_session():
|
|
session = await session_store.create_session(tier="free", user_id="u1")
|
|
from app.models.tone_event import ToneEvent
|
|
tone = ToneEvent(
|
|
session_id=session.session_id, label="Neutral", confidence=0.9,
|
|
speaker_id="speaker_a", shift_magnitude=0.0, timestamp=0.0,
|
|
subtext="", affect="neutral",
|
|
)
|
|
session.history.append(tone)
|
|
|
|
with patch("app.services.session_store._save_snapshot") as mock_save:
|
|
await session_store.end_session(session.session_id)
|
|
mock_save.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_not_saved_for_empty_paid_session():
|
|
session = await session_store.create_session(tier="paid", user_id="u1")
|
|
# No events in history
|
|
with patch("app.services.session_store._save_snapshot") as mock_save:
|
|
await session_store.end_session(session.session_id)
|
|
mock_save.assert_not_called()
|
|
|
|
|
|
# ── Translator ────────────────────────────────────────────────────────────────
|
|
|
|
def _make_session(target_lang="", byok="", tier="free"):
|
|
s = Session(tier=tier, target_lang=target_lang, byok_deepl_key=byok)
|
|
return s
|
|
|
|
|
|
def _mock_deepl_resp(translated: str):
|
|
resp = MagicMock()
|
|
resp.ok = True
|
|
resp.json.return_value = {"translations": [{"text": translated}]}
|
|
return resp
|
|
|
|
|
|
def test_translator_none_when_no_target_lang():
|
|
s = _make_session(target_lang="")
|
|
assert Translator.for_session(s) is None
|
|
|
|
|
|
def test_translator_none_when_no_key(monkeypatch):
|
|
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "")
|
|
s = _make_session(target_lang="fr")
|
|
assert Translator.for_session(s) is None
|
|
|
|
|
|
def test_translator_uses_byok_key(monkeypatch):
|
|
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "")
|
|
s = _make_session(target_lang="fr", byok="my-deepl-key")
|
|
t = Translator.for_session(s)
|
|
assert t is not None
|
|
assert t._url.endswith("api-free.deepl.com/v2/translate")
|
|
|
|
|
|
def test_translator_uses_cf_key(monkeypatch):
|
|
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "cf-pro-key")
|
|
s = _make_session(target_lang="es")
|
|
t = Translator.for_session(s)
|
|
assert t is not None
|
|
assert t._url.endswith("api.deepl.com/v2/translate")
|
|
|
|
|
|
def test_translator_byok_takes_precedence_over_cf_key(monkeypatch):
|
|
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "cf-pro-key")
|
|
s = _make_session(target_lang="de", byok="user-free-key")
|
|
t = Translator.for_session(s)
|
|
assert t._url.endswith("api-free.deepl.com/v2/translate")
|
|
assert t._api_key == "user-free-key"
|
|
|
|
|
|
def test_translate_calls_deepl():
|
|
t = Translator(target_lang="fr", api_key="k", pro=False)
|
|
with patch("app.services.translator.requests.post", return_value=_mock_deepl_resp("Stressé")):
|
|
result = t.translate("Stressed")
|
|
assert result == "Stressé"
|
|
|
|
|
|
def test_translate_caches_result():
|
|
t = Translator(target_lang="fr", api_key="k", pro=False)
|
|
with patch("app.services.translator.requests.post", return_value=_mock_deepl_resp("Stressé")) as mock_post:
|
|
t.translate("Stressed")
|
|
t.translate("Stressed")
|
|
assert mock_post.call_count == 1
|
|
|
|
|
|
def test_translate_returns_original_on_error():
|
|
t = Translator(target_lang="fr", api_key="k", pro=False)
|
|
with patch("app.services.translator.requests.post", side_effect=Exception("timeout")):
|
|
result = t.translate("Stressed")
|
|
assert result == "Stressed"
|
|
|
|
|
|
def test_translate_target_lang_uppercased():
|
|
t = Translator(target_lang="fr", api_key="k", pro=False)
|
|
assert t._target_lang == "FR"
|
|
|
|
|
|
def test_translate_empty_label_returns_empty():
|
|
t = Translator(target_lang="fr", api_key="k", pro=False)
|
|
assert t.translate("") == ""
|