chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
View File
+198
View File
@@ -0,0 +1,198 @@
"""Regression tests for #481 and the auth-dep refactor.
When ``require_auth`` was declared as a sync ``def``, FastAPI dispatched it
through ``anyio.to_thread.run_sync``, which executes the function in a worker
thread under a *copy* of the request context. Any ``ContextVar.set`` inside
that thread is discarded when the thread returns, so the endpoint reads the
unset default. The user-scoped path service then silently falls back to the
admin workspace and non-admin users hit 404 on every session request.
These tests pin three invariants:
1. ``require_auth`` and ``require_admin`` are declared ``async``.
2. ``_install_current_user`` is the single point of truth for the
payload-to-CurrentUser mapping used by both HTTP and WebSocket entry
points (``None`` → local admin, payload → ``user_from_token_payload``).
3. With ``AUTH_ENABLED=true`` and a valid token, the user ContextVar set
inside ``require_auth`` is visible from inside the endpoint, so
``get_path_service()`` resolves to the per-user workspace.
"""
from __future__ import annotations
import inspect
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
def test_require_auth_is_async_def() -> None:
from deeptutor.api.routers.auth import require_admin, require_auth
assert inspect.iscoroutinefunction(require_auth), (
"require_auth must be async — a sync dep is run in a threadpool whose "
"ContextVar mutations don't propagate back to the endpoint. See #481."
)
assert inspect.iscoroutinefunction(require_admin), (
"require_admin must be async for the same reason."
)
def test_install_current_user_maps_none_to_local_admin() -> None:
"""``_install_current_user(None)`` is the AUTH_ENABLED=false branch
for both HTTP and WS deps. It must install the local admin user so
that ``get_current_path_service()`` resolves to the admin workspace
rather than silently falling back through the None path."""
from deeptutor.api.routers.auth import _install_current_user
from deeptutor.multi_user.context import get_current_user_or_none, reset_current_user
from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
token = _install_current_user(None)
try:
user = get_current_user_or_none()
assert user is not None
assert user.id == LOCAL_ADMIN_ID
assert user.username == LOCAL_ADMIN_USERNAME
assert user.role == "admin"
assert user.scope.kind == "admin"
finally:
reset_current_user(token)
def test_install_current_user_maps_payload_to_scoped_user() -> None:
"""``_install_current_user(payload)`` must produce a user-scoped
CurrentUser whose workspace root lives under USERS_ROOT — the
same shape that ``ws_require_auth`` and HTTP deps need to install."""
from deeptutor.api.routers.auth import _install_current_user
from deeptutor.multi_user.context import get_current_user_or_none, reset_current_user
from deeptutor.services.auth import TokenPayload
token = _install_current_user(TokenPayload(username="alice", role="user", user_id="u_alice"))
try:
user = get_current_user_or_none()
assert user is not None
assert user.id == "u_alice"
assert user.username == "alice"
assert user.role == "user"
assert user.scope.kind == "user"
finally:
reset_current_user(token)
def test_local_admin_token_payload_matches_local_admin_user() -> None:
"""The synthetic admin TokenPayload returned by ``require_admin`` when
AUTH_ENABLED=false must use the same identity constants as
``local_admin_user()`` — drift between the two reintroduces the kind
of dual-source-of-truth bug that #481 lived in."""
from deeptutor.api.routers.auth import _local_admin_token_payload
from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
from deeptutor.multi_user.paths import local_admin_user
tp = _local_admin_token_payload()
user = local_admin_user()
assert tp.username == LOCAL_ADMIN_USERNAME == user.username
assert tp.user_id == LOCAL_ADMIN_ID == user.id
assert tp.role == "admin" == user.role
def test_require_auth_propagates_user_contextvar_to_endpoint(monkeypatch) -> None:
"""End-to-end: a valid token through require_auth makes the user
ContextVar visible to the endpoint."""
from deeptutor.api.routers import auth as auth_router
from deeptutor.multi_user.context import get_current_user_or_none
from deeptutor.services.auth import TokenPayload
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
monkeypatch.setattr(
auth_router,
"decode_token",
lambda _t: TokenPayload(username="alice", role="user", user_id="u_alice"),
)
app = FastAPI()
@app.get("/whoami")
async def whoami(_=Depends(auth_router.require_auth)) -> dict:
user = get_current_user_or_none()
if user is None:
return {"seen": None}
return {"seen": user.username, "role": user.role, "scope_kind": user.scope.kind}
with TestClient(app) as client:
resp = client.get("/whoami", headers={"Authorization": "Bearer test-token"})
assert resp.status_code == 200
body = resp.json()
assert body["seen"] == "alice", (
"Endpoint should observe the user ContextVar set inside require_auth. "
"If this returns None the dependency is being run in a threadpool and "
"the ContextVar mutation is discarded — see #481."
)
assert body["role"] == "user"
assert body["scope_kind"] == "user"
def test_require_auth_propagates_admin_contextvar_to_endpoint(monkeypatch) -> None:
from deeptutor.api.routers import auth as auth_router
from deeptutor.multi_user.context import get_current_user_or_none
from deeptutor.services.auth import TokenPayload
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
monkeypatch.setattr(
auth_router,
"decode_token",
lambda _t: TokenPayload(username="root", role="admin", user_id="u_root"),
)
app = FastAPI()
@app.get("/whoami")
async def whoami(_=Depends(auth_router.require_auth)) -> dict:
user = get_current_user_or_none()
return {"role": None if user is None else user.role}
with TestClient(app) as client:
resp = client.get("/whoami", headers={"Authorization": "Bearer test-token"})
assert resp.status_code == 200
assert resp.json() == {"role": "admin"}
def test_path_service_resolves_per_user_workspace_through_dependency(monkeypatch, tmp_path) -> None:
"""The full chain that the reporter exercised in #481: a non-admin
request lands on an endpoint that calls ``get_path_service()`` and
that path service must point at ``data/users/<uid>/``, not the
admin fallback."""
from deeptutor.api.routers import auth as auth_router
from deeptutor.multi_user import paths as mu_paths
from deeptutor.services.auth import TokenPayload
from deeptutor.services.path_service import get_path_service
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
monkeypatch.setattr(mu_paths, "USERS_ROOT", tmp_path / "data" / "users")
monkeypatch.setattr(mu_paths, "_path_services", {})
monkeypatch.setattr(
auth_router,
"decode_token",
lambda _t: TokenPayload(username="alice", role="user", user_id="u_alice"),
)
app = FastAPI()
@app.get("/db-path")
async def db_path(_=Depends(auth_router.require_auth)) -> dict:
service = get_path_service()
return {"chat_db": str(service.get_chat_history_db())}
with TestClient(app) as client:
resp = client.get("/db-path", headers={"Authorization": "Bearer test-token"})
assert resp.status_code == 200
chat_db = resp.json()["chat_db"]
expected_root = str((tmp_path / "data" / "users" / "u_alice").resolve())
assert chat_db.startswith(expected_root), (
"Per-user request should resolve under the user's USERS_ROOT scope. "
f"Expected prefix {expected_root!r}, got: {chat_db!r}. If this fails, the "
"ContextVar mutation in require_auth is not reaching the endpoint — "
"see #481."
)
+141
View File
@@ -0,0 +1,141 @@
"""Co-Writer backend tests: doc id validation, storage CRUD, history limits."""
from pathlib import Path
from fastapi import HTTPException
import pytest
from deeptutor.api.routers.co_writer import _validate_doc_id
from deeptutor.co_writer import edit_agent
from deeptutor.co_writer.storage import CoWriterStorage
class _StubPathService:
def __init__(self, root: Path):
self.root = root
def get_co_writer_dir(self) -> Path:
return self.root
def get_co_writer_history_file(self) -> Path:
return self.root / "history.json"
def get_co_writer_tool_calls_dir(self) -> Path:
return self.root / "tool_calls"
def get_co_writer_docs_dir(self) -> Path:
return self.root / "documents"
def get_co_writer_doc_root(self, doc_id: str) -> Path:
return self.get_co_writer_docs_dir() / f"doc_{doc_id}"
def get_co_writer_doc_manifest(self, doc_id: str) -> Path:
return self.get_co_writer_doc_root(doc_id) / "manifest.json"
# ── doc_id validation ────────────────────────────────────────────────────
def test_validate_doc_id_accepts_generated_ids():
assert _validate_doc_id("a1b2c3d4e5f6") == "a1b2c3d4e5f6"
@pytest.mark.parametrize(
"bad",
[
"../x",
"a/../../etc",
"a1b2c3d4e5f6/../../x",
"doc_1; rm -rf",
"A1B2C3D4E5F6",
"",
"a" * 40,
],
)
def test_validate_doc_id_rejects_traversal_and_junk(bad):
with pytest.raises(HTTPException) as exc:
_validate_doc_id(bad)
assert exc.value.status_code == 404
# ── storage CRUD ─────────────────────────────────────────────────────────
def test_storage_crud_roundtrip(tmp_path):
storage = CoWriterStorage(path_service=_StubPathService(tmp_path))
doc = storage.create_document(title=None, content="# Hello\nWorld")
assert doc.title == "Hello"
assert _validate_doc_id(doc.id) == doc.id
loaded = storage.load_document(doc.id)
assert loaded is not None
assert loaded.content.startswith("# Hello")
# Explicit titles stick across content updates.
updated = storage.update_document(doc.id, content="# Renamed\nBody")
assert updated is not None
assert updated.title == "Hello"
assert storage.delete_document(doc.id) is True
assert storage.load_document(doc.id) is None
def test_storage_untitled_doc_follows_first_heading(tmp_path):
storage = CoWriterStorage(path_service=_StubPathService(tmp_path))
doc = storage.create_document(title=None, content="")
assert doc.title == "Untitled draft"
updated = storage.update_document(doc.id, content="# Fresh title\nBody")
assert updated is not None
assert updated.title == "Fresh title"
def test_storage_list_sorted_by_recency(tmp_path):
storage = CoWriterStorage(path_service=_StubPathService(tmp_path))
first = storage.create_document(title="first", content="a")
second = storage.create_document(title="second", content="b")
storage.update_document(first.id, content="a updated")
summaries = storage.list_documents()
assert [s.id for s in summaries][0] == first.id
assert {s.id for s in summaries} == {first.id, second.id}
# ── history limits ───────────────────────────────────────────────────────
def test_append_history_caps_entries(tmp_path, monkeypatch):
stub = _StubPathService(tmp_path)
monkeypatch.setattr(edit_agent, "get_path_service", lambda: stub)
overflow = 5
for i in range(edit_agent._HISTORY_MAX_ENTRIES + overflow):
edit_agent.append_history({"id": str(i)})
history = edit_agent.load_history()
assert len(history) == edit_agent._HISTORY_MAX_ENTRIES
assert history[-1]["id"] == str(edit_agent._HISTORY_MAX_ENTRIES + overflow - 1)
assert history[0]["id"] == str(overflow)
def test_append_history_clips_long_texts(tmp_path, monkeypatch):
stub = _StubPathService(tmp_path)
monkeypatch.setattr(edit_agent, "get_path_service", lambda: stub)
long_text = "x" * (edit_agent._HISTORY_TEXT_LIMIT + 500)
edit_agent.append_history({"id": "clip", "input": {"original_text": long_text}})
record = edit_agent.load_history()[-1]
stored = record["input"]["original_text"]
assert stored.endswith("…[truncated]")
assert len(stored) < len(long_text)
def test_load_history_survives_corrupt_file(tmp_path, monkeypatch):
stub = _StubPathService(tmp_path)
monkeypatch.setattr(edit_agent, "get_path_service", lambda: stub)
stub.get_co_writer_dir().mkdir(parents=True, exist_ok=True)
stub.get_co_writer_history_file().write_text("{not json", encoding="utf-8")
assert edit_agent.load_history() == []
+75
View File
@@ -0,0 +1,75 @@
"""Tests for FastAPI CORS settings."""
from __future__ import annotations
from fastapi.testclient import TestClient
from deeptutor.api import main as api_main
def test_cors_allows_remote_http_origins_when_auth_disabled(
monkeypatch,
) -> None:
monkeypatch.delenv("AUTH_ENABLED", raising=False)
monkeypatch.delenv("CORS_ORIGIN", raising=False)
monkeypatch.delenv("CORS_ORIGINS", raising=False)
monkeypatch.setenv("FRONTEND_PORT", "3782")
settings = api_main._build_cors_settings()
assert settings["allow_origin_regex"] == r"https?://.*"
assert "http://localhost:3782" in settings["allow_origins"]
assert "http://127.0.0.1:3782" in settings["allow_origins"]
def test_cors_requires_explicit_origins_when_auth_enabled(monkeypatch) -> None:
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("CORS_ORIGIN", "https://app.example.com/")
monkeypatch.setenv(
"CORS_ORIGINS",
"https://foo.example.com, https://bar.example.com\nhttps://foo.example.com",
)
settings = api_main._build_cors_settings()
assert settings["allow_origin_regex"] is None
assert "https://app.example.com" in settings["allow_origins"]
assert "https://foo.example.com" in settings["allow_origins"]
assert "https://bar.example.com" in settings["allow_origins"]
assert settings["allow_origins"].count("https://foo.example.com") == 1
def test_cors_normalizes_common_origin_input_mistakes(monkeypatch) -> None:
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv(
"CORS_ORIGIN",
"172.26.0.10:3782; https://learn.example.com/app/",
)
monkeypatch.setenv("CORS_ORIGINS", "http://localhost:3000;api.example.com")
settings = api_main._build_cors_settings()
assert settings["allow_origin_regex"] is None
assert "http://172.26.0.10:3782" in settings["allow_origins"]
assert "https://learn.example.com" in settings["allow_origins"]
assert "http://api.example.com" in settings["allow_origins"]
def test_cors_preflight_allows_partner_patch_save() -> None:
client = TestClient(api_main.app)
response = client.options(
"/api/v1/partners/partner",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "PATCH",
"Access-Control-Request-Headers": "content-type",
},
)
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "http://localhost:3000"
allowed_methods = {
method.strip() for method in response.headers["access-control-allow-methods"].split(",")
}
assert "PATCH" in allowed_methods
+150
View File
@@ -0,0 +1,150 @@
"""Tests for the chat-history import endpoints.
The handlers are exercised directly (no TestClient) with the per-user store
factory monkeypatched to a tmp database, so nothing touches the real chat DB.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import HTTPException
from pydantic import ValidationError
import pytest
from deeptutor.api.routers import imports as imports_router
from deeptutor.api.routers.imports import (
ChatHistoryImportRequest,
import_chat_history,
list_imported_chat_history,
)
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
@pytest.fixture
def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SQLiteSessionStore:
instance = SQLiteSessionStore(db_path=tmp_path / "test.db")
monkeypatch.setattr(imports_router, "get_sqlite_session_store", lambda: instance)
return instance
def _payload(
external_id: str = "s1",
messages=None,
agent_id: str = "",
agent_name: str = "",
) -> ChatHistoryImportRequest:
return ChatHistoryImportRequest(
source="codex",
agent_id=agent_id,
agent_name=agent_name,
sessions=[
{
"external_id": external_id,
"title": "T",
"source_cwd": "/p",
"created_at": 1.0,
"updated_at": 2.0,
"messages": messages
if messages is not None
else [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "a"},
],
}
],
)
def _imported_prefs(store: SQLiteSessionStore) -> dict:
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
return listed["sessions"][0]["preferences"]["import"]
def test_source_validation_rejects_unknown() -> None:
with pytest.raises(ValidationError):
ChatHistoryImportRequest(source="opencode", sessions=[])
def test_source_is_normalized_lowercase() -> None:
assert ChatHistoryImportRequest(source="Claude_Code", sessions=[]).source == ("claude_code")
def test_import_endpoint_persists_and_dedups(store: SQLiteSessionStore) -> None:
res = asyncio.run(import_chat_history(_payload()))
assert res["imported"] == 1
assert res["skipped"] == 0
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
assert len(listed["sessions"]) == 1
assert listed["sessions"][0]["message_count"] == 2
# Re-import the same session → deduped, not duplicated.
again = asyncio.run(import_chat_history(_payload()))
assert again["imported"] == 0
assert again["skipped"] == 1
assert len(asyncio.run(list_imported_chat_history(limit=50, offset=0))["sessions"]) == 1
def test_empty_content_messages_are_dropped(store: SQLiteSessionStore) -> None:
res = asyncio.run(
import_chat_history(
_payload(
messages=[
{"role": "user", "content": "q"},
{"role": "assistant", "content": " "}, # whitespace only
{"role": "assistant", "content": "a"},
]
)
)
)
assert res["imported"] == 1
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
assert listed["sessions"][0]["message_count"] == 2
def test_session_with_only_empty_messages_is_skipped(
store: SQLiteSessionStore,
) -> None:
res = asyncio.run(import_chat_history(_payload(messages=[{"role": "user", "content": " "}])))
assert res["imported"] == 0
assert res["skipped"] == 1
def test_import_rejects_empty_request(store: SQLiteSessionStore) -> None:
with pytest.raises(HTTPException) as exc:
asyncio.run(import_chat_history(ChatHistoryImportRequest(source="codex", sessions=[])))
assert exc.value.status_code == 400
def test_agent_attribution_persisted(store: SQLiteSessionStore) -> None:
asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="Research")))
meta = _imported_prefs(store)
assert meta["agent_id"] == "codex-a1"
assert meta["agent_name"] == "Research"
assert meta["source"] == "codex"
def test_reimport_backfills_agent_attribution(store: SQLiteSessionStore) -> None:
# First import had no agent (legacy client) — no attribution stored.
asyncio.run(import_chat_history(_payload()))
assert "agent_id" not in _imported_prefs(store)
# Re-syncing under an agent backfills attribution without duplicating the
# session or re-adding messages.
again = asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="Research")))
assert again["imported"] == 0
assert again["skipped"] == 1
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
assert len(listed["sessions"]) == 1
assert listed["sessions"][0]["message_count"] == 2
meta = listed["sessions"][0]["preferences"]["import"]
assert meta["agent_id"] == "codex-a1"
assert meta["agent_name"] == "Research"
def test_agent_rename_propagates_on_resync(store: SQLiteSessionStore) -> None:
asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="Old name")))
asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="New name")))
assert _imported_prefs(store)["agent_name"] == "New name"
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
"""Wiring tests for safe ``.zip`` upload handling in the knowledge router.
The deep security guards live in ``tests/utils/test_archive_extractor.py``;
these check that ``_save_uploaded_files`` routes ``.zip`` uploads through the
safe extractor, registers only supported members, and never persists the
archive itself.
"""
from __future__ import annotations
import io
from pathlib import Path
import zipfile
import pytest
pytest.importorskip("fastapi")
from fastapi import HTTPException, UploadFile
from deeptutor.api.routers.knowledge import _save_uploaded_files
ALLOWED = {".txt", ".md", ".pdf", ".zip"}
@pytest.fixture(autouse=True)
def _disable_pocketbase(monkeypatch):
monkeypatch.setattr("deeptutor.services.pocketbase_client.is_pocketbase_enabled", lambda: False)
def _zip_upload(filename: str, entries: list[tuple[str, bytes]]) -> UploadFile:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, data in entries:
zf.writestr(name, data)
buf.seek(0)
return UploadFile(filename=filename, file=buf)
def test_zip_upload_extracts_only_supported_members(tmp_path: Path) -> None:
upload = _zip_upload(
"bundle.zip",
[
("notes.txt", b"hello"),
("paper.md", b"# title"),
("malware.exe", b"x"), # disallowed -> skipped
("inner.zip", b"PK\x03\x04"), # nested archive -> skipped
],
)
raw = tmp_path / "raw"
raw.mkdir()
names, paths = _save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
assert sorted(names) == ["notes.txt", "paper.md"]
assert (raw / "notes.txt").read_bytes() == b"hello"
# The archive itself is never persisted or registered.
assert not (raw / "bundle.zip").exists()
assert all(not p.endswith(".zip") for p in paths)
def test_zip_upload_with_zip_slip_member_stays_in_target(tmp_path: Path) -> None:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(zipfile.ZipInfo("../../escape.txt"), b"x")
zf.writestr("safe.txt", b"y")
buf.seek(0)
upload = UploadFile(filename="evil.zip", file=buf)
raw = tmp_path / "raw"
raw.mkdir()
names, _ = _save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
assert sorted(names) == ["escape.txt", "safe.txt"]
assert (raw / "escape.txt").exists()
assert not (tmp_path / "escape.txt").exists() # did not escape
def test_invalid_zip_is_rejected(tmp_path: Path) -> None:
upload = UploadFile(filename="broken.zip", file=io.BytesIO(b"not a zip"))
raw = tmp_path / "raw"
raw.mkdir()
with pytest.raises(HTTPException) as exc_info:
_save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
assert exc_info.value.status_code == 400
def test_zip_with_no_supported_members_is_rejected(tmp_path: Path) -> None:
upload = _zip_upload("only-junk.zip", [("a.exe", b"x"), ("b.sh", b"y")])
raw = tmp_path / "raw"
raw.mkdir()
with pytest.raises(HTTPException) as exc_info:
_save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
assert exc_info.value.status_code == 400
+161
View File
@@ -0,0 +1,161 @@
"""Tests for the main notebook router (/api/v1/notebook).
Verifies that records can only be saved using real notebook UUIDs
(from /api/v1/notebook/list), not question-notebook category integer IDs.
"""
from __future__ import annotations
import asyncio
import importlib
import json
import pytest
pytest.importorskip("fastapi")
FastAPI = pytest.importorskip("fastapi").FastAPI
TestClient = pytest.importorskip("fastapi.testclient").TestClient
notebook_router = importlib.import_module("deeptutor.api.routers.notebook").router
from deeptutor.services.notebook.service import NotebookManager
def _build_app(manager: NotebookManager) -> FastAPI:
app = FastAPI()
app.include_router(notebook_router, prefix="/api/v1/notebook")
return app
@pytest.fixture
def manager(tmp_path, monkeypatch) -> NotebookManager:
instance = NotebookManager(base_dir=str(tmp_path / "notebooks"))
monkeypatch.setattr(
"deeptutor.api.routers.notebook.notebook_manager",
instance,
)
return instance
def test_list_notebooks_empty(manager: NotebookManager) -> None:
with TestClient(_build_app(manager)) as client:
resp = client.get("/api/v1/notebook/list")
assert resp.status_code == 200
data = resp.json()
assert data["notebooks"] == []
assert data["total"] == 0
def test_create_and_list_notebook(manager: NotebookManager) -> None:
with TestClient(_build_app(manager)) as client:
create_resp = client.post(
"/api/v1/notebook/create",
json={"name": "Study Notes", "description": "Physics"},
)
assert create_resp.status_code == 200
nb = create_resp.json()["notebook"]
assert nb["name"] == "Study Notes"
nb_id = nb["id"]
listing = client.get("/api/v1/notebook/list").json()
assert listing["total"] == 1
assert listing["notebooks"][0]["id"] == nb_id
def test_add_record_with_valid_notebook_id(manager: NotebookManager) -> None:
"""Records saved with a real notebook UUID must appear in that notebook."""
nb = manager.create_notebook(name="My Notes")
nb_id = nb["id"]
with TestClient(_build_app(manager)) as client:
resp = client.post(
"/api/v1/notebook/add_record",
json={
"notebook_ids": [nb_id],
"record_type": "chat",
"title": "Draft on Fourier",
"summary": "Existing summary",
"user_query": "Explain Fourier",
"output": "Fourier transform is...",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert nb_id in body["added_to_notebooks"]
detail = client.get(f"/api/v1/notebook/{nb_id}").json()
assert len(detail["records"]) == 1
assert detail["records"][0]["title"] == "Draft on Fourier"
def test_add_record_with_numeric_category_id_saves_nothing(manager: NotebookManager) -> None:
"""Using a question-notebook integer category ID must NOT match any notebook.
This is the root cause of issue #301: the old SaveToNotebookModal sent
numeric category IDs from /api/v1/question-notebook/categories instead of
UUID notebook IDs from /api/v1/notebook/list.
"""
manager.create_notebook(name="My Notes")
with TestClient(_build_app(manager)) as client:
resp = client.post(
"/api/v1/notebook/add_record",
json={
"notebook_ids": ["1", "42"],
"record_type": "chat",
"title": "Lost draft",
"summary": "This should not be saved anywhere",
"user_query": "...",
"output": "...",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["added_to_notebooks"] == []
def test_stream_add_record_with_summary_strips_thinking_tags(
manager: NotebookManager,
monkeypatch,
) -> None:
class FakeSummarizeAgent:
def __init__(self, language: str = "en") -> None:
self.language = language
async def stream_summary(self, **_kwargs):
yield "<thi"
yield "nk>private reasoning</think>\n"
yield "Final reusable summary."
monkeypatch.setattr(
"deeptutor.api.routers.notebook.NotebookSummarizeAgent",
FakeSummarizeAgent,
)
nb = manager.create_notebook(name="My Notes")
async def collect_events() -> list[dict]:
request = importlib.import_module("deeptutor.api.routers.notebook").AddRecordRequest(
notebook_ids=[nb["id"]],
record_type="chat",
title="Streaming save",
user_query="Explain Fourier",
output="Fourier transform is...",
)
events: list[dict] = []
async for raw in importlib.import_module(
"deeptutor.api.routers.notebook"
)._stream_add_record_with_summary(request):
assert "<think" not in raw.lower()
assert "private reasoning" not in raw
events.append(json.loads(raw.removeprefix("data: ").strip()))
return events
events = asyncio.run(collect_events())
assert events[-1]["type"] == "result"
assert events[-1]["summary"] == "Final reusable summary."
detail = manager.get_notebook(nb["id"])
assert detail is not None
assert detail["records"][0]["summary"] == "Final reusable summary."
+82
View File
@@ -0,0 +1,82 @@
"""GET /api/v1/memory/resolve_entry/{id} → (layer, key).
L3 docs cite L2 entries by their ``m_<ULID>`` entry id. The resolver
turns an L3 footnote click into "which L2 surface do I navigate to".
"""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
pytest.importorskip("fastapi")
FastAPI = pytest.importorskip("fastapi").FastAPI
TestClient = pytest.importorskip("fastapi.testclient").TestClient
memory_router = importlib.import_module("deeptutor.api.routers.memory").router
paths_mod = importlib.import_module("deeptutor.services.memory.paths")
document_mod = importlib.import_module("deeptutor.services.memory.document")
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
monkeypatch.setattr(paths_mod, "memory_root", lambda: tmp_path)
(tmp_path / "L2").mkdir()
(tmp_path / "L3").mkdir()
app = FastAPI()
app.include_router(memory_router, prefix="/api/v1/memory")
return TestClient(app)
def _seed_l2(tmp_path: Path, surface: str, entry_id: str) -> None:
doc = document_mod.Document(
title=f"{surface} memory",
sections=[
(
"Themes",
[
document_mod.Entry(
id=entry_id,
section="Themes",
text="some fact",
refs=[f"{surface}:r1"],
),
],
),
],
)
(tmp_path / "L2" / f"{surface}.md").write_text(document_mod.serialize(doc), encoding="utf-8")
def test_resolve_entry_returns_owning_surface(client: TestClient, tmp_path: Path) -> None:
entry_id = "m_01HZK1ABCDEFGHJKMNPQRSTVWX"
_seed_l2(tmp_path, "notebook", entry_id)
res = client.get(f"/api/v1/memory/resolve_entry/{entry_id}")
assert res.status_code == 200
body = res.json()
assert body == {"layer": "L2", "key": "notebook", "entry_id": entry_id}
def test_resolve_entry_404_when_missing(client: TestClient) -> None:
res = client.get("/api/v1/memory/resolve_entry/m_01HZK1ABCDEFGHJKMNPQRSTVWX")
assert res.status_code == 404
def test_resolve_entry_400_on_bad_id(client: TestClient) -> None:
res = client.get("/api/v1/memory/resolve_entry/not-an-entry-id")
assert res.status_code == 400
def test_resolve_entry_first_hit_wins(client: TestClient, tmp_path: Path) -> None:
"""Iteration order is the SURFACES tuple — chat before notebook."""
entry_id = "m_01HZK1ABCDEFGHJKMNPQRSTVWX"
_seed_l2(tmp_path, "chat", entry_id)
_seed_l2(tmp_path, "notebook", entry_id) # duplicate (shouldn't happen IRL)
res = client.get(f"/api/v1/memory/resolve_entry/{entry_id}")
assert res.status_code == 200
assert res.json()["key"] == "chat"
+362
View File
@@ -0,0 +1,362 @@
from __future__ import annotations
import asyncio
import importlib
from pathlib import Path
import pytest
pytest.importorskip("fastapi")
FastAPI = pytest.importorskip("fastapi").FastAPI
TestClient = pytest.importorskip("fastapi.testclient").TestClient
notebook_router = importlib.import_module("deeptutor.api.routers.question_notebook").router
sessions_router = importlib.import_module("deeptutor.api.routers.sessions").router
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
def _build_app(store: SQLiteSessionStore) -> FastAPI:
app = FastAPI()
app.include_router(notebook_router, prefix="/api/v1/question-notebook")
app.include_router(sessions_router, prefix="/api/v1/sessions")
return app
@pytest.fixture
def store(tmp_path: Path, monkeypatch) -> SQLiteSessionStore:
instance = SQLiteSessionStore(db_path=tmp_path / "router-test.db")
monkeypatch.setattr(
"deeptutor.api.routers.question_notebook.get_sqlite_session_store",
lambda: instance,
)
monkeypatch.setattr(
"deeptutor.api.routers.sessions.get_sqlite_session_store",
lambda: instance,
)
return instance
def _quiz_answers():
return [
{
"question_id": "q1",
"question": "Capital of France?",
"question_type": "choice",
"options": {"A": "Berlin", "B": "Paris"},
"user_answer": "A",
"correct_answer": "B",
"explanation": "Paris is the capital.",
"difficulty": "easy",
"is_correct": False,
},
{
"question_id": "q2",
"question": "2+2?",
"question_type": "choice",
"options": {"A": "3", "B": "4"},
"user_answer": "B",
"correct_answer": "B",
"is_correct": True,
},
]
def test_list_entries_empty(store: SQLiteSessionStore) -> None:
with TestClient(_build_app(store)) as client:
resp = client.get("/api/v1/question-notebook/entries")
assert resp.status_code == 200
assert resp.json() == {"items": [], "total": 0}
def test_quiz_results_populates_notebook(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session(title="Quiz Session"))
sid = session["id"]
with TestClient(_build_app(store)) as client:
resp = client.post(
f"/api/v1/sessions/{sid}/quiz-results",
json={"answers": _quiz_answers()},
)
assert resp.status_code == 200
body = resp.json()
assert body["recorded"] is True
assert body["notebook_count"] == 2
assert "[Quiz Performance]" in body["content"]
listing = client.get("/api/v1/question-notebook/entries")
assert listing.status_code == 200
items = listing.json()["items"]
assert len(items) == 2
def test_quiz_results_upserts_on_retry(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
sid = session["id"]
with TestClient(_build_app(store)) as client:
client.post(f"/api/v1/sessions/{sid}/quiz-results", json={"answers": _quiz_answers()})
updated = _quiz_answers()
updated[0]["user_answer"] = "B"
updated[0]["is_correct"] = True
client.post(f"/api/v1/sessions/{sid}/quiz-results", json={"answers": updated})
listing = client.get("/api/v1/question-notebook/entries").json()
assert listing["total"] == 2
q1 = next(e for e in listing["items"] if e["question_id"] == "q1")
assert q1["is_correct"] is True
assert q1["user_answer"] == "B"
def test_bookmark_toggle(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
asyncio.run(
store.upsert_notebook_entries(
session["id"],
[
{
"question_id": "q1",
"question": "Q?",
"is_correct": False,
}
],
)
)
eid = asyncio.run(store.list_notebook_entries())["items"][0]["id"]
with TestClient(_build_app(store)) as client:
resp = client.patch(
f"/api/v1/question-notebook/entries/{eid}",
json={"bookmarked": True},
)
assert resp.status_code == 200
bm = client.get("/api/v1/question-notebook/entries?bookmarked=true").json()
assert bm["total"] == 1
client.patch(f"/api/v1/question-notebook/entries/{eid}", json={"bookmarked": False})
bm2 = client.get("/api/v1/question-notebook/entries?bookmarked=true").json()
assert bm2["total"] == 0
def test_delete_entry(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
asyncio.run(
store.upsert_notebook_entries(
session["id"],
[
{
"question_id": "q1",
"question": "Q?",
"is_correct": False,
}
],
)
)
eid = asyncio.run(store.list_notebook_entries())["items"][0]["id"]
with TestClient(_build_app(store)) as client:
assert client.delete(f"/api/v1/question-notebook/entries/{eid}").status_code == 200
assert client.delete(f"/api/v1/question-notebook/entries/{eid}").status_code == 404
def test_category_crud_and_association(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
asyncio.run(
store.upsert_notebook_entries(
session["id"],
[
{
"question_id": "q1",
"question": "Q?",
"is_correct": False,
}
],
)
)
eid = asyncio.run(store.list_notebook_entries())["items"][0]["id"]
with TestClient(_build_app(store)) as client:
cat_resp = client.post(
"/api/v1/question-notebook/categories",
json={"name": "Math"},
)
assert cat_resp.status_code == 201
cat_id = cat_resp.json()["id"]
cats = client.get("/api/v1/question-notebook/categories").json()
assert len(cats) == 1
assert cats[0]["name"] == "Math"
add_resp = client.post(
f"/api/v1/question-notebook/entries/{eid}/categories",
json={"category_id": cat_id},
)
assert add_resp.status_code == 200
by_cat = client.get(f"/api/v1/question-notebook/entries?category_id={cat_id}").json()
assert by_cat["total"] == 1
rm_resp = client.delete(f"/api/v1/question-notebook/entries/{eid}/categories/{cat_id}")
assert rm_resp.status_code == 200
by_cat2 = client.get(f"/api/v1/question-notebook/entries?category_id={cat_id}").json()
assert by_cat2["total"] == 0
client.patch(f"/api/v1/question-notebook/categories/{cat_id}", json={"name": "Algebra"})
cats2 = client.get("/api/v1/question-notebook/categories").json()
assert cats2[0]["name"] == "Algebra"
client.delete(f"/api/v1/question-notebook/categories/{cat_id}")
assert client.get("/api/v1/question-notebook/categories").json() == []
def test_lookup_entry_by_question(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
asyncio.run(
store.upsert_notebook_entries(
session["id"],
[
{
"question_id": "q1",
"question": "Q?",
"is_correct": False,
}
],
)
)
with TestClient(_build_app(store)) as client:
resp = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": session["id"], "question_id": "q1"},
)
assert resp.status_code == 200
assert resp.json()["question_id"] == "q1"
resp404 = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": session["id"], "question_id": "nope"},
)
assert resp404.status_code == 404
def test_quiz_state_isolated_per_turn(store: SQLiteSessionStore) -> None:
"""Regression test for #487 — two quizzes in the same chat session must
not share answer state, even when the positional ``question_id`` (e.g.
``q_1``) collides. The producing turn_id scopes notebook entries.
"""
session = asyncio.run(store.create_session())
sid = session["id"]
with TestClient(_build_app(store)) as client:
first = _quiz_answers()
resp1 = client.post(
f"/api/v1/sessions/{sid}/quiz-results",
json={"answers": first, "turn_id": "turn_A"},
)
assert resp1.status_code == 200
assert resp1.json()["notebook_count"] == 2
second = _quiz_answers()
second[0]["user_answer"] = ""
second[0]["is_correct"] = False
resp2 = client.post(
f"/api/v1/sessions/{sid}/quiz-results",
json={"answers": second, "turn_id": "turn_B"},
)
assert resp2.status_code == 200
assert resp2.json()["notebook_count"] == 2
listing = client.get("/api/v1/question-notebook/entries").json()
assert listing["total"] == 4
# Looking up q1 scoped to the first turn returns the first quiz's
# answer, not the second.
scoped_a = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": sid, "question_id": "q1", "turn_id": "turn_A"},
)
assert scoped_a.status_code == 200
assert scoped_a.json()["user_answer"] == "A"
assert scoped_a.json()["turn_id"] == "turn_A"
# The second turn has no recorded answer for q1.
scoped_b = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": sid, "question_id": "q1", "turn_id": "turn_B"},
)
assert scoped_b.status_code == 200
assert scoped_b.json()["user_answer"] == ""
assert scoped_b.json()["turn_id"] == "turn_B"
def test_lookup_without_turn_id_falls_back_to_latest(
store: SQLiteSessionStore,
) -> None:
"""Callers that don't pass turn_id (legacy entries / external API) get the
most recently updated matching entry — deterministic even when multiple
turns share a question_id."""
session = asyncio.run(store.create_session())
sid = session["id"]
asyncio.run(
store.upsert_notebook_entries(
sid,
[
{
"turn_id": "turn_old",
"question_id": "q1",
"question": "Q?",
"user_answer": "A",
"is_correct": False,
}
],
)
)
asyncio.run(
store.upsert_notebook_entries(
sid,
[
{
"turn_id": "turn_new",
"question_id": "q1",
"question": "Q?",
"user_answer": "B",
"is_correct": True,
}
],
)
)
with TestClient(_build_app(store)) as client:
resp = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": sid, "question_id": "q1"},
)
assert resp.status_code == 200
body = resp.json()
assert body["turn_id"] == "turn_new"
assert body["user_answer"] == "B"
def test_lookup_missing_entry_returns_404_by_default(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
sid = session["id"]
with TestClient(_build_app(store)) as client:
resp = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": sid, "question_id": "absent"},
)
assert resp.status_code == 404
def test_lookup_missing_entry_returns_204_when_missing_ok(store: SQLiteSessionStore) -> None:
session = asyncio.run(store.create_session())
sid = session["id"]
with TestClient(_build_app(store)) as client:
resp = client.get(
"/api/v1/question-notebook/entries/lookup/by-question",
params={"session_id": sid, "question_id": "absent", "missing_ok": "true"},
)
assert resp.status_code == 204
assert resp.content == b""
+191
View File
@@ -0,0 +1,191 @@
"""Tests for the schema-driven channels endpoint helpers.
Covers:
* ``resolve_config_model``: maps each ``XxxChannel`` to its ``XxxConfig``.
* ``inline_refs``: flattens nested model ``$ref``s (slack ``dm`` subtree).
* ``collect_secret_fields``: only flags **string-typed** secret-looking keys
(so e.g. ``user_token_read_only: bool`` is excluded).
* ``GET /api/v1/partners/channels/schema`` integration: shape, snake_case
property names, and that telegram/slack/discord schemas survive the trip.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
import pytest
from deeptutor.api.routers._partners_channel_schema import (
all_channel_schemas,
channel_schema_payload,
collect_secret_fields,
inline_refs,
resolve_config_model,
)
class TestResolveConfigModel:
def test_telegram_pairs_with_telegram_config(self) -> None:
from deeptutor.partners.channels.telegram import TelegramChannel, TelegramConfig
assert resolve_config_model(TelegramChannel) is TelegramConfig
def test_slack_pairs_with_slack_config(self) -> None:
from deeptutor.partners.channels.slack import SlackChannel, SlackConfig
assert resolve_config_model(SlackChannel) is SlackConfig
def test_discord_pairs_with_discord_config(self) -> None:
from deeptutor.partners.channels.discord import DiscordChannel, DiscordConfig
assert resolve_config_model(DiscordChannel) is DiscordConfig
class TestInlineRefs:
def test_inlines_simple_def(self) -> None:
schema = {
"type": "object",
"properties": {"dm": {"$ref": "#/$defs/SlackDMConfig"}},
"$defs": {
"SlackDMConfig": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
}
},
}
out = inline_refs(schema)
assert "$defs" not in out
assert out["properties"]["dm"]["type"] == "object"
assert out["properties"]["dm"]["properties"]["enabled"]["type"] == "boolean"
def test_per_field_overrides_take_precedence(self) -> None:
# Pydantic sometimes emits {"$ref": "...", "description": "..."}; the
# description should override the referenced model's description.
schema = {
"type": "object",
"properties": {
"child": {"$ref": "#/$defs/Foo", "description": "override"},
},
"$defs": {
"Foo": {"type": "object", "description": "original"},
},
}
out = inline_refs(schema)
assert out["properties"]["child"]["description"] == "override"
class TestCollectSecretFields:
def test_flags_string_token_field(self) -> None:
schema = {
"properties": {
"token": {"type": "string"},
"enabled": {"type": "boolean"},
}
}
assert collect_secret_fields(schema) == ["token"]
def test_skips_boolean_with_secret_substring(self) -> None:
# Slack's ``user_token_read_only`` is a flag, not a secret.
schema = {
"properties": {
"user_token_read_only": {"type": "boolean"},
"bot_token": {"type": "string"},
}
}
assert collect_secret_fields(schema) == ["bot_token"]
def test_walks_nested_objects(self) -> None:
schema = {
"properties": {
"dm": {
"type": "object",
"properties": {"webhook_secret": {"type": "string"}},
},
}
}
assert collect_secret_fields(schema) == ["dm.webhook_secret"]
def test_handles_nullable_strings(self) -> None:
# Pydantic's ``Optional[str]`` becomes ``anyOf: [{type: string}, {type: null}]``.
schema = {
"properties": {
"encrypt_key": {"anyOf": [{"type": "string"}, {"type": "null"}]},
}
}
assert collect_secret_fields(schema) == ["encrypt_key"]
class TestChannelSchemaPayload:
def test_telegram_payload_shape(self) -> None:
from deeptutor.partners.channels.telegram import TelegramChannel
payload = channel_schema_payload(TelegramChannel)
assert payload is not None
assert payload["name"] == "telegram"
assert payload["display_name"] == "Telegram"
assert payload["secret_fields"] == ["token"]
# Snake_case wire format (matches the storage form).
props = payload["json_schema"]["properties"]
assert "allow_from" in props and "allowFrom" not in props
assert payload["default_config"]["enabled"] is False
def test_slack_dm_subtree_inlined(self) -> None:
from deeptutor.partners.channels.slack import SlackChannel
payload = channel_schema_payload(SlackChannel)
assert payload is not None
dm = payload["json_schema"]["properties"]["dm"]
assert dm["type"] == "object"
assert "enabled" in dm["properties"]
# Bool flags whose names contain "token" must NOT be flagged secret.
assert "user_token_read_only" not in payload["secret_fields"]
assert "bot_token" in payload["secret_fields"]
class TestEndpoint:
@pytest.fixture
def client(self) -> TestClient:
# Build a minimal FastAPI app with just the partners router; the
# ``/channels/schema`` endpoint doesn't touch the manager so no
# fixturing of ``get_partner_manager`` is needed.
from fastapi import FastAPI
from deeptutor.api.routers import partners as partners_router
app = FastAPI()
app.include_router(partners_router.router, prefix="/api/v1/partners")
return TestClient(app)
def test_returns_channels_only(self, client: TestClient) -> None:
res = client.get("/api/v1/partners/channels/schema")
assert res.status_code == 200
body = res.json()
assert set(body.keys()) == {"channels"}
# Telegram is always installed (no extra deps).
assert "telegram" in body["channels"]
def test_telegram_entry_has_secret_fields(self, client: TestClient) -> None:
res = client.get("/api/v1/partners/channels/schema")
tg = res.json()["channels"]["telegram"]
assert tg["secret_fields"] == ["token"]
assert "token" in tg["json_schema"]["properties"]
def test_delivery_flags_are_per_channel(self, client: TestClient) -> None:
res = client.get("/api/v1/partners/channels/schema")
props = res.json()["channels"]["telegram"]["json_schema"]["properties"]
assert "send_progress" in props
assert "send_tool_hints" in props
class TestAllChannelSchemas:
def test_returns_at_least_telegram(self) -> None:
out = all_channel_schemas()
assert "telegram" in out
# Every payload has the four documented keys.
for entry in out.values():
assert {
"name",
"display_name",
"default_config",
"secret_fields",
"json_schema",
} <= entry.keys()
+384
View File
@@ -0,0 +1,384 @@
"""API surface tests for /api/v1/partners (create / config / soul / assets)."""
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
import yaml
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
except Exception: # pragma: no cover
FastAPI = None
TestClient = None
pytestmark = pytest.mark.skipif(
FastAPI is None or TestClient is None, reason="fastapi not installed"
)
@pytest.fixture
def isolated_root(tmp_path, monkeypatch) -> Path:
from deeptutor.multi_user import paths
project_root = tmp_path
admin_root = (project_root / "data").resolve()
monkeypatch.setattr(paths, "PROJECT_ROOT", project_root)
monkeypatch.setattr(paths, "ADMIN_WORKSPACE_ROOT", admin_root)
monkeypatch.setattr(paths, "USERS_ROOT", admin_root / "users")
monkeypatch.setattr(paths, "SYSTEM_ROOT", admin_root / "system")
monkeypatch.setattr(paths, "_path_services", {})
admin_root.mkdir(parents=True, exist_ok=True)
return admin_root
@pytest.fixture
def client(isolated_root, monkeypatch) -> TestClient:
import deeptutor.api.routers.partners as partners_router_mod
from deeptutor.services.partners.manager import PartnerManager
# Fresh manager per test so the module-level singleton can't leak
# tmp-path state across tests.
mgr = PartnerManager()
monkeypatch.setattr(partners_router_mod, "get_partner_manager", lambda: mgr)
partners_router_mod._start_locks.clear()
app = FastAPI()
app.include_router(partners_router_mod.router, prefix="/api/v1/partners")
return TestClient(app)
def _create(client: TestClient, **overrides):
payload = {
"name": "Ada",
"description": "study partner",
"soul": {"source": "custom", "content": "# Soul\nBe rigorous."},
"start": False,
**overrides,
}
return client.post("/api/v1/partners", json=payload)
class TestCreate:
def test_create_returns_masked_config(self, client):
res = _create(
client,
channels={"telegram": {"enabled": True, "token": "123:ABC"}},
enabled_tools=["web_search"],
mcp_tools=[],
)
assert res.status_code == 200
body = res.json()
assert body["partner_id"] == "ada"
assert body["channels"]["telegram"]["token"] == "***"
assert body["enabled_tools"] == ["web_search"]
assert body["mcp_tools"] == []
assert body["soul_origin"] == {"type": "custom", "id": ""}
assert body["provisioning"]["errors"] == []
def test_duplicate_id_conflicts(self, client):
assert _create(client).status_code == 200
assert _create(client).status_code == 409
def test_top_level_delivery_flags_rejected(self, client):
res = _create(client, channels={"send_progress": False})
assert res.status_code == 422
def test_create_from_library_soul(self, client):
res = _create(
client,
partner_id="mathy",
soul={"source": "library", "id": "math-tutor"},
)
assert res.status_code == 200
soul = client.get("/api/v1/partners/mathy/soul").json()
assert "math tutor" in soul["content"].lower()
def test_create_with_unknown_library_soul_404(self, client):
res = _create(client, soul={"source": "library", "id": "ghost"})
assert res.status_code == 404
class TestConfigAndSoul:
def test_get_masks_secrets_by_default(self, client):
_create(client, channels={"telegram": {"enabled": True, "token": "raw"}})
body = client.get("/api/v1/partners/ada").json()
assert body["channels"]["telegram"]["token"] == "***"
body = client.get("/api/v1/partners/ada?include_secrets=true").json()
assert body["channels"]["telegram"]["token"] == "raw"
def test_patch_updates_tools_and_clears(self, client):
_create(client, enabled_tools=["web_search", "paper_search"])
res = client.patch(
"/api/v1/partners/ada",
json={"enabled_tools": [], "mcp_tools": ["mcp_x_y"]},
)
assert res.status_code == 200
body = client.get("/api/v1/partners/ada").json()
assert body["enabled_tools"] == []
assert body["mcp_tools"] == ["mcp_x_y"]
def test_builtin_tools_create_and_patch(self, client):
res = _create(client, builtin_tools=["rag", "read_memory"])
assert res.status_code == 200
assert res.json()["builtin_tools"] == ["rag", "read_memory"]
# Default (omitted) stays null = no gating; an explicit deny persists.
_create(client, partner_id="bob", name="Bob")
assert client.get("/api/v1/partners/bob").json()["builtin_tools"] is None
res = client.patch("/api/v1/partners/ada", json={"builtin_tools": []})
assert res.status_code == 200
assert client.get("/api/v1/partners/ada").json()["builtin_tools"] == []
def test_tool_options_exposes_builtin_tools(self, client):
body = client.get("/api/v1/partners/tool-options").json()
assert {"tools", "builtin_tools", "mcp_tools"} <= set(body)
builtin_names = {t["name"] for t in body["builtin_tools"]}
# rag stays owner-configurable; the chat memory tools are NOT — partners
# use the mandatory partner_read / partner_memorize / partner_search
# instead, so they never surface in the partner config UI.
assert "rag" in builtin_names
assert "read_memory" not in builtin_names
assert "write_memory" not in builtin_names
def test_avatar_roundtrip_and_validation(self, client):
_create(client)
avatar = "data:image/png;base64,iVBORw0KGgo="
res = client.patch("/api/v1/partners/ada", json={"avatar": avatar})
assert res.status_code == 200
assert client.get("/api/v1/partners/ada").json()["avatar"] == avatar
# Clearing works; junk and oversized payloads are rejected.
assert client.patch("/api/v1/partners/ada", json={"avatar": ""}).status_code == 200
assert client.get("/api/v1/partners/ada").json()["avatar"] == ""
res = client.patch("/api/v1/partners/ada", json={"avatar": "https://evil.example/x.png"})
assert res.status_code == 422
res = client.patch(
"/api/v1/partners/ada",
json={"avatar": "data:image/png;base64," + "A" * 200_001},
)
assert res.status_code == 422
def test_soul_roundtrip(self, client):
_create(client)
res = client.put("/api/v1/partners/ada/soul", json={"content": "# Soul\nUpdated."})
assert res.status_code == 200
assert client.get("/api/v1/partners/ada/soul").json()["content"] == "# Soul\nUpdated."
def test_404_for_unknown_partner(self, client):
assert client.get("/api/v1/partners/ghost").status_code == 404
assert client.get("/api/v1/partners/ghost/soul").status_code == 404
class TestAssets:
def _seed_skill(self, admin_root: Path, name="focus"):
skill = admin_root / "user" / "workspace" / "skills" / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: d\n---\nBody", encoding="utf-8"
)
def test_add_list_remove_assets(self, client, isolated_root):
self._seed_skill(isolated_root)
_create(client)
res = client.post("/api/v1/partners/ada/assets", json={"skills": ["focus"]})
assert res.status_code == 200
assert res.json()["copied"]["skills"] == ["focus"]
assert [s["name"] for s in res.json()["assets"]["skills"]] == ["focus"]
res = client.delete("/api/v1/partners/ada/assets/skill/focus")
assert res.status_code == 200
assert res.json()["assets"]["skills"] == []
def test_unknown_asset_reported_in_errors(self, client):
_create(client)
res = client.post("/api/v1/partners/ada/assets", json={"skills": ["ghost"]})
assert res.status_code == 200
assert res.json()["errors"][0]["type"] == "skill"
class TestSoulLibraryEndpoints:
def test_souls_crud(self, client):
res = client.get("/api/v1/partners/souls")
assert res.status_code == 200
assert any(s["id"] == "math-tutor" for s in res.json())
res = client.post(
"/api/v1/partners/souls",
json={"id": "custom-soul", "name": "Custom", "content": "# Soul"},
)
assert res.status_code == 200
assert client.get("/api/v1/partners/souls/custom-soul").status_code == 200
assert (
client.put("/api/v1/partners/souls/custom-soul", json={"name": "Renamed"}).json()[
"name"
]
== "Renamed"
)
assert client.delete("/api/v1/partners/souls/custom-soul").status_code == 200
assert client.get("/api/v1/partners/souls/custom-soul").status_code == 404
def test_soul_cjk_id_is_ascii_safe(self, client):
# A pure-CJK soul name must not become a non-ASCII (unreachable) id: the
# server slugs it authoritatively and the returned id is URL-safe.
res = client.post(
"/api/v1/partners/souls",
json={"id": "我的灵魂", "name": "我的灵魂", "content": "# Soul"},
)
assert res.status_code == 200
soul_id = res.json()["id"]
assert soul_id.isascii() and soul_id.startswith("soul-")
# …and the soul is reachable / deletable by that returned id.
assert client.get(f"/api/v1/partners/souls/{soul_id}").status_code == 200
assert client.delete(f"/api/v1/partners/souls/{soul_id}").status_code == 200
def test_soul_sources_shape(self, client):
body = client.get("/api/v1/partners/soul-sources").json()
assert "library" in body and "personas" in body
class TestHistory:
def test_history_reads_session_store(self, client, isolated_root):
_create(client)
sessions = isolated_root / "partners" / "ada" / "sessions"
sessions.mkdir(parents=True, exist_ok=True)
(sessions / "telegram_42.jsonl").write_text(
json.dumps({"role": "user", "content": "hi", "timestamp": "2026-01-01T00:00:00"})
+ "\n",
encoding="utf-8",
)
res = client.get("/api/v1/partners/ada/history")
assert res.status_code == 200
assert res.json()[0]["content"] == "hi"
def test_history_scoped_by_web_session_id(self, client, isolated_root):
_create(client)
sessions = isolated_root / "partners" / "ada" / "sessions"
sessions.mkdir(parents=True, exist_ok=True)
# Two distinct web sessions; the endpoint must scope to the one asked for.
(sessions / "web_s1.jsonl").write_text(
json.dumps({"role": "user", "content": "from s1", "timestamp": "t"}) + "\n",
encoding="utf-8",
)
(sessions / "web_s2.jsonl").write_text(
json.dumps({"role": "user", "content": "from s2", "timestamp": "t"}) + "\n",
encoding="utf-8",
)
res = client.get("/api/v1/partners/ada/history?session_id=s1")
assert res.status_code == 200
contents = [m["content"] for m in res.json()]
assert contents == ["from s1"]
def test_sessions_list_carries_title(self, client, isolated_root):
_create(client)
sessions = isolated_root / "partners" / "ada" / "sessions"
sessions.mkdir(parents=True, exist_ok=True)
(sessions / "web_s1.jsonl").write_text(
json.dumps({"role": "user", "content": "what is recursion?", "timestamp": "t"}) + "\n",
encoding="utf-8",
)
res = client.get("/api/v1/partners/ada/sessions")
assert res.status_code == 200
assert res.json()[0]["title"] == "what is recursion?"
def _seed_session(self, isolated_root: Path, key: str, content: str) -> None:
sessions = isolated_root / "partners" / "ada" / "sessions"
sessions.mkdir(parents=True, exist_ok=True)
(sessions / f"{key}.jsonl").write_text(
json.dumps({"role": "user", "content": content, "timestamp": "t"}) + "\n",
encoding="utf-8",
)
def test_archive_then_resume_roundtrip(self, client, isolated_root):
_create(client)
self._seed_session(isolated_root, "web-a", "hi")
assert (
client.post("/api/v1/partners/ada/sessions/archive", json={"session_key": "web-a"})
).status_code == 200
archived = {s["session_key"]: s for s in client.get("/api/v1/partners/ada/sessions").json()}
assert archived["web-a"]["archived"] is True
assert (
client.post("/api/v1/partners/ada/sessions/resume", json={"session_key": "web-a"})
).status_code == 200
live = {s["session_key"]: s for s in client.get("/api/v1/partners/ada/sessions").json()}
assert live["web-a"]["archived"] is False
def test_branch_copies_and_archives(self, client, isolated_root):
_create(client)
self._seed_session(isolated_root, "web-a", "carry me")
res = client.post(
"/api/v1/partners/ada/sessions/branch",
json={"source_key": "web-a", "new_key": "web-b"},
)
assert res.status_code == 200
assert res.json()["session"]["session_key"] == "web-b"
hist = client.get("/api/v1/partners/ada/history?session_key=web-b").json()
assert [m["content"] for m in hist] == ["carry me"]
sessions = {s["session_key"]: s for s in client.get("/api/v1/partners/ada/sessions").json()}
assert sessions["web-a"]["archived"] is True
def test_delete_session_endpoint(self, client, isolated_root):
_create(client)
self._seed_session(isolated_root, "web-a", "bye")
assert (
client.post("/api/v1/partners/ada/sessions/delete", json={"session_key": "web-a"})
).status_code == 200
assert client.get("/api/v1/partners/ada/sessions").json() == []
# Deleting a missing session is a 404.
assert (
client.post("/api/v1/partners/ada/sessions/delete", json={"session_key": "web-a"})
).status_code == 404
class TestChatAttachments:
def test_chat_does_not_auto_start_stopped_partner(self, client):
# ``start=True`` spawns a real PartnerRunner task; drive every request
# through one shared event loop (context-managed TestClient) so the
# runner started by create can be cancelled by stop — otherwise each
# request runs on its own loop and the cancel raises a cross-loop error.
with client:
assert _create(client, start=True).status_code == 200
assert client.post("/api/v1/partners/ada/stop").status_code == 200
res = client.post("/api/v1/partners/ada/chat", json={"content": "hello"})
assert res.status_code == 409
from deeptutor.core.i18n import t
assert res.json()["detail"] == t("api.partner_stopped_start_required")
def test_create_start_false_disables_auto_start(self, client, isolated_root):
assert _create(client, start=False).status_code == 200
data = yaml.safe_load(
(isolated_root / "partners" / "ada" / "config.yaml").read_text(encoding="utf-8")
)
assert data["auto_start"] is False
def test_materialize_partner_attachment_writes_partner_media(self, isolated_root):
from deeptutor.api.routers.partners import (
ChatAttachmentRequest,
_materialize_partner_attachments,
)
paths = _materialize_partner_attachments(
"ada",
[
ChatAttachmentRequest(
type="file",
filename="notes.txt",
base64=base64.b64encode(b"hello").decode("ascii"),
mime_type="text/plain",
)
],
)
assert len(paths) == 1
path = Path(paths[0])
assert path.read_bytes() == b"hello"
assert path.name.endswith("_notes.txt")
assert path.parent == isolated_root / "partners" / "ada" / "media" / "web"
+124
View File
@@ -0,0 +1,124 @@
"""Regression tests for partner-compatible plugin capability streaming."""
from __future__ import annotations
import importlib
import json
import re
from typing import Any
import pytest
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
except Exception: # pragma: no cover
FastAPI = None
TestClient = None
pytestmark = pytest.mark.skipif(
FastAPI is None or TestClient is None, reason="fastapi not installed"
)
def _events(text: str) -> list[tuple[str, dict[str, Any]]]:
events: list[tuple[str, dict[str, Any]]] = []
for block in text.strip().split("\n\n"):
event = ""
data = "{}"
for line in block.splitlines():
if line.startswith("event: "):
event = line[len("event: ") :]
elif line.startswith("data: "):
data = line[len("data: ") :]
if event:
events.append((event, json.loads(data)))
return events
def _client_with_fake_partner(monkeypatch):
from deeptutor.core.stream import StreamEvent, StreamEventType
from deeptutor.services.partners.manager import PartnerConfig
class FakeInstance:
running = True
class FakeMgr:
def __init__(self):
self.calls: list[dict[str, Any]] = []
def get_partner(self, partner_id: str):
return FakeInstance()
def load_config(self, partner_id: str):
return PartnerConfig(name=partner_id)
async def start_partner(self, partner_id: str, config: PartnerConfig):
return FakeInstance()
async def send_message(self, partner_id: str, content: str, **kwargs):
self.calls.append({"partner_id": partner_id, "content": content, **kwargs})
on_event = kwargs.get("on_event")
if on_event:
await on_event(StreamEvent(type=StreamEventType.THINKING, content="thinking"))
return "streamed answer"
mgr = FakeMgr()
partners_router_mod = importlib.import_module("deeptutor.api.routers.partners")
plugins_router_mod = importlib.import_module("deeptutor.api.routers.plugins_api")
monkeypatch.setattr(partners_router_mod, "get_partner_manager", lambda: mgr)
partners_router_mod._start_locks.clear()
app = FastAPI()
app.include_router(plugins_router_mod.router, prefix="/api/v1/plugins")
return TestClient(app), mgr
def test_plugin_chat_stream_routes_to_specified_partner_session(monkeypatch):
client, mgr = _client_with_fake_partner(monkeypatch)
response = client.post(
"/api/v1/plugins/capabilities/chat/execute-stream",
json={
"content": "hi",
# Legacy TutorBot HTTP API field name still addresses a partner.
"bot_id": "math-bot",
"session_id": "lesson-1",
"enabledTools": [],
"knowledgeBases": [],
"language": "zh",
"llmSelection": {"profile_id": "p-alt", "model_id": "m-alt"},
},
)
assert response.status_code == 200
events = _events(response.text)
assert ("session", {"partner_id": "math-bot", "session_id": "lesson-1"}) in events
assert ("thinking", {"content": "thinking"}) in events
assert ("content", {"content": "streamed answer"}) in events
assert ("done", {"partner_id": "math-bot", "session_id": "lesson-1"}) in events
assert len(mgr.calls) == 1
call = mgr.calls[0]
assert call["partner_id"] == "math-bot"
assert call["content"] == "hi"
assert call["chat_id"] == "lesson-1"
assert call["session_id"] == "lesson-1"
assert callable(call["on_event"])
def test_plugin_chat_stream_creates_session_when_missing(monkeypatch):
client, mgr = _client_with_fake_partner(monkeypatch)
response = client.post(
"/api/v1/plugins/capabilities/chat/execute-stream",
json={"content": "first message", "bot_id": "math-bot"},
)
assert response.status_code == 200
assert "event: done" in response.text
match = re.search(r'"session_id": "([^"]+)"', response.text)
assert match is not None
session_id = match.group(1)
assert session_id != "web"
assert mgr.calls[0]["session_id"] == session_id
assert mgr.calls[0]["chat_id"] == session_id
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
from contextlib import contextmanager
import importlib
from pathlib import Path
import sys
import types
import pytest
FastAPI = pytest.importorskip("fastapi").FastAPI
TestClient = pytest.importorskip("fastapi.testclient").TestClient
@pytest.fixture(autouse=True)
def _cleanup_question_router_module():
yield
sys.modules.pop("deeptutor.api.routers.question", None)
class _DummyProcessLogEvent:
def __init__(self, **kwargs) -> None:
self.data = {"type": "process_log", **kwargs}
def to_dict(self):
return self.data
@contextmanager
def _noop_context(*_args, **_kwargs):
yield
def _package(name: str) -> types.ModuleType:
module = types.ModuleType(name)
module.__path__ = []
return module
def _load_question_router_module(monkeypatch: pytest.MonkeyPatch):
sys.modules.pop("deeptutor.api.routers.question", None)
fake_agents = _package("deeptutor.agents")
fake_agents_question = types.ModuleType("deeptutor.agents.question")
fake_agents_question.AgentCoordinator = object
fake_agents.question = fake_agents_question
monkeypatch.setitem(sys.modules, "deeptutor.agents", fake_agents)
monkeypatch.setitem(sys.modules, "deeptutor.agents.question", fake_agents_question)
fake_logging = _package("deeptutor.logging")
fake_logging.ProcessLogEvent = _DummyProcessLogEvent
fake_logging.bind_log_context = _noop_context
fake_logging.capture_process_logs = _noop_context
fake_logging.current_log_context = lambda: {}
monkeypatch.setitem(sys.modules, "deeptutor.logging", fake_logging)
fake_config = types.ModuleType("deeptutor.services.config")
fake_config.PROJECT_ROOT = Path.cwd()
fake_config.load_config_with_main = lambda *_args, **_kwargs: {}
monkeypatch.setitem(sys.modules, "deeptutor.services.config", fake_config)
fake_llm_package = _package("deeptutor.services.llm")
fake_llm_config = types.ModuleType("deeptutor.services.llm.config")
fake_llm_config.get_llm_config = lambda: None
fake_llm_package.config = fake_llm_config
monkeypatch.setitem(sys.modules, "deeptutor.services.llm", fake_llm_package)
monkeypatch.setitem(sys.modules, "deeptutor.services.llm.config", fake_llm_config)
fake_settings_package = _package("deeptutor.services.settings")
fake_interface_settings = types.ModuleType("deeptutor.services.settings.interface_settings")
fake_interface_settings.get_ui_language = lambda default="en": default
fake_settings_package.interface_settings = fake_interface_settings
monkeypatch.setitem(sys.modules, "deeptutor.services.settings", fake_settings_package)
monkeypatch.setitem(
sys.modules,
"deeptutor.services.settings.interface_settings",
fake_interface_settings,
)
fake_tools = _package("deeptutor.tools")
fake_tools_question = types.ModuleType("deeptutor.tools.question")
async def _default_mimic_exam_questions(*_args, **_kwargs):
return {"success": True}
fake_tools_question.mimic_exam_questions = _default_mimic_exam_questions
fake_tools.question = fake_tools_question
monkeypatch.setitem(sys.modules, "deeptutor.tools", fake_tools)
monkeypatch.setitem(sys.modules, "deeptutor.tools.question", fake_tools_question)
return importlib.import_module("deeptutor.api.routers.question")
def _build_app(router_module) -> FastAPI:
app = FastAPI()
app.include_router(router_module.router, prefix="/api/v1/question")
return app
def test_mimic_websocket_accepts_config_and_returns_messages(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
question_router_module = _load_question_router_module(monkeypatch)
async def _fake_mimic_exam_questions(*_args, **_kwargs):
return {"success": False, "error": "stub mimic failure"}
monkeypatch.setattr(question_router_module, "mimic_exam_questions", _fake_mimic_exam_questions)
# ``MIMIC_OUTPUT_DIR`` was a module-level constant resolved at import time
# (which froze it to the admin path). It's now a per-call helper so the
# path follows whichever user is running. Patch the helper instead.
monkeypatch.setattr(
question_router_module, "_mimic_output_dir", lambda: tmp_path / "mimic_papers"
)
with TestClient(_build_app(question_router_module)) as client:
with client.websocket_connect("/api/v1/question/mimic") as websocket:
websocket.send_json(
{
"mode": "parsed",
"paper_path": str(tmp_path / "paper"),
"kb_name": "demo-kb",
"max_questions": 3,
}
)
messages = [websocket.receive_json() for _ in range(3)]
assert [message["type"] for message in messages] == ["status", "status", "error"]
assert messages[0]["stage"] == "init"
assert messages[1]["stage"] == "processing"
assert messages[2]["content"] == "stub mimic failure"
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the selective_access_log middleware in main.py.
Verifies that non-200 responses are logged with the 5-element args tuple
(client_addr, method, full_path, http_version, status_code) — the shape that
was needed for uvicorn's AccessFormatter in #334 — while 200s stay silent.
The real middleware logs through the ``deeptutor.access`` logger, which carries
its own stdout handler with ``propagate=False`` (uvicorn's own access log is
disabled on every launch path). Because it does not propagate to root, we
capture by attaching a handler directly to that logger rather than via caplog.
"""
from __future__ import annotations
import logging
import pytest
pytest.importorskip("fastapi")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.requests import Request
from starlette.responses import JSONResponse
ACCESS_LOGGER = "deeptutor.access"
def _build_app_with_middleware():
"""Build a minimal app that replicates the selective_access_log middleware."""
test_app = FastAPI()
_access_logger = logging.getLogger(ACCESS_LOGGER)
@test_app.middleware("http")
async def selective_access_log(request: Request, call_next):
response = await call_next(request)
if response.status_code != 200:
_access_logger.info(
'%s - "%s %s HTTP/%s" %d',
request.client.host if request.client else "-",
request.method,
request.url.path,
request.scope.get("http_version", "1.1"),
response.status_code,
)
return response
@test_app.get("/ok")
def ok():
return {"status": "ok"}
@test_app.get("/not-found")
def not_found():
return JSONResponse({"error": "not found"}, status_code=404)
return test_app
class _RecordCollector(logging.Handler):
"""Capture emitted records directly on the access logger (no propagation)."""
def __init__(self) -> None:
super().__init__(level=logging.INFO)
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
class _CaptureAccess:
def __enter__(self) -> _RecordCollector:
self._logger = logging.getLogger(ACCESS_LOGGER)
self._handler = _RecordCollector()
self._prev_level = self._logger.level
self._logger.setLevel(logging.INFO)
self._logger.addHandler(self._handler)
return self._handler
def __exit__(self, *exc) -> None:
self._logger.removeHandler(self._handler)
self._logger.setLevel(self._prev_level)
class TestSelectiveAccessLog:
"""selective_access_log middleware must emit 5-arg tuples for the formatter."""
def test_non_200_log_has_five_args(self):
"""Non-200 response log record args must have 5 elements (#334)."""
app = _build_app_with_middleware()
with _CaptureAccess() as cap:
with TestClient(app) as client:
client.get("/not-found")
assert len(cap.records) >= 1
record = cap.records[0]
assert len(record.args) == 5, (
f"Expected 5-element args for AccessFormatter, got {len(record.args)}"
)
client_addr, method, path, http_version, status_code = record.args
assert method == "GET"
assert path == "/not-found"
assert http_version in ("1.0", "1.1", "2")
assert status_code == 404
def test_200_not_logged(self):
"""200 responses should not produce deeptutor.access log records."""
app = _build_app_with_middleware()
with _CaptureAccess() as cap:
with TestClient(app) as client:
client.get("/ok")
ok_records = [
r for r in cap.records if r.args and len(r.args) >= 3 and "/ok" in str(r.args[2])
]
assert len(ok_records) == 0
+84
View File
@@ -0,0 +1,84 @@
"""Tests for oversized event-payload truncation in the session GET response.
Covers ``_truncate_oversized_events`` (introduced via PR #524 and refactored to
operate directly on the store's parsed ``events`` list). The session store
returns each message with an ``events`` list and no ``events_json`` key, so the
helper mutates that list in place.
"""
from deeptutor.api.routers.sessions import (
_TRUNCATION_NOTICE,
MAX_EVENT_PAYLOAD,
_truncate_oversized_events,
)
def _big(n: int) -> str:
return "x" * n
def test_truncates_oversized_tool_result_content():
big = _big(MAX_EVENT_PAYLOAD + 100)
messages = [{"events": [{"type": "tool_result", "content": big}]}]
_truncate_oversized_events(messages)
event = messages[0]["events"][0]
assert len(event["content"]) == MAX_EVENT_PAYLOAD + len(_TRUNCATION_NOTICE)
assert event["content"].endswith(_TRUNCATION_NOTICE)
assert event["_truncated"] is True
def test_truncates_nested_tool_metadata_fields():
big = _big(MAX_EVENT_PAYLOAD + 1)
messages = [
{
"events": [
{
"type": "observation",
"content": "short",
"metadata": {"tool_metadata": {"content": big, "answer": big}},
}
]
}
]
_truncate_oversized_events(messages)
tm = messages[0]["events"][0]["metadata"]["tool_metadata"]
assert tm["content"].endswith(_TRUNCATION_NOTICE)
assert tm["answer"].endswith(_TRUNCATION_NOTICE)
assert messages[0]["events"][0]["_truncated"] is True
# Untouched short top-level content stays intact.
assert messages[0]["events"][0]["content"] == "short"
def test_small_payloads_are_left_untouched():
messages = [
{"events": [{"type": "tool_result", "content": "tiny"}]},
{"events": [{"type": "thinking", "content": _big(MAX_EVENT_PAYLOAD + 5)}]},
]
_truncate_oversized_events(messages)
# Small payload unchanged, no truncation marker added.
assert messages[0]["events"][0]["content"] == "tiny"
assert "_truncated" not in messages[0]["events"][0]
# Non-truncatable event type left alone even when oversized.
assert "_truncated" not in messages[1]["events"][0]
assert len(messages[1]["events"][0]["content"]) == MAX_EVENT_PAYLOAD + 5
def test_handles_messages_without_events_or_malformed_events():
messages = [
{"role": "user", "content": "hi"}, # no events key
{"events": None},
{"events": "not-a-list"},
{"events": ["not-a-dict", {"type": "tool_result"}]}, # missing content
]
# Must not raise.
_truncate_oversized_events(messages)
# Event with no content gains no spurious content key.
assert "content" not in messages[3]["events"][1]
+653
View File
@@ -0,0 +1,653 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
import pytest
from deeptutor.api.routers import settings as settings_router
from deeptutor.services.config.provider_runtime import (
ResolvedEmbeddingConfig,
ResolvedLLMConfig,
)
from deeptutor.services.config.runtime_settings import RuntimeSettingsService
from deeptutor.services.embedding import client as embedding_client_module
from deeptutor.services.embedding import config as embedding_config_module
from deeptutor.services.llm import client as llm_client_module
from deeptutor.services.llm import config as llm_config_module
class _FakeEmbeddingAdapter:
def __init__(self, config: dict[str, Any]):
self.config = config
async def embed(self, request):
return type("EmbeddingResponse", (), {"embeddings": [[] for _ in request.texts]})()
class _FakeCatalogService:
def __init__(self, catalog: dict[str, Any]):
self._catalog = deepcopy(catalog)
def save(self, catalog: dict[str, Any]) -> dict[str, Any]:
self._catalog = deepcopy(catalog)
return deepcopy(self._catalog)
def load(self) -> dict[str, Any]:
return deepcopy(self._catalog)
def apply(self, catalog: dict[str, Any]) -> dict[str, Any]:
current = self.save(catalog)
return {
"catalog_path": "memory://model_catalog.json",
"services": list(current["services"]),
}
def _build_catalog(
*,
llm_model: str,
llm_base_url: str,
llm_api_key: str,
embedding_model: str,
embedding_base_url: str,
embedding_api_key: str,
) -> dict[str, Any]:
return {
"version": 1,
"services": {
"llm": {
"active_profile_id": "llm-profile-default",
"active_model_id": "llm-model-default",
"profiles": [
{
"id": "llm-profile-default",
"name": "Default LLM Endpoint",
"binding": "openai",
"base_url": llm_base_url,
"api_key": llm_api_key,
"api_version": "",
"extra_headers": {},
"models": [
{
"id": "llm-model-default",
"name": llm_model,
"model": llm_model,
}
],
}
],
},
"embedding": {
"active_profile_id": "embedding-profile-default",
"active_model_id": "embedding-model-default",
"profiles": [
{
"id": "embedding-profile-default",
"name": "Default Embedding Endpoint",
"binding": "openai",
"base_url": embedding_base_url,
"api_key": embedding_api_key,
"api_version": "",
"extra_headers": {},
"models": [
{
"id": "embedding-model-default",
"name": embedding_model,
"model": embedding_model,
"dimension": "1536",
}
],
}
],
},
"search": {
"active_profile_id": None,
"profiles": [],
},
},
}
def _patch_runtime(
monkeypatch: pytest.MonkeyPatch,
service: _FakeCatalogService,
) -> None:
monkeypatch.setattr(settings_router, "get_model_catalog_service", lambda: service)
monkeypatch.setattr(
embedding_client_module,
"_resolve_adapter_class",
lambda _binding: _FakeEmbeddingAdapter,
)
def _resolve_llm_runtime_config() -> ResolvedLLMConfig:
catalog = service.load()
profile = catalog["services"]["llm"]["profiles"][0]
model = profile["models"][0]
return ResolvedLLMConfig(
model=model["model"],
provider_name=profile["binding"],
provider_mode="standard",
binding_hint=profile["binding"],
binding=profile["binding"],
api_key=profile["api_key"],
base_url=profile["base_url"],
effective_url=profile["base_url"],
api_version=None,
extra_headers={},
reasoning_effort=None,
)
def _resolve_embedding_runtime_config() -> ResolvedEmbeddingConfig:
catalog = service.load()
profile = catalog["services"]["embedding"]["profiles"][0]
model = profile["models"][0]
return ResolvedEmbeddingConfig(
model=model["model"],
provider_name=profile["binding"],
provider_mode="standard",
binding_hint=profile["binding"],
binding=profile["binding"],
api_key=profile["api_key"],
base_url=profile["base_url"],
effective_url=profile["base_url"],
api_version=None,
extra_headers={},
dimension=int(model["dimension"]),
request_timeout=60,
batch_size=10,
)
monkeypatch.setattr(
llm_config_module,
"resolve_llm_runtime_config",
_resolve_llm_runtime_config,
)
monkeypatch.setattr(
embedding_config_module,
"resolve_embedding_runtime_config",
_resolve_embedding_runtime_config,
)
@pytest.mark.asyncio
async def test_network_settings_roundtrip_normalizes_cors_origins(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
service.save_system({"backend_port": 8001, "frontend_port": 3782})
service.save_auth({"enabled": True, "cookie_secure": True})
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
payload = settings_router.NetworkSettingsUpdate(
backend_port=8101,
frontend_port=3882,
public_api_base="https://api.example.com/deeptutor",
cors_origins=["app.example.com; https://learn.example.com/path"],
)
response = await settings_router.update_network_settings(payload)
assert response["settings"]["backend_port"] == 8101
assert response["settings"]["public_api_base"] == "https://api.example.com/deeptutor"
assert response["settings"]["cors_origins"] == [
"http://app.example.com",
"https://learn.example.com",
]
assert response["effective"]["cors_mode"] == "explicit"
assert response["auth"]["cross_site_cookie_ready"] is True
@pytest.mark.asyncio
async def test_mineru_settings_roundtrip_redacts_token(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
payload = settings_router.MinerUSettingsUpdate(
mode="cloud",
api_base_url="https://mineru.net/",
api_token="secret-token",
model_version="vlm",
)
response = await settings_router.update_mineru_settings(payload)
# The raw token never leaves the backend; only a boolean flag does.
assert response["api_token_set"] is True
assert "api_token" not in response["settings"]
assert response["settings"]["mode"] == "cloud"
assert response["settings"]["api_base_url"] == "https://mineru.net"
assert response["settings"]["model_version"] == "vlm"
# Persisted on disk under the canonical key.
assert service.load_mineru()["api_token"] == "secret-token"
@pytest.mark.asyncio
async def test_mineru_token_tristate_keep_then_clear(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
service.save_mineru({"mode": "cloud", "api_token": "keep-me"})
# api_token=None → keep the stored token.
await settings_router.update_mineru_settings(
settings_router.MinerUSettingsUpdate(mode="cloud", api_token=None)
)
assert service.load_mineru()["api_token"] == "keep-me"
# api_token="" → explicitly clear it.
await settings_router.update_mineru_settings(
settings_router.MinerUSettingsUpdate(mode="cloud", api_token="")
)
assert service.load_mineru()["api_token"] == ""
@pytest.mark.asyncio
async def test_mineru_test_connection_reports_missing_token(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
result = await settings_router.test_mineru_connection(
settings_router.MinerUSettingsUpdate(mode="cloud", api_token="")
)
assert result["ok"] is False
assert "token" in result["message"].lower()
@pytest.mark.asyncio
async def test_mineru_payload_includes_local_cli_probe(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
from deeptutor.services.parsing.engines.mineru import backend as mineru_backend
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
monkeypatch.setattr(
mineru_backend,
"local_cli_probe",
lambda *a: {"found": True, "command": "mineru", "path": "/env/bin/mineru"},
)
payload = await settings_router.get_mineru_settings()
assert payload["local_cli"] == {
"found": True,
"command": "mineru",
"path": "/env/bin/mineru",
}
@pytest.mark.asyncio
async def test_mineru_test_connection_local_mode(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
from deeptutor.services.parsing.engines.mineru import backend as mineru_backend
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
# CLI present → ok with version detail.
monkeypatch.setattr(
mineru_backend,
"local_cli_probe",
lambda *a: {"found": True, "command": "mineru", "path": "/env/bin/mineru"},
)
monkeypatch.setattr(mineru_backend, "local_cli_version", lambda cmd: "mineru, version 2.5.0")
result = await settings_router.test_mineru_connection(
settings_router.MinerUSettingsUpdate(mode="local")
)
assert result["ok"] is True
assert "2.5.0" in result["message"]
# CLI absent → actionable failure message.
monkeypatch.setattr(
mineru_backend, "local_cli_probe", lambda *a: {"found": False, "command": "", "path": ""}
)
result = await settings_router.test_mineru_connection(
settings_router.MinerUSettingsUpdate(mode="local")
)
assert result["ok"] is False
assert "not found" in result["message"].lower()
# Bad configured path → message points at the path, not at PATH install.
monkeypatch.setattr(
mineru_backend,
"local_cli_probe",
lambda *a: {"found": False, "command": "", "path": "/bad/mineru", "source": "configured"},
)
result = await settings_router.test_mineru_connection(
settings_router.MinerUSettingsUpdate(mode="local", local_cli_path="/bad/mineru")
)
assert result["ok"] is False
assert "/bad/mineru" in result["message"]
@pytest.mark.asyncio
async def test_mineru_models_download_start_requires_downloader(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from deeptutor.services.parsing.engines.mineru import models as mineru_models
monkeypatch.setattr(
mineru_models, "resolve_models_downloader", lambda p: {"found": False, "path": ""}
)
result = await settings_router.start_mineru_models_download(
settings_router.MinerUModelDownloadPayload()
)
assert result["ok"] is False
assert "not found" in result["message"].lower()
# Configured CLI without a sibling downloader → message names the path.
monkeypatch.setattr(
mineru_models,
"resolve_models_downloader",
lambda p: {"found": False, "path": "/env/bin/mineru-models-download"},
)
result = await settings_router.start_mineru_models_download(
settings_router.MinerUModelDownloadPayload(local_cli_path="/env/bin/mineru")
)
assert result["ok"] is False
assert "/env/bin/mineru-models-download" in result["message"]
@pytest.mark.asyncio
async def test_mineru_models_download_start_and_status_passthrough(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from deeptutor.services.parsing.engines.mineru import models as mineru_models
calls: dict[str, object] = {}
class _FakeManager:
def start(self, **kwargs):
calls.update(kwargs)
return {"ok": True, "message": ""}
def status(self, cursor=0):
return {"state": "running", "lines": ["l1"], "next_cursor": 1, "message": ""}
def cancel(self):
return {"ok": True, "message": ""}
monkeypatch.setattr(
mineru_models,
"resolve_models_downloader",
lambda p: {"found": True, "path": "/env/bin/mineru-models-download"},
)
monkeypatch.setattr(mineru_models, "get_model_download_manager", lambda: _FakeManager())
result = await settings_router.start_mineru_models_download(
settings_router.MinerUModelDownloadPayload(
model_type="all", source="modelscope", endpoint="https://hf-mirror.com"
)
)
assert result["ok"] is True
assert calls["downloader"] == "/env/bin/mineru-models-download"
assert calls["model_type"] == "all"
assert calls["source"] == "modelscope"
status = await settings_router.mineru_models_download_status(cursor=0)
assert status["lines"] == ["l1"]
cancel = await settings_router.cancel_mineru_models_download()
assert cancel["ok"] is True
def test_embedding_provider_choices_use_full_endpoint_urls() -> None:
embedding = {item["value"]: item for item in settings_router._provider_choices()["embedding"]}
assert embedding["openrouter"]["base_url"] == "https://openrouter.ai/api/v1/embeddings"
assert embedding["ollama"]["base_url"] == "http://localhost:11434/api/embed"
assert embedding["openai"]["base_url"] == "https://api.openai.com/v1/embeddings"
assert "custom_openai_sdk" not in embedding
@pytest.mark.asyncio
async def test_get_llm_options_returns_redacted_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
catalog = _build_catalog(
llm_model="gpt-4o-mini",
llm_base_url="https://llm.example/v1",
llm_api_key="secret-key",
embedding_model="text-embedding-3-small",
embedding_base_url="https://emb.example/v1/embeddings",
embedding_api_key="emb-key",
)
service = _FakeCatalogService(catalog)
monkeypatch.setattr(settings_router, "get_model_catalog_service", lambda: service)
response = await settings_router.get_llm_options()
assert response["active"] == {
"profile_id": "llm-profile-default",
"model_id": "llm-model-default",
}
assert response["options"][0]["model"] == "gpt-4o-mini"
assert "api_key" not in response["options"][0]
assert "base_url" not in response["options"][0]
@pytest.fixture(autouse=True)
def _reset_runtime_state() -> None:
llm_config_module.clear_llm_config_cache()
llm_client_module.reset_llm_client()
embedding_client_module.reset_embedding_client()
yield
llm_config_module.clear_llm_config_cache()
llm_client_module.reset_llm_client()
embedding_client_module.reset_embedding_client()
@pytest.mark.asyncio
async def test_update_catalog_invalidates_runtime_caches(monkeypatch: pytest.MonkeyPatch) -> None:
initial_catalog = _build_catalog(
llm_model="gpt-old",
llm_base_url="https://old-llm.example/v1",
llm_api_key="old-llm-key",
embedding_model="text-embedding-old",
embedding_base_url="https://old-embedding.example/v1/embeddings",
embedding_api_key="old-embedding-key",
)
updated_catalog = _build_catalog(
llm_model="gpt-new",
llm_base_url="https://new-llm.example/v1",
llm_api_key="new-llm-key",
embedding_model="text-embedding-new",
embedding_base_url="https://new-embedding.example/v1/embeddings",
embedding_api_key="new-embedding-key",
)
service = _FakeCatalogService(initial_catalog)
_patch_runtime(monkeypatch, service)
old_llm_config = llm_config_module.get_llm_config()
old_llm_client = llm_client_module.get_llm_client()
old_embedding_client = embedding_client_module.get_embedding_client()
response = await settings_router.update_catalog(
settings_router.CatalogPayload(catalog=updated_catalog)
)
new_llm_config = llm_config_module.get_llm_config()
new_llm_client = llm_client_module.get_llm_client()
new_embedding_client = embedding_client_module.get_embedding_client()
assert response == {"catalog": updated_catalog}
assert old_llm_config.model == "gpt-old"
assert new_llm_config.model == "gpt-new"
assert new_llm_config.base_url == "https://new-llm.example/v1"
assert new_llm_config is not old_llm_config
assert new_llm_client is not old_llm_client
assert new_llm_client.config.model == "gpt-new"
assert new_embedding_client is not old_embedding_client
assert new_embedding_client.config.model == "text-embedding-new"
assert new_embedding_client.config.base_url == "https://new-embedding.example/v1/embeddings"
@pytest.mark.asyncio
async def test_apply_catalog_invalidates_runtime_caches(monkeypatch: pytest.MonkeyPatch) -> None:
initial_catalog = _build_catalog(
llm_model="gpt-before-apply",
llm_base_url="https://before-apply-llm.example/v1",
llm_api_key="before-apply-llm-key",
embedding_model="text-embedding-before-apply",
embedding_base_url="https://before-apply-embedding.example/v1/embeddings",
embedding_api_key="before-apply-embedding-key",
)
applied_catalog = _build_catalog(
llm_model="gpt-after-apply",
llm_base_url="https://after-apply-llm.example/v1",
llm_api_key="after-apply-llm-key",
embedding_model="text-embedding-after-apply",
embedding_base_url="https://after-apply-embedding.example/v1/embeddings",
embedding_api_key="after-apply-embedding-key",
)
service = _FakeCatalogService(initial_catalog)
_patch_runtime(monkeypatch, service)
llm_config_module.get_llm_config()
old_llm_client = llm_client_module.get_llm_client()
old_embedding_client = embedding_client_module.get_embedding_client()
response = await settings_router.apply_catalog(
settings_router.CatalogPayload(catalog=applied_catalog)
)
new_llm_config = llm_config_module.get_llm_config()
new_llm_client = llm_client_module.get_llm_client()
new_embedding_client = embedding_client_module.get_embedding_client()
assert response["catalog"] == applied_catalog
assert response["runtime"]["catalog_path"]
assert new_llm_config.model == "gpt-after-apply"
assert new_llm_client is not old_llm_client
assert new_llm_client.config.base_url == "https://after-apply-llm.example/v1"
assert new_embedding_client is not old_embedding_client
assert new_embedding_client.config.model == "text-embedding-after-apply"
@pytest.mark.asyncio
async def test_enabled_tools_roundtrip(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_file = tmp_path / "interface.json"
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
# Default state — no file yet, so the loader emits the full toggleable set.
assert set(settings_router.get_enabled_optional_tools()) == set(
settings_router.USER_TOGGLEABLE_TOOL_NAMES
)
# PUT a partial set; unknown tool names get filtered out.
update = settings_router.EnabledToolsUpdate(
enabled_tools=["web_search", "reason", "not_a_real_tool"]
)
response = await settings_router.update_enabled_tools(update)
assert response == {"enabled_optional_tools": ["web_search", "reason"]}
assert settings_router.get_enabled_optional_tools() == ["web_search", "reason"]
# Empty selection is a valid "all off" state.
response = await settings_router.update_enabled_tools(
settings_router.EnabledToolsUpdate(enabled_tools=[])
)
assert response == {"enabled_optional_tools": []}
assert settings_router.get_enabled_optional_tools() == []
@pytest.mark.asyncio
async def test_complete_tour_invalidates_runtime_caches(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
initial_catalog = _build_catalog(
llm_model="gpt-before-tour",
llm_base_url="https://before-tour-llm.example/v1",
llm_api_key="before-tour-llm-key",
embedding_model="text-embedding-before-tour",
embedding_base_url="https://before-tour-embedding.example/v1/embeddings",
embedding_api_key="before-tour-embedding-key",
)
completed_catalog = _build_catalog(
llm_model="gpt-after-tour",
llm_base_url="https://after-tour-llm.example/v1",
llm_api_key="after-tour-llm-key",
embedding_model="text-embedding-after-tour",
embedding_base_url="https://after-tour-embedding.example/v1/embeddings",
embedding_api_key="after-tour-embedding-key",
)
service = _FakeCatalogService(initial_catalog)
_patch_runtime(monkeypatch, service)
tour_cache = tmp_path / ".tour_cache.json"
tour_cache.write_text('{"status": "running"}', encoding="utf-8")
monkeypatch.setattr(settings_router, "TOUR_CACHE", tour_cache)
llm_config_module.get_llm_config()
old_llm_client = llm_client_module.get_llm_client()
old_embedding_client = embedding_client_module.get_embedding_client()
response = await settings_router.complete_tour(
settings_router.TourCompletePayload(catalog=completed_catalog)
)
new_llm_config = llm_config_module.get_llm_config()
new_llm_client = llm_client_module.get_llm_client()
new_embedding_client = embedding_client_module.get_embedding_client()
cache = tour_cache.read_text(encoding="utf-8")
assert response["runtime"]["catalog_path"]
assert response["status"] == "completed"
assert new_llm_config.model == "gpt-after-tour"
assert new_llm_client is not old_llm_client
assert new_embedding_client is not old_embedding_client
assert '"status": "completed"' in cache
@pytest.mark.asyncio
async def test_fetch_models_returns_picker_options(monkeypatch: pytest.MonkeyPatch) -> None:
import deeptutor.services.llm.factory as factory_module
async def _fake_fetch(binding: str, base_url: str, api_key: str | None = None):
assert binding == "openai" # "OpenAI" is normalized to lowercase
assert base_url == "https://api.example.com/v1"
assert api_key == "sk-x"
return ["gpt-4o", "gpt-4o-mini"]
monkeypatch.setattr(factory_module, "fetch_models", _fake_fetch)
response = await settings_router.fetch_models_from_provider(
settings_router.FetchModelsPayload(
binding="OpenAI", base_url="https://api.example.com/v1", api_key="sk-x"
)
)
assert response == {
"models": [
{"id": "gpt-4o", "name": "gpt-4o"},
{"id": "gpt-4o-mini", "name": "gpt-4o-mini"},
]
}
@pytest.mark.asyncio
async def test_fetch_models_requires_base_url() -> None:
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
await settings_router.fetch_models_from_provider(
settings_router.FetchModelsPayload(base_url=" ")
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_fetch_models_maps_provider_error_to_502(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
import deeptutor.services.llm.factory as factory_module
async def _boom(binding: str, base_url: str, api_key: str | None = None):
raise RuntimeError("connection refused")
monkeypatch.setattr(factory_module, "fetch_models", _boom)
with pytest.raises(HTTPException) as exc_info:
await settings_router.fetch_models_from_provider(
settings_router.FetchModelsPayload(binding="custom", base_url="https://x/v1")
)
assert exc_info.value.status_code == 502
+106
View File
@@ -0,0 +1,106 @@
"""API tests for the in-app EduHub skill browser endpoints.
``GET /api/v1/skills/hub/catalog`` and ``/hub/detail`` proxy a hub's public
catalog so the web panel can render it in DeepTutor's own UI (no iframe, no
login). The hub provider is mocked over an ``httpx`` transport.
"""
from __future__ import annotations
import importlib
import httpx
import pytest
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
except Exception: # pragma: no cover - optional dependency in lightweight envs
FastAPI = None
TestClient = None
pytestmark = pytest.mark.skipif(
FastAPI is None or TestClient is None, reason="fastapi not installed"
)
from deeptutor.services.skill import hub as hub_module
from deeptutor.services.skill.hub import ClawHubProvider
def _mock_provider() -> ClawHubProvider:
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/api/v1/skills":
return httpx.Response(
200,
json={
"skills": [
{
"slug": "socratic-tutor",
"displayName": "Socratic Tutor",
"summary": "Teach by asking.",
"version": "1.0.0",
"stats": {"downloads": 8, "stars": 2},
"owner": {
"displayName": "DeepTutor",
"htmlUrl": "https://deeptutor.info",
},
}
]
},
)
if path == "/api/v1/skills/socratic-tutor":
return httpx.Response(
200,
json={
"skill": {
"slug": "socratic-tutor",
"displayName": "Socratic Tutor",
"summary": "Teach.",
"description": "---\nname: socratic-tutor\n---\n\n# Body\n",
"tags": ["tutor"],
"stats": {"downloads": 8, "stars": 2},
},
"owner": {"displayName": "DeepTutor", "htmlUrl": "https://deeptutor.info"},
"distTags": {"latest": "1.0.0"},
},
)
return httpx.Response(404, text="nope")
return ClawHubProvider(
"eduhub",
base_url="https://eduhub.deeptutor.info/api/v1",
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
def _build_app() -> FastAPI:
router = importlib.import_module("deeptutor.api.routers.skills").router
app = FastAPI()
app.include_router(router, prefix="/api/v1/skills")
return app
def test_hub_catalog_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(hub_module, "get_hub_provider", lambda name: _mock_provider())
client = TestClient(_build_app())
resp = client.get("/api/v1/skills/hub/catalog")
assert resp.status_code == 200
data = resp.json()
assert data["hub"] == "eduhub"
assert data["web_url"] == "https://eduhub.deeptutor.info"
row = data["skills"][0]
assert row["slug"] == "socratic-tutor"
assert row["downloads"] == 8 and row["stars"] == 2
assert row["owner"] == "DeepTutor"
def test_hub_detail_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(hub_module, "get_hub_provider", lambda name: _mock_provider())
client = TestClient(_build_app())
resp = client.get("/api/v1/skills/hub/detail", params={"slug": "socratic-tutor"})
assert resp.status_code == 200
data = resp.json()
assert data["version"] == "1.0.0"
assert "# Body" in data["content"]
assert data["web_url"] == "https://eduhub.deeptutor.info/skills/socratic-tutor"
+356
View File
@@ -0,0 +1,356 @@
"""API tests for the subagents router (detect / connect / list / disconnect).
Built on a standalone app with a fake KB manager and a stubbed detector, so it
exercises the HTTP contract without spawning a real CLI or touching the data
tree (mirrors test_knowledge_router's isolation).
"""
from __future__ import annotations
import importlib
import json
from pathlib import Path
import pytest
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
except Exception: # pragma: no cover - optional dependency in lightweight envs
FastAPI = None
TestClient = None
pytestmark = pytest.mark.skipif(
FastAPI is None or TestClient is None, reason="fastapi not installed"
)
if FastAPI is not None and TestClient is not None:
subagents_module = importlib.import_module("deeptutor.api.routers.subagents")
else: # pragma: no cover
subagents_module = None
class _FakeKBManager:
def __init__(self) -> None:
self.kbs: dict[str, dict] = {}
def list_knowledge_bases(self) -> list[str]:
return sorted(self.kbs)
def get_metadata(self, name: str | None = None) -> dict:
return dict(self.kbs.get(name or "", {}))
def register_subagent_connection(
self, name, agent_kind, *, cwd="", partner_id="", description=""
):
if name in self.kbs:
raise ValueError(f"A knowledge base named '{name}' already exists.")
entry = {
"path": name,
"type": "subagent",
"agent_kind": agent_kind,
"cwd": cwd,
"partner_id": partner_id,
"description": description or f"Connected subagent: {name}",
}
self.kbs[name] = entry
return entry
def delete_knowledge_base(self, name, confirm=False):
self.kbs.pop(name, None)
return True
@pytest.fixture
def client(monkeypatch, tmp_path):
manager = _FakeKBManager()
monkeypatch.setattr(subagents_module, "current_kb_manager", lambda: manager)
monkeypatch.setattr(
subagents_module, "list_backend_kinds", lambda: ["claude_code", "codex", "partner"]
)
monkeypatch.setattr(subagents_module, "assert_path_allowed", lambda p: Path(p))
# Isolate settings persistence to a temp file — the PUT path otherwise
# writes the developer's real data/user/settings/subagent.json.
monkeypatch.setattr(
"deeptutor.services.subagent.config._settings_path",
lambda: tmp_path / "subagent.json",
)
async def fake_detect():
from deeptutor.services.subagent.types import DetectResult
return [
DetectResult("claude_code", "Claude Code", available=True, version="2.x"),
DetectResult("codex", "Codex", available=False, detail="not installed"),
]
monkeypatch.setattr(subagents_module, "detect_all", fake_detect)
app = FastAPI()
app.include_router(subagents_module.router, prefix="/api/v1/subagents")
# Settings PUT is admin-gated; bypass the auth dependency for the contract test.
app.dependency_overrides[subagents_module.require_admin] = lambda: None
return TestClient(app)
def test_detect_reports_backends(client):
res = client.get("/api/v1/subagents/detect")
assert res.status_code == 200
backends = {b["kind"]: b for b in res.json()["backends"]}
assert backends["claude_code"]["available"] is True
assert backends["codex"]["available"] is False
def test_connect_list_and_disconnect_roundtrip(client):
created = client.post(
"/api/v1/subagents/connections",
json={"name": "MyClaude", "agent_kind": "claude_code", "cwd": "/tmp"},
)
assert created.status_code == 200
assert created.json()["agent_kind"] == "claude_code"
listed = client.get("/api/v1/subagents/connections").json()["connections"]
assert len(listed) == 1
assert listed[0]["name"] == "MyClaude"
assert listed[0]["agent_kind"] == "claude_code"
assert listed[0]["cwd"] == "/tmp"
gone = client.delete("/api/v1/subagents/connections/MyClaude")
assert gone.status_code == 200
assert client.get("/api/v1/subagents/connections").json()["connections"] == []
def test_connect_rejects_unknown_kind(client):
res = client.post(
"/api/v1/subagents/connections",
json={"name": "X", "agent_kind": "bogus"},
)
assert res.status_code == 400
class _FakePartnerManagerForConnect:
def __init__(self, known: set[str]) -> None:
self._known = known
def partner_exists(self, pid: str) -> bool:
return pid in self._known
def _patch_partner_existence(monkeypatch, known: set[str]) -> None:
import deeptutor.services.partners as partners_pkg
monkeypatch.setattr(
partners_pkg, "get_partner_manager", lambda: _FakePartnerManagerForConnect(known)
)
def test_connect_partner_binds_partner_id(client, monkeypatch):
_patch_partner_existence(monkeypatch, {"paul"})
created = client.post(
"/api/v1/subagents/connections",
json={"name": "Paul", "agent_kind": "partner", "partner_id": "paul"},
)
assert created.status_code == 200
body = created.json()
assert body["agent_kind"] == "partner"
assert body["partner_id"] == "paul"
assert body["cwd"] == ""
listed = client.get("/api/v1/subagents/connections").json()["connections"]
assert listed[0]["agent_kind"] == "partner"
assert listed[0]["partner_id"] == "paul"
def test_list_visible_partners(client, monkeypatch):
monkeypatch.setattr(
subagents_module,
"visible_partner_cards",
lambda: [{"partner_id": "p1", "name": "P1", "emoji": "🤖"}],
)
res = client.get("/api/v1/subagents/partners")
assert res.status_code == 200
partners = res.json()["partners"]
assert partners == [{"partner_id": "p1", "name": "P1", "emoji": "🤖"}]
def test_connect_partner_denied_when_not_assigned(client, monkeypatch):
# A non-admin connecting an unassigned partner is rejected by the
# assignment guard before the connection is created.
from fastapi import HTTPException
_patch_partner_existence(monkeypatch, {"paul"})
def deny(_pid):
raise HTTPException(status_code=403, detail="Partner is not assigned to you")
monkeypatch.setattr(subagents_module, "assert_partner_allowed", deny)
res = client.post(
"/api/v1/subagents/connections",
json={"name": "Paul", "agent_kind": "partner", "partner_id": "paul"},
)
assert res.status_code == 403
# Nothing was connected.
assert client.get("/api/v1/subagents/connections").json()["connections"] == []
def test_connect_partner_requires_partner_id(client, monkeypatch):
_patch_partner_existence(monkeypatch, {"paul"})
res = client.post(
"/api/v1/subagents/connections",
json={"name": "Paul", "agent_kind": "partner"},
)
assert res.status_code == 400
def test_connect_partner_rejects_unknown_partner(client, monkeypatch):
_patch_partner_existence(monkeypatch, set())
res = client.post(
"/api/v1/subagents/connections",
json={"name": "Ghost", "agent_kind": "partner", "partner_id": "ghost"},
)
assert res.status_code == 400
def test_disconnect_unknown_is_404(client):
res = client.delete("/api/v1/subagents/connections/nope")
assert res.status_code == 404
def test_backend_options_endpoint_shape(client, monkeypatch):
from deeptutor.services.subagent import models as models_mod
from deeptutor.services.subagent.models import BackendOptions, ModelOption
async def fake_options():
return [
BackendOptions(
kind="codex",
display_name="Codex",
available=True,
version="0.x",
default_model="gpt-5.5",
models=[ModelOption("gpt-5.5", "GPT-5.5", "medium", ["low", "medium", "high"])],
efforts=["low", "medium", "high"],
allow_custom_model=True,
synced_at="2026-06-17T00:00:00Z",
)
]
# The route imports list_backend_options lazily from the models module.
monkeypatch.setattr(models_mod, "list_backend_options", fake_options)
res = client.get("/api/v1/subagents/backends/options")
assert res.status_code == 200
backends = res.json()["backends"]
assert backends[0]["kind"] == "codex"
assert backends[0]["default_model"] == "gpt-5.5"
assert backends[0]["models"][0]["efforts"] == ["low", "medium", "high"]
assert backends[0]["synced_at"] == "2026-06-17T00:00:00Z"
def test_message_connection_streams_and_persists(client, monkeypatch, tmp_path):
# Connect, then message the agent directly: the run streams as NDJSON and the
# backend session id is remembered (keyed by chat session + connection).
client.post(
"/api/v1/subagents/connections",
json={"name": "MyClaude", "agent_kind": "claude_code"},
)
from deeptutor.services.subagent import sessions as sess
from deeptutor.services.subagent.types import ConsultResult, SubagentEvent
monkeypatch.setattr(sess, "_path", lambda: tmp_path / "sessions.json")
class _FakeBackend:
kind = "claude_code"
async def consult(
self, message, *, on_event, cwd, session_id, config, images=None, partner_id=None
):
await on_event(SubagentEvent(kind="text", text="hi", meta={"merge_id": "txt:m:0"}))
return ConsultResult(final_text="hi", session_id="sess-9", success=True, event_count=1)
monkeypatch.setattr("deeptutor.services.subagent.get_backend", lambda kind: _FakeBackend())
res = client.post(
"/api/v1/subagents/connections/MyClaude/message",
json={"chat_session_id": "chatA", "message": "hello"},
)
assert res.status_code == 200
lines = [json.loads(line) for line in res.text.splitlines() if line.strip()]
# The user's own message heads the exchange.
assert lines[0] == {"channel": "user_question", "text": "hello"}
# The agent's event carries a sidebar-namespaced merge id.
assert any(
line.get("channel") == "text" and line.get("merge_id") == "side:txt:m:0" for line in lines
)
# The final line reports the session id, now persisted for the next turn.
assert lines[-1]["done"] is True and lines[-1]["session_id"] == "sess-9"
assert sess.get_session(sess.session_key("chatA", "MyClaude")) == "sess-9"
def test_message_connection_unknown_is_404(client):
res = client.post(
"/api/v1/subagents/connections/nope/message",
json={"chat_session_id": "x", "message": "hi"},
)
assert res.status_code == 404
def test_backend_sync_endpoint(client, monkeypatch):
from deeptutor.services.subagent import models as models_mod
from deeptutor.services.subagent.models import BackendOptions, ModelOption
async def fake_sync(kind):
return BackendOptions(
kind=kind,
display_name="Claude Code",
available=True,
models=[ModelOption("opus", "Opus 4.8 · 1M context", efforts=["high"])],
efforts=["high"],
allow_custom_model=True,
synced_at="2026-06-17T00:00:00Z",
)
monkeypatch.setattr(models_mod, "sync_backend_options", fake_sync)
res = client.post("/api/v1/subagents/backends/claude_code/sync")
assert res.status_code == 200
body = res.json()
assert body["kind"] == "claude_code"
assert body["models"][0]["slug"] == "opus"
assert body["synced_at"] == "2026-06-17T00:00:00Z"
# Unknown backend is rejected.
assert client.post("/api/v1/subagents/backends/bogus/sync").status_code == 400
def test_settings_put_merges_per_field_and_backend(client):
# Save one field for claude_code …
r1 = client.put(
"/api/v1/subagents/settings",
json={"backends": {"claude_code": {"model": "opus"}}},
)
assert r1.status_code == 200
# … then another field — the first must survive the merge.
r2 = client.put(
"/api/v1/subagents/settings",
json={"backends": {"claude_code": {"effort": "high"}}},
)
cc = r2.json()["backends"]["claude_code"]
assert cc["model"] == "opus" and cc["effort"] == "high"
# Saving the OTHER backend must not clobber claude_code.
r3 = client.put(
"/api/v1/subagents/settings",
json={"backends": {"codex": {"sandbox": "read-only"}}},
)
body = r3.json()
assert body["backends"]["claude_code"]["model"] == "opus"
assert body["backends"]["codex"]["sandbox"] == "read-only"
# Updating only the budget must leave the backends intact.
r4 = client.put("/api/v1/subagents/settings", json={"consult_budget": 7})
body = r4.json()
assert body["consult_budget"] == 7
assert body["backends"]["claude_code"]["model"] == "opus"
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from deeptutor.api.routers import system as system_router
@pytest.mark.asyncio
async def test_embeddings_connection_uses_batch_probe(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, list[str]] = {}
class _FakeClient:
async def embed(self, texts: list[str]):
captured["texts"] = texts
return [[0.1, 0.2], [0.3, 0.4]]
monkeypatch.setattr(
system_router,
"get_embedding_config",
lambda: SimpleNamespace(model="embed-test", binding="openai"),
)
monkeypatch.setattr(system_router, "get_embedding_client", lambda: _FakeClient())
response = await system_router.test_embeddings_connection()
assert response.success is True
assert captured["texts"] == ["test", "retrieval batch probe"]
@pytest.mark.asyncio
async def test_embeddings_connection_rejects_partial_batch_response(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakeClient:
async def embed(self, texts: list[str]):
return [[0.1, 0.2]]
monkeypatch.setattr(
system_router,
"get_embedding_config",
lambda: SimpleNamespace(model="embed-test", binding="openai"),
)
monkeypatch.setattr(system_router, "get_embedding_client", lambda: _FakeClient())
response = await system_router.test_embeddings_connection()
assert response.success is False
assert response.message == "Embeddings connection failed: Invalid response"
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import pytest
from deeptutor.api.routers import settings as settings_router
from deeptutor.api.routers import tools as tools_router
from deeptutor.tools.builtin import (
BUILTIN_TOOL_NAMES,
COMING_SOON_TOOL_NAMES,
USER_TOGGLEABLE_TOOL_NAMES,
)
@pytest.mark.asyncio
async def test_list_builtin_tools_marks_toggleable_set(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""The /api/v1/tools response must clearly separate user-toggleable
tools from locked-on (auto-mounted) tools so the /settings/tools UI
can render the right control per row."""
settings_file = tmp_path / "interface.json"
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
response = await tools_router.list_builtin_tools()
by_name = {tool.name: tool for tool in response.tools}
# Every registered tool + every coming-soon placeholder shows up.
assert set(by_name) == set(BUILTIN_TOOL_NAMES) | set(COMING_SOON_TOOL_NAMES)
# Coming-soon entries are NOT toggleable and report enabled=False so
# the UI can lock the toggle and badge them appropriately.
for name in COMING_SOON_TOOL_NAMES:
assert by_name[name].coming_soon is True, name
assert by_name[name].toggleable is False, name
assert by_name[name].enabled is False, name
# Exactly the documented set is toggleable. Pinning the literal here
# (in addition to the constant) is intentional — this list is the
# product contract for the /settings/tools "体验增强" section.
toggleable_names = {name for name, tool in by_name.items() if tool.toggleable}
assert toggleable_names == set(USER_TOGGLEABLE_TOOL_NAMES)
assert toggleable_names == {
"brainstorm",
"web_search",
"paper_search",
"reason",
"geogebra_analysis",
"imagegen",
"videogen",
}
# Locked-on (non-toggleable, non-coming-soon) tools always report
# enabled=True.
for name, tool in by_name.items():
if not tool.toggleable and not tool.coming_soon:
assert tool.enabled is True, name
# On a fresh settings file the default toggleable set is "all on".
assert set(response.enabled_optional_tools) == set(USER_TOGGLEABLE_TOOL_NAMES)
for name in USER_TOGGLEABLE_TOOL_NAMES:
assert by_name[name].enabled is True
@pytest.mark.asyncio
async def test_list_builtin_tools_marks_capability_owned_tools(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""Capability-owned tools (solve_*, mastery_*) report their owning
capability so the /settings/tools UI groups them below the built-ins;
plain system built-ins report ``capability=None``."""
settings_file = tmp_path / "interface.json"
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
response = await tools_router.list_builtin_tools()
by_name = {tool.name: tool for tool in response.tools}
assert by_name["solve_plan"].capability == "solve"
assert by_name["mastery_status"].capability == "mastery"
assert by_name["web_fetch"].capability is None
# Capability tools stay locked-on (not user-toggleable).
assert by_name["solve_plan"].toggleable is False
assert by_name["solve_plan"].enabled is True
@pytest.mark.asyncio
async def test_list_builtin_tools_reflects_user_toggle(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_file = tmp_path / "interface.json"
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
# User disables a subset of toggleable tools.
await settings_router.update_enabled_tools(
settings_router.EnabledToolsUpdate(enabled_tools=["web_search", "reason"])
)
response = await tools_router.list_builtin_tools()
by_name = {tool.name: tool for tool in response.tools}
assert response.enabled_optional_tools == ["reason", "web_search"]
assert by_name["web_search"].enabled is True
assert by_name["reason"].enabled is True
assert by_name["brainstorm"].enabled is False
assert by_name["paper_search"].enabled is False
# Locked-on tools stay on regardless (code_execution is now auto-mounted,
# gated by sandbox availability rather than a user toggle).
assert by_name["code_execution"].enabled is True
assert by_name["rag"].enabled is True
assert by_name["web_fetch"].enabled is True
+913
View File
@@ -0,0 +1,913 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from deeptutor.core.stream import StreamEvent, StreamEventType
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
from deeptutor.services.session.turn_runtime import TurnRuntimeManager
async def _noop_async(*_args, **_kwargs):
return None
def _fake_skill_service() -> SimpleNamespace:
return SimpleNamespace(
summary_entries=lambda: [],
load_always_for_context=lambda: "",
load_for_context=lambda _skills: "",
list_skills=lambda: [],
)
def _fake_persona_service() -> SimpleNamespace:
# Non-empty render so the resolved persona is recorded in the snapshot.
return SimpleNamespace(
load_for_context=lambda name: (
f"## Active Persona\n### Persona: {name}\n\nbody" if name else ""
)
)
def _model_catalog() -> dict:
return {
"version": 1,
"services": {
"llm": {
"active_profile_id": "p-default",
"active_model_id": "m-default",
"profiles": [
{
"id": "p-default",
"name": "Default",
"binding": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-test",
"models": [
{
"id": "m-default",
"name": "Default",
"model": "gpt-4o-mini",
}
],
},
{
"id": "p-alt",
"name": "Alt",
"binding": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-alt",
"models": [
{
"id": "m-alt",
"name": "Alt Model",
"model": "anthropic/claude-sonnet-4",
}
],
},
],
}
},
}
@pytest.mark.asyncio
async def test_turn_runtime_replays_events_and_materializes_messages(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
captured: dict[str, object] = {}
publish_order: list[str] = []
original_publish = runtime._publish_live_event
async def publish_with_status_capture(execution, event):
publish_order.append(event.type.value)
if event.type == StreamEventType.DONE:
persisted_turn = await store.get_turn(execution.turn_id)
captured["turn_status_when_done_published"] = (persisted_turn or {}).get("status")
return await original_publish(execution, event)
monkeypatch.setattr(runtime, "_publish_live_event", publish_with_status_capture)
async def title_after_done(*_args, **_kwargs):
captured["title_started_after_done"] = "done" in publish_order
monkeypatch.setattr(runtime, "_maybe_generate_session_title", title_after_done)
class FakeContextBuilder:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def build(self, **kwargs):
on_event = kwargs.get("on_event")
if on_event is not None:
await on_event(
StreamEvent(
type=StreamEventType.PROGRESS,
source="context",
stage="summarizing",
content="summarize context",
)
)
return SimpleNamespace(
conversation_history=[],
conversation_summary="",
context_text="",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, context):
captured["user_message"] = context.user_message
captured["metadata"] = context.metadata
captured["source_manifest"] = context.source_manifest
yield StreamEvent(
type=StreamEventType.CONTENT,
source="chat",
stage="responding",
content="Hello Frank",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="chat")
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.book.context.build_book_context",
lambda *_args, **_kwargs: SimpleNamespace(
text="## Page: Signal Basics\nA selected page.",
references=[{"book_id": "book-1", "page_ids": ["page-1"]}],
warnings=[],
),
)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(
read_l3_concat=lambda: "",
emit=_noop_async,
),
)
monkeypatch.setattr(
"deeptutor.services.skill.get_skill_service",
_fake_skill_service,
)
monkeypatch.setattr(
"deeptutor.services.persona.get_persona_service",
_fake_persona_service,
)
session, turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "hello, i'm frank",
"session_id": None,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"persona": "socratic",
"memory_references": ["summary"],
"book_references": [{"book_id": "book-1", "page_ids": ["page-1"]}],
"config": {},
}
)
events = []
async for event in runtime.subscribe_turn(turn["id"], after_seq=0):
events.append(event)
# session_meta may arrive after `done` from the title generator —
# filter it out so the timing race doesn't flake the assertion.
assert [e["type"] for e in events if e["type"] != "session_meta"] == [
"session",
"content",
"done",
]
done_event = next(e for e in events if e["type"] == "done")
assert done_event["metadata"]["status"] == "completed"
assert captured["turn_status_when_done_published"] == "completed"
assert captured["title_started_after_done"] is True
detail = await store.get_session_with_messages(session["id"])
assert detail is not None
assert [message["role"] for message in detail["messages"]] == ["user", "assistant"]
assert detail["messages"][0]["metadata"]["request_snapshot"]["persona"] == "socratic"
assert detail["messages"][0]["metadata"]["request_snapshot"]["memoryReferences"] == ["summary"]
assert detail["messages"][0]["metadata"]["request_snapshot"]["bookReferences"] == [
{"book_id": "book-1", "page_ids": ["page-1"]}
]
# Chat capability now routes attached sources through the manifest +
# ``read_source`` tool instead of inlining ``[Book Context]`` into the
# user message. The raw user message stays raw; the book payload
# surfaces in ``context.source_manifest`` and ``metadata.source_index``.
assert str(captured["user_message"]) == "hello, i'm frank"
manifest = str(captured.get("source_manifest") or "")
assert "[Attached Sources]" in manifest
# Book source id is now per-book (``bk-{book_id}``) so multi-book
# sessions can read_source each independently. The mocked book has id
# "book-1".
assert "bk-book-1" in manifest
source_index = (captured.get("metadata") or {}).get("source_index") or {}
assert "bk-book-1" in source_index
assert "A selected page." in source_index["bk-book-1"]
assert captured["metadata"] and captured["metadata"]["book_references"] == [
{"book_id": "book-1", "page_ids": ["page-1"]}
]
assert detail["messages"][1]["content"] == "Hello Frank"
assert detail["preferences"] == {
"capability": "chat",
"tools": [],
"knowledge_bases": [],
"language": "en",
# Explicit persona in the payload is persisted as a session-level
# preference (survives reloads; later turns fall back to it).
"persona": "socratic",
}
persisted_turn = await store.get_turn(turn["id"])
assert persisted_turn is not None
assert persisted_turn["status"] == "completed"
@pytest.mark.asyncio
async def test_turn_runtime_persists_llm_selection_in_turn_snapshot(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
captured: dict[str, object] = {}
class FakeContextBuilder:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def build(self, **kwargs):
captured["builder_llm_config"] = kwargs["llm_config"]
return SimpleNamespace(
conversation_history=[],
conversation_summary="",
context_text="",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, context):
captured["metadata"] = context.metadata
yield StreamEvent(
type=StreamEventType.CONTENT,
source="chat",
stage="responding",
content="Alt reply",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="chat")
def fake_activate(selection):
captured["activated_selection"] = selection
return SimpleNamespace(
model="anthropic/claude-sonnet-4", provider_name="openrouter"
), object()
monkeypatch.setattr(
"deeptutor.services.config.get_model_catalog_service",
lambda: SimpleNamespace(load=_model_catalog),
)
monkeypatch.setattr(
"deeptutor.services.model_selection.runtime.activate_llm_selection",
fake_activate,
)
monkeypatch.setattr(
"deeptutor.services.model_selection.runtime.reset_llm_selection",
lambda _token: captured.setdefault("reset_called", True),
)
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(
read_l3_concat=lambda: "",
emit=_noop_async,
),
)
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
selection = {"profile_id": "p-alt", "model_id": "m-alt"}
session, turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "use the alt model",
"session_id": None,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"config": {},
"llm_selection": selection,
}
)
async for _event in runtime.subscribe_turn(turn["id"], after_seq=0):
pass
detail = await store.get_session_with_messages(session["id"])
assert detail is not None
assert detail["preferences"]["llm_selection"] == selection
assert detail["messages"][0]["metadata"]["request_snapshot"]["llmSelection"] == selection
assert captured["activated_selection"] == selection
assert captured["builder_llm_config"].model == "anthropic/claude-sonnet-4"
assert captured["metadata"]["llm_selection"] == selection
assert captured["metadata"]["llm_model"] == "anthropic/claude-sonnet-4"
assert captured["metadata"]["llm_provider"] == "openrouter"
assert captured["reset_called"] is True
@pytest.mark.asyncio
async def test_turn_runtime_session_persona_persists_falls_back_and_clears(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""Persona is a session preference: explicit key persists (incl. ""),
absent key falls back to the stored preference."""
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
class FakeContextBuilder:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def build(self, **_kwargs):
return SimpleNamespace(
conversation_history=[],
conversation_summary="",
context_text="",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, context):
yield StreamEvent(
type=StreamEventType.CONTENT,
source="chat",
stage="responding",
content="ok",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="chat")
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(read_l3_concat=lambda: "", emit=_noop_async),
)
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
async def run_turn(session_id, extra):
session, turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "hi",
"session_id": session_id,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"config": {},
**extra,
}
)
async for _event in runtime.subscribe_turn(turn["id"], after_seq=0):
pass
return session
# Turn 1 — explicit persona: applied to the turn AND persisted.
session = await run_turn(None, {"persona": "socratic"})
detail = await store.get_session_with_messages(session["id"])
assert detail["preferences"]["persona"] == "socratic"
assert detail["messages"][0]["metadata"]["request_snapshot"]["persona"] == "socratic"
# Turn 2 — persona key ABSENT: falls back to the stored preference, so
# the persona keeps applying to follow-up questions in the session.
await run_turn(session["id"], {})
detail = await store.get_session_with_messages(session["id"])
assert detail["preferences"]["persona"] == "socratic"
assert detail["messages"][2]["metadata"]["request_snapshot"]["persona"] == "socratic"
# Turn 3 — explicit "" (Default): clears the stored preference and the
# turn runs without a persona.
await run_turn(session["id"], {"persona": ""})
detail = await store.get_session_with_messages(session["id"])
assert detail["preferences"]["persona"] == ""
assert "persona" not in detail["messages"][4]["metadata"]["request_snapshot"]
@pytest.mark.asyncio
async def test_turn_runtime_rejects_invalid_llm_selection(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
monkeypatch.setattr(
"deeptutor.services.config.get_model_catalog_service",
lambda: SimpleNamespace(load=_model_catalog),
)
with pytest.raises(RuntimeError, match="Invalid LLM selection"):
await runtime.start_turn(
{
"type": "start_turn",
"content": "bad model",
"session_id": None,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"config": {},
"llm_selection": {"profile_id": "p-alt", "model_id": "m-default"},
}
)
@pytest.mark.asyncio
async def test_turn_runtime_allows_model_switching_within_same_session(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
activated: list[dict] = []
metadata_seen: list[dict] = []
class FakeContextBuilder:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def build(self, **_kwargs):
return SimpleNamespace(
conversation_history=[],
conversation_summary="",
context_text="",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, context):
metadata_seen.append(context.metadata)
yield StreamEvent(
type=StreamEventType.CONTENT,
source="chat",
stage="responding",
content=f"Reply from {context.metadata['llm_model']}",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="chat")
def fake_activate(selection):
activated.append(dict(selection or {}))
is_alt = (selection or {}).get("profile_id") == "p-alt"
return (
SimpleNamespace(
model="anthropic/claude-sonnet-4" if is_alt else "gpt-4o-mini",
provider_name="openrouter" if is_alt else "openai",
),
object(),
)
monkeypatch.setattr(
"deeptutor.services.config.get_model_catalog_service",
lambda: SimpleNamespace(load=_model_catalog),
)
monkeypatch.setattr(
"deeptutor.services.model_selection.runtime.activate_llm_selection",
fake_activate,
)
monkeypatch.setattr(
"deeptutor.services.model_selection.runtime.reset_llm_selection",
lambda _token: None,
)
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(
read_l3_concat=lambda: "",
emit=_noop_async,
),
)
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
first_selection = {"profile_id": "p-default", "model_id": "m-default"}
second_selection = {"profile_id": "p-alt", "model_id": "m-alt"}
session, first_turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "first model",
"session_id": None,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"config": {},
"llm_selection": first_selection,
}
)
async for _event in runtime.subscribe_turn(first_turn["id"], after_seq=0):
pass
same_session, second_turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "second model",
"session_id": session["id"],
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"config": {},
"llm_selection": second_selection,
}
)
async for _event in runtime.subscribe_turn(second_turn["id"], after_seq=0):
pass
detail = await store.get_session_with_messages(session["id"])
assert same_session["id"] == session["id"]
assert detail is not None
assert detail["preferences"]["llm_selection"] == second_selection
assert activated == [first_selection, second_selection]
assert metadata_seen[0]["llm_model"] == "gpt-4o-mini"
assert metadata_seen[1]["llm_model"] == "anthropic/claude-sonnet-4"
user_messages = [message for message in detail["messages"] if message["role"] == "user"]
assert user_messages[0]["metadata"]["request_snapshot"]["llmSelection"] == first_selection
assert user_messages[1]["metadata"]["request_snapshot"]["llmSelection"] == second_selection
@pytest.mark.asyncio
async def test_regenerate_reuses_snapshot_or_override_llm_selection(tmp_path) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
captured_payloads: list[dict] = []
class CapturingRuntime(TurnRuntimeManager):
async def start_turn(self, payload: dict):
captured_payloads.append(payload)
return {"id": payload["session_id"]}, {"id": "turn-test"}
runtime = CapturingRuntime(store)
session = await store.create_session(session_id="session-with-snapshot")
await store.update_session_preferences(
session["id"],
{"llm_selection": {"profile_id": "p-default", "model_id": "m-default"}},
)
await store.add_message(
session_id=session["id"],
role="user",
content="again",
capability="chat",
metadata={
"request_snapshot": {
"content": "again",
"llmSelection": {"profile_id": "p-alt", "model_id": "m-alt"},
}
},
)
await runtime.regenerate_last_turn(session["id"])
assert captured_payloads[-1]["llm_selection"] == {
"profile_id": "p-alt",
"model_id": "m-alt",
}
await runtime.regenerate_last_turn(
session["id"],
overrides={"llm_selection": {"profile_id": "p-default", "model_id": "m-default"}},
)
assert captured_payloads[-1]["llm_selection"] == {
"profile_id": "p-default",
"model_id": "m-default",
}
@pytest.mark.asyncio
async def test_turn_runtime_bootstraps_question_followup_context_once(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
captured: dict[str, object] = {}
class FakeContextBuilder:
def __init__(self, session_store, *_args, **_kwargs) -> None:
self.store = session_store
async def build(self, **kwargs):
messages = await self.store.get_messages_for_context(kwargs["session_id"])
captured["history_messages"] = messages
return SimpleNamespace(
conversation_history=[
{"role": item["role"], "content": item["content"]} for item in messages
],
conversation_summary="",
context_text="",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, context):
captured["conversation_history"] = context.conversation_history
captured["config_overrides"] = context.config_overrides
captured["metadata"] = context.metadata
yield StreamEvent(
type=StreamEventType.CONTENT,
source="chat",
stage="responding",
content="Let's discuss this question.",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="chat")
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(
read_l3_concat=lambda: "",
emit=_noop_async,
),
)
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
session, turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "Why is my answer wrong?",
"session_id": None,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"language": "en",
"config": {
"followup_question_context": {
"parent_quiz_session_id": "quiz_session_1",
"question_id": "q_2",
"question_type": "choice",
"difficulty": "hard",
"concentration": "win-rate comparison",
"question": "Which criterion best describes density?",
"options": {
"A": "Coverage",
"B": "Informative value",
"C": "Relevant content without redundancy",
"D": "Credibility",
},
"user_answer": "B",
"correct_answer": "C",
"explanation": "Density focuses on including relevant content without redundancy.",
"knowledge_context": "Density measures whether content is relevant and non-redundant.",
}
},
}
)
events = []
async for event in runtime.subscribe_turn(turn["id"], after_seq=0):
events.append(event)
# session_meta may arrive after `done` from the title generator —
# filter it out so the timing race doesn't flake the assertion.
assert [e["type"] for e in events if e["type"] != "session_meta"] == [
"session",
"content",
"done",
]
detail = await store.get_session_with_messages(session["id"])
assert detail is not None
assert [message["role"] for message in detail["messages"]] == ["system", "user", "assistant"]
assert "Question Follow-up Context" in detail["messages"][0]["content"]
assert "Which criterion best describes density?" in detail["messages"][0]["content"]
assert "User answer: B" in detail["messages"][0]["content"]
assert captured["conversation_history"][0]["role"] == "system"
assert "followup_question_context" not in captured["config_overrides"]
assert captured["metadata"]["question_followup_context"]["question_id"] == "q_2"
@pytest.mark.asyncio
async def test_turn_runtime_rejects_deep_research_without_explicit_config(
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
with pytest.raises(RuntimeError, match="Invalid deep research config"):
await runtime.start_turn(
{
"type": "start_turn",
"content": "research transformers",
"session_id": None,
"capability": "deep_research",
"tools": ["rag"],
"knowledge_bases": ["research-kb"],
"attachments": [],
"language": "en",
"config": {},
}
)
@pytest.mark.asyncio
async def test_turn_runtime_persists_deep_research_session_preference(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
class FakeContextBuilder:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def build(self, **_kwargs):
return SimpleNamespace(
conversation_history=[],
conversation_summary="",
context_text="",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, _context):
yield StreamEvent(
type=StreamEventType.CONTENT,
source="deep_research",
stage="reporting",
content="Research report ready.",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="deep_research")
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(
read_l3_concat=lambda: "",
emit=_noop_async,
),
)
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
session, turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "research transformers",
"session_id": None,
"capability": "deep_research",
"tools": ["rag", "web_search"],
"knowledge_bases": ["research-kb"],
"attachments": [],
"language": "en",
"config": {
"mode": "report",
"depth": "standard",
},
}
)
events = []
async for event in runtime.subscribe_turn(turn["id"], after_seq=0):
events.append(event)
# session_meta may arrive after `done` from the title generator —
# filter it out so the timing race doesn't flake the assertion.
assert [e["type"] for e in events if e["type"] != "session_meta"] == [
"session",
"content",
"done",
]
detail = await store.get_session_with_messages(session["id"])
assert detail is not None
assert detail["preferences"]["capability"] == "deep_research"
assert detail["preferences"]["tools"] == ["rag", "web_search"]
@pytest.mark.asyncio
async def test_turn_runtime_injects_memory_and_refreshes_after_completion(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
store = SQLiteSessionStore(tmp_path / "chat_history.db")
runtime = TurnRuntimeManager(store)
captured: dict[str, object] = {}
class FakeContextBuilder:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def build(self, **_kwargs):
return SimpleNamespace(
conversation_history=[],
conversation_summary="",
context_text="Recent chat summary",
token_count=0,
budget=0,
)
class FakeOrchestrator:
async def handle(self, context):
captured["conversation_history"] = context.conversation_history
captured["memory_context"] = context.memory_context
captured["conversation_context_text"] = context.metadata.get(
"conversation_context_text"
)
yield StreamEvent(
type=StreamEventType.CONTENT,
source="chat",
stage="responding",
content="Stored reply",
metadata={"call_kind": "llm_final_response"},
)
yield StreamEvent(type=StreamEventType.DONE, source="chat")
emit_calls: list[object] = []
async def fake_emit(event):
emit_calls.append(event)
return None
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
monkeypatch.setattr(
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
)
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
monkeypatch.setattr(
"deeptutor.services.memory.get_memory_store",
lambda: SimpleNamespace(
read_l3_concat=lambda: "## Memory\n## Preferences\n- Prefer concise answers.",
emit=fake_emit,
),
)
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
_session, turn = await runtime.start_turn(
{
"type": "start_turn",
"content": "hello, i'm frank",
"session_id": None,
"capability": None,
"tools": [],
"knowledge_bases": [],
"attachments": [],
"memory_references": ["preferences"],
"language": "en",
"config": {},
}
)
async for _event in runtime.subscribe_turn(turn["id"], after_seq=0):
pass
assert captured["memory_context"] == "## Memory\n## Preferences\n- Prefer concise answers."
assert captured["conversation_history"] == []
assert captured["conversation_context_text"] == "Recent chat summary"
+111
View File
@@ -0,0 +1,111 @@
"""Voice router tests — /tts and /stt request/response contracts."""
from __future__ import annotations
import io
from typing import Any
import wave
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from deeptutor.api.routers import voice as voice_router
from deeptutor.services.voice import VoiceProviderError
@pytest.fixture()
def client() -> TestClient:
app = FastAPI()
app.include_router(voice_router.router, prefix="/api/v1/voice")
return TestClient(app)
def test_tts_returns_audio_bytes(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
async def fake_synth(text: str, *, voice=None, response_format=None, **_: Any):
captured["text"] = text
captured["voice"] = voice
captured["format"] = response_format
return b"audio-bytes", "audio/mpeg"
monkeypatch.setattr(voice_router, "synthesize_speech", fake_synth)
resp = client.post("/api/v1/voice/tts", json={"text": "hello", "voice": "nova"})
assert resp.status_code == 200
assert resp.content == b"audio-bytes"
assert resp.headers["content-type"] == "audio/mpeg"
assert captured == {"text": "hello", "voice": "nova", "format": None}
def test_tts_wraps_pcm_bytes_as_browser_playable_wav(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pcm = b"\x00\x00\x01\x00" * 12
async def fake_synth(text: str, *, voice=None, response_format=None, **_: Any):
return pcm, "audio/pcm;rate=24000;channels=1"
monkeypatch.setattr(voice_router, "synthesize_speech", fake_synth)
resp = client.post("/api/v1/voice/tts", json={"text": "hello"})
assert resp.status_code == 200
assert resp.headers["content-type"] == "audio/wav"
assert resp.content.startswith(b"RIFF")
with wave.open(io.BytesIO(resp.content), "rb") as wav:
assert wav.getframerate() == 24000
assert wav.getnchannels() == 1
assert wav.getsampwidth() == 2
assert wav.readframes(wav.getnframes()) == pcm
def test_tts_rejects_empty_text(client: TestClient) -> None:
resp = client.post("/api/v1/voice/tts", json={"text": ""})
assert resp.status_code == 422 # pydantic min_length
def test_tts_provider_error_is_502(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
async def boom(*_: Any, **__: Any):
raise VoiceProviderError("upstream down")
monkeypatch.setattr(voice_router, "synthesize_speech", boom)
resp = client.post("/api/v1/voice/tts", json={"text": "hi"})
assert resp.status_code == 502
assert "upstream down" in resp.json()["detail"]
def test_tts_missing_config_is_400(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
async def no_config(*_: Any, **__: Any):
raise ValueError("No active TTS model is configured.")
monkeypatch.setattr(voice_router, "synthesize_speech", no_config)
resp = client.post("/api/v1/voice/tts", json={"text": "hi"})
assert resp.status_code == 400
def test_stt_returns_text(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
async def fake_transcribe(audio: bytes, *, filename: str, content_type: str, language=None):
captured["bytes"] = len(audio)
captured["filename"] = filename
return "hello world"
monkeypatch.setattr(voice_router, "transcribe_audio", fake_transcribe)
resp = client.post(
"/api/v1/voice/stt",
files={"file": ("clip.webm", b"audiobytes", "audio/webm")},
)
assert resp.status_code == 200
assert resp.json() == {"text": "hello world"}
assert captured["filename"] == "clip.webm"
assert captured["bytes"] == 10
def test_stt_rejects_empty_upload(client: TestClient) -> None:
resp = client.post(
"/api/v1/voice/stt",
files={"file": ("empty.webm", b"", "audio/webm")},
)
assert resp.status_code == 400