VoiceVault / tests /test_phase5.py
NinjainPJs's picture
Initial release: VoiceVault v1.0.0 — Voice-First RAG Knowledge Agent
85f900d
"""
tests/test_phase5.py
====================
Phase 5 — UI, TTS & Access Control Tests
Tests:
- KBManager: create, list, get, delete, password verification,
duplicate detection, slug validation
- WebSpeech (TTS): prepare_for_tts citation stripping, refusal handling
- CitationPanel: format_citations_markdown output structure
- UI helpers: KB choice lists, KB table formatting
- AppStartup: verify build_app does not crash with mock components
Run with: pytest tests/test_phase5.py -v
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from voicevault.models import Citation, KnowledgeBase
# ------------------------------------------------------------------ #
# Fixtures #
# ------------------------------------------------------------------ #
@pytest.fixture
def manager(tmp_path: Path):
"""KBManager backed by a temporary SQLite database."""
from voicevault.kb.kb_manager import KBManager
db_path = tmp_path / "test.db"
# Ensure parent exists (tmp_path always exists)
return KBManager(db_path=db_path)
def _make_citation(
source_file: str = "doc.pdf",
page_number: int = 1,
section: str = "Intro",
excerpt: str = "Some text here.",
relevance_score: float = 0.8,
) -> Citation:
return Citation(
source_file=source_file,
page_number=page_number,
section=section,
excerpt=excerpt,
relevance_score=relevance_score,
)
# ------------------------------------------------------------------ #
# KBManager — Creation #
# ------------------------------------------------------------------ #
class TestKBManagerCreate:
"""KB creation, validation, and listing."""
def test_create_returns_knowledge_base(self, manager) -> None:
kb = manager.create_kb("test-kb", "Test KB")
assert isinstance(kb, KnowledgeBase)
def test_create_sets_kb_name(self, manager) -> None:
kb = manager.create_kb("my-kb", "My KB")
assert kb.kb_name == "my-kb"
def test_create_sets_display_name(self, manager) -> None:
kb = manager.create_kb("my-kb", "My Knowledge Base")
assert kb.display_name == "My Knowledge Base"
def test_create_public_kb_has_no_hash(self, manager) -> None:
kb = manager.create_kb("public-kb", "Public KB")
assert kb.password_hash is None
assert not kb.is_protected
def test_create_protected_kb_has_hash(self, manager) -> None:
kb = manager.create_kb("secure-kb", "Secure KB", password="secret123")
assert kb.password_hash is not None
assert kb.is_protected
def test_create_single_char_name_valid(self, manager) -> None:
kb = manager.create_kb("a", "Single Char")
assert kb.kb_name == "a"
def test_duplicate_name_raises(self, manager) -> None:
from voicevault.kb.kb_manager import KBManagerError
manager.create_kb("dup-kb", "First")
with pytest.raises(KBManagerError, match="already exists"):
manager.create_kb("dup-kb", "Second")
def test_invalid_name_uppercase_raises(self, manager) -> None:
from voicevault.kb.kb_manager import KBManagerError
with pytest.raises(KBManagerError):
manager.create_kb("MyKB", "Invalid")
def test_invalid_name_spaces_raises(self, manager) -> None:
from voicevault.kb.kb_manager import KBManagerError
with pytest.raises(KBManagerError):
manager.create_kb("my kb", "Invalid")
def test_invalid_name_leading_hyphen_raises(self, manager) -> None:
from voicevault.kb.kb_manager import KBManagerError
with pytest.raises(KBManagerError):
manager.create_kb("-my-kb", "Invalid")
def test_invalid_name_trailing_hyphen_raises(self, manager) -> None:
from voicevault.kb.kb_manager import KBManagerError
with pytest.raises(KBManagerError):
manager.create_kb("my-kb-", "Invalid")
def test_list_kbs_initially_empty(self, manager) -> None:
assert manager.list_kbs() == []
def test_list_kbs_after_create(self, manager) -> None:
manager.create_kb("alpha", "Alpha")
manager.create_kb("beta", "Beta")
kbs = manager.list_kbs()
assert len(kbs) == 2
def test_list_kbs_returns_knowledge_base_models(self, manager) -> None:
manager.create_kb("kb1", "KB One")
kbs = manager.list_kbs()
assert all(isinstance(kb, KnowledgeBase) for kb in kbs)
def test_get_kb_returns_none_for_unknown(self, manager) -> None:
assert manager.get_kb("nonexistent") is None
def test_get_kb_returns_correct_kb(self, manager) -> None:
manager.create_kb("findme", "Find Me")
kb = manager.get_kb("findme")
assert kb is not None
assert kb.kb_name == "findme"
# ------------------------------------------------------------------ #
# KBManager — Delete #
# ------------------------------------------------------------------ #
class TestKBManagerDelete:
"""KB deletion behavior."""
def test_delete_removes_from_list(self, manager) -> None:
manager.create_kb("todelete", "To Delete")
assert manager.get_kb("todelete") is not None
manager.delete_kb("todelete")
assert manager.get_kb("todelete") is None
def test_delete_nonexistent_raises(self, manager) -> None:
from voicevault.kb.kb_manager import KBManagerError
with pytest.raises(KBManagerError):
manager.delete_kb("does-not-exist")
def test_delete_reduces_list_count(self, manager) -> None:
manager.create_kb("kb-a", "KB A")
manager.create_kb("kb-b", "KB B")
manager.delete_kb("kb-a")
assert len(manager.list_kbs()) == 1
# ------------------------------------------------------------------ #
# KBManager — Password Verification #
# ------------------------------------------------------------------ #
class TestKBManagerPassword:
"""Password hashing and verification."""
def test_public_kb_accessible_without_password(self, manager) -> None:
manager.create_kb("open-kb", "Open KB")
assert manager.verify_password("open-kb", None) is True
def test_public_kb_accessible_with_any_password(self, manager) -> None:
manager.create_kb("open-kb", "Open KB")
assert manager.verify_password("open-kb", "anything") is True
def test_protected_kb_correct_password_returns_true(self, manager) -> None:
manager.create_kb("secure-kb", "Secure KB", password="correct-pass")
assert manager.verify_password("secure-kb", "correct-pass") is True
def test_protected_kb_wrong_password_returns_false(self, manager) -> None:
manager.create_kb("secure-kb", "Secure KB", password="correct-pass")
assert manager.verify_password("secure-kb", "wrong-pass") is False
def test_protected_kb_no_password_returns_false(self, manager) -> None:
manager.create_kb("secure-kb", "Secure KB", password="correct-pass")
assert manager.verify_password("secure-kb", None) is False
def test_unknown_kb_returns_false(self, manager) -> None:
assert manager.verify_password("ghost-kb", "any") is False
def test_bcrypt_hash_stored_not_plaintext(self, manager) -> None:
manager.create_kb("hashed-kb", "Hashed KB", password="secret")
kb = manager.get_kb("hashed-kb")
assert kb.password_hash != "secret"
assert kb.password_hash.startswith("$2b$") or kb.password_hash.startswith("$2a$")
# ------------------------------------------------------------------ #
# KBManager — Query Stats #
# ------------------------------------------------------------------ #
class TestKBManagerStats:
"""Query stats from SQLite."""
def test_get_query_stats_returns_dict(self, manager) -> None:
stats = manager.get_query_stats()
assert isinstance(stats, dict)
def test_get_query_stats_has_required_keys(self, manager) -> None:
stats = manager.get_query_stats()
assert "total_queries" in stats
assert "avg_latency_ms" in stats
assert "avg_citation_count" in stats
assert "queries_by_day" in stats
def test_get_query_stats_empty_db_returns_zeros(self, manager) -> None:
stats = manager.get_query_stats()
assert stats["total_queries"] == 0
# ------------------------------------------------------------------ #
# TTS — prepare_for_tts #
# ------------------------------------------------------------------ #
class TestPreparForTTS:
"""Test citation stripping for TTS."""
def test_strips_source_citation_markers(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
text = "The accuracy was 94% [Source: report.pdf, p.3]."
result = prepare_for_tts(text)
assert "[Source:" not in result
assert "94%" in result
def test_strips_multiple_citation_markers(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
text = "First [Source: a.pdf, p.1]. Second [Source: b.pdf, p.2]."
result = prepare_for_tts(text)
assert "[Source:" not in result
assert "First" in result
assert "Second" in result
def test_returns_empty_for_refusal(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
assert prepare_for_tts("I could not find this.", is_refusal=True) == ""
def test_returns_empty_for_empty_string(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
assert prepare_for_tts("") == ""
def test_normal_text_unchanged(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
text = "Machine learning is a form of artificial intelligence."
assert prepare_for_tts(text) == text
def test_no_double_spaces_after_stripping(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
text = "The result [Source: x.pdf, p.1] was good."
result = prepare_for_tts(text)
assert " " not in result
def test_stripped_text_is_stripped(self) -> None:
from voicevault.tts.web_speech import prepare_for_tts
result = prepare_for_tts(" Hello world ")
assert result == result.strip()
# ------------------------------------------------------------------ #
# Citation Panel #
# ------------------------------------------------------------------ #
class TestCitationPanel:
"""Verify Markdown formatting of citation panel."""
def test_empty_citations_returns_placeholder(self) -> None:
from ui.components.citation_panel import format_citations_markdown
result = format_citations_markdown([])
assert "No citations" in result
def test_single_citation_includes_filename(self) -> None:
from ui.components.citation_panel import format_citations_markdown
citations = [_make_citation("paper.pdf", 3)]
result = format_citations_markdown(citations)
assert "paper.pdf" in result
def test_single_citation_includes_page_number(self) -> None:
from ui.components.citation_panel import format_citations_markdown
citations = [_make_citation("paper.pdf", 7)]
result = format_citations_markdown(citations)
assert "7" in result
def test_single_citation_includes_section(self) -> None:
from ui.components.citation_panel import format_citations_markdown
citations = [_make_citation(section="Methodology")]
result = format_citations_markdown(citations)
assert "Methodology" in result
def test_single_citation_includes_excerpt(self) -> None:
from ui.components.citation_panel import format_citations_markdown
citations = [_make_citation(excerpt="Key finding here.")]
result = format_citations_markdown(citations)
assert "Key finding here." in result
def test_multiple_citations_all_present(self) -> None:
from ui.components.citation_panel import format_citations_markdown
citations = [
_make_citation("alpha.pdf", 1),
_make_citation("beta.pdf", 2),
]
result = format_citations_markdown(citations)
assert "alpha.pdf" in result
assert "beta.pdf" in result
def test_citations_numbered(self) -> None:
from ui.components.citation_panel import format_citations_markdown
citations = [_make_citation("doc.pdf", 1), _make_citation("doc.pdf", 2)]
result = format_citations_markdown(citations)
assert "[1]" in result
assert "[2]" in result
def test_output_is_string(self) -> None:
from ui.components.citation_panel import format_citations_markdown
assert isinstance(format_citations_markdown([]), str)
# ------------------------------------------------------------------ #
# UI Helper Functions #
# ------------------------------------------------------------------ #
class TestUIHelpers:
"""Test UI helper functions from ask_tab and kb_tab."""
def test_get_kb_choices_empty_when_no_kbs(self, manager) -> None:
from ui.tabs.ask_tab import _get_kb_choices
choices = _get_kb_choices(manager)
assert choices == []
def test_get_kb_choices_lists_kb_names(self, manager) -> None:
from ui.tabs.ask_tab import _get_kb_choices
manager.create_kb("kb-one", "KB One")
manager.create_kb("kb-two", "KB Two")
choices = _get_kb_choices(manager)
assert "kb-one" in choices
assert "kb-two" in choices
def test_get_kb_table_placeholder_when_empty(self, manager) -> None:
from ui.tabs.kb_tab import _get_kb_table
table = _get_kb_table(manager)
assert len(table) == 1
assert "(none)" in table[0][0]
def test_get_kb_table_row_per_kb(self, manager) -> None:
from ui.tabs.kb_tab import _get_kb_table
manager.create_kb("first-kb", "First KB")
manager.create_kb("second-kb", "Second KB")
table = _get_kb_table(manager)
assert len(table) == 2
def test_get_kb_table_protected_shows_lock(self, manager) -> None:
from ui.tabs.kb_tab import _get_kb_table
manager.create_kb("locked-kb", "Locked KB", password="secret")
table = _get_kb_table(manager)
assert "🔒" in table[0][4]
def test_append_chat_adds_both_messages(self) -> None:
from ui.tabs.ask_tab import _append_chat
chatbot = [{"role": "assistant", "content": "Hello!"}]
result = _append_chat(chatbot, "user query", "assistant response")
assert len(result) == 3
assert result[-2]["role"] == "user"
assert result[-1]["role"] == "assistant"
assert result[-2]["content"] == "user query"
def test_append_chat_does_not_mutate_original(self) -> None:
from ui.tabs.ask_tab import _append_chat
chatbot = []
_append_chat(chatbot, "q", "a")
assert chatbot == []
# ------------------------------------------------------------------ #
# App Startup (Smoke Test) #
# ------------------------------------------------------------------ #
class TestAppStartup:
"""Verify app builds without errors using mock pipeline objects."""
def test_build_app_returns_blocks(self, tmp_path: Path) -> None:
import gradio as gr
from app import build_app
from voicevault.kb.kb_manager import KBManager
db_path = tmp_path / "test.db"
kb_manager = KBManager(db_path=db_path)
transcriber = MagicMock()
answer_chain = MagicMock()
app = build_app(kb_manager, transcriber, answer_chain)
assert isinstance(app, gr.Blocks)
def test_ask_tab_builds_without_error(self, tmp_path: Path) -> None:
import gradio as gr
from ui.tabs.ask_tab import build_ask_tab
from voicevault.kb.kb_manager import KBManager
db_path = tmp_path / "test.db"
kb_manager = KBManager(db_path=db_path)
with gr.Blocks():
build_ask_tab(MagicMock(), MagicMock(), kb_manager, db_path)
def test_kb_tab_builds_without_error(self, tmp_path: Path) -> None:
import gradio as gr
from ui.tabs.kb_tab import build_kb_tab
from voicevault.kb.kb_manager import KBManager
db_path = tmp_path / "test.db"
kb_manager = KBManager(db_path=db_path)
with gr.Blocks():
build_kb_tab(kb_manager, db_path)
def test_analytics_tab_builds_without_error(self, tmp_path: Path) -> None:
import gradio as gr
from ui.tabs.analytics_tab import build_analytics_tab
from voicevault.kb.kb_manager import KBManager
db_path = tmp_path / "test.db"
kb_manager = KBManager(db_path=db_path)
with gr.Blocks():
build_analytics_tab(kb_manager, db_path)