From 8461ef5088b6915928bcd9faa571bc6d0f85f67c Mon Sep 17 00:00:00 2001 From: Leon Sander Date: Mon, 6 Jul 2026 10:39:34 +0200 Subject: [PATCH 1/4] moved files into src folder for clearer root --- README.md | 24 ++++++++++--------- docker-compose.yml | 2 +- docker-compose_without_ollama.yml | 2 +- docker/Dockerfile | 2 +- app.py => src/app.py | 0 audio_handler.py => src/audio_handler.py | 0 .../chat_api_handler.py | 0 .../database_operations.py | 0 html_templates.py => src/html_templates.py | 0 pdf_handler.py => src/pdf_handler.py | 0 .../prompt_templates.py | 0 utils.py => src/utils.py | 0 .../vectordb_handler.py | 0 13 files changed, 16 insertions(+), 14 deletions(-) rename app.py => src/app.py (100%) rename audio_handler.py => src/audio_handler.py (100%) rename chat_api_handler.py => src/chat_api_handler.py (100%) rename database_operations.py => src/database_operations.py (100%) rename html_templates.py => src/html_templates.py (100%) rename pdf_handler.py => src/pdf_handler.py (100%) rename prompt_templates.py => src/prompt_templates.py (100%) rename utils.py => src/utils.py (100%) rename vectordb_handler.py => src/vectordb_handler.py (100%) diff --git a/README.md b/README.md index bdadbe9..475f081 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ Local Multimodal AI Chat integrates several AI models behind a single Streamlit ```mermaid flowchart TD - U["User"] --> UI["Streamlit UI (app.py)"] - UI -->|"record / upload audio"| W["Whisper ASR (audio_handler)"] + U["User"] --> UI["Streamlit UI (src/app.py)"] + UI -->|"record / upload audio"| W["Whisper ASR (src/audio_handler)"] W --> R["ChatAPIHandler router"] UI -->|"text / image / PDF / commands"| R UI -->|"upload PDF"| P["PDF handler: pypdfium2 + chunking"] @@ -50,15 +50,17 @@ flowchart TD **Module layout** +All Python source lives under `src/`. + | File | Responsibility | |------|----------------| -| `app.py` | Streamlit UI, session state, orchestration | -| `chat_api_handler.py` | Endpoint router (Ollama / OpenAI), text + image + RAG calls | -| `vectordb_handler.py` | Chroma client + Ollama embeddings | -| `pdf_handler.py` | PDF text extraction, chunking, indexing | -| `audio_handler.py` | Whisper transcription (webm→wav via ffmpeg) | -| `database_operations.py` | SQLite repositories (messages, settings) | -| `utils.py` / `prompt_templates.py` / `html_templates.py` | Helpers, prompts, UI styling | +| `src/app.py` | Streamlit UI, session state, orchestration | +| `src/chat_api_handler.py` | Endpoint router (Ollama / OpenAI), text + image + RAG calls | +| `src/vectordb_handler.py` | Chroma client + Ollama embeddings | +| `src/pdf_handler.py` | PDF text extraction, chunking, indexing | +| `src/audio_handler.py` | Whisper transcription (webm→wav via ffmpeg) | +| `src/database_operations.py` | SQLite repositories (messages, settings) | +| `src/utils.py` / `src/prompt_templates.py` / `src/html_templates.py` | Helpers, prompts, UI styling | ## Tech Stack @@ -105,8 +107,8 @@ Running Ollama inside Docker is slow on Windows (system calls are translated bet ``` 4. **Run**: ```bash - python3 database_operations.py # initializes the SQLite database - streamlit run app.py + python3 src/database_operations.py # initializes the SQLite database + streamlit run src/app.py ``` 5. **Pull models** as described above. diff --git a/docker-compose.yml b/docker-compose.yml index 41d8350..9de5e9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: - "8501:8501" environment: - PYTHONUNBUFFERED=1 - command: /bin/sh -c "python3 database_operations.py && streamlit run app.py --server.fileWatcherType poll" + command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll" depends_on: - ollama diff --git a/docker-compose_without_ollama.yml b/docker-compose_without_ollama.yml index d5f8f61..804e458 100644 --- a/docker-compose_without_ollama.yml +++ b/docker-compose_without_ollama.yml @@ -10,4 +10,4 @@ services: - "11434:11434" environment: - PYTHONUNBUFFERED=1 - command: /bin/sh -c "python3 database_operations.py && streamlit run app.py --server.fileWatcherType poll" \ No newline at end of file + command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll" \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index e397882..e96710c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,4 +10,4 @@ RUN pip install --upgrade pip RUN pip install --no-cache-dir -r requirements.txt RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu EXPOSE 8501 -CMD ["streamlit","run", "app.py"] \ No newline at end of file +CMD ["streamlit","run", "src/app.py"] \ No newline at end of file diff --git a/app.py b/src/app.py similarity index 100% rename from app.py rename to src/app.py diff --git a/audio_handler.py b/src/audio_handler.py similarity index 100% rename from audio_handler.py rename to src/audio_handler.py diff --git a/chat_api_handler.py b/src/chat_api_handler.py similarity index 100% rename from chat_api_handler.py rename to src/chat_api_handler.py diff --git a/database_operations.py b/src/database_operations.py similarity index 100% rename from database_operations.py rename to src/database_operations.py diff --git a/html_templates.py b/src/html_templates.py similarity index 100% rename from html_templates.py rename to src/html_templates.py diff --git a/pdf_handler.py b/src/pdf_handler.py similarity index 100% rename from pdf_handler.py rename to src/pdf_handler.py diff --git a/prompt_templates.py b/src/prompt_templates.py similarity index 100% rename from prompt_templates.py rename to src/prompt_templates.py diff --git a/utils.py b/src/utils.py similarity index 100% rename from utils.py rename to src/utils.py diff --git a/vectordb_handler.py b/src/vectordb_handler.py similarity index 100% rename from vectordb_handler.py rename to src/vectordb_handler.py From 273eb4df9b14606a7b363e930c85c71a2300b0d3 Mon Sep 17 00:00:00 2001 From: Leon Sander Date: Mon, 6 Jul 2026 15:48:17 +0200 Subject: [PATCH 2/4] Add contract-level test suite with CI matrix (Ubuntu/Windows) 20 pytest contract tests over the stable behavior layer: SQLite repositories, pure utils, and chat API request construction. Tests pin behavioral promises, never implementation structure, so forks can refactor internals freely (philosophy documented in TESTING.md). - tests/ run hermetically: import-time side effects are sandboxed into a temp dir, network access is blocked at the socket level - GitHub Actions matrix runs the suite on ubuntu + windows (Python 3.10) - UI, CSS, audio transcription and live model calls deliberately stay untested/manual (see TESTING.md) --- .github/workflows/tests.yml | 41 +++++++ README.md | 11 ++ TESTING.md | 58 +++++++++ pytest.ini | 3 + requirements-dev.txt | 2 + tests/conftest.py | 149 ++++++++++++++++++++++ tests/test_chat_api_handler.py | 197 ++++++++++++++++++++++++++++++ tests/test_database_operations.py | 141 +++++++++++++++++++++ tests/test_utils.py | 55 +++++++++ 9 files changed, 657 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 TESTING.md create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_chat_api_handler.py create mode 100644 tests/test_database_operations.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..450471f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10"] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements*.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + # Catches import-time breakage (config paths, heavy deps) with a clear + # error before the test run. torch is deliberately NOT installed: only + # audio_handler needs it, and audio stays manual (see TESTING.md). + - name: Smoke-import the modules under test + run: python -c "import chat_api_handler, database_operations, utils" + env: + PYTHONPATH: src + + - name: Run tests + run: pytest diff --git a/README.md b/README.md index 475f081..eb7f2eb 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,17 @@ Running Ollama inside Docker is slow on Windows (system calls are translated bet Key settings live in `config.yaml` (Ollama base URL and embedding model, Whisper model, Chroma path/collection, chat-sessions DB path). Runtime options (endpoint, model, chunk size/overlap, retrieved chunks, chat memory) are adjustable in the sidebar and persisted per user. +## Testing + +A small contract-level test suite covers the SQLite layer, utility functions, and API request construction (UI, audio, and real model calls stay manual by design): + +```bash +pip install -r requirements-dev.txt +pytest +``` + +CI runs the suite on Ubuntu and Windows. See [TESTING.md](./TESTING.md) for the philosophy and what is deliberately not tested. + ## Roadmap - [ ] Additional model providers (Gemini, others) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..063bee6 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,58 @@ +# Testing + +This repo has a small, deliberate test suite: **20 contract tests** over the parts of the code whose behavior is stable by design. It is meant to stay cheap to maintain — including for forks. + +## Philosophy: contracts, not structure + +A test here pins a **behavioral promise** (what a function guarantees to its callers), never an implementation detail. The intended property: **a test only fails when behavior actually changed.** If you fork this repo, rewrite internals freely — a red test means you changed something user-visible, which is exactly when you want to know. + +A few places accept **medium churn** deliberately, each documented inline where it occurs: + +- `test_pdf_chat_stuffs_retrieved_context_into_prompt` patches the internal `load_vectordb` seam (the alternative is a real Chroma + Ollama instance). +- `test_command_dispatch` patches the internal `pull_model_in_background` seam (the alternative is asyncio + a live Ollama pull). +- The handler tests patch two values that src binds **at import time** (`chat_api_handler.config`, `chat_api_handler.openai_api_key`). A behavior-preserving refactor to call-time lookups would require updating the two fixtures at the top of `tests/test_chat_api_handler.py` — a two-line fix, accepted as the cost of avoiding real env vars and network. + +## What is covered + +| File | Contracts | +|------|-----------| +| `tests/test_database_operations.py` (9) | message roundtrips (text + binary), LLM-context feed ordering/filtering, per-session delete isolation, session id listing, settings read-through defaults + persistence, restart durability | +| `tests/test_utils.py` (3) | base64/data-URL wire format, `/command` dispatch, config save/load roundtrip | +| `tests/test_chat_api_handler.py` (8) | endpoint routing (ollama/openai/unknown), request bodies + auth headers, image wire formats (Ollama bare-b64 vs OpenAI data URL), RAG context stuffing, error-shape handling (string vs nested object) | + +## What deliberately stays untested (and why) + +- **UI structure and CSS** (`app.py`, `html_templates.py`) — Streamlit class names churn every release; tests would break without any behavior change. +- **Audio transcription** (`audio_handler.py`) — needs Whisper model downloads; the browser microphone path can't be unit-tested anyway. +- **Real model calls / PDF ingestion** — network- and model-dependent; the request *construction* is tested, the live call is not. +- **Trivial helpers** (`convert_ns_to_seconds`, `get_avatar`, timestamp formatting) — near-zero regression value for the count they add. +- **The manual smoke test remains**: launch the app, send a text message, record audio once. That is the irreducible browser part. + +## Running + +From the **repo root**: + +```bash +pip install -r requirements-dev.txt # includes requirements.txt +pytest +``` + +CI runs the same suite on Ubuntu and Windows (Python 3.10) on every push/PR to `main` — which also doubles as a "does it still install on Windows" check. + +## Gotchas when writing new tests + +The src modules bind several things **at import time** — patch the right namespace: + +| To fake | Patch | Why | +|---------|-------|-----| +| OpenAI key | `chat_api_handler.openai_api_key` | `os.getenv` ran at import; `monkeypatch.setenv` does nothing | +| Ollama base URL | `chat_api_handler.config` | module-level `load_config()` froze it | +| Vector DB | `chat_api_handler.load_vectordb` | from-import binding; patching `vectordb_handler` does nothing | +| HTTP | `requests.post` (module attribute) | call-time lookup, reaches all src modules | +| Session state | `streamlit.session_state` → `FakeSessionState` (conftest) | never rely on bare-mode session_state | + +Other invariants: + +- An autouse `no_network` fixture replaces `requests.get/post` and blocks raw socket connects (which also covers `aiohttp` and `requests.Session`) — a test that reaches for the network fails loudly instead of silently hitting a real endpoint. +- DB tests build their own `DatabaseManager` on `tmp_path` and must `close()` it (Windows cannot delete open sqlite files). Never assert through the module-level singleton in `database_operations`. +- `tests/conftest.py` chdirs into a throwaway **sandbox** (with its own minimal `config.yaml` and `chat_sessions/`) before anything imports the src modules — their import-time side effects (config read, sqlite creation) land in the sandbox, the repo's real `config.yaml` and `chat_sessions/` are never read or written, and pytest works from any invocation directory. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..442bb94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = src +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1441c92 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==9.1.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..077b200 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,149 @@ +"""Shared fixtures for the contract-level test suite. + +IMPORTANT - import-time side effects in src/ are load-bearing: utils.py, +chat_api_handler.py and database_operations.py read the CWD-relative +config.yaml when first imported, and database_operations.py opens a sqlite +database at the configured path at import time. The module-level code below +therefore chdirs into a throwaway sandbox seeded with a minimal config.yaml +BEFORE any test module (and hence any src module) is imported: every +import-time side effect lands in the sandbox, the repo's real config.yaml and +chat_sessions/ are never read or written, and the suite works from any +invocation directory. +""" +import atexit +import os +import shutil +import socket +import tempfile +from pathlib import Path + +import pytest +import requests + +_SANDBOX = Path(tempfile.mkdtemp(prefix="local-multimodal-chat-tests-")) +(_SANDBOX / "chat_sessions").mkdir() +(_SANDBOX / "config.yaml").write_text( + "\n".join( + [ + "ollama:", + ' embedding_model: "sandbox-embed"', + " base_url: http://sandbox-ollama:11434", + "", + 'whisper_model: "sandbox-whisper"', + "", + "chromadb:", + ' chromadb_path: "chroma_db"', + ' collection_name: "pdfs"', + "", + 'chat_sessions_database_path: "./chat_sessions/chat_sessions.db"', + "", + ] + ) +) +# Best-effort cleanup; ignore_errors because the module-level singleton in +# database_operations holds its sandbox db open for the whole session. +atexit.register(shutil.rmtree, _SANDBOX, ignore_errors=True) + + +def pytest_configure(config): + # chdir in the configure hook, not at conftest import: pytest resolves + # testpaths against the CWD first, and collection (which imports the test + # modules and therefore the src modules) only starts afterwards - so the + # import-time side effects still land in the sandbox. + os.chdir(_SANDBOX) + + +class FakeSessionState(dict): + """Stand-in for st.session_state covering the four access patterns used in + src/: st.session_state["key"], .get("key", default), attribute access, and + membership tests. Never rely on Streamlit's bare-mode session_state - it is + a process-global singleton with undocumented semantics.""" + + def __getattr__(self, name): + try: + return self[name] + except KeyError: + raise AttributeError(name) from None + + def __setattr__(self, name, value): + self[name] = value + + +class DummyResponse: + """Minimal requests.Response stand-in; .json() is repeatably callable.""" + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +class RecordingPost: + """Callable that records every requests.post(...) call and returns a + canned response. The signature mirrors the real + requests.post(url, data=None, json=None, ...) so that a refactor in src to + positional arguments cannot silently record the body under the wrong key.""" + + def __init__(self, response_payload): + self.response_payload = response_payload + self.calls = [] + + def __call__(self, url=None, data=None, json=None, headers=None, **kwargs): + self.calls.append({"url": url, "data": data, "json": json, "headers": headers}) + return DummyResponse(self.response_payload) + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + """Fail loudly if any test hits the network without explicitly patching + it: requests.get/post are replaced for a clear error message, and raw + socket connects are blocked as a catch-all (this also covers aiohttp and + requests.Session). captured_post overrides requests.post for tests that + expect an HTTP call.""" + + def _blocked(*args, **kwargs): + raise RuntimeError( + "Network access is disabled in tests; use the captured_post " + "fixture or patch the network call explicitly." + ) + + monkeypatch.setattr(requests, "get", _blocked) + monkeypatch.setattr(requests, "post", _blocked) + monkeypatch.setattr(socket.socket, "connect", _blocked) + + +@pytest.fixture +def captured_post(monkeypatch): + """Factory: captured_post(payload) patches requests.post to record calls + and answer with `payload`. Works because src modules do `import requests` + and look up requests.post at call time.""" + + def _install(response_payload): + recorder = RecordingPost(response_payload) + monkeypatch.setattr(requests, "post", recorder) + return recorder + + return _install + + +@pytest.fixture +def fake_session_state(monkeypatch): + """Replace st.session_state for every src module at once (they all do + `import streamlit as st`, so patching the module attribute reaches all).""" + import streamlit as st + + state = FakeSessionState() + monkeypatch.setattr(st, "session_state", state) + return state + + +@pytest.fixture +def db_manager(tmp_path): + """A DatabaseManager on a throwaway sqlite file. close() in teardown is + mandatory: an open handle makes tmp_path cleanup fail on Windows.""" + from database_operations import DatabaseManager + + manager = DatabaseManager(str(tmp_path / "test.db")) + yield manager + manager.close() diff --git a/tests/test_chat_api_handler.py b/tests/test_chat_api_handler.py new file mode 100644 index 0000000..129bcc4 --- /dev/null +++ b/tests/test_chat_api_handler.py @@ -0,0 +1,197 @@ +"""Contract tests for endpoint routing and request construction. + +Everything is exercised through the public ChatAPIHandler.chat() entry point; +HTTP and vector-db access are patched at the seams. Namespace discipline +matters (see TESTING.md): openai_api_key, config and load_vectordb must be +patched on the chat_api_handler module because they are bound there at import +time. +""" +import base64 +from types import SimpleNamespace + +import pytest + +import chat_api_handler +from chat_api_handler import ChatAPIHandler + +OLLAMA_RESPONSE = {"message": {"content": "the answer"}} +OPENAI_RESPONSE = {"choices": [{"message": {"content": "the answer"}}]} + + +@pytest.fixture +def ollama_config(monkeypatch): + # Accepted churn (see TESTING.md): config is bound at import time in + # chat_api_handler, so this fixture patches that binding. A refactor to + # call-time load_config() would require updating this fixture, not the + # tests. + monkeypatch.setattr( + chat_api_handler, + "config", + {"ollama": {"base_url": "http://test-ollama:11434"}}, + ) + + +@pytest.fixture +def openai_key(monkeypatch): + # Accepted churn (see TESTING.md): os.getenv ran once at module import, + # so monkeypatch.setenv would be useless. A refactor to call-time getenv + # would require updating this fixture, not the tests. + monkeypatch.setattr(chat_api_handler, "openai_api_key", "sk-test") + + +def test_chat_ollama_endpoint_posts_to_ollama(fake_session_state, captured_post, ollama_config): + fake_session_state.update(endpoint_to_use="ollama", model_to_use="llama3") + post = captured_post(OLLAMA_RESPONSE) + history = [ + {"role": "user", "content": "earlier question"}, + {"role": "assistant", "content": "earlier answer"}, + ] + + answer = ChatAPIHandler.chat(user_input="new question", chat_history=list(history)) + + assert answer == "the answer" + assert len(post.calls) == 1 + call = post.calls[0] + assert call["url"] == "http://test-ollama:11434/api/chat" + assert call["data"] is None # the body must travel as json=, not form data + assert call["json"]["model"] == "llama3" + assert call["json"]["stream"] is False + assert call["json"]["messages"] == history + [{"role": "user", "content": "new question"}] + + +def test_chat_openai_endpoint_posts_to_openai(fake_session_state, captured_post, openai_key): + fake_session_state.update(endpoint_to_use="openai", model_to_use="gpt-4o") + post = captured_post(OPENAI_RESPONSE) + history = [ + {"role": "user", "content": "earlier question"}, + {"role": "assistant", "content": "earlier answer"}, + ] + + answer = ChatAPIHandler.chat(user_input="a question", chat_history=list(history)) + + assert answer == "the answer" + assert len(post.calls) == 1 + call = post.calls[0] + assert call["url"] == "https://api.openai.com/v1/chat/completions" + assert call["headers"]["Authorization"] == "Bearer sk-test" + assert call["data"] is None # the body must travel as json=, not form data + assert call["json"]["model"] == "gpt-4o" + # Non-empty prior history: dropping conversation memory must fail here. + assert call["json"]["messages"] == history + [{"role": "user", "content": "a question"}] + + +def test_chat_unknown_endpoint_raises_value_error(fake_session_state): + fake_session_state.update(endpoint_to_use="azure", model_to_use="anything") + + # The autouse no_network fixture guarantees this raises before any HTTP + # attempt - a network call would surface as RuntimeError, not ValueError. + with pytest.raises(ValueError): + ChatAPIHandler.chat(user_input="q", chat_history=[]) + + +def test_ollama_image_chat_wire_format(fake_session_state, captured_post, ollama_config): + fake_session_state.update(endpoint_to_use="ollama", model_to_use="llava") + post = captured_post(OLLAMA_RESPONSE) + image = b"\x89PNG\r\n\x1a\n fake image" + history = [{"role": "user", "content": "earlier turn"}] + + ChatAPIHandler.chat(user_input="what is shown here?", chat_history=list(history), image=image) + + messages = post.calls[0]["json"]["messages"] + assert messages[:-1] == history # prior turns must still reach the model + # Ollama vision wire format: bare base64 in an "images" list next to the + # text content. + assert messages[-1] == { + "role": "user", + "content": "what is shown here?", + "images": [base64.b64encode(image).decode("utf-8")], + } + + +def test_openai_image_chat_data_url_wire_format(fake_session_state, captured_post, openai_key): + fake_session_state.update(endpoint_to_use="openai", model_to_use="gpt-4o") + post = captured_post(OPENAI_RESPONSE) + image = b"\x89PNG\r\n\x1a\n fake image" + history = [{"role": "user", "content": "earlier turn"}] + + ChatAPIHandler.chat(user_input="describe this", chat_history=list(history), image=image) + + messages = post.calls[0]["json"]["messages"] + assert messages[:-1] == history # prior turns must still reach the model + message = messages[-1] + assert message["role"] == "user" + text_parts = [p for p in message["content"] if p.get("type") == "text"] + image_parts = [p for p in message["content"] if p.get("type") == "image_url"] + assert [p["text"] for p in text_parts] == ["describe this"] + # OpenAI vision wire format: a data URL whose payload roundtrips back to + # the input bytes (roundtrip assert, not golden string). + url = image_parts[0]["image_url"]["url"] + prefix = "data:image/jpeg;base64," + assert url.startswith(prefix) + assert base64.b64decode(url[len(prefix):]) == image + + +def test_pdf_chat_stuffs_retrieved_context_into_prompt( + fake_session_state, captured_post, ollama_config, monkeypatch +): + chunks = [ + SimpleNamespace(page_content="first retrieved chunk"), + SimpleNamespace(page_content="second retrieved chunk"), + ] + search_calls = [] + + def fake_similarity_search(query, k): + search_calls.append((query, k)) + return chunks + + # Must patch the chat_api_handler namespace: the from-import at the top of + # the module means patching vectordb_handler.load_vectordb does nothing. + monkeypatch.setattr( + chat_api_handler, + "load_vectordb", + lambda: SimpleNamespace(similarity_search=fake_similarity_search), + ) + fake_session_state.update( + endpoint_to_use="ollama", + model_to_use="llama3", + pdf_chat=True, + retrieved_documents=2, + ) + post = captured_post(OLLAMA_RESPONSE) + history = [{"role": "user", "content": "earlier turn"}] + + ChatAPIHandler.chat(user_input="what does the paper say?", chat_history=list(history)) + + assert search_calls == [("what does the paper say?", 2)] + messages = post.calls[0]["json"]["messages"] + assert messages[:-1] == history # prior turns must still reach the model + prompt = messages[-1]["content"] + # Substring asserts only - the exact prompt template wording is not a + # contract, but every retrieved chunk and the question must reach the model. + assert "first retrieved chunk" in prompt + assert "second retrieved chunk" in prompt + assert "what does the paper say?" in prompt + + +def test_ollama_api_call_surfaces_error_string(fake_session_state, captured_post, ollama_config): + fake_session_state.update(endpoint_to_use="ollama", model_to_use="missing-model") + # Ollama error values are plain strings (unlike OpenAI's nested objects). + captured_post({"error": "model 'missing-model' not found"}) + + answer = ChatAPIHandler.chat(user_input="q", chat_history=[]) + + assert isinstance(answer, str) + assert "model 'missing-model' not found" in answer + + +def test_openai_api_call_surfaces_error_message(fake_session_state, captured_post, openai_key): + fake_session_state.update(endpoint_to_use="openai", model_to_use="gpt-4o") + # OpenAI error values are nested objects with a message field. + captured_post( + {"error": {"message": "Incorrect API key provided", "type": "invalid_request_error"}} + ) + + answer = ChatAPIHandler.chat(user_input="q", chat_history=[]) + + assert isinstance(answer, str) + assert "Incorrect API key provided" in answer diff --git a/tests/test_database_operations.py b/tests/test_database_operations.py new file mode 100644 index 0000000..18d9e8a --- /dev/null +++ b/tests/test_database_operations.py @@ -0,0 +1,141 @@ +"""Contract tests for the SQLite repositories. + +Every test runs against a real sqlite database on tmp_path via the db_manager +fixture - no mocking. The module-level db_manager singleton that +database_operations.py creates at import time lands in the conftest sandbox +(never in the repo's real chat_sessions/); tests never assert through it. +""" +import pytest + +from database_operations import DatabaseManager + +SESSION = "2024-01-15 09:30:00" +OTHER_SESSION = "2024-02-20 18:45:00" + + +def test_save_and_load_text_message_roundtrip(db_manager): + db_manager.message_repo.save_message(SESSION, "user", "text", "hello world") + + messages = db_manager.message_repo.load_messages(SESSION) + + assert len(messages) == 1 + message = messages[0] + assert message["content"] == "hello world" + assert message["sender_type"] == "user" + assert message["message_type"] == "text" + + +@pytest.mark.parametrize("message_type", ["image", "audio"]) +def test_blob_message_roundtrip_preserves_exact_bytes(db_manager, message_type): + payload = b"\x89PNG\r\n\x1a\n\x00\xff" # non-UTF-8: catches accidental text decoding + + db_manager.message_repo.save_message(SESSION, "user", message_type, payload) + messages = db_manager.message_repo.load_messages(SESSION) + + assert len(messages) == 1 + assert isinstance(messages[0]["content"], bytes) + assert messages[0]["content"] == payload + + +def test_load_last_k_text_messages_excludes_non_text(db_manager): + repo = db_manager.message_repo + repo.save_message(SESSION, "user", "text", "first text") + repo.save_message(SESSION, "user", "image", b"\x00\x01") + repo.save_message(SESSION, "user", "audio", b"\x02\x03") + repo.save_message(SESSION, "assistant", "text", "second text") + + messages = repo.load_last_k_text_messages(SESSION, 10) + + # Blob rows must never leak into the LLM context feed. + assert [m["message_type"] for m in messages] == ["text", "text"] + assert [m["content"] for m in messages] == ["first text", "second text"] + + +def test_load_last_k_text_messages_returns_k_most_recent_oldest_first(db_manager): + repo = db_manager.message_repo + for i in range(1, 6): + repo.save_message(SESSION, "user", "text", f"m{i}") + + messages = repo.load_last_k_text_messages(SESSION, 2) + + # The k newest messages, re-sorted chronologically: this is the LLM + # context feed - returning them newest-first would feed the conversation + # history to the model backwards. + assert [m["content"] for m in messages] == ["m4", "m5"] + + +def test_delete_chat_history_removes_only_target_session(db_manager): + repo = db_manager.message_repo + repo.save_message(SESSION, "user", "text", "to be deleted") + repo.save_message(SESSION, "user", "image", b"\x00") + repo.save_message(OTHER_SESSION, "user", "text", "survivor") + + repo.delete_chat_history(SESSION) + + assert repo.load_messages(SESSION) == [] + assert [m["content"] for m in repo.load_messages(OTHER_SESSION)] == ["survivor"] + assert repo.get_all_chat_history_ids() == [OTHER_SESSION] + + +def test_get_all_chat_history_ids_distinct_and_ascending(db_manager): + repo = db_manager.message_repo + # Inserted out of chronological order, one session appearing twice. + for session in [ + "2024-03-01 10:00:00", + "2024-01-15 09:30:00", + "2024-03-01 10:00:00", + "2024-02-20 18:45:00", + ]: + repo.save_message(session, "user", "text", "msg") + + ids = repo.get_all_chat_history_ids() + + # Distinct and ascending - for the timestamp-formatted ids the app + # generates, ascending string order doubles as chronological sidebar order. + assert ids == [ + "2024-01-15 09:30:00", + "2024-02-20 18:45:00", + "2024-03-01 10:00:00", + ] + + +def test_get_setting_persists_default_on_first_read(db_manager): + repo = db_manager.settings_repo + + first = repo.get_setting("chat_memory_length", 4) + assert int(first) == 4 + + # Read-through write: the default was persisted, so a different fallback + # is ignored. Asserted via int() coercion throughout - stored values may + # come back as strings and every consumer in app.py coerces, so the + # persisted VALUE is the contract, not the return type. + assert int(repo.get_setting("chat_memory_length", 99)) == 4 + + +def test_update_setting_overwrites(db_manager): + repo = db_manager.settings_repo + + repo.update_setting("chunk_size", 2048) + assert int(repo.get_setting("chunk_size", 1)) == 2048 + + repo.update_setting("chunk_size", 512) + assert int(repo.get_setting("chunk_size", 1)) == 512 + + +def test_data_persists_across_manager_instances(tmp_path): + db_path = str(tmp_path / "persist.db") + + first = DatabaseManager(db_path) + first.message_repo.save_message(SESSION, "user", "text", "survives restart") + first.settings_repo.update_setting("chunk_size", 2048) + first.close() + + # A fresh manager re-runs _initialize_database on the existing file: + # chat history surviving an app restart is a user-facing promise. + second = DatabaseManager(db_path) + try: + messages = second.message_repo.load_messages(SESSION) + assert [m["content"] for m in messages] == ["survives restart"] + assert int(second.settings_repo.get_setting("chunk_size", 1)) == 2048 + finally: + second.close() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..030bd4b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,55 @@ +"""Contract tests for the pure helpers in src/utils.py.""" +import base64 + +import utils + + +def test_base64_conversions(): + payload = b"\x00\xff fake image bytes" + + encoded = utils.convert_bytes_to_base64(payload) + assert isinstance(encoded, str) + assert base64.b64decode(encoded) == payload + + # The prefix is the OpenAI vision data-URL wire format - pinning it + # verbatim pins behavior, not styling. + prefixed = utils.convert_bytes_to_base64_with_prefix(payload) + prefix = "data:image/jpeg;base64," + assert prefixed.startswith(prefix) + assert base64.b64decode(prefixed[len(prefix):]) == payload + + +def test_command_dispatch(monkeypatch): + calls = [] + # Accepted churn (see TESTING.md): pull_model_in_background is an internal + # seam - patching it is the price of not running asyncio + a live Ollama + # pull here. Renaming that function means updating this one line. + monkeypatch.setattr( + utils, + "pull_model_in_background", + lambda model_name: calls.append(model_name) or "pull result", + ) + + # /pull dispatches exactly once with the model name and forwards the result. + assert utils.command("/pull llama3") == "pull result" + assert calls == ["llama3"] + + # /help and unknown commands answer with a string and never dispatch a + # pull. Deliberately no assertions on wording - message text is not a + # contract. + assert isinstance(utils.command("/help"), str) + assert isinstance(utils.command("/nonsense"), str) + assert calls == ["llama3"] + + +def test_config_save_load_roundtrip(monkeypatch, tmp_path): + # save_config hardcodes the relative path "config.yaml", so run in an + # empty tmp dir - both functions resolve the path at call time, the real + # config.yaml is never touched. + monkeypatch.chdir(tmp_path) + config = {"ollama": {"base_url": "http://example:11434"}, "values": [1, 2, 3]} + + utils.save_config(config) + + assert utils.load_config() == config + assert utils.load_config(str(tmp_path / "config.yaml")) == config From f53248942488a730efb53b5c8d549843252ce4dc Mon Sep 17 00:00:00 2001 From: Leon-Sander <72946124+Leon-Sander@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:10:33 +0200 Subject: [PATCH 3/4] ollama port definition removed from compose without ollama --- docker-compose_without_ollama.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose_without_ollama.yml b/docker-compose_without_ollama.yml index 804e458..d24b9f7 100644 --- a/docker-compose_without_ollama.yml +++ b/docker-compose_without_ollama.yml @@ -7,7 +7,6 @@ services: - ./:/app ports: - "8501:8501" - - "11434:11434" environment: - PYTHONUNBUFFERED=1 command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll" \ No newline at end of file From bf3a71a06c2ed38f75be3feb5f11404c50cea762 Mon Sep 17 00:00:00 2001 From: Leon-Sander <72946124+Leon-Sander@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:32:07 +0200 Subject: [PATCH 4/4] readme updated with command to run tests in docker and changelog update --- README.md | 7 +++++++ TESTING.md | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb7f2eb..a467dce 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ pip install -r requirements-dev.txt pytest ``` +Or run it inside the app container (no local Python setup, matches the CI environment): + +```bash +docker compose -f docker-compose_without_ollama.yml run --rm app sh -c "pip install --no-cache-dir -q pytest && pytest" +``` + CI runs the suite on Ubuntu and Windows. See [TESTING.md](./TESTING.md) for the philosophy and what is deliberately not tested. ## Roadmap @@ -137,6 +143,7 @@ CI runs the suite on Ubuntu and Windows. See [TESTING.md](./TESTING.md) for the
Changelog (highlights) +- **2026-07** (v2.6.0): Contract-level test suite (21 tests) plus GitHub Actions CI on Ubuntu and Windows; moved all source into `src/` for a cleaner root. Breaking: run commands are now `streamlit run src/app.py`. - **2025-05** (v2.5.0): file upload via the chat bar. - **2024-09**: Model serving moved to the Ollama API; OpenAI API added. - **2024-08**: Docker Compose added. diff --git a/TESTING.md b/TESTING.md index 063bee6..89edcb2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,6 +1,6 @@ # Testing -This repo has a small, deliberate test suite: **20 contract tests** over the parts of the code whose behavior is stable by design. It is meant to stay cheap to maintain — including for forks. +This repo has a small, deliberate test suite: **21 contract tests** over the parts of the code whose behavior is stable by design. It is meant to stay cheap to maintain — including for forks. ## Philosophy: contracts, not structure @@ -16,10 +16,12 @@ A few places accept **medium churn** deliberately, each documented inline where | File | Contracts | |------|-----------| -| `tests/test_database_operations.py` (9) | message roundtrips (text + binary), LLM-context feed ordering/filtering, per-session delete isolation, session id listing, settings read-through defaults + persistence, restart durability | +| `tests/test_database_operations.py` (10) | message roundtrips (text + binary), LLM-context feed ordering/filtering, per-session delete isolation, session id listing, settings read-through defaults + persistence, restart durability | | `tests/test_utils.py` (3) | base64/data-URL wire format, `/command` dispatch, config save/load roundtrip | | `tests/test_chat_api_handler.py` (8) | endpoint routing (ollama/openai/unknown), request bodies + auth headers, image wire formats (Ollama bare-b64 vs OpenAI data URL), RAG context stuffing, error-shape handling (string vs nested object) | +Counts are executed test cases, not test functions: `test_database_operations.py` has 9 test functions but one is parametrized over `["image", "audio"]`, so it runs 10 cases — 21 in total. + ## What deliberately stays untested (and why) - **UI structure and CSS** (`app.py`, `html_templates.py`) — Streamlit class names churn every release; tests would break without any behavior change.