Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only) - backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the old checkout, PYTHONPATH pins THIS backend's code over any editable install - repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh watches shared/ + backend/ - Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010, self-migrates, /healthz ok
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
113
backend/tests/conftest.py
Normal file
113
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Pytest fixtures.
|
||||
|
||||
Tests run against a real PostgreSQL (deploy/docker-compose.dev.yml) using a
|
||||
dedicated ``shonar_test`` database, plus a temporary local storage root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
# Configure env BEFORE importing the app so Settings picks it up.
|
||||
TEST_DB = os.environ.get(
|
||||
"SHONAR_TEST_DATABASE_URL",
|
||||
"postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test",
|
||||
)
|
||||
os.environ["SHONAR_DATABASE_URL"] = TEST_DB
|
||||
os.environ["SHONAR_SECRET_KEY"] = "test-secret-key-0123456789abcdef0123456789abcdef"
|
||||
os.environ["SHONAR_STORAGE_BACKEND"] = "local"
|
||||
# Effectively disable the auth rate limit under test (dedicated tests cover
|
||||
# the limiter behaviour itself).
|
||||
os.environ["SHONAR_RATE_LIMIT_AUTH"] = "10000/minute"
|
||||
|
||||
_tmp_storage = tempfile.mkdtemp(prefix="shonar-test-storage-")
|
||||
os.environ["SHONAR_STORAGE_PATH"] = _tmp_storage
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def _setup_db() -> AsyncIterator[None]:
|
||||
from shonar.db import models # noqa: F401
|
||||
from shonar.db.base import Base
|
||||
from shonar.db.session import dispose_engine, get_engine
|
||||
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# The tsvector generated columns live only in migration
|
||||
# fts0000000001 (not in the ORM models), so create_all misses them.
|
||||
# Apply the same DDL the migration applies (M9 search needs them).
|
||||
if engine.dialect.name == "postgresql":
|
||||
# Reuse the real migration's DDL (not a copy) via a sync
|
||||
# MigrationContext — op.execute() is synchronous there.
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
def _apply(sync_conn):
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"fts_migration",
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "migrations/versions/fts0000000001_fts_columns.py",
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
ctx = MigrationContext.configure(sync_conn)
|
||||
with Operations.context(ctx):
|
||||
mod.upgrade()
|
||||
|
||||
await conn.run_sync(_apply)
|
||||
yield
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session", autouse=True)
|
||||
async def clean_db(_setup_db: None) -> AsyncIterator[None]:
|
||||
"""Truncate between tests for isolation."""
|
||||
yield
|
||||
from sqlalchemy import delete, text
|
||||
|
||||
from shonar.db.base import Base
|
||||
from shonar.db.session import _session_factory, get_engine # type: ignore[attr-defined]
|
||||
|
||||
assert _session_factory is not None
|
||||
async with _session_factory() as s:
|
||||
if get_engine().dialect.name == "postgresql":
|
||||
await s.execute(
|
||||
text(
|
||||
"TRUNCATE users, devices, refresh_tokens, recordings, assets, "
|
||||
"upload_sessions, upload_chunks, transcripts, summaries, tags, "
|
||||
"recording_tags, processing_jobs, export_jobs, app_settings "
|
||||
"RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
else:
|
||||
# SQLite: delete child-first (reverse dependency order).
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
await s.execute(delete(table))
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session")
|
||||
async def client(_setup_db) -> AsyncIterator:
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from shonar.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage_root() -> Path:
|
||||
return Path(_tmp_storage)
|
||||
184
backend/tests/test_ai_adapters.py
Normal file
184
backend/tests/test_ai_adapters.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Adapter wire-protocol tests (M7): JSON shapes in/out, error mapping.
|
||||
|
||||
HTTP adapters take an injectable httpx client; faster-whisper is an
|
||||
optional dependency and is only exercised when installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from shonar.core.config import Settings
|
||||
from shonar.services import ai
|
||||
from shonar.services.ai import ProviderConfigError, SummaryResult
|
||||
from shonar.services.ai.ollama import OllamaProvider
|
||||
from shonar.services.ai.openai_compat import OpenAICompatProvider
|
||||
from shonar.services.ai.whisper_http import WhisperHttpProvider
|
||||
|
||||
|
||||
def mock_client(handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
# --- factories -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_factories_none_means_skip():
|
||||
s = Settings(transcription_provider="none", llm_provider="none")
|
||||
assert ai.get_transcription_provider(s) is None
|
||||
assert ai.get_llm_provider(s) is None
|
||||
|
||||
|
||||
def test_factories_unknown_names_raise_config_error():
|
||||
s = Settings(transcription_provider="whisper-9k")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_transcription_provider(s)
|
||||
s = Settings(llm_provider="clippy")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_llm_provider(s)
|
||||
|
||||
|
||||
def test_factories_missing_urls_raise_config_error():
|
||||
s = Settings(transcription_provider="whisper_http", transcription_base_url="")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_transcription_provider(s)
|
||||
s = Settings(llm_provider="openai_compat", llm_base_url="http://x", llm_model="")
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_llm_provider(s)
|
||||
|
||||
|
||||
def test_faster_whisper_missing_dep_is_config_error():
|
||||
if importlib.util.find_spec("faster_whisper") is not None:
|
||||
pytest.skip("faster-whisper installed; missing-dep path N/A")
|
||||
# Constructor validates eagerly so misconfiguration fails at startup,
|
||||
# not on the first recording.
|
||||
with pytest.raises(ProviderConfigError):
|
||||
ai.get_transcription_provider(Settings(transcription_provider="faster_whisper"))
|
||||
|
||||
|
||||
# --- whisper_http -----------------------------------------------------------------
|
||||
|
||||
|
||||
def whisper_ok(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/audio/transcriptions"
|
||||
assert request.method == "POST"
|
||||
return httpx.Response(200, json={
|
||||
"text": "hello world",
|
||||
"language": "en",
|
||||
"segments": [{"start": 0.0, "end": 1.2, "text": "hello world"}],
|
||||
})
|
||||
|
||||
|
||||
async def test_whisper_http_happy_path():
|
||||
p = WhisperHttpProvider("http://stt:8000", model="small",
|
||||
http_client=mock_client(whisper_ok))
|
||||
res = await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||
assert res.text == "hello world"
|
||||
assert res.language == "en"
|
||||
assert [(s.start, s.end, s.text) for s in res.segments] == [(0.0, 1.2, "hello world")]
|
||||
assert res.model == "small"
|
||||
|
||||
|
||||
async def test_whisper_http_401_is_config_error():
|
||||
async def denied(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"detail": "nope"})
|
||||
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(denied))
|
||||
with pytest.raises(ProviderConfigError):
|
||||
await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||
|
||||
|
||||
async def test_whisper_http_503_is_transient():
|
||||
async def busy(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503, text="overloaded")
|
||||
from shonar.services.ai import ProviderTransientError
|
||||
|
||||
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(busy))
|
||||
with pytest.raises(ProviderTransientError):
|
||||
await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||
|
||||
|
||||
# --- openai_compat ------------------------------------------------------------------
|
||||
|
||||
|
||||
def chat_ok(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/chat/completions"
|
||||
body = {
|
||||
"short": "Standup.",
|
||||
"detailed": "The team met.",
|
||||
"key_points": ["a", "b"],
|
||||
"decisions": ["ship"],
|
||||
"action_items": [{"not": "a string"}, "call ana"],
|
||||
"questions": [],
|
||||
"extra_key": "ignored",
|
||||
}
|
||||
import json as _json
|
||||
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": _json.dumps(body)}}]})
|
||||
|
||||
|
||||
async def test_openai_compat_parses_and_sanitizes():
|
||||
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||
http_client=mock_client(chat_ok))
|
||||
res = await p.summarize("a very long meeting transcript", title="Standup")
|
||||
assert isinstance(res, SummaryResult)
|
||||
assert res.short == "Standup."
|
||||
assert res.action_items == ("call ana",) # non-strings dropped
|
||||
assert res.model == "qwen"
|
||||
|
||||
|
||||
async def test_openai_compat_partial_json_gets_defaults():
|
||||
import json as _json
|
||||
|
||||
async def partial(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": _json.dumps({"short": "Hi."})}}]
|
||||
})
|
||||
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||
http_client=mock_client(partial))
|
||||
res = await p.summarize("hi")
|
||||
assert res.short == "Hi."
|
||||
assert res.detailed == "" and res.key_points == ()
|
||||
|
||||
|
||||
async def test_openai_compat_non_json_is_transient():
|
||||
async def garbage(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": "Sure! Here it is..."}}]
|
||||
})
|
||||
from shonar.services.ai import ProviderTransientError
|
||||
|
||||
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||
http_client=mock_client(garbage))
|
||||
with pytest.raises(ProviderTransientError):
|
||||
await p.summarize("hi")
|
||||
|
||||
|
||||
# --- ollama ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ollama_happy_path():
|
||||
import json as _json
|
||||
|
||||
async def ok(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/chat"
|
||||
payload = _json.loads(request.content.decode())
|
||||
assert payload["format"] == "json" and payload["stream"] is False
|
||||
return httpx.Response(200, json={
|
||||
"message": {"content": _json.dumps({"short": "S.", "detailed": "D."})}
|
||||
})
|
||||
p = OllamaProvider("http://ollama:11434", model="llama3",
|
||||
http_client=mock_client(ok))
|
||||
res = await p.summarize("meeting notes")
|
||||
assert res.short == "S." and res.model == "llama3"
|
||||
|
||||
|
||||
async def test_ollama_404_is_config_error():
|
||||
async def missing(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404, text="model not found")
|
||||
p = OllamaProvider("http://ollama:11434", model="nope",
|
||||
http_client=mock_client(missing))
|
||||
with pytest.raises(ProviderConfigError):
|
||||
await p.summarize("hi")
|
||||
410
backend/tests/test_ai_pipeline.py
Normal file
410
backend/tests/test_ai_pipeline.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
"""AI pipeline tests (M7): status flow, versioning, failure modes, endpoints.
|
||||
|
||||
Provider fakes stand in for real STT/LLM services (no network, no model
|
||||
downloads); adapter wire-protocol tests live in test_ai_adapters.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import (
|
||||
JobStatus,
|
||||
JobType,
|
||||
ProcessingJob,
|
||||
Transcript,
|
||||
)
|
||||
from shonar.services import processing
|
||||
from shonar.services.ai import (
|
||||
ProviderConfigError,
|
||||
ProviderTransientError,
|
||||
Segment,
|
||||
SummaryResult,
|
||||
TranscriptResult,
|
||||
)
|
||||
|
||||
|
||||
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||
data = data[:payload_len]
|
||||
header = (
|
||||
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
|
||||
+ b"data" + struct.pack("<I", len(data))
|
||||
)
|
||||
return header + data
|
||||
|
||||
|
||||
AUTH = {"email": "m7@example.com", "password": "m7-test-passw0rd-123"}
|
||||
|
||||
|
||||
async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]):
|
||||
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
async def upload_recording(client, token, client_id=None, title="M7 standup"):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
data = wav_bytes()
|
||||
r = await client.post(
|
||||
"/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
|
||||
"client_recording_id": client_id, "title": title},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
sid = r.json()["id"]
|
||||
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream"})
|
||||
assert r.status_code == 201, r.text
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize",
|
||||
json={"duration_seconds": 5.0}, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
class FakeTranscriber:
|
||||
name = "fake-stt"
|
||||
|
||||
def __init__(self, text="hello world from the meeting", fail=None):
|
||||
self.text = text
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
async def transcribe(self, audio, mime, *, language_hint=None):
|
||||
self.calls += 1
|
||||
assert len(audio) > 0 and mime == "audio/wav"
|
||||
if self.fail is not None:
|
||||
raise self.fail
|
||||
return TranscriptResult(
|
||||
text=self.text, language="en",
|
||||
segments=[Segment(0.0, 1.0, self.text)], model="fake-stt-1",
|
||||
)
|
||||
|
||||
|
||||
class FakeLlm:
|
||||
name = "fake-llm"
|
||||
|
||||
def __init__(self, fail=None):
|
||||
self.fail = fail
|
||||
self.seen = []
|
||||
|
||||
async def summarize(self, transcript, *, title=None):
|
||||
self.seen.append(transcript)
|
||||
if self.fail is not None:
|
||||
raise self.fail
|
||||
return SummaryResult(
|
||||
short="Standup happened.", detailed="The team met and spoke.",
|
||||
key_points=("a",), decisions=(), action_items=("ship it",),
|
||||
questions=(), model="fake-llm-1",
|
||||
)
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET):
|
||||
tprov = FakeTranscriber() if stt is _UNSET else stt
|
||||
lprov = FakeLlm() if llm is _UNSET else llm
|
||||
monkeypatch.setattr(processing, "get_transcription_provider", lambda settings: tprov)
|
||||
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: lprov)
|
||||
|
||||
|
||||
async def jobs_for(recording_id):
|
||||
async with db_session._session_factory() as s:
|
||||
rows = (await s.scalars(
|
||||
select(ProcessingJob).where(ProcessingJob.recording_id == uuid.UUID(recording_id))
|
||||
)).all()
|
||||
return {j.job_type: j for j in rows}
|
||||
|
||||
|
||||
# --- no AI configured --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_none_configured_marks_ai_disabled(client):
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token)
|
||||
assert rec["processing_status"] == "ai_disabled"
|
||||
assert await jobs_for(rec["id"]) == {}
|
||||
# Status endpoints: nothing there yet, but the recording exists.
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert r.status_code == 404
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert r.status_code == 404
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
assert r.status_code == 200 and r.json() == []
|
||||
|
||||
|
||||
# --- happy path ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_full_pipeline_transcribe_then_summarize(client, monkeypatch):
|
||||
stt, llm = FakeTranscriber(), FakeLlm()
|
||||
use_fakes(monkeypatch, stt, llm)
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-full-1")
|
||||
assert rec["processing_status"] == "queued"
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert set(jobs) == {JobType.transcribe}
|
||||
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.succeeded
|
||||
assert set(jobs) == {JobType.transcribe, JobType.summarize}
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "processing"
|
||||
|
||||
t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert t.status_code == 200, t.text
|
||||
body = t.json()
|
||||
assert body["text"] == "hello world from the meeting"
|
||||
assert body["version"] == 1 and body["provider"] == "fake-stt"
|
||||
assert body["segments"][0]["text"].startswith("hello")
|
||||
assert llm.seen == []
|
||||
|
||||
await processing.run_summarize({}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.summarize].status == JobStatus.succeeded
|
||||
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert s.status_code == 200, s.text
|
||||
content = s.json()["content"]
|
||||
assert content["short"] == "Standup happened."
|
||||
assert content["action_items"] == ["ship it"]
|
||||
assert set(content) == {"short", "detailed", "key_points", "decisions",
|
||||
"action_items", "questions"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "completed"
|
||||
assert llm.seen == ["hello world from the meeting"]
|
||||
|
||||
jobs_rows = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
assert jobs_rows.status_code == 200
|
||||
assert {j["job_type"] for j in jobs_rows.json()} == {"transcribe", "summarize"}
|
||||
|
||||
|
||||
async def test_enqueue_is_idempotent(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-idem-1")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
await processing.run_summarize({}, rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, uuid.UUID(rec["id"]))
|
||||
queued = await processing.enqueue_for_recording(s, row)
|
||||
assert queued == []
|
||||
n = await s.scalar(
|
||||
select(func.count(ProcessingJob.id)).where(
|
||||
ProcessingJob.recording_id == uuid.UUID(rec["id"]))
|
||||
)
|
||||
assert n == 2
|
||||
await s.commit()
|
||||
|
||||
|
||||
# --- failure modes -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_transient_failure_retries_then_fails(client, monkeypatch):
|
||||
stt = FakeTranscriber(fail=ProviderTransientError("stt down"))
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-fail-1")
|
||||
# First tries raise for arq retry; the attempt rolls back with the
|
||||
# transaction, so the row still reads queued (arq tracks the tries).
|
||||
with pytest.raises(ProviderTransientError):
|
||||
await processing.run_transcribe({"job_try": 1}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||
# Last try marks the job (and recording) failed with a safe message.
|
||||
await processing.run_transcribe({"job_try": 3}, rec["id"])
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||
assert jobs[JobType.transcribe].error == "stt down"
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "failed"
|
||||
assert r.json()["processing_error"] == "stt down"
|
||||
|
||||
|
||||
async def test_config_error_fails_fast(client, monkeypatch):
|
||||
stt = FakeTranscriber(fail=ProviderConfigError("bad credentials"))
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-cfg-1")
|
||||
await processing.run_transcribe({"job_try": 1}, rec["id"]) # no raise
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||
assert stt.calls == 1
|
||||
|
||||
|
||||
# --- versioning -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_rerun_supersedes_auto_but_not_user_edits(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber("v1 text"), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-ver-1")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
rid = uuid.UUID(rec["id"])
|
||||
|
||||
async def versions():
|
||||
async with db_session._session_factory() as s:
|
||||
rows = (await s.scalars(
|
||||
select(Transcript).where(Transcript.recording_id == rid)
|
||||
.order_by(Transcript.version))).all()
|
||||
return [(r.version, r.text, r.superseded_at is not None, r.edited_by_user)
|
||||
for r in rows]
|
||||
|
||||
assert await versions() == [(1, "v1 text", False, False)]
|
||||
|
||||
# Queue another transcription run manually (re-entry path).
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
await processing.enqueue_for_recording(s, row)
|
||||
await s.commit()
|
||||
use_fakes(monkeypatch, FakeTranscriber("v2 text"), FakeLlm())
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
assert await versions() == [(1, "v1 text", True, False), (2, "v2 text", False, False)]
|
||||
|
||||
# A user edit wins: the next auto run inserts nothing.
|
||||
async with db_session._session_factory() as s:
|
||||
v2 = await s.scalar(
|
||||
select(Transcript).where(Transcript.recording_id == rid,
|
||||
Transcript.version == 2))
|
||||
v2.edited_by_user = True
|
||||
v2.text = "user corrected text"
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
await processing.enqueue_for_recording(s, row)
|
||||
await s.commit()
|
||||
use_fakes(monkeypatch, FakeTranscriber("v3 text"), FakeLlm())
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
got = await versions()
|
||||
assert len(got) == 2 and got[1][1] == "user corrected text"
|
||||
|
||||
|
||||
# --- llm-only and skips ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_llm_only_without_transcript_queues_nothing(client, monkeypatch):
|
||||
use_fakes(monkeypatch, None, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-llm-1")
|
||||
assert rec["processing_status"] == "uploaded"
|
||||
assert await jobs_for(rec["id"]) == {}
|
||||
|
||||
|
||||
async def test_llm_only_with_manual_transcript_summarizes(client, monkeypatch):
|
||||
use_fakes(monkeypatch, None, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-llm-2")
|
||||
rid = uuid.UUID(rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
s.add(Transcript(recording_id=rid, version=1, provider="manual",
|
||||
text="handwritten notes", edited_by_user=True))
|
||||
await s.flush()
|
||||
queued = await processing.enqueue_for_recording(s, row)
|
||||
assert queued == [JobType.summarize]
|
||||
await s.commit()
|
||||
await processing.run_summarize({}, rec["id"])
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert s.status_code == 200
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "completed"
|
||||
|
||||
|
||||
# --- sweep ------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_sweep_requeues_stale_jobs(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-sweep-1")
|
||||
rid = uuid.UUID(rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
# Simulate a crashed worker + a lost transport, respectively.
|
||||
await processing.reset_or_create(s, row, JobType.transcribe)
|
||||
jobs = await jobs_for(rec["id"])
|
||||
jobs[JobType.transcribe].status = JobStatus.running
|
||||
await s.commit()
|
||||
# reset_or_create leaves running rows alone, so force the second shape:
|
||||
async with db_session._session_factory() as s:
|
||||
extra = ProcessingJob(recording_id=rid, job_type=JobType.summarize,
|
||||
status=JobStatus.queued)
|
||||
s.add(extra)
|
||||
await s.commit()
|
||||
count = await processing.sweep_stale()
|
||||
assert count == 2
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||
assert jobs[JobType.summarize].status == JobStatus.queued
|
||||
|
||||
|
||||
async def test_sweep_leaves_live_running_jobs_alone(client, monkeypatch):
|
||||
"""Regression: sweep re-enqueued every in-flight transcription every
|
||||
5 minutes; with local whisper each copy ran concurrently and one worker
|
||||
OOM-swap-swelled the box. A running job started recently is live work."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-sweep-live")
|
||||
rid = uuid.UUID(rec["id"])
|
||||
async with db_session._session_factory() as s:
|
||||
from shonar.db.models import Recording
|
||||
|
||||
row = await s.get(Recording, rid)
|
||||
await processing.reset_or_create(s, row, JobType.transcribe)
|
||||
live = (await s.scalars(
|
||||
select(ProcessingJob).where(
|
||||
ProcessingJob.recording_id == rid,
|
||||
ProcessingJob.job_type == JobType.transcribe)
|
||||
)).one()
|
||||
live.status = JobStatus.running
|
||||
live.started_at = datetime.now(UTC) # worker is chewing it right now
|
||||
stale = ProcessingJob(recording_id=rid, job_type=JobType.summarize,
|
||||
status=JobStatus.running, attempt=1)
|
||||
stale.started_at = datetime.now(UTC) - timedelta(
|
||||
hours=processing.STALE_RUNNING_AFTER.total_seconds() / 3600 + 1)
|
||||
s.add(stale)
|
||||
await s.commit()
|
||||
count = await processing.sweep_stale()
|
||||
assert count == 1 # only the orphaned old run
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.running
|
||||
assert jobs[JobType.summarize].status == JobStatus.queued
|
||||
|
||||
|
||||
# --- ownership ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ai_endpoints_enforce_ownership(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-own-1")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
other = await user_tokens(client, email="m7-other@example.com",
|
||||
password="m7-test-passw0rd-456")
|
||||
h = {"Authorization": f"Bearer {other}"}
|
||||
for path in ("transcript", "summary", "jobs"):
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/{path}", headers=h)
|
||||
assert r.status_code == 404, path
|
||||
110
backend/tests/test_auth.py
Normal file
110
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Auth flow tests: register, login, refresh rotation + reuse detection,
|
||||
logout, protected access, account guards."""
|
||||
|
||||
AUTH = {"email": "test@example.com", "password": "correct-horse-battery"}
|
||||
|
||||
|
||||
async def register(client, email=AUTH["email"], password=AUTH["password"]):
|
||||
return await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": password, "display_name": "Tester"},
|
||||
)
|
||||
|
||||
|
||||
async def test_register_returns_token_pair(client):
|
||||
r = await register(client)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["expires_in"] == 15 * 60
|
||||
assert body["access_token"] and body["refresh_token"]
|
||||
|
||||
|
||||
async def test_register_rejects_duplicate_email(client):
|
||||
assert (await register(client)).status_code == 201
|
||||
r = await register(client)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
async def test_register_rejects_weak_password(client):
|
||||
r = await client.post(
|
||||
"/api/v1/auth/register", json={"email": "x@example.com", "password": "short"}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_login_success_and_failure(client):
|
||||
await register(client)
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login", json={"email": AUTH["email"], "password": AUTH["password"]}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["device_id"]
|
||||
|
||||
bad = await client.post("/api/v1/auth/login", json={"email": AUTH["email"], "password": "***"})
|
||||
assert bad.status_code == 401
|
||||
# Same generic message either way (no user enumeration via password check)
|
||||
bad2 = await client.post(
|
||||
"/api/v1/auth/login", json={"email": "nobody@example.com", "password": "***"}
|
||||
)
|
||||
assert bad2.status_code == 401
|
||||
assert bad.json()["detail"] == bad2.json()["detail"]
|
||||
|
||||
|
||||
async def test_me_requires_valid_token(client):
|
||||
r = await client.get("/api/v1/auth/me")
|
||||
assert r.status_code == 401
|
||||
tok = (await register(client)).json()["access_token"]
|
||||
r = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {tok}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["email"] == AUTH["email"]
|
||||
|
||||
|
||||
async def test_refresh_rotates_and_detects_reuse(client):
|
||||
tok = (await register(client)).json()
|
||||
old_refresh = tok["refresh_token"]
|
||||
|
||||
r = await client.post("/api/v1/auth/refresh", json={"refresh_token": old_refresh})
|
||||
assert r.status_code == 200
|
||||
new_refresh = r.json()["refresh_token"]
|
||||
assert new_refresh != old_refresh
|
||||
|
||||
# Old token is dead; reuse kills the whole family.
|
||||
r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": old_refresh})
|
||||
assert r2.status_code == 401
|
||||
|
||||
# The replacement is also revoked (family revocation).
|
||||
r3 = await client.post("/api/v1/auth/refresh", json={"refresh_token": new_refresh})
|
||||
assert r3.status_code == 401
|
||||
|
||||
|
||||
async def test_logout_revokes_refresh(client):
|
||||
tok = (await register(client)).json()
|
||||
r = await client.post("/api/v1/auth/logout", json={"refresh_token": tok["refresh_token"]})
|
||||
assert r.status_code == 204
|
||||
r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]})
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
async def test_access_token_with_refresh_type_rejected(client):
|
||||
tok = (await register(client)).json()
|
||||
r = await client.get(
|
||||
"/api/v1/auth/me", headers={"Authorization": f"Bearer {tok['refresh_token']}"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_delete_account_requires_password(client):
|
||||
tok = (await register(client)).json()
|
||||
h = {"Authorization": f"Bearer {tok['access_token']}"}
|
||||
r = await client.post("/api/v1/auth/delete-account", json={"password": "***"}, headers=h)
|
||||
assert r.status_code == 403
|
||||
r = await client.post(
|
||||
"/api/v1/auth/delete-account", json={"password": AUTH["password"]}, headers=h
|
||||
)
|
||||
assert r.status_code == 202
|
||||
# Deleted account can no longer log in.
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login", json={"email": AUTH["email"], "password": AUTH["password"]}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
29
backend/tests/test_health.py
Normal file
29
backend/tests/test_health.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Health-check and system-status tests."""
|
||||
|
||||
|
||||
async def test_healthz(client):
|
||||
r = await client.get("/api/v1/healthz")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["uptime_seconds"] >= 0
|
||||
|
||||
|
||||
async def test_readyz_with_db(client):
|
||||
r = await client.get("/api/v1/readyz")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "ok", "database": True}
|
||||
|
||||
|
||||
async def test_system_status_reports_ai_state_honestly(client):
|
||||
r = await client.get("/api/v1/system/status")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Default test config: AI disabled, no external calls.
|
||||
assert body["ai"]["transcription_enabled"] is False
|
||||
assert body["ai"]["llm_enabled"] is False
|
||||
assert body["ai"]["external_ai_in_use"] is False
|
||||
# No secrets ever present in the public status payload.
|
||||
body_text = r.text.lower()
|
||||
for leak in ("api_key", "password", "secret_key"):
|
||||
assert leak not in body_text
|
||||
153
backend/tests/test_inline_queue.py
Normal file
153
backend/tests/test_inline_queue.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Inline queue tests (desktop bundled-lite engine): with
|
||||
``queue_backend=inline`` an upload runs transcribe→summarize automatically
|
||||
in-process — no arq, no Redis. Providers are fakes (see test_ai_pipeline).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from shonar.core.config import get_settings
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import JobStatus, JobType, ProcessingJob
|
||||
from shonar.services import inline_queue, processing
|
||||
|
||||
from .test_ai_pipeline import FakeLlm, FakeTranscriber, upload_recording, use_fakes, user_tokens
|
||||
|
||||
|
||||
def use_inline(monkeypatch):
|
||||
"""Force queue_backend=inline for everything resolved via processing."""
|
||||
inline_settings = get_settings().model_copy(update={"queue_backend": "inline"})
|
||||
monkeypatch.setattr(processing, "get_settings", lambda: inline_settings)
|
||||
|
||||
|
||||
async def _wait_terminal(recording_id: str, want: int = 2, timeout: float = 20.0):
|
||||
"""Poll the DB until `want` jobs reach a terminal state."""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
rows: list[ProcessingJob] = []
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
async with db_session._session_factory() as s:
|
||||
rows = list(
|
||||
(
|
||||
await s.scalars(
|
||||
select(ProcessingJob).where(
|
||||
ProcessingJob.recording_id == uuid.UUID(recording_id)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if sum(
|
||||
1
|
||||
for j in rows
|
||||
if j.status in (JobStatus.succeeded, JobStatus.failed, JobStatus.skipped)
|
||||
) >= want:
|
||||
return {j.job_type: j.status for j in rows}
|
||||
await asyncio.sleep(0.1)
|
||||
raise AssertionError(
|
||||
f"jobs did not reach terminal state in {timeout}s: "
|
||||
f"{[(j.job_type, j.status) for j in rows]}"
|
||||
)
|
||||
|
||||
|
||||
async def test_inline_backend_processes_upload_without_arq(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||
use_inline(monkeypatch)
|
||||
await inline_queue.start()
|
||||
try:
|
||||
token = await user_tokens(client, email="inline@shonar.dev")
|
||||
rec = await upload_recording(client, token, client_id="inline-1")
|
||||
|
||||
statuses = await _wait_terminal(rec["id"], want=2)
|
||||
assert statuses[JobType.transcribe] == JobStatus.succeeded
|
||||
assert statuses[JobType.summarize] == JobStatus.succeeded
|
||||
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "completed"
|
||||
t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert t.json()["text"] == "hello world from the meeting"
|
||||
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||
assert s.json()["content"]["short"] == "Standup happened."
|
||||
finally:
|
||||
await inline_queue.stop()
|
||||
|
||||
|
||||
async def test_inline_retries_transient_failure(client, monkeypatch):
|
||||
stt = FakeTranscriber()
|
||||
state = {"tries": 0}
|
||||
original = stt.transcribe
|
||||
|
||||
async def flaky(audio, mime, *, language_hint=None):
|
||||
state["tries"] += 1
|
||||
if state["tries"] == 1:
|
||||
raise processing.ProviderTransientError("temporary hiccup")
|
||||
return await original(audio, mime, language_hint=language_hint)
|
||||
|
||||
stt.transcribe = flaky
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
use_inline(monkeypatch)
|
||||
monkeypatch.setattr(inline_queue, "RETRY_DELAY_SECONDS", 0.05)
|
||||
await inline_queue.start()
|
||||
try:
|
||||
token = await user_tokens(client, email="inline2@shonar.dev")
|
||||
rec = await upload_recording(client, token, client_id="inline-2")
|
||||
statuses = await _wait_terminal(rec["id"], want=2)
|
||||
assert statuses[JobType.transcribe] == JobStatus.succeeded
|
||||
assert state["tries"] >= 2 # the retry actually happened
|
||||
finally:
|
||||
await inline_queue.stop()
|
||||
|
||||
|
||||
async def test_reprocess_reruns_in_place_with_model(client, monkeypatch):
|
||||
"""POST /reprocess?job=transcribe&model= re-runs the pipeline on the
|
||||
SAME recording (no re-upload) and persists the model override."""
|
||||
stt = FakeTranscriber()
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
use_inline(monkeypatch)
|
||||
await inline_queue.start()
|
||||
try:
|
||||
token = await user_tokens(client, email="reproc@shonar.dev")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = await upload_recording(client, token, client_id="reproc-1")
|
||||
await _wait_terminal(rec["id"], want=2)
|
||||
calls_before = stt.calls
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe&model=small",
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
statuses = await _wait_terminal(rec["id"], want=2)
|
||||
assert statuses[JobType.transcribe] == JobStatus.succeeded
|
||||
assert stt.calls == calls_before + 1 # re-ran, no new recording
|
||||
|
||||
# Same recording id, model override persisted on the row.
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["id"] == rec["id"]
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
finally:
|
||||
await inline_queue.stop()
|
||||
|
||||
|
||||
async def test_reprocess_validation(client, monkeypatch):
|
||||
token = await user_tokens(client, email="reproc2@shonar.dev")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = await upload_recording(client, token, client_id="reproc-2")
|
||||
# Nothing transcribed yet -> summarize refuses.
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=summarize", headers=h)
|
||||
assert r.status_code == 409
|
||||
# Bad model name -> 422 (never silently substituted).
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe&model=not-a-model",
|
||||
headers=h)
|
||||
assert r.status_code == 422
|
||||
# Not the owner -> 404.
|
||||
other = await user_tokens(client, email="reproc3@shonar.dev")
|
||||
r = await client.post(
|
||||
f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe",
|
||||
headers={"Authorization": f"Bearer {other}"})
|
||||
assert r.status_code == 404
|
||||
304
backend/tests/test_m9.py
Normal file
304
backend/tests/test_m9.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""M9 tests: search, exports, retention sweep.
|
||||
|
||||
Search runs against the real backend dialect (Postgres in CI/dev, SQLite
|
||||
via SHONAR_TEST_DATABASE_URL) — both paths share the endpoint contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from tests.test_recordings import auth, user_tokens, wav_bytes
|
||||
|
||||
|
||||
async def make_recording(client, token, title, notes=None, tags=None, recorded_at=None):
|
||||
h = await auth(token)
|
||||
audio = wav_bytes()
|
||||
body = {"declared_mime_type": "audio/wav", "declared_size_bytes": len(audio), "title": title}
|
||||
r = await client.post("/api/v1/uploads", json=body, headers=h)
|
||||
sid = r.json()["id"]
|
||||
await client.put(
|
||||
f"/api/v1/uploads/{sid}/chunks/0", content=audio,
|
||||
headers={**h, "content-type": "application/octet-stream"},
|
||||
)
|
||||
fin = {"duration_seconds": 5.0}
|
||||
if recorded_at:
|
||||
fin["recorded_at"] = recorded_at
|
||||
if notes:
|
||||
fin["notes"] = notes
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize", json=fin, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
rec = r.json()
|
||||
if tags:
|
||||
r = await client.patch(
|
||||
f"/api/v1/recordings/{rec['id']}", json={"tags": tags}, headers=h
|
||||
)
|
||||
assert r.status_code == 200
|
||||
return rec["id"]
|
||||
|
||||
|
||||
async def add_transcript(rec_id: str, text: str, segments=None):
|
||||
"""Insert a transcript row directly (no AI provider under test)."""
|
||||
from shonar.db.models import Transcript
|
||||
from shonar.db.session import session_factory
|
||||
|
||||
async with session_factory()() as s:
|
||||
s.add(Transcript(recording_id=rec_id, text=text, segments=segments, provider="test"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def add_summary(rec_id: str, content: dict):
|
||||
from shonar.db.models import Summary
|
||||
from shonar.db.session import session_factory
|
||||
|
||||
async with session_factory()() as s:
|
||||
s.add(Summary(recording_id=rec_id, content=content, provider="test"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
# --- search -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_search_title_and_transcript(client):
|
||||
token = await user_tokens(client, email="m9s1@example.com")
|
||||
rid_t = await make_recording(client, token, "Quarterly budget review")
|
||||
rid_x = await make_recording(client, token, "Grocery list")
|
||||
await add_transcript(rid_x, "remember to buy kale chips and quinoa tonight")
|
||||
|
||||
r = await client.get("/api/v1/search", params={"q": "budget"}, headers=await auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["id"] == rid_t
|
||||
assert body["items"][0]["field"] == "title"
|
||||
|
||||
r = await client.get("/api/v1/search", params={"q": "quinoa"}, headers=await auth(token))
|
||||
body = r.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["id"] == rid_x
|
||||
assert body["items"][0]["field"] == "transcript"
|
||||
assert "quinoa" in body["items"][0]["snippet"].lower()
|
||||
|
||||
|
||||
async def test_search_scope_and_tag(client):
|
||||
token = await user_tokens(client, email="m9s2@example.com")
|
||||
rid = await make_recording(client, token, "Standup", tags=["daily"])
|
||||
await add_transcript(rid, "we discussed the daily standup format")
|
||||
|
||||
# tag scope finds by tag name
|
||||
r = await client.get(
|
||||
"/api/v1/search", params={"q": "daily", "scope": "tag"}, headers=await auth(token)
|
||||
)
|
||||
assert r.json()["total"] == 1
|
||||
assert r.json()["items"][0]["field"] == "tag"
|
||||
|
||||
# a transcript-only word does NOT match under scope=title
|
||||
r = await client.get(
|
||||
"/api/v1/search", params={"q": "discussed", "scope": "title"}, headers=await auth(token)
|
||||
)
|
||||
assert r.json()["total"] == 0
|
||||
r = await client.get(
|
||||
"/api/v1/search", params={"q": "discussed", "scope": "transcript"},
|
||||
headers=await auth(token),
|
||||
)
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
|
||||
async def test_search_isolation_and_deleted(client):
|
||||
token_a = await user_tokens(client, email="m9s3a@example.com")
|
||||
token_b = await user_tokens(client, email="m9s3b@example.com")
|
||||
rid = await make_recording(client, token_a, "secret sauce recipe")
|
||||
|
||||
h_b = await auth(token_b)
|
||||
r = await client.get("/api/v1/search", params={"q": "secret"}, headers=h_b)
|
||||
assert r.json()["total"] == 0 # other users' data invisible
|
||||
|
||||
# soft-deleted rows drop out of search
|
||||
await client.delete(f"/api/v1/recordings/{rid}", headers=await auth(token_a))
|
||||
r = await client.get(
|
||||
"/api/v1/search", params={"q": "secret"}, headers=await auth(token_a)
|
||||
)
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_search_summary_and_notes(client):
|
||||
token = await user_tokens(client, email="m9s4@example.com")
|
||||
rid = await make_recording(client, token, "Meeting", notes="bring the projector cable")
|
||||
await add_summary(rid, {"short": "sprint retro", "action_items": ["fix flaky test"]})
|
||||
|
||||
r = await client.get("/api/v1/search", params={"q": "projector"}, headers=await auth(token))
|
||||
assert r.json()["total"] == 1
|
||||
r = await client.get("/api/v1/search", params={"q": "flaky"}, headers=await auth(token))
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
|
||||
# --- list filters ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_list_filters_tag_status_date(client):
|
||||
token = await user_tokens(client, email="m9f1@example.com")
|
||||
await make_recording(client, token, "Old one", tags=["keep"],
|
||||
recorded_at="2020-01-01T10:00:00Z")
|
||||
rid_new = await make_recording(client, token, "New one", tags=["keep"],
|
||||
recorded_at="2026-01-01T10:00:00Z")
|
||||
h = await auth(token)
|
||||
|
||||
r = await client.get("/api/v1/recordings", params={"tag": "keep"}, headers=h)
|
||||
assert r.json()["total"] == 2
|
||||
r = await client.get(
|
||||
"/api/v1/recordings", params={"tag": "keep", "from_date": "2025-06-01T00:00:00Z"},
|
||||
headers=h,
|
||||
)
|
||||
body = r.json()
|
||||
assert body["total"] == 1 and body["items"][0]["id"] == rid_new
|
||||
|
||||
r = await client.get("/api/v1/recordings", params={"status": "ai_disabled"}, headers=h)
|
||||
assert r.json()["total"] == 2
|
||||
r = await client.get("/api/v1/recordings", params={"status": "bogus"}, headers=h)
|
||||
assert r.status_code == 422
|
||||
r = await client.get("/api/v1/recordings", params={"tag": "nope"}, headers=h)
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
# --- exports --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_export_formats(client):
|
||||
token = await user_tokens(client, email="m9e1@example.com")
|
||||
rid = await make_recording(client, token, "Retro & Planning", notes="retro notes here",
|
||||
tags=["team"])
|
||||
await add_transcript(
|
||||
rid, "first segment second segment",
|
||||
segments=[{"start": 0.0, "end": 2.5, "text": "first segment", "speaker": None},
|
||||
{"start": 2.5, "end": 5.0, "text": "second segment", "speaker": "S1"}],
|
||||
)
|
||||
await add_summary(rid, {"short": "one line", "action_items": ["do the thing"]})
|
||||
h = await auth(token)
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "txt"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.text == "first segment second segment"
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
assert ".txt" in r.headers["content-disposition"]
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "md"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert "# Retro & Planning" in r.text
|
||||
assert "do the thing" in r.text
|
||||
assert "`[00:02.5]` **S1**: second segment" in r.text
|
||||
assert "`team`" in r.text
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "zip"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/zip"
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as z:
|
||||
names = z.namelist()
|
||||
assert "transcript.txt" in names and "notes.md" in names
|
||||
assert any(n.endswith(".wav") for n in names)
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "audio"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.content.startswith(b"RIFF")
|
||||
|
||||
|
||||
async def test_export_missing_and_foreign(client):
|
||||
token = await user_tokens(client, email="m9e2@example.com")
|
||||
rid = await make_recording(client, token, "Bare") # no transcript/summary/notes
|
||||
token2 = await user_tokens(client, email="m9e2b@example.com")
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "txt"},
|
||||
headers=await auth(token))
|
||||
assert r.status_code == 404 # no transcript yet
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "md"},
|
||||
headers=await auth(token))
|
||||
assert r.status_code == 404
|
||||
# zip still works with just the audio
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "zip"},
|
||||
headers=await auth(token))
|
||||
assert r.status_code == 200
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "audio"},
|
||||
headers=await auth(token2))
|
||||
assert r.status_code == 404 # not yours
|
||||
|
||||
|
||||
# --- retention sweep --------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_retention_sweep_purges_expired(client):
|
||||
from datetime import timedelta
|
||||
|
||||
from shonar.db.models import Asset, Recording, utcnow
|
||||
from shonar.db.session import session_factory
|
||||
from shonar.services import retention
|
||||
|
||||
token = await user_tokens(client, email="m9r1@example.com")
|
||||
rid = await make_recording(client, token, "Doomed")
|
||||
h = await auth(token)
|
||||
|
||||
# Soft-delete, then push deleted_at past the grace window directly.
|
||||
r = await client.delete(f"/api/v1/recordings/{rid}", headers=h)
|
||||
assert r.status_code == 204
|
||||
async with session_factory()() as s:
|
||||
rec = await s.get(Recording, rid)
|
||||
rec.deleted_at = utcnow() - timedelta(days=31)
|
||||
# remember the storage key before the row vanishes
|
||||
from shonar.db.models import Asset
|
||||
|
||||
asset = (
|
||||
await s.execute(
|
||||
Asset.__table__.select().where(Asset.recording_id == rec.id) # noqa: SLF001
|
||||
)
|
||||
).first()
|
||||
storage_key = asset.storage_key if asset else None
|
||||
await s.commit()
|
||||
assert storage_key is not None
|
||||
|
||||
purged = await retention.sweep_deleted()
|
||||
assert purged["recordings"] == 1
|
||||
assert purged["files"] >= 1
|
||||
|
||||
async with session_factory()() as s:
|
||||
assert await s.get(Recording, rid) is None
|
||||
from shonar.storage import get_storage
|
||||
|
||||
assert not await get_storage().exists(storage_key) # file gone too
|
||||
|
||||
# Within the window, nothing is purged (cancellable).
|
||||
rid2 = await make_recording(client, token, "Fresh delete")
|
||||
await client.delete(f"/api/v1/recordings/{rid2}", headers=h)
|
||||
purged = await retention.sweep_deleted()
|
||||
assert purged["recordings"] == 0
|
||||
async with session_factory()() as s:
|
||||
assert await s.get(Recording, rid2) is not None
|
||||
|
||||
|
||||
async def test_retention_sweep_account(client):
|
||||
from datetime import timedelta
|
||||
|
||||
from shonar.db.models import Recording, utcnow
|
||||
from shonar.db.session import session_factory
|
||||
from shonar.services import retention
|
||||
|
||||
token = await user_tokens(client, email="m9r2@example.com")
|
||||
rid = await make_recording(client, token, "Gone with user")
|
||||
|
||||
async with session_factory()() as s:
|
||||
user = await s.scalar(select_user("m9r2@example.com"))
|
||||
user.deleted_at = utcnow() - timedelta(days=31)
|
||||
await s.commit()
|
||||
|
||||
purged = await retention.sweep_deleted()
|
||||
assert purged["users"] == 1
|
||||
async with session_factory()() as s:
|
||||
assert await s.get(Recording, rid) is None # cascade
|
||||
assert await s.scalar(select_user("m9r2@example.com")) is None
|
||||
|
||||
|
||||
def select_user(email: str):
|
||||
from sqlalchemy import select
|
||||
|
||||
from shonar.db.models import User
|
||||
|
||||
return select(User).where(User.email == email)
|
||||
245
backend/tests/test_models.py
Normal file
245
backend/tests/test_models.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Stage 1: per-recording transcription models + models API.
|
||||
|
||||
Global default (base) with optional per-recording overrides; the worker
|
||||
uses the exact saved model; history is never rewritten; unknown models
|
||||
are rejected; missing downloads fail fast with instructions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import Recording
|
||||
from shonar.services import processing
|
||||
|
||||
|
||||
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||
data = data[:payload_len]
|
||||
return (
|
||||
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
|
||||
+ b"data" + struct.pack("<I", len(data))
|
||||
) + data
|
||||
|
||||
|
||||
AUTH = {"email": "models@example.com", "password": "models-test-passw0rd-123"}
|
||||
|
||||
|
||||
async def user_tokens(client):
|
||||
r = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": AUTH["email"], "password": AUTH["password"]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
async def upload_recording(client, token, client_id, session_model=None, finalize_model=None):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
data = wav_bytes()
|
||||
body = {"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
|
||||
"client_recording_id": client_id}
|
||||
if session_model is not None:
|
||||
body["transcription_model"] = session_model
|
||||
r = await client.post("/api/v1/uploads", json=body, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
sid = r.json()["id"]
|
||||
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream"})
|
||||
assert r.status_code == 201, r.text
|
||||
fbody = {"duration_seconds": 5.0}
|
||||
if finalize_model is not None:
|
||||
fbody["transcription_model"] = finalize_model
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize", json=fbody, headers=h)
|
||||
return r
|
||||
|
||||
|
||||
async def test_models_endpoint_lists_five(client):
|
||||
token = await user_tokens(client)
|
||||
r = await client.get("/api/v1/models", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["default_model"] == "base"
|
||||
assert [m["name"] for m in body["models"]] == ["tiny", "base", "small", "medium", "large-v3"]
|
||||
base = next(m for m in body["models"] if m["name"] == "base")
|
||||
assert base["is_default"] is True
|
||||
assert "ordinary computers" in base["description"]
|
||||
for m in body["models"]:
|
||||
assert isinstance(m["downloaded"], bool)
|
||||
assert isinstance(m["available"], bool)
|
||||
|
||||
|
||||
async def test_default_get_put_validation(client):
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get("/api/v1/models/default", headers=h)
|
||||
assert r.json() == {"default_model": "base"}
|
||||
r = await client.put("/api/v1/models/default", json={"model": "small"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"default_model": "small"}
|
||||
r = await client.put("/api/v1/models/default", json={"model": "xxl-turbo"}, headers=h)
|
||||
assert r.status_code == 422
|
||||
assert "Supported models" in r.text
|
||||
# rejected change did not stick
|
||||
r = await client.get("/api/v1/models/default", headers=h)
|
||||
assert r.json() == {"default_model": "small"}
|
||||
|
||||
|
||||
async def test_finalize_override_and_default_history(client):
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await upload_recording(client, token, "m-override-1", finalize_model="small")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
|
||||
r = await upload_recording(client, token, "m-default-1")
|
||||
assert r.json()["transcription_model"] == "base"
|
||||
|
||||
# Changing the default affects future rows only.
|
||||
r = await client.put("/api/v1/models/default", json={"model": "tiny"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
r = await upload_recording(client, token, "m-default-2")
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
|
||||
async with db_session._session_factory() as s:
|
||||
got = dict((await s.execute(
|
||||
select(Recording.client_recording_id, Recording.transcription_model)
|
||||
.where(Recording.client_recording_id.in_(
|
||||
["m-override-1", "m-default-1", "m-default-2"])))
|
||||
).all())
|
||||
assert got == {"m-override-1": "small", "m-default-1": "base", "m-default-2": "tiny"}
|
||||
|
||||
|
||||
async def test_session_override_and_finalize_wins(client):
|
||||
token = await user_tokens(client)
|
||||
r = await upload_recording(client, token, "m-sess-1", session_model="small")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
|
||||
r = await upload_recording(client, token, "m-sess-2", session_model="small",
|
||||
finalize_model="tiny")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
|
||||
|
||||
async def test_finalize_invalid_override_rejected(client):
|
||||
token = await user_tokens(client)
|
||||
r = await upload_recording(client, token, "m-bad-1", finalize_model="xxl-turbo")
|
||||
assert r.status_code == 422
|
||||
assert "Supported models" in r.text
|
||||
|
||||
|
||||
class SpyTranscriber:
|
||||
"""Stands in for FasterWhisperProvider; records the model it was built with."""
|
||||
|
||||
name = "faster_whisper"
|
||||
seen_models: list = []
|
||||
|
||||
def __init__(self, model: str = "base"):
|
||||
type(self).seen_models.append(model)
|
||||
self.model = model
|
||||
|
||||
async def transcribe(self, audio, mime, *, language_hint=None):
|
||||
from shonar.services.ai import Segment, TranscriptResult
|
||||
|
||||
return TranscriptResult(
|
||||
text="spy text", language="en",
|
||||
segments=[Segment(0.0, 1.0, "spy text")], model=self.model,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_spy():
|
||||
SpyTranscriber.seen_models = []
|
||||
yield
|
||||
SpyTranscriber.seen_models = []
|
||||
|
||||
|
||||
async def _run_with_spy(monkeypatch, recording_id):
|
||||
import shonar.services.ai.faster_whisper as fw
|
||||
|
||||
# The worker rebuilds a faster_whisper provider from the saved model;
|
||||
# the late import inside run_transcribe picks up this spy.
|
||||
monkeypatch.setattr(fw, "FasterWhisperProvider", SpyTranscriber)
|
||||
monkeypatch.setattr(
|
||||
processing, "get_transcription_provider",
|
||||
lambda settings: SpyTranscriber(model="ignored"),
|
||||
)
|
||||
await processing.run_transcribe({}, recording_id)
|
||||
|
||||
|
||||
async def test_worker_uses_saved_model(client, monkeypatch):
|
||||
token = await user_tokens(client)
|
||||
rec = (await upload_recording(client, token, "m-work-1", finalize_model="small")).json()
|
||||
await _run_with_spy(monkeypatch, rec["id"])
|
||||
# Last construction is the worker rebuild from the saved model.
|
||||
assert SpyTranscriber.seen_models[-1] == "small"
|
||||
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["model"] == "small"
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
job = next(j for j in r.json() if j["job_type"] == "transcribe")
|
||||
assert job["status"] == "succeeded"
|
||||
assert job["stage"] is None
|
||||
assert job["progress"] == 100
|
||||
|
||||
|
||||
async def test_worker_keeps_history_after_default_change(client, monkeypatch):
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = (await upload_recording(client, token, "m-work-2")).json()
|
||||
assert rec["transcription_model"] == "base"
|
||||
r = await client.put("/api/v1/models/default", json={"model": "tiny"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
await _run_with_spy(monkeypatch, rec["id"])
|
||||
assert SpyTranscriber.seen_models[-1] == "base"
|
||||
|
||||
|
||||
async def test_unavailable_model_fails_with_instructions(client, monkeypatch):
|
||||
import shonar.services.ai.faster_whisper as fw
|
||||
from shonar.services.ai.faster_whisper import FasterWhisperProvider
|
||||
|
||||
# Real provider class, but the model is (simulated) not downloaded.
|
||||
monkeypatch.setattr(fw, "is_model_downloaded", lambda name: False)
|
||||
monkeypatch.setattr(
|
||||
processing, "get_transcription_provider",
|
||||
lambda settings: FasterWhisperProvider(model="base"),
|
||||
)
|
||||
token = await user_tokens(client)
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rec = (await upload_recording(client, token, "m-work-3")).json()
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "failed"
|
||||
assert "not downloaded" in (r.json()["processing_error"] or "")
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||
job = next(j for j in r.json() if j["job_type"] == "transcribe")
|
||||
assert job["status"] == "failed"
|
||||
assert "download" in (job["error"] or "").lower()
|
||||
|
||||
|
||||
async def test_reupload_explicit_override_updates_model(client):
|
||||
token = await user_tokens(client)
|
||||
r = await upload_recording(client, token, "m-reup-1", finalize_model="small")
|
||||
assert r.json()["transcription_model"] == "small"
|
||||
# Same client id, new explicit choice: model history moves with it.
|
||||
r = await upload_recording(client, token, "m-reup-1", finalize_model="tiny")
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
# Same client id, no choice: history untouched.
|
||||
r = await upload_recording(client, token, "m-reup-1")
|
||||
assert r.json()["transcription_model"] == "tiny"
|
||||
|
||||
|
||||
async def test_models_need_auth(client):
|
||||
r = await client.get("/api/v1/models")
|
||||
assert r.status_code in (401, 403)
|
||||
35
backend/tests/test_provider_info.py
Normal file
35
backend/tests/test_provider_info.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""provider-info handshake tests (P2/P3 client probing depends on this)."""
|
||||
|
||||
|
||||
async def test_provider_info_identifies_shonar(client):
|
||||
r = await client.get("/api/v1/provider-info")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["kind"] == "shonar"
|
||||
assert body["api_version"] == "v1"
|
||||
caps = body["capabilities"]
|
||||
assert caps["chunked_upload"] is True
|
||||
assert caps["account_deletion"] is True
|
||||
# test env configures no AI providers -> flags must be false
|
||||
assert caps["server_transcription"] is False
|
||||
assert caps["server_summary"] is False
|
||||
assert body["storage_backend"] in ("local", "s3")
|
||||
|
||||
|
||||
async def test_provider_info_leaks_no_paths_or_secrets(client):
|
||||
r = await client.get("/api/v1/provider-info")
|
||||
body = r.json()
|
||||
# every string value must be a bare identifier, never a filesystem path
|
||||
def walk(v):
|
||||
if isinstance(v, str):
|
||||
assert not v.startswith("/"), f"path-like value in handshake: {v!r}"
|
||||
low = v.lower()
|
||||
for banned in ("secret", "token", "password", "key"):
|
||||
assert banned not in low
|
||||
elif isinstance(v, dict):
|
||||
for x in v.values():
|
||||
walk(x)
|
||||
elif isinstance(v, list):
|
||||
for x in v:
|
||||
walk(x)
|
||||
walk(body)
|
||||
264
backend/tests/test_recordings.py
Normal file
264
backend/tests/test_recordings.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""Upload sessions + recordings CRUD tests (M2).
|
||||
|
||||
Uses real WAV magic bytes; validation is byte-level, so fakes would be
|
||||
testing the wrong thing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
|
||||
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||
data = data[:payload_len]
|
||||
header = (
|
||||
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
|
||||
+ b"data" + struct.pack("<I", len(data))
|
||||
)
|
||||
return header + data
|
||||
|
||||
|
||||
def mp4_bytes() -> bytes:
|
||||
return b"\x00\x00\x00 ftypM4A " + b"\x00" * 64
|
||||
|
||||
|
||||
AUTH = {"email": "m2@example.com",
|
||||
"password": "m2-test-" + "passw0rd-123"}
|
||||
|
||||
|
||||
async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]):
|
||||
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
async def auth(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def upload_full(client, token: str, data: bytes, mime="audio/wav",
|
||||
client_id=None, title=None):
|
||||
h = await auth(token)
|
||||
r = await client.post(
|
||||
"/api/v1/uploads",
|
||||
json={"declared_mime_type": mime, "declared_size_bytes": len(data),
|
||||
"client_recording_id": client_id, "title": title},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
sid = r.json()["id"]
|
||||
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream"})
|
||||
assert r.status_code == 201, r.text
|
||||
r = await client.post(
|
||||
f"/api/v1/uploads/{sid}/finalize",
|
||||
json={"duration_seconds": 12.5},
|
||||
headers=h,
|
||||
)
|
||||
return sid, r
|
||||
|
||||
|
||||
# --- upload session ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_upload_happy_path_creates_recording(client):
|
||||
token = await user_tokens(client)
|
||||
data = wav_bytes()
|
||||
sid, r = await upload_full(client, token, data, title="Standup")
|
||||
assert r.status_code == 201, r.text
|
||||
rec = r.json()
|
||||
assert rec["title"] == "Standup"
|
||||
assert rec["has_audio"] is True
|
||||
# M7: with no AI configured the pipeline marks audio-only explicitly.
|
||||
assert rec["processing_status"] == "ai_disabled"
|
||||
assert rec["duration_seconds"] == 12.5
|
||||
# No storage keys or internals leak.
|
||||
assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "")
|
||||
|
||||
|
||||
async def test_upload_rejects_bad_mime_declared(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
r = await client.post("/api/v1/uploads",
|
||||
json={"declared_mime_type": "application/x-msdownload",
|
||||
"declared_size_bytes": 100}, headers=h)
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
async def test_upload_rejects_oversize(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
r = await client.post("/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav",
|
||||
"declared_size_bytes": 5 * 1024**3}, headers=h)
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
async def test_finalize_rejects_bytes_not_matching_mime(client):
|
||||
token = await user_tokens(client)
|
||||
data = mp4_bytes()
|
||||
_sid, r = await upload_full(client, token, data, mime="audio/wav")
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
async def test_finalize_rejects_size_mismatch(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
data = wav_bytes()
|
||||
r = await client.post("/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav",
|
||||
"declared_size_bytes": len(data) + 10}, headers=h)
|
||||
sid = r.json()["id"]
|
||||
await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream"})
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize", json={}, headers=h)
|
||||
assert r.status_code == 422
|
||||
detail = r.json()["detail"].lower()
|
||||
assert "missing chunks" in detail or "size mismatch" in detail
|
||||
|
||||
|
||||
async def test_chunk_resume_status_and_idempotency(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
data = wav_bytes()
|
||||
r = await client.post("/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav",
|
||||
"declared_size_bytes": len(data)}, headers=h)
|
||||
sid = r.json()["id"]
|
||||
r = await client.get(f"/api/v1/uploads/{sid}", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["received_chunk_indexes"] == []
|
||||
hdr = {**h, "content-type": "application/octet-stream"}
|
||||
await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, headers=hdr)
|
||||
# Duplicate PUT of chunk 0 (retry) must not duplicate or corrupt.
|
||||
await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, headers=hdr)
|
||||
r = await client.get(f"/api/v1/uploads/{sid}", headers=h)
|
||||
assert r.json()["received_chunk_indexes"] == [0]
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize", json={}, headers=h)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
async def test_chunk_checksum_enforced(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
data = wav_bytes()
|
||||
r = await client.post("/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav",
|
||||
"declared_size_bytes": len(data)}, headers=h)
|
||||
sid = r.json()["id"]
|
||||
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream",
|
||||
"x-chunk-sha256": "0" * 64})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_finalize_idempotent_per_client_recording_id(client):
|
||||
token = await user_tokens(client)
|
||||
cid = str(uuid.uuid4())
|
||||
_sid1, r1 = await upload_full(client, token, wav_bytes(64), client_id=cid)
|
||||
_sid2, r2 = await upload_full(client, token, wav_bytes(64), client_id=cid, title="Renamed")
|
||||
assert r1.status_code == 201 and r2.status_code == 201
|
||||
# Same recording id, original preserved, metadata updated.
|
||||
assert r1.json()["id"] == r2.json()["id"]
|
||||
assert r2.json()["title"] == "Renamed"
|
||||
|
||||
|
||||
# --- ownership ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cross_user_isolation(client):
|
||||
ta = await user_tokens(client, "a@example.com")
|
||||
tb = await user_tokens(client, "b@example.com")
|
||||
_sid, r = await upload_full(client, ta, wav_bytes())
|
||||
rec_id = r.json()["id"]
|
||||
r = await client.get(f"/api/v1/recordings/{rec_id}", headers=await auth(tb))
|
||||
assert r.status_code == 404
|
||||
r = await client.get(f"/api/v1/recordings/{rec_id}/audio", headers=await auth(tb))
|
||||
assert r.status_code == 404
|
||||
r = await client.get("/api/v1/recordings", headers=await auth(tb))
|
||||
assert r.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_uploads_require_auth(client):
|
||||
r = await client.get("/api/v1/recordings")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# --- recordings CRUD -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_update_metadata_and_tags(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
_sid, r = await upload_full(client, token, wav_bytes())
|
||||
rec_id = r.json()["id"]
|
||||
r = await client.patch(f"/api/v1/recordings/{rec_id}",
|
||||
json={"title": "Sync meeting", "notes": "n1",
|
||||
"tags": ["Work", " meeting ", "work"]}, headers=h)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["title"] == "Sync meeting"
|
||||
assert body["tags"] == ["meeting", "work"] # normalized, deduped, sorted
|
||||
# listing shows same
|
||||
r = await client.get("/api/v1/recordings", headers=h)
|
||||
assert r.json()["total"] == 1
|
||||
assert r.json()["items"][0]["tags"] == ["meeting", "work"]
|
||||
|
||||
|
||||
async def test_location_dropped_without_consent(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
_sid, r = await upload_full(client, token, wav_bytes())
|
||||
rec_id = r.json()["id"]
|
||||
r = await client.patch(f"/api/v1/recordings/{rec_id}",
|
||||
json={"latitude": 41.8, "longitude": -87.6}, headers=h)
|
||||
assert r.json()["latitude"] is None
|
||||
# enable consent
|
||||
await client.patch("/api/v1/users/me", json={"location_storage_enabled": True}, headers=h)
|
||||
_sid2, r2 = await upload_full(client, token, wav_bytes(128), client_id=str(uuid.uuid4()))
|
||||
rid2 = r2.json()["id"]
|
||||
r = await client.patch(f"/api/v1/recordings/{rid2}",
|
||||
json={"latitude": 41.8, "longitude": -87.6}, headers=h)
|
||||
assert r.json()["latitude"] == 41.8
|
||||
|
||||
|
||||
async def test_soft_then_purge_delete(client, storage_root):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
_sid, r = await upload_full(client, token, wav_bytes())
|
||||
rec_id = r.json()["id"]
|
||||
|
||||
files_before = list(storage_root.rglob("*"))
|
||||
assert any(p.is_file() for p in files_before)
|
||||
|
||||
r = await client.delete(f"/api/v1/recordings/{rec_id}", headers=h)
|
||||
assert r.status_code == 204
|
||||
r = await client.get(f"/api/v1/recordings/{rec_id}", headers=h)
|
||||
assert r.status_code == 404
|
||||
|
||||
# Purge deletes rows AND stored files.
|
||||
token2 = await user_tokens(client, "p2@example.com")
|
||||
h2 = await auth(token2)
|
||||
_sid, r = await upload_full(client, token2, wav_bytes(96))
|
||||
rec2 = r.json()["id"]
|
||||
r = await client.delete(f"/api/v1/recordings/{rec2}?purge=true", headers=h2)
|
||||
assert r.status_code == 204
|
||||
remaining = [p for p in storage_root.rglob("*") if p.is_file() and f"{rec2}" in str(p)]
|
||||
assert remaining == []
|
||||
|
||||
|
||||
async def test_download_audio_roundtrip(client):
|
||||
token = await user_tokens(client)
|
||||
h = await auth(token)
|
||||
data = wav_bytes(128)
|
||||
_sid, r = await upload_full(client, token, data)
|
||||
rec_id = r.json()["id"]
|
||||
r = await client.get(f"/api/v1/recordings/{rec_id}/audio", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.content == data
|
||||
assert r.headers["content-type"] == "audio/wav"
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
assert "no-store" in r.headers["cache-control"]
|
||||
189
backend/tests/test_transcript_edits.py
Normal file
189
backend/tests/test_transcript_edits.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""M8: user edits to transcript/summary via PUT endpoints.
|
||||
|
||||
Edits create a new version with edited_by_user=True; the AI pipeline
|
||||
must not overwrite them afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from shonar.db import session as db_session
|
||||
from shonar.db.models import Transcript
|
||||
from shonar.services import processing
|
||||
|
||||
AUTH = {"email": "m8@example.com", "password": "m8-test-passw0rd-123"}
|
||||
|
||||
|
||||
async def user_tokens(client):
|
||||
r = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": AUTH["email"], "password": AUTH["password"]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||
data = data[:payload_len]
|
||||
header = (
|
||||
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
|
||||
+ b"data" + struct.pack("<I", len(data))
|
||||
)
|
||||
return header + data
|
||||
|
||||
|
||||
async def upload_recording(client, token, client_id=None):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
data = wav_bytes()
|
||||
r = await client.post(
|
||||
"/api/v1/uploads",
|
||||
json={"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
|
||||
"client_recording_id": client_id},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
sid = r.json()["id"]
|
||||
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||
headers={**h, "content-type": "application/octet-stream"})
|
||||
assert r.status_code == 201, r.text
|
||||
r = await client.post(f"/api/v1/uploads/{sid}/finalize",
|
||||
json={"duration_seconds": 5.0}, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def test_put_transcript_creates_user_version(client):
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m8-t-1")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rid = rec["id"]
|
||||
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
|
||||
json={"text": "hello corrected"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["version"] == 1
|
||||
assert body["text"] == "hello corrected"
|
||||
assert body["edited_by_user"] is True
|
||||
assert body["provider"] == "user"
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/transcript", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["text"] == "hello corrected"
|
||||
|
||||
# Second edit supersedes the first.
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
|
||||
json={"text": "second pass",
|
||||
"segments": [{"start": 0.0, "end": 1.0, "text": "second pass"}]},
|
||||
headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["version"] == 2
|
||||
assert r.json()["segments"][0]["text"] == "second pass"
|
||||
|
||||
async with db_session._session_factory() as s:
|
||||
rows = (await s.scalars(
|
||||
select(Transcript).where(Transcript.recording_id == uuid.UUID(rid))
|
||||
.order_by(Transcript.version))).all()
|
||||
assert [(r.version, r.superseded_at is not None, r.edited_by_user) for r in rows] == [
|
||||
(1, True, True), (2, False, True)]
|
||||
|
||||
|
||||
async def test_put_summary_creates_user_version(client):
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m8-s-1")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rid = rec["id"]
|
||||
|
||||
content = {"short": "s", "action_items": ["ship it"]}
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/summary",
|
||||
json={"content": content}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["version"] == 1
|
||||
assert body["content"] == content
|
||||
assert body["edited_by_user"] is True
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/summary", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content"]["action_items"] == ["ship it"]
|
||||
|
||||
|
||||
async def test_user_edit_survives_auto_pipeline(client, monkeypatch):
|
||||
"""An auto transcribe run after a user edit inserts nothing."""
|
||||
from shonar.services.ai import Segment as AiSegment
|
||||
from shonar.services.ai import SummaryResult, TranscriptResult
|
||||
|
||||
class FakeTranscriber:
|
||||
name = "fake-stt"
|
||||
|
||||
async def transcribe(self, audio, mime, *, language_hint=None):
|
||||
return TranscriptResult(
|
||||
text="auto text", language="en",
|
||||
segments=[AiSegment(0.0, 1.0, "auto text")], model="fake-stt-1",
|
||||
)
|
||||
|
||||
class FakeLlm:
|
||||
name = "fake-llm"
|
||||
|
||||
async def summarize(self, transcript, *, title=None):
|
||||
return SummaryResult(short="s", detailed="d", key_points=(),
|
||||
decisions=(), action_items=(), questions=(),
|
||||
model="fake-llm-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
processing, "get_transcription_provider", lambda settings: FakeTranscriber())
|
||||
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: FakeLlm())
|
||||
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m8-t-2")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rid = rec["id"]
|
||||
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
|
||||
json={"text": "user verdict"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
monkeypatch.setattr(
|
||||
processing, "get_transcription_provider", lambda settings: FakeTranscriber())
|
||||
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: FakeLlm())
|
||||
await processing.run_transcribe({}, rid)
|
||||
|
||||
r = await client.get(f"/api/v1/recordings/{rid}/transcript", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["text"] == "user verdict"
|
||||
|
||||
|
||||
async def test_edit_endpoints_enforce_ownership(client):
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m8-t-3")
|
||||
rid = rec["id"]
|
||||
|
||||
r = await client.post("/api/v1/auth/register",
|
||||
json={"email": "m8-other@example.com", "password": "m8-other-passw0rd-1"})
|
||||
assert r.status_code == 201, r.text
|
||||
other = r.json()["access_token"]
|
||||
h2 = {"Authorization": f"Bearer {other}"}
|
||||
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
|
||||
json={"text": "hijack"}, headers=h2)
|
||||
assert r.status_code == 404
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/summary",
|
||||
json={"content": {}}, headers=h2)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_edit_validation(client):
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m8-t-4")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
rid = rec["id"]
|
||||
|
||||
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
|
||||
json={"text": ""}, headers=h)
|
||||
assert r.status_code == 422
|
||||
Loading…
Add table
Add a link
Reference in a new issue