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:
avi 2026-09-14 17:14:54 -05:00
commit 76c867fca4
136 changed files with 21099 additions and 0 deletions

View file

113
backend/tests/conftest.py Normal file
View 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)

View 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")

View 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
View 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

View 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

View 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
View 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)

View 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)

View 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)

View 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"]

View 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