diff --git a/tests/test_namespaces.py b/tests/test_namespaces.py
new file mode 100644
index 00000000..0f849264
--- /dev/null
+++ b/tests/test_namespaces.py
@@ -0,0 +1,279 @@
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+import pytest
+
+from application.templates.namespaces import (
+ NamespaceBuilder,
+ NamespaceManager,
+ PassthroughNamespace,
+ SourceNamespace,
+ SystemNamespace,
+ ToolsNamespace,
+)
+
+
+# ── SystemNamespace ────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestSystemNamespace:
+ def test_namespace_name(self):
+ ns = SystemNamespace()
+ assert ns.namespace_name == "system"
+
+ def test_build_returns_expected_keys(self):
+ ns = SystemNamespace()
+ result = ns.build()
+ assert "date" in result
+ assert "time" in result
+ assert "timestamp" in result
+ assert "request_id" in result
+ assert "user_id" in result
+
+ def test_build_with_request_id(self):
+ ns = SystemNamespace()
+ result = ns.build(request_id="req-123")
+ assert result["request_id"] == "req-123"
+
+ def test_build_with_user_id(self):
+ ns = SystemNamespace()
+ result = ns.build(user_id="user-456")
+ assert result["user_id"] == "user-456"
+
+ def test_build_generates_uuid_when_no_request_id(self):
+ ns = SystemNamespace()
+ result = ns.build()
+ assert len(result["request_id"]) == 36 # UUID format
+
+ def test_user_id_defaults_to_none(self):
+ ns = SystemNamespace()
+ result = ns.build()
+ assert result["user_id"] is None
+
+ def test_date_format(self):
+ ns = SystemNamespace()
+ fixed = datetime(2026, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
+ with patch("application.templates.namespaces.datetime") as mock_dt:
+ mock_dt.now.return_value = fixed
+ mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+ result = ns.build()
+ assert result["date"] == "2026-01-15"
+ assert result["time"] == "10:30:45"
+
+ def test_extra_kwargs_ignored(self):
+ ns = SystemNamespace()
+ result = ns.build(unknown_param="whatever")
+ assert "date" in result
+
+
+# ── PassthroughNamespace ───────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestPassthroughNamespace:
+ def test_namespace_name(self):
+ ns = PassthroughNamespace()
+ assert ns.namespace_name == "passthrough"
+
+ def test_none_data_returns_empty(self):
+ ns = PassthroughNamespace()
+ assert ns.build(passthrough_data=None) == {}
+
+ def test_no_data_returns_empty(self):
+ ns = PassthroughNamespace()
+ assert ns.build() == {}
+
+ def test_safe_types_pass_through(self):
+ ns = PassthroughNamespace()
+ data = {"s": "string", "i": 42, "f": 3.14, "b": True, "n": None}
+ result = ns.build(passthrough_data=data)
+ assert result == data
+
+ def test_non_serializable_types_filtered(self):
+ ns = PassthroughNamespace()
+ data = {"good": "ok", "bad": object()}
+ result = ns.build(passthrough_data=data)
+ assert result == {"good": "ok"}
+
+ def test_list_value_filtered(self):
+ ns = PassthroughNamespace()
+ data = {"list_val": [1, 2, 3]}
+ result = ns.build(passthrough_data=data)
+ assert result == {}
+
+ def test_dict_value_filtered(self):
+ ns = PassthroughNamespace()
+ data = {"nested": {"key": "val"}}
+ result = ns.build(passthrough_data=data)
+ assert result == {}
+
+
+# ── SourceNamespace ────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestSourceNamespace:
+ def test_namespace_name(self):
+ ns = SourceNamespace()
+ assert ns.namespace_name == "source"
+
+ def test_no_docs_returns_empty(self):
+ ns = SourceNamespace()
+ assert ns.build() == {}
+
+ def test_with_docs(self):
+ ns = SourceNamespace()
+ docs = [{"text": "doc1"}, {"text": "doc2"}]
+ result = ns.build(docs=docs)
+ assert result["documents"] == docs
+ assert result["count"] == 2
+
+ def test_with_docs_together(self):
+ ns = SourceNamespace()
+ result = ns.build(docs_together="all content together")
+ assert result["content"] == "all content together"
+ assert result["docs_together"] == "all content together"
+ assert result["summaries"] == "all content together"
+
+ def test_with_both_docs_and_docs_together(self):
+ ns = SourceNamespace()
+ docs = [{"text": "doc1"}]
+ result = ns.build(docs=docs, docs_together="combined")
+ assert result["documents"] == docs
+ assert result["count"] == 1
+ assert result["content"] == "combined"
+
+ def test_empty_docs_list_returns_empty(self):
+ ns = SourceNamespace()
+ result = ns.build(docs=[])
+ assert "documents" not in result
+
+
+# ── ToolsNamespace ─────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestToolsNamespace:
+ def test_namespace_name(self):
+ ns = ToolsNamespace()
+ assert ns.namespace_name == "tools"
+
+ def test_none_data_returns_empty(self):
+ ns = ToolsNamespace()
+ assert ns.build(tools_data=None) == {}
+
+ def test_no_data_returns_empty(self):
+ ns = ToolsNamespace()
+ assert ns.build() == {}
+
+ def test_safe_types_pass_through(self):
+ ns = ToolsNamespace()
+ data = {
+ "str_tool": "result",
+ "dict_tool": {"key": "val"},
+ "list_tool": [1, 2],
+ "int_tool": 42,
+ "float_tool": 3.14,
+ "bool_tool": True,
+ "none_tool": None,
+ }
+ result = ns.build(tools_data=data)
+ assert result == data
+
+ def test_non_serializable_filtered(self):
+ ns = ToolsNamespace()
+ data = {"good": "ok", "bad": object()}
+ result = ns.build(tools_data=data)
+ assert result == {"good": "ok"}
+
+
+# ── NamespaceBuilder ABC ──────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestNamespaceBuilderABC:
+ def test_cannot_instantiate(self):
+ with pytest.raises(TypeError):
+ NamespaceBuilder()
+
+ def test_subclass_must_implement_both(self):
+ class Incomplete(NamespaceBuilder):
+ pass
+
+ with pytest.raises(TypeError):
+ Incomplete()
+
+ def test_concrete_subclass_works(self):
+ class Complete(NamespaceBuilder):
+ @property
+ def namespace_name(self):
+ return "test"
+
+ def build(self, **kwargs):
+ return {"ok": True}
+
+ inst = Complete()
+ assert inst.namespace_name == "test"
+ assert inst.build() == {"ok": True}
+
+
+# ── NamespaceManager ──────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestNamespaceManager:
+ def test_build_context_contains_all_namespaces(self):
+ mgr = NamespaceManager()
+ ctx = mgr.build_context()
+ assert "system" in ctx
+ assert "passthrough" in ctx
+ assert "source" in ctx
+ assert "tools" in ctx
+
+ def test_system_namespace_populated(self):
+ mgr = NamespaceManager()
+ ctx = mgr.build_context(request_id="r1", user_id="u1")
+ assert ctx["system"]["request_id"] == "r1"
+ assert ctx["system"]["user_id"] == "u1"
+
+ def test_passthrough_namespace_populated(self):
+ mgr = NamespaceManager()
+ ctx = mgr.build_context(passthrough_data={"key": "val"})
+ assert ctx["passthrough"] == {"key": "val"}
+
+ def test_source_namespace_populated(self):
+ mgr = NamespaceManager()
+ docs = [{"text": "doc"}]
+ ctx = mgr.build_context(docs=docs)
+ assert ctx["source"]["count"] == 1
+
+ def test_tools_namespace_populated(self):
+ mgr = NamespaceManager()
+ ctx = mgr.build_context(tools_data={"search": "results"})
+ assert ctx["tools"] == {"search": "results"}
+
+ def test_empty_kwargs_all_namespaces_present(self):
+ mgr = NamespaceManager()
+ ctx = mgr.build_context()
+ for ns in ["system", "passthrough", "source", "tools"]:
+ assert ns in ctx
+ assert isinstance(ctx[ns], dict)
+
+ def test_builder_exception_returns_empty_namespace(self):
+ mgr = NamespaceManager()
+ with patch.object(
+ mgr._builders["system"], "build", side_effect=RuntimeError("boom")
+ ):
+ ctx = mgr.build_context()
+ assert ctx["system"] == {}
+ assert "passthrough" in ctx
+
+ def test_get_builder_existing(self):
+ mgr = NamespaceManager()
+ builder = mgr.get_builder("system")
+ assert isinstance(builder, SystemNamespace)
+
+ def test_get_builder_nonexistent(self):
+ mgr = NamespaceManager()
+ assert mgr.get_builder("nonexistent") is None
diff --git a/tests/test_retriever.py b/tests/test_retriever.py
new file mode 100644
index 00000000..d80a8ee0
--- /dev/null
+++ b/tests/test_retriever.py
@@ -0,0 +1,353 @@
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+
+from application.retriever.base import BaseRetriever
+from application.retriever.retriever_creator import RetrieverCreator
+
+
+# ── BaseRetriever ──────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestBaseRetriever:
+ def test_cannot_instantiate_directly(self):
+ with pytest.raises(TypeError):
+ BaseRetriever()
+
+ def test_subclass_must_implement_search(self):
+ class Incomplete(BaseRetriever):
+ pass
+
+ with pytest.raises(TypeError):
+ Incomplete()
+
+ def test_concrete_subclass_works(self):
+ class Concrete(BaseRetriever):
+ def search(self, *args, **kwargs):
+ return "ok"
+
+ instance = Concrete()
+ assert instance.search() == "ok"
+
+
+# ── RetrieverCreator ───────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestRetrieverCreator:
+ def test_create_classic(self):
+ mock_cls = Mock(return_value="rag_instance")
+ original = RetrieverCreator.retrievers.copy()
+ RetrieverCreator.retrievers["classic"] = mock_cls
+ try:
+ result = RetrieverCreator.create_retriever("classic", "arg1", key="val")
+ mock_cls.assert_called_once_with("arg1", key="val")
+ assert result == "rag_instance"
+ finally:
+ RetrieverCreator.retrievers.update(original)
+
+ def test_create_default(self):
+ mock_cls = Mock(return_value="rag_instance")
+ original = RetrieverCreator.retrievers.copy()
+ RetrieverCreator.retrievers["default"] = mock_cls
+ try:
+ result = RetrieverCreator.create_retriever("default")
+ mock_cls.assert_called_once_with()
+ assert result == "rag_instance"
+ finally:
+ RetrieverCreator.retrievers.update(original)
+
+ def test_create_none_type_uses_default(self):
+ mock_cls = Mock(return_value="rag_instance")
+ original = RetrieverCreator.retrievers.copy()
+ RetrieverCreator.retrievers["default"] = mock_cls
+ try:
+ result = RetrieverCreator.create_retriever(None)
+ mock_cls.assert_called_once()
+ assert result == "rag_instance"
+ finally:
+ RetrieverCreator.retrievers.update(original)
+
+ def test_case_insensitive(self):
+ mock_cls = Mock(return_value="rag_instance")
+ original = RetrieverCreator.retrievers.copy()
+ RetrieverCreator.retrievers["classic"] = mock_cls
+ try:
+ RetrieverCreator.create_retriever("CLASSIC")
+ mock_cls.assert_called_once()
+ finally:
+ RetrieverCreator.retrievers.update(original)
+
+ def test_invalid_type_raises(self):
+ with pytest.raises(ValueError, match="No retievers class found"):
+ RetrieverCreator.create_retriever("nonexistent")
+
+
+# ── ClassicRAG ─────────────────────────────────────────────────────────────────
+
+
+@pytest.fixture
+def _patch_llm_creator(mock_llm, monkeypatch):
+ """Patch LLMCreator.create_llm to return the shared mock_llm fixture."""
+ monkeypatch.setattr(
+ "application.retriever.classic_rag.LLMCreator.create_llm",
+ Mock(return_value=mock_llm),
+ )
+ return mock_llm
+
+
+def _make_rag(source=None, _patch_llm_creator=None, **overrides):
+ """Helper – builds a ClassicRAG with sensible defaults."""
+ from application.retriever.classic_rag import ClassicRAG
+
+ defaults = dict(
+ source=source or {"question": "hello"},
+ chat_history=None,
+ prompt="",
+ chunks=2,
+ doc_token_limit=50000,
+ model_id="test-model",
+ user_api_key=None,
+ agent_id=None,
+ llm_name="openai",
+ api_key="fake",
+ decoded_token={"sub": "user1"},
+ )
+ defaults.update(overrides)
+ return ClassicRAG(**defaults)
+
+
+@pytest.mark.unit
+class TestClassicRAGInit:
+ def test_basic_init(self, _patch_llm_creator):
+ rag = _make_rag()
+ assert rag.original_question == "hello"
+ assert rag.chunks == 2
+ assert rag.vectorstores == []
+
+ def test_active_docs_as_list(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q", "active_docs": ["a", "b"]})
+ assert rag.vectorstores == ["a", "b"]
+
+ def test_active_docs_as_string(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q", "active_docs": "single"})
+ assert rag.vectorstores == ["single"]
+
+ def test_active_docs_none(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q", "active_docs": None})
+ assert rag.vectorstores == []
+
+ def test_chunks_string_converted(self, _patch_llm_creator):
+ rag = _make_rag(chunks="5")
+ assert rag.chunks == 5
+
+ def test_chunks_invalid_string_defaults(self, _patch_llm_creator):
+ rag = _make_rag(chunks="abc")
+ assert rag.chunks == 2
+
+ def test_decoded_token_none(self, _patch_llm_creator):
+ rag = _make_rag(decoded_token=None)
+ assert rag.decoded_token is None
+
+
+@pytest.mark.unit
+class TestClassicRAGValidateVectorstore:
+ def test_removes_empty_ids(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q", "active_docs": ["ok", "", " ", "good"]})
+ assert rag.vectorstores == ["ok", "good"]
+
+ def test_empty_vectorstores_no_error(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q"})
+ assert rag.vectorstores == []
+
+
+@pytest.mark.unit
+class TestClassicRAGRephraseQuery:
+ def test_no_history_returns_original(self, _patch_llm_creator):
+ rag = _make_rag(
+ source={"question": "original", "active_docs": ["vs1"]},
+ chat_history=[],
+ )
+ assert rag.question == "original"
+
+ def test_no_vectorstores_returns_original(self, _patch_llm_creator):
+ rag = _make_rag(
+ source={"question": "original"},
+ chat_history=[{"prompt": "hi", "response": "hello"}],
+ )
+ assert rag.question == "original"
+
+ def test_chunks_zero_returns_original(self, _patch_llm_creator):
+ rag = _make_rag(
+ source={"question": "original", "active_docs": ["vs1"]},
+ chat_history=[{"prompt": "hi", "response": "hello"}],
+ chunks=0,
+ )
+ assert rag.question == "original"
+
+ def test_rephrase_called_with_history(self, _patch_llm_creator, mock_llm):
+ mock_llm.gen = Mock(return_value="rephrased question")
+ rag = _make_rag(
+ source={"question": "original", "active_docs": ["vs1"]},
+ chat_history=[{"prompt": "hi", "response": "hello"}],
+ )
+ assert rag.question == "rephrased question"
+ mock_llm.gen.assert_called_once()
+
+ def test_rephrase_llm_returns_empty_falls_back(self, _patch_llm_creator, mock_llm):
+ mock_llm.gen = Mock(return_value="")
+ rag = _make_rag(
+ source={"question": "original", "active_docs": ["vs1"]},
+ chat_history=[{"prompt": "hi", "response": "hello"}],
+ )
+ assert rag.question == "original"
+
+ def test_rephrase_llm_exception_falls_back(self, _patch_llm_creator, mock_llm):
+ mock_llm.gen = Mock(side_effect=RuntimeError("boom"))
+ rag = _make_rag(
+ source={"question": "original", "active_docs": ["vs1"]},
+ chat_history=[{"prompt": "hi", "response": "hello"}],
+ )
+ assert rag.question == "original"
+
+
+@pytest.mark.unit
+class TestClassicRAGGetData:
+ def test_chunks_zero_returns_empty(self, _patch_llm_creator):
+ rag = _make_rag(chunks=0)
+ assert rag._get_data() == []
+
+ def test_no_vectorstores_returns_empty(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q"})
+ assert rag._get_data() == []
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=10)
+ def test_returns_docs_with_metadata(self, mock_tokens, mock_vc, _patch_llm_creator):
+ mock_docsearch = MagicMock()
+ mock_doc = MagicMock()
+ mock_doc.page_content = "content here"
+ mock_doc.metadata = {
+ "title": "path/to/Title",
+ "filename": "/docs/file.txt",
+ "source": "http://example.com",
+ }
+ mock_docsearch.search.return_value = [mock_doc]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+
+ rag = _make_rag(source={"question": "q", "active_docs": ["vs1"]})
+ docs = rag._get_data()
+
+ assert len(docs) == 1
+ assert docs[0]["text"] == "content here"
+ assert docs[0]["title"] == "Title"
+ assert docs[0]["filename"] == "file.txt"
+ assert docs[0]["source"] == "http://example.com"
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=10)
+ def test_dict_style_docs(self, mock_tokens, mock_vc, _patch_llm_creator):
+ mock_docsearch = MagicMock()
+ mock_docsearch.search.return_value = [
+ {"text": "dict content", "metadata": {"title": "Dict Title"}}
+ ]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+
+ rag = _make_rag(source={"question": "q", "active_docs": ["vs1"]})
+ docs = rag._get_data()
+
+ assert len(docs) == 1
+ assert docs[0]["text"] == "dict content"
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=100000)
+ def test_token_budget_respected(self, mock_tokens, mock_vc, _patch_llm_creator):
+ mock_docsearch = MagicMock()
+ mock_doc = MagicMock()
+ mock_doc.page_content = "big content"
+ mock_doc.metadata = {"title": "t"}
+ mock_docsearch.search.return_value = [mock_doc, mock_doc, mock_doc]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+
+ rag = _make_rag(
+ source={"question": "q", "active_docs": ["vs1"]},
+ doc_token_limit=100,
+ )
+ docs = rag._get_data()
+ # tokens (100000) exceed budget (90), so no docs should be added
+ assert len(docs) == 0
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ def test_vectorstore_error_continues(self, mock_vc, _patch_llm_creator):
+ mock_vc.create_vectorstore.side_effect = RuntimeError("connection failed")
+
+ rag = _make_rag(source={"question": "q", "active_docs": ["vs1"]})
+ docs = rag._get_data()
+ assert docs == []
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=10)
+ def test_multiple_vectorstores(self, mock_tokens, mock_vc, _patch_llm_creator):
+ mock_docsearch = MagicMock()
+ mock_doc = MagicMock()
+ mock_doc.page_content = "content"
+ mock_doc.metadata = {"title": "t", "source": "s"}
+ mock_docsearch.search.return_value = [mock_doc]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+
+ rag = _make_rag(source={"question": "q", "active_docs": ["vs1", "vs2"]})
+ docs = rag._get_data()
+ assert len(docs) == 2
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=10)
+ def test_doc_missing_filename_uses_title(self, mock_tokens, mock_vc, _patch_llm_creator):
+ mock_docsearch = MagicMock()
+ mock_doc = MagicMock()
+ mock_doc.page_content = "content"
+ mock_doc.metadata = {"title": "MyTitle"}
+ mock_docsearch.search.return_value = [mock_doc]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+
+ rag = _make_rag(source={"question": "q", "active_docs": ["vs1"]})
+ docs = rag._get_data()
+ assert docs[0]["filename"] == "MyTitle"
+
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=10)
+ def test_non_string_title_converted(self, mock_tokens, mock_vc, _patch_llm_creator):
+ mock_docsearch = MagicMock()
+ mock_doc = MagicMock()
+ mock_doc.page_content = "content"
+ mock_doc.metadata = {"title": 42}
+ mock_docsearch.search.return_value = [mock_doc]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+
+ rag = _make_rag(source={"question": "q", "active_docs": ["vs1"]})
+ docs = rag._get_data()
+ assert docs[0]["title"] == "42"
+
+
+@pytest.mark.unit
+class TestClassicRAGSearch:
+ @patch("application.retriever.classic_rag.VectorCreator")
+ @patch("application.retriever.classic_rag.num_tokens_from_string", return_value=10)
+ def test_search_with_query_override(self, mock_tokens, mock_vc, _patch_llm_creator, mock_llm):
+ mock_docsearch = MagicMock()
+ mock_doc = MagicMock()
+ mock_doc.page_content = "result"
+ mock_doc.metadata = {"title": "t"}
+ mock_docsearch.search.return_value = [mock_doc]
+ mock_vc.create_vectorstore.return_value = mock_docsearch
+ mock_llm.gen = Mock(return_value="")
+
+ rag = _make_rag(source={"question": "original", "active_docs": ["vs1"]})
+ docs = rag.search(query="override query")
+ assert rag.original_question == "override query"
+ assert len(docs) == 1
+
+ def test_search_without_query_uses_default(self, _patch_llm_creator):
+ rag = _make_rag(source={"question": "q"})
+ docs = rag.search()
+ assert docs == []
diff --git a/tests/test_seeder.py b/tests/test_seeder.py
new file mode 100644
index 00000000..c4ea842e
--- /dev/null
+++ b/tests/test_seeder.py
@@ -0,0 +1,494 @@
+import os
+from unittest.mock import MagicMock, Mock, patch, mock_open
+
+import mongomock
+import pytest
+from bson import ObjectId
+from bson.dbref import DBRef
+
+from application.seed.seeder import DatabaseSeeder
+
+
+@pytest.fixture
+def mock_db():
+ client = mongomock.MongoClient()
+ return client["test_docsgpt"]
+
+
+@pytest.fixture
+def seeder(mock_db):
+ return DatabaseSeeder(mock_db)
+
+
+# ── __init__ ───────────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestDatabaseSeederInit:
+ def test_collections_set(self, seeder, mock_db):
+ assert seeder.db is mock_db
+ assert seeder.tools_collection == mock_db["user_tools"]
+ assert seeder.sources_collection == mock_db["sources"]
+ assert seeder.agents_collection == mock_db["agents"]
+ assert seeder.prompts_collection == mock_db["prompts"]
+ assert seeder.system_user_id == "system"
+
+
+# ── _is_already_seeded ─────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestIsAlreadySeeded:
+ def test_not_seeded(self, seeder):
+ assert seeder._is_already_seeded() is False
+
+ def test_already_seeded(self, seeder, mock_db):
+ mock_db["agents"].insert_one({"user": "system", "name": "test"})
+ assert seeder._is_already_seeded() is True
+
+
+# ── _process_config ────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestProcessConfig:
+ def test_env_var_substitution(self, seeder, monkeypatch):
+ monkeypatch.setenv("MY_SECRET", "secret_value")
+ result = seeder._process_config({"key": "${MY_SECRET}"})
+ assert result["key"] == "secret_value"
+
+ def test_missing_env_var_defaults_empty(self, seeder, monkeypatch):
+ monkeypatch.delenv("NONEXISTENT_VAR", raising=False)
+ result = seeder._process_config({"key": "${NONEXISTENT_VAR}"})
+ assert result["key"] == ""
+
+ def test_non_env_value_unchanged(self, seeder):
+ result = seeder._process_config({"key": "plain_value", "num": 42})
+ assert result == {"key": "plain_value", "num": 42}
+
+ def test_partial_env_syntax_unchanged(self, seeder):
+ result = seeder._process_config({"key": "${INCOMPLETE"})
+ assert result["key"] == "${INCOMPLETE"
+
+ def test_empty_config(self, seeder):
+ assert seeder._process_config({}) == {}
+
+
+# ── _handle_prompt ─────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestHandlePrompt:
+ def test_no_prompt_returns_none(self, seeder):
+ assert seeder._handle_prompt({"name": "agent1"}) is None
+
+ def test_empty_content_returns_none(self, seeder):
+ config = {"name": "agent1", "prompt": {"name": "p", "content": ""}}
+ assert seeder._handle_prompt(config) is None
+
+ def test_creates_prompt(self, seeder, mock_db):
+ config = {
+ "name": "agent1",
+ "prompt": {"name": "My Prompt", "content": "You are helpful."},
+ }
+ result = seeder._handle_prompt(config)
+ assert result is not None
+ doc = mock_db["prompts"].find_one({"name": "My Prompt"})
+ assert doc is not None
+ assert doc["content"] == "You are helpful."
+ assert doc["user"] == "system"
+
+ def test_duplicate_prompt_returns_existing(self, seeder, mock_db):
+ config = {
+ "name": "agent1",
+ "prompt": {"name": "Dup Prompt", "content": "content"},
+ }
+ id1 = seeder._handle_prompt(config)
+ id2 = seeder._handle_prompt(config)
+ assert id1 == id2
+ assert mock_db["prompts"].count_documents({"name": "Dup Prompt"}) == 1
+
+ def test_default_prompt_name(self, seeder, mock_db):
+ config = {"name": "agent1", "prompt": {"content": "hello"}}
+ seeder._handle_prompt(config)
+ doc = mock_db["prompts"].find_one({"name": "agent1 Prompt"})
+ assert doc is not None
+
+ def test_exception_returns_none(self, seeder):
+ with patch.object(
+ seeder.prompts_collection, "find_one", side_effect=RuntimeError("db error")
+ ):
+ config = {
+ "name": "agent1",
+ "prompt": {"name": "p", "content": "c"},
+ }
+ assert seeder._handle_prompt(config) is None
+
+
+# ── _handle_tools ──────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestHandleTools:
+ def test_no_tools_returns_empty(self, seeder):
+ assert seeder._handle_tools({"name": "agent1"}) == []
+
+ @patch("application.seed.seeder.tool_manager")
+ def test_creates_tool(self, mock_tm, seeder, mock_db):
+ mock_tool = MagicMock()
+ mock_tool.get_actions_metadata.return_value = [{"name": "act1"}]
+ mock_tm.tools = {"my_tool": mock_tool}
+
+ config = {
+ "name": "agent1",
+ "tools": [{"name": "my_tool", "description": "desc"}],
+ }
+ ids = seeder._handle_tools(config)
+ assert len(ids) == 1
+ doc = mock_db["user_tools"].find_one({"name": "my_tool"})
+ assert doc is not None
+ assert doc["user"] == "system"
+
+ @patch("application.seed.seeder.tool_manager")
+ def test_duplicate_tool_returns_existing(self, mock_tm, seeder, mock_db):
+ mock_tool = MagicMock()
+ mock_tool.get_actions_metadata.return_value = []
+ mock_tm.tools = {"my_tool": mock_tool}
+
+ config = {
+ "name": "agent1",
+ "tools": [{"name": "my_tool"}],
+ }
+ ids1 = seeder._handle_tools(config)
+ ids2 = seeder._handle_tools(config)
+ assert ids1 == ids2
+ assert mock_db["user_tools"].count_documents({"name": "my_tool"}) == 1
+
+ def test_tool_exception_continues(self, seeder):
+ config = {
+ "name": "agent1",
+ "tools": [{"name": "broken_tool"}],
+ }
+ # tool_manager.tools will KeyError on "broken_tool"
+ ids = seeder._handle_tools(config)
+ assert ids == []
+
+ @patch("application.seed.seeder.tool_manager")
+ def test_tool_config_env_expansion(self, mock_tm, seeder, monkeypatch):
+ monkeypatch.setenv("TOOL_KEY", "expanded_val")
+ mock_tool = MagicMock()
+ mock_tool.get_actions_metadata.return_value = []
+ mock_tm.tools = {"my_tool": mock_tool}
+
+ config = {
+ "name": "agent1",
+ "tools": [{"name": "my_tool", "config": {"api_key": "${TOOL_KEY}"}}],
+ }
+ seeder._handle_tools(config)
+ doc = seeder.tools_collection.find_one({"name": "my_tool"})
+ assert doc["config"]["api_key"] == "expanded_val"
+
+
+# ── _handle_source ─────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestHandleSource:
+ def test_no_source_returns_none(self, seeder):
+ assert seeder._handle_source({"name": "a"}) is None
+
+ def test_existing_source_returns_id(self, seeder, mock_db):
+ inserted = mock_db["sources"].insert_one(
+ {"user": "system", "remote_data": "http://example.com"}
+ )
+ config = {
+ "name": "a",
+ "source": {"url": "http://example.com", "name": "src"},
+ }
+ result = seeder._handle_source(config)
+ assert result == inserted.inserted_id
+
+ @patch("application.seed.seeder.ingest_remote")
+ def test_new_source_ingestion(self, mock_ingest, seeder):
+ mock_task = MagicMock()
+ mock_task.get.return_value = {"id": "new_source_id"}
+ mock_task.successful.return_value = True
+ mock_ingest.delay.return_value = mock_task
+
+ config = {
+ "name": "a",
+ "source": {"url": "http://new.com", "name": "new_src", "loader": "web"},
+ }
+ result = seeder._handle_source(config)
+ assert result == "new_source_id"
+ mock_ingest.delay.assert_called_once_with(
+ source_data="http://new.com",
+ job_name="new_src",
+ user="system",
+ loader="web",
+ )
+
+ @patch("application.seed.seeder.ingest_remote")
+ def test_source_ingestion_failure_returns_false(self, mock_ingest, seeder):
+ mock_task = MagicMock()
+ mock_task.get.side_effect = RuntimeError("timeout")
+ mock_ingest.delay.return_value = mock_task
+
+ config = {
+ "name": "a",
+ "source": {"url": "http://fail.com", "name": "fail_src"},
+ }
+ result = seeder._handle_source(config)
+ assert result is False
+
+ @patch("application.seed.seeder.ingest_remote")
+ def test_source_missing_id_returns_false(self, mock_ingest, seeder):
+ mock_task = MagicMock()
+ mock_task.get.return_value = {"no_id_key": True}
+ mock_task.successful.return_value = True
+ mock_ingest.delay.return_value = mock_task
+
+ config = {
+ "name": "a",
+ "source": {"url": "http://bad.com", "name": "bad_src"},
+ }
+ result = seeder._handle_source(config)
+ assert result is False
+
+ @patch("application.seed.seeder.ingest_remote")
+ def test_default_loader(self, mock_ingest, seeder):
+ mock_task = MagicMock()
+ mock_task.get.return_value = {"id": "sid"}
+ mock_task.successful.return_value = True
+ mock_ingest.delay.return_value = mock_task
+
+ config = {
+ "name": "a",
+ "source": {"url": "http://x.com", "name": "s"},
+ }
+ seeder._handle_source(config)
+ call_kwargs = mock_ingest.delay.call_args[1]
+ assert call_kwargs["loader"] == "url"
+
+
+# ── seed_initial_data ──────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestSeedInitialData:
+ def test_already_seeded_skips(self, seeder, mock_db):
+ mock_db["agents"].insert_one({"user": "system", "name": "existing"})
+ with patch.object(seeder, "_seed_from_config") as mock_seed:
+ seeder.seed_initial_data()
+ mock_seed.assert_not_called()
+
+ def test_force_reseeds(self, seeder, mock_db):
+ mock_db["agents"].insert_one({"user": "system", "name": "existing"})
+ yaml_content = "agents: []"
+ with patch("builtins.open", mock_open(read_data=yaml_content)):
+ with patch.object(seeder, "_seed_from_config") as mock_seed:
+ seeder.seed_initial_data(force=True)
+ mock_seed.assert_called_once()
+
+ def test_config_file_not_found_raises(self, seeder):
+ with pytest.raises(Exception):
+ seeder.seed_initial_data(config_path="/nonexistent/path.yaml")
+
+ def test_custom_config_path(self, seeder):
+ yaml_content = "agents:\n - name: test_agent"
+ with patch("builtins.open", mock_open(read_data=yaml_content)):
+ with patch.object(seeder, "_seed_from_config") as mock_seed:
+ seeder.seed_initial_data(config_path="/custom/path.yaml")
+ mock_seed.assert_called_once()
+
+
+# ── _seed_from_config ──────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestSeedFromConfig:
+ def test_no_agents_in_config(self, seeder):
+ seeder._seed_from_config({})
+ assert seeder.agents_collection.count_documents({}) == 0
+
+ def test_empty_agents_list(self, seeder):
+ seeder._seed_from_config({"agents": []})
+ assert seeder.agents_collection.count_documents({}) == 0
+
+ @patch.object(DatabaseSeeder, "_handle_source", return_value=None)
+ @patch.object(DatabaseSeeder, "_handle_tools", return_value=[])
+ @patch.object(DatabaseSeeder, "_handle_prompt", return_value=None)
+ def test_creates_agent(self, mock_prompt, mock_tools, mock_source, seeder, mock_db):
+ config = {
+ "agents": [
+ {
+ "name": "TestAgent",
+ "description": "A test agent",
+ "agent_type": "classic",
+ }
+ ]
+ }
+ seeder._seed_from_config(config)
+ agent = mock_db["agents"].find_one({"name": "TestAgent"})
+ assert agent is not None
+ assert agent["user"] == "system"
+ assert agent["agent_type"] == "classic"
+ assert agent["status"] == "template"
+
+ @patch.object(DatabaseSeeder, "_handle_source", return_value=None)
+ @patch.object(DatabaseSeeder, "_handle_tools", return_value=[])
+ @patch.object(DatabaseSeeder, "_handle_prompt", return_value=None)
+ def test_updates_existing_agent(self, mock_prompt, mock_tools, mock_source, seeder, mock_db):
+ mock_db["agents"].insert_one(
+ {"user": "system", "name": "TestAgent", "description": "old"}
+ )
+ config = {
+ "agents": [
+ {
+ "name": "TestAgent",
+ "description": "updated",
+ "agent_type": "classic",
+ }
+ ]
+ }
+ seeder._seed_from_config(config)
+ assert mock_db["agents"].count_documents({"name": "TestAgent"}) == 1
+ agent = mock_db["agents"].find_one({"name": "TestAgent"})
+ assert agent["description"] == "updated"
+
+ @patch.object(DatabaseSeeder, "_handle_source", return_value=False)
+ def test_source_failure_skips_agent(self, mock_source, seeder, mock_db):
+ config = {
+ "agents": [
+ {
+ "name": "SkippedAgent",
+ "description": "skip",
+ "agent_type": "classic",
+ }
+ ]
+ }
+ seeder._seed_from_config(config)
+ assert mock_db["agents"].count_documents({"name": "SkippedAgent"}) == 0
+
+ @patch.object(DatabaseSeeder, "_handle_source", side_effect=KeyError("name"))
+ def test_agent_exception_continues(self, mock_source, seeder, mock_db):
+ config = {
+ "agents": [
+ {"name": "Bad", "description": "x", "agent_type": "y"},
+ {"name": "Good", "description": "x", "agent_type": "y"},
+ ]
+ }
+ with patch.object(seeder, "_handle_tools", return_value=[]):
+ with patch.object(seeder, "_handle_prompt", return_value=None):
+ seeder._seed_from_config(config)
+ # Both agents should be attempted; first errors, second might too
+ # Main assertion: no unhandled exception
+
+ @patch.object(DatabaseSeeder, "_handle_source", return_value=None)
+ @patch.object(DatabaseSeeder, "_handle_tools")
+ @patch.object(DatabaseSeeder, "_handle_prompt", return_value="prompt_id_123")
+ def test_agent_with_source_and_tools(self, mock_prompt, mock_tools, mock_source, seeder, mock_db):
+ tool_id = ObjectId()
+ mock_tools.return_value = [tool_id]
+
+ source_id = ObjectId()
+ mock_source.return_value = source_id
+
+ config = {
+ "agents": [
+ {
+ "name": "FullAgent",
+ "description": "full",
+ "agent_type": "classic",
+ "chunks": "5",
+ "retriever": "classic",
+ "image": "img.png",
+ }
+ ]
+ }
+ seeder._seed_from_config(config)
+ agent = mock_db["agents"].find_one({"name": "FullAgent"})
+ assert agent is not None
+ assert agent["prompt_id"] == "prompt_id_123"
+ assert str(tool_id) in agent["tools"]
+ assert agent["chunks"] == "5"
+ assert agent["image"] == "img.png"
+
+
+# ── initialize_from_env ────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestInitializeFromEnv:
+ @patch("application.seed.seeder.MongoClient")
+ def test_creates_seeder_from_env(self, mock_client_cls, monkeypatch):
+ monkeypatch.setenv("MONGO_URI", "mongodb://test:27017")
+ monkeypatch.setenv("MONGO_DB_NAME", "testdb")
+
+ mock_db = mongomock.MongoClient()["testdb"]
+ mock_client = MagicMock()
+ mock_client.__getitem__ = MagicMock(return_value=mock_db)
+ mock_client_cls.return_value = mock_client
+
+ seeder = DatabaseSeeder.initialize_from_env()
+ mock_client_cls.assert_called_once_with("mongodb://test:27017")
+ assert isinstance(seeder, DatabaseSeeder)
+
+ @patch("application.seed.seeder.MongoClient")
+ def test_default_env_values(self, mock_client_cls, monkeypatch):
+ monkeypatch.delenv("MONGO_URI", raising=False)
+ monkeypatch.delenv("MONGO_DB_NAME", raising=False)
+
+ mock_db = mongomock.MongoClient()["docsgpt"]
+ mock_client = MagicMock()
+ mock_client.__getitem__ = MagicMock(return_value=mock_db)
+ mock_client_cls.return_value = mock_client
+
+ DatabaseSeeder.initialize_from_env()
+ mock_client_cls.assert_called_once_with("mongodb://localhost:27017")
+
+
+# ── seed CLI commands ──────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestSeedCommands:
+ @patch("application.seed.commands.DatabaseSeeder")
+ @patch("application.seed.commands.MongoDB")
+ @patch("application.seed.commands.settings")
+ def test_init_command(self, mock_settings, mock_mongodb, mock_seeder_cls):
+ from click.testing import CliRunner
+ from application.seed.commands import seed
+
+ mock_settings.MONGO_DB_NAME = "testdb"
+ mock_client = MagicMock()
+ mock_client.__getitem__ = MagicMock(return_value="mock_db")
+ mock_mongodb.get_client.return_value = mock_client
+
+ mock_seeder = MagicMock()
+ mock_seeder_cls.return_value = mock_seeder
+
+ runner = CliRunner()
+ result = runner.invoke(seed, ["init"])
+ assert result.exit_code == 0
+ mock_seeder.seed_initial_data.assert_called_once_with(force=False)
+
+ @patch("application.seed.commands.DatabaseSeeder")
+ @patch("application.seed.commands.MongoDB")
+ @patch("application.seed.commands.settings")
+ def test_init_command_with_force(self, mock_settings, mock_mongodb, mock_seeder_cls):
+ from click.testing import CliRunner
+ from application.seed.commands import seed
+
+ mock_settings.MONGO_DB_NAME = "testdb"
+ mock_client = MagicMock()
+ mock_client.__getitem__ = MagicMock(return_value="mock_db")
+ mock_mongodb.get_client.return_value = mock_client
+
+ mock_seeder = MagicMock()
+ mock_seeder_cls.return_value = mock_seeder
+
+ runner = CliRunner()
+ result = runner.invoke(seed, ["init", "--force"])
+ assert result.exit_code == 0
+ mock_seeder.seed_initial_data.assert_called_once_with(force=True)
diff --git a/tests/test_template_engine.py b/tests/test_template_engine.py
new file mode 100644
index 00000000..83c3d1dd
--- /dev/null
+++ b/tests/test_template_engine.py
@@ -0,0 +1,186 @@
+from unittest.mock import patch
+
+import pytest
+
+from application.templates.template_engine import TemplateEngine, TemplateRenderError
+
+
+@pytest.fixture
+def engine():
+ return TemplateEngine()
+
+
+# ── render ─────────────────────────────────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestRender:
+ def test_simple_variable(self, engine):
+ result = engine.render("Hello {{ name }}", {"name": "World"})
+ assert result == "Hello World"
+
+ def test_empty_template_returns_empty(self, engine):
+ assert engine.render("", {"x": 1}) == ""
+
+ def test_none_template_returns_empty(self, engine):
+ assert engine.render(None, {"x": 1}) == ""
+
+ def test_no_variables(self, engine):
+ assert engine.render("plain text", {}) == "plain text"
+
+ def test_multiple_variables(self, engine):
+ tpl = "{{ a }} and {{ b }}"
+ assert engine.render(tpl, {"a": "X", "b": "Y"}) == "X and Y"
+
+ def test_nested_dict_access(self, engine):
+ tpl = "{{ data.key }}"
+ assert engine.render(tpl, {"data": {"key": "value"}}) == "value"
+
+ def test_loop(self, engine):
+ tpl = "{% for i in items %}{{ i }} {% endfor %}"
+ result = engine.render(tpl, {"items": ["a", "b", "c"]})
+ assert result.strip() == "a b c"
+
+ def test_conditional(self, engine):
+ tpl = "{% if show %}yes{% else %}no{% endif %}"
+ assert engine.render(tpl, {"show": True}) == "yes"
+ assert engine.render(tpl, {"show": False}) == "no"
+
+ def test_syntax_error_raises_template_render_error(self, engine):
+ with pytest.raises(TemplateRenderError, match="syntax error"):
+ engine.render("{% if %}", {})
+
+ def test_undefined_variable_chainable(self, engine):
+ # ChainableUndefined should NOT raise; it silently returns empty
+ result = engine.render("{{ missing }}", {})
+ assert result == ""
+
+ def test_autoescape_html(self, engine):
+ result = engine.render("{{ content }}", {"content": ""})
+ assert "