Replaces the three scattered test modules (chat_backend/tests.py, services/tests.py, services/prompt_classifier/tests.py) with a chat_backend/tests/ package: 242 deterministic tests plus 6 opt-in live-Ollama checks, up from 10 tests (3 of which were skipped and 4 of which were never even discovered).
The suite runs fully offline — no Ollama, Chroma, SMTP or network access. LangChain runnables are replaced by a small FakeChain, Chroma/embeddings are mocked, email uses Django's locmem backend, and blobs go through DatabaseStorage.
New llm_be/test_runner.py (wired via TEST_RUNNER) sets SKIP_RAG_INIT=1 and an MD5 password hasher, so the suite cannot accidentally reach a model server and finishes in ~5s on SQLite (~17s on Postgres) instead of ~30s.
Live checks (non-deterministic, need a model server):
cd llm_be
RUN_LIVE_OLLAMA_TESTS=1 uv run python manage.py test chat_backend.tests.test_live_ollama
Bugs the tests surfaced (fixed here)
ConversationDetailView.post silently dropped every prompt.import datetime shadowed from datetime import datetime, so datetime.now() raised AttributeError inside a bare except and the endpoint returned 200 without saving. Now uses timezone.now().
Prompt attachments never reached the LLM.get_conversation_file_async (both consumers) did sync_to_async(prompt.file.read) — with DatabaseStorage the attribute access itself opens the blob, i.e. a DB query in async context, raising SynchronousOnlyOperation that was swallowed and returned (None, None). The read now happens inside the thread.
DatabaseStorage._save crashed on a str-backed ContentFile (TypeError: sequence item 0: expected a bytes-like object); chunks are encoded when needed.
services/prompt_classifier/__init__.,py (note the comma) meant the directory was only an implicit namespace package, which is why its test module was never collected. Renamed, and its duplicate live-Ollama tests folded into test_live_ollama.py.
Known-broken paths deliberately left untested and unchanged: reset_password / ResetUserPassword reference an unimported requests plus undefined locals, and DocumentDetailView.get references an undefined workspaces on its success path. Worth a follow-up ticket.
Test plan
cd llm_be && uv run python manage.py test → 248 tests, OK (6 skipped, all opt-in live)
Same suite against Postgres 16 (DATABASE_URL=postgres://…) → OK, matching the containerized run in deploy.yml
uv run black clean on all added files
Gitea Actions Unit Tests + CI green on this PR
Closes #5
## Summary
- Replaces the three scattered test modules (`chat_backend/tests.py`, `services/tests.py`, `services/prompt_classifier/tests.py`) with a `chat_backend/tests/` package: **242 deterministic tests plus 6 opt-in live-Ollama checks**, up from 10 tests (3 of which were skipped and 4 of which were never even discovered).
- The suite runs fully offline — no Ollama, Chroma, SMTP or network access. LangChain runnables are replaced by a small `FakeChain`, Chroma/embeddings are mocked, email uses Django's locmem backend, and blobs go through `DatabaseStorage`.
- New `llm_be/test_runner.py` (wired via `TEST_RUNNER`) sets `SKIP_RAG_INIT=1` and an MD5 password hasher, so the suite cannot accidentally reach a model server and finishes in ~5s on SQLite (~17s on Postgres) instead of ~30s.
## Coverage
| Area | File |
|------|------|
| `TimeInfoBase.save`, slugs, `get_duration`, `file_exists`, cascades | `test_models.py` |
| `DatabaseStorage` save/open/exists/size/listdir/delete/times | `test_storage.py` |
| JWT claim, prompt/user/feedback/document serializers | `test_serializers.py` |
| auth + token, invite, feedback, company users, set-password, TOS | `test_views_users.py` |
| conversation list/order/create/detail/soft-delete | `test_views_conversations.py` |
| all four analytics endpoints, including empty-month behaviour | `test_views_analytics.py` |
| workspace + document upload/list/detail, 404 and 400 paths | `test_views_documents.py` |
| prompt classifier rules/parsing, moderation fail-safe, title cleanup | `test_services_classifiers.py` |
| CSV/XLSX/DOCX/PDF analysis, plot generation, error payloads | `test_services_data_analysis.py` |
| loader selection, filename sanitising, ingest, temp-file cleanup, search filters | `test_services_rag.py` |
| history formatting and streaming | `test_services_llm.py` |
| document re-index on create/delete, `SKIP_RAG_INIT` guard | `test_signals.py` |
| consumer DB helpers, LangGraph nodes (moderation, classification, generation, search flags), websocket routes | `test_consumers.py` |
Live checks (non-deterministic, need a model server):
```bash
cd llm_be
RUN_LIVE_OLLAMA_TESTS=1 uv run python manage.py test chat_backend.tests.test_live_ollama
```
## Bugs the tests surfaced (fixed here)
1. **`ConversationDetailView.post` silently dropped every prompt.** `import datetime` shadowed `from datetime import datetime`, so `datetime.now()` raised `AttributeError` inside a bare `except` and the endpoint returned 200 without saving. Now uses `timezone.now()`.
2. **Prompt attachments never reached the LLM.** `get_conversation_file_async` (both consumers) did `sync_to_async(prompt.file.read)` — with `DatabaseStorage` the attribute access itself opens the blob, i.e. a DB query in async context, raising `SynchronousOnlyOperation` that was swallowed and returned `(None, None)`. The read now happens inside the thread.
3. **`DatabaseStorage._save` crashed on a str-backed `ContentFile`** (`TypeError: sequence item 0: expected a bytes-like object`); chunks are encoded when needed.
4. **`services/prompt_classifier/__init__.,py`** (note the comma) meant the directory was only an implicit namespace package, which is why its test module was never collected. Renamed, and its duplicate live-Ollama tests folded into `test_live_ollama.py`.
Known-broken paths deliberately left untested and unchanged: `reset_password` / `ResetUserPassword` reference an unimported `requests` plus undefined locals, and `DocumentDetailView.get` references an undefined `workspaces` on its success path. Worth a follow-up ticket.
## Test plan
- [x] `cd llm_be && uv run python manage.py test` → 248 tests, OK (6 skipped, all opt-in live)
- [x] Same suite against Postgres 16 (`DATABASE_URL=postgres://…`) → OK, matching the containerized run in `deploy.yml`
- [x] `uv run black` clean on all added files
- [ ] Gitea Actions **Unit Tests** + **CI** green on this PR
Replaces the three scattered test modules with a `chat_backend/tests/` package
covering models, DatabaseStorage, serializers, every REST endpoint, the LLM
services, signals and both websocket consumers — 242 deterministic tests that
run without Ollama, Chroma, SMTP or network access, plus 6 opt-in live-Ollama
checks behind RUN_LIVE_OLLAMA_TESTS.
A custom test runner sets SKIP_RAG_INIT and a cheap password hasher so the suite
finishes in seconds and can no longer reach a model server by accident.
Bugs the new tests surfaced, fixed here:
- views.ConversationDetailView.post silently dropped every prompt: `import
datetime` shadowed `from datetime import datetime`, so `datetime.now()` raised
inside a bare except.
- get_conversation_file_async in both consumers always returned None with
DatabaseStorage, because `prompt.file.read` opens the blob (a DB query) during
attribute access in async context.
- DatabaseStorage._save crashed on str-backed ContentFile.
- prompt_classifier/__init__.,py was never importable as a package init.
Co-authored-by: Cursor <cursoragent@cursor.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #5
Summary
chat_backend/tests.py,services/tests.py,services/prompt_classifier/tests.py) with achat_backend/tests/package: 242 deterministic tests plus 6 opt-in live-Ollama checks, up from 10 tests (3 of which were skipped and 4 of which were never even discovered).FakeChain, Chroma/embeddings are mocked, email uses Django's locmem backend, and blobs go throughDatabaseStorage.llm_be/test_runner.py(wired viaTEST_RUNNER) setsSKIP_RAG_INIT=1and an MD5 password hasher, so the suite cannot accidentally reach a model server and finishes in ~5s on SQLite (~17s on Postgres) instead of ~30s.Coverage
TimeInfoBase.save, slugs,get_duration,file_exists, cascadestest_models.pyDatabaseStoragesave/open/exists/size/listdir/delete/timestest_storage.pytest_serializers.pytest_views_users.pytest_views_conversations.pytest_views_analytics.pytest_views_documents.pytest_services_classifiers.pytest_services_data_analysis.pytest_services_rag.pytest_services_llm.pySKIP_RAG_INITguardtest_signals.pytest_consumers.pyLive checks (non-deterministic, need a model server):
Bugs the tests surfaced (fixed here)
ConversationDetailView.postsilently dropped every prompt.import datetimeshadowedfrom datetime import datetime, sodatetime.now()raisedAttributeErrorinside a bareexceptand the endpoint returned 200 without saving. Now usestimezone.now().get_conversation_file_async(both consumers) didsync_to_async(prompt.file.read)— withDatabaseStoragethe attribute access itself opens the blob, i.e. a DB query in async context, raisingSynchronousOnlyOperationthat was swallowed and returned(None, None). The read now happens inside the thread.DatabaseStorage._savecrashed on a str-backedContentFile(TypeError: sequence item 0: expected a bytes-like object); chunks are encoded when needed.services/prompt_classifier/__init__.,py(note the comma) meant the directory was only an implicit namespace package, which is why its test module was never collected. Renamed, and its duplicate live-Ollama tests folded intotest_live_ollama.py.Known-broken paths deliberately left untested and unchanged:
reset_password/ResetUserPasswordreference an unimportedrequestsplus undefined locals, andDocumentDetailView.getreferences an undefinedworkspaceson its success path. Worth a follow-up ticket.Test plan
cd llm_be && uv run python manage.py test→ 248 tests, OK (6 skipped, all opt-in live)DATABASE_URL=postgres://…) → OK, matching the containerized run indeploy.ymluv run blackclean on all added files