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