From 572c0f1621606eb5ec24c752c13c1e4e4ccb09cf Mon Sep 17 00:00:00 2001 From: ITheEqualizer Date: Tue, 2 Jun 2026 10:24:22 +0330 Subject: [PATCH] Add unit test suite and GitHub Actions CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comprehensive pytest suite (262 tests) covering pure logic, the SQLite data layer, the service layer, and the Telegram handler pipeline: - Tooling: pyproject.toml (pytest + coverage config), requirements-dev.txt, and a root conftest.py with shared fixtures (temp DB, fake Config, clean env, duck-typed Message/CallbackQuery, stub bot, fernet key). - Pure logic: utils (escaping, splitting, sensitive detection, masking, time), config validators, keyboards callback-data limits, embeddings (cosine/keyword/matrix), KB chunking/context. - Data layer: settings coercion round-trips and full DAL incl. approval double-tap atomicity, FK cascade, owner-pending TTL — against a real temp DB. - Services: OpenAI (Fernet protect/unprotect, _map_error, max_tokens retry), Codex (prompt build, flag fallback, subprocess mocked), prompt assembly, rate-limit windows, KB retrieval, memory ordering/trim, business pause expiry, file sanitization. No network or subprocess is ever touched. - Handlers: process_business_message branch-by-branch, approval card/edit flow, telegram_api send adapter + fallback, notify_owner. CI: .github/workflows/tests.yml runs the suite on a Python 3.10/3.11/3.12 matrix for every push and PR to main, with coverage measured and uploaded as an artifact (report-only) and an aggregate "tests" check for branch protection. README documents local runs and the merge-protection setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 81 +++++++ .gitignore | 1 + README.md | 59 +++++ conftest.py | 166 +++++++++++++++ pyproject.toml | 32 +++ requirements-dev.txt | 9 + tests/unit/handlers/test_approvals.py | 86 ++++++++ tests/unit/handlers/test_business_updates.py | 171 +++++++++++++++ tests/unit/handlers/test_handlers_init.py | 26 +++ tests/unit/services/test_business_service.py | 84 ++++++++ tests/unit/services/test_codex_service.py | 122 +++++++++++ tests/unit/services/test_embedding_service.py | 100 +++++++++ tests/unit/services/test_file_service.py | 104 +++++++++ tests/unit/services/test_kb_service.py | 131 ++++++++++++ tests/unit/services/test_memory_service.py | 43 ++++ tests/unit/services/test_openai_service.py | 201 ++++++++++++++++++ tests/unit/services/test_prompt_service.py | 51 +++++ .../unit/services/test_rate_limit_service.py | 54 +++++ tests/unit/test_config.py | 129 +++++++++++ tests/unit/test_database_dal.py | 188 ++++++++++++++++ tests/unit/test_database_settings.py | 79 +++++++ tests/unit/test_keyboards.py | 82 +++++++ tests/unit/test_security.py | 77 +++++++ tests/unit/test_telegram_api.py | 110 ++++++++++ tests/unit/test_utils.py | 174 +++++++++++++++ 25 files changed, 2360 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 conftest.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/unit/handlers/test_approvals.py create mode 100644 tests/unit/handlers/test_business_updates.py create mode 100644 tests/unit/handlers/test_handlers_init.py create mode 100644 tests/unit/services/test_business_service.py create mode 100644 tests/unit/services/test_codex_service.py create mode 100644 tests/unit/services/test_embedding_service.py create mode 100644 tests/unit/services/test_file_service.py create mode 100644 tests/unit/services/test_kb_service.py create mode 100644 tests/unit/services/test_memory_service.py create mode 100644 tests/unit/services/test_openai_service.py create mode 100644 tests/unit/services/test_prompt_service.py create mode 100644 tests/unit/services/test_rate_limit_service.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_database_dal.py create mode 100644 tests/unit/test_database_settings.py create mode 100644 tests/unit/test_keyboards.py create mode 100644 tests/unit/test_security.py create mode 100644 tests/unit/test_telegram_api.py create mode 100644 tests/unit/test_utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bfa7dc1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,81 @@ +name: tests + +# Run the unit-test suite on every push to main and on every pull request that +# targets main, so a red suite blocks merges (see README "Testing & CI"). +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.10 is the project's minimum supported runtime (the most likely place + # a `X | Y`-at-runtime regression surfaces); 3.11/3.12 are mainstream. + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests with coverage + run: | + pytest \ + --cov=. \ + --cov-report=term-missing \ + --cov-report=xml \ + --cov-report=html \ + --junitxml=junit.xml + + - name: Upload coverage + test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-py${{ matrix.python-version }} + path: | + coverage.xml + htmlcov/ + junit.xml + retention-days: 14 + + # Aggregate gate: a single check named "tests" that branch protection can + # require, instead of requiring each matrix leg individually. + tests: + name: tests + runs-on: ubuntu-latest + needs: test + if: always() + steps: + - name: Verify the test matrix succeeded + run: | + if [ "${{ needs.test.result }}" != "success" ]; then + echo "One or more test matrix jobs failed." + exit 1 + fi + echo "All test matrix jobs passed." diff --git a/.gitignore b/.gitignore index 307998f..a6585f1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +junit.xml *.cover *.py.cover .hypothesis/ diff --git a/README.md b/README.md index d11a008..8ea8826 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,65 @@ Bootstrap secrets/identity live in `.env`; behavioral settings live in the --- +## Automated tests & CI + +SmarTel ships a `pytest` unit-test suite (pure logic, the SQLite data layer, +the service layer, and the Telegram handler pipeline) plus a GitHub Actions +workflow that runs it on every push and pull request. + +### Run the tests locally + +```bash +# One-time: install the test dependencies (also installs the runtime deps). +pip install -r requirements-dev.txt + +# Run the whole suite (fast). +pytest + +# Run a single module while iterating. +pytest tests/unit/test_utils.py -q + +# Run with coverage, exactly like CI. +pytest --cov=. --cov-report=term-missing --cov-report=html +# then open htmlcov/index.html +``` + +Tests use a temporary SQLite database and mock all network/subprocess calls, so +they never touch Telegram, OpenAI, Codex, or your real `.env`. + +### Continuous integration + +[`.github/workflows/tests.yml`](.github/workflows/tests.yml) runs the suite on a +Python 3.10 / 3.11 / 3.12 matrix for every push to `main` and every pull +request that targets `main`. Coverage is **measured and uploaded as an +artifact** but is not a hard gate — a green suite is the gate. + +### Using it to protect merges + +After the workflow has run at least once (so the check is registered): + +1. Open **Settings → Branches → Add branch ruleset / protection rule** for `main`. +2. Enable **Require status checks to pass before merging** and + **Require branches to be up to date before merging**. +3. Select the status check named **`tests`** (the aggregate job). + +Now the PR merge button stays blocked until `tests` is green and the branch is +up to date with `main`. After a merge, the `push` trigger re-runs `tests` on +`main` as a post-merge guard. + +The same protection can be applied from the CLI (run it after the first CI run): + +```bash +gh api -X PUT repos/ITheEqualizer/SmarTel/branches/main/protection \ + -f required_status_checks.strict=true \ + -f 'required_status_checks.contexts[]=tests' \ + -f enforce_admins=true \ + -f required_pull_request_reviews.required_approving_review_count=0 \ + -f restrictions= +``` + +--- + ## Testing (end-to-end) 1. `python main.py` and send `/start` to the bot as the owner. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a11ef68 --- /dev/null +++ b/conftest.py @@ -0,0 +1,166 @@ +"""Shared pytest fixtures for the SmarTel test suite. + +Lives at the repo root so its fixtures are visible to every test module and so +the repo root is importable (the app uses flat imports like ``import database``). + +Design notes: +* ``database`` keeps module-level state (``_DB_PATH``, ``_SEED_DEFAULTS`` and a + ``threading.local`` connection cache). The ``db`` fixture initialises a fresh + temp DB per test and tears that state down so tests never leak into each other. +* ``clean_env`` (autouse) strips the developer's real ``.env``/shell secrets so + service routing (OpenAI vs Codex vs custom endpoint) is deterministic. +* Telegram ``Message``/``CallbackQuery`` objects are duck-typed with + ``SimpleNamespace`` — the code only ever uses attribute access, so we don't + need to build real telebot payloads. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +ROOT = Path(__file__).resolve().parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import config # noqa: E402 (import after sys.path setup) +import database # noqa: E402 + + +# Env vars the services read at runtime. Cleared before each test so a real +# local .env / shell environment can never change a test's outcome. +_MANAGED_ENV_VARS = [ + "OPENAI_API_KEY", "OPENAI_BASE_URL", "MODEL_BACKEND", "SMARTEL_FERNET_KEY", + "CODEX_MODEL", "CODEX_BIN", "CODEX_EXEC_ARGS", "CODEX_EXEC_TIMEOUT", + "CODEX_HOME", "DB_PATH", "UPLOADS_DIR", "VECTOR_STORE_DIR", "WORKER_THREADS", + "DEBUG", "BOT_TOKEN", "OWNER_USER_ID", "DEFAULT_MODEL", "DEFAULT_AUTO_REPLY", + "DEFAULT_APPROVAL_MODE", +] + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + """Strip managed env vars so runtime routing is deterministic per test.""" + for name in _MANAGED_ENV_VARS: + monkeypatch.delenv(name, raising=False) + yield + + +@pytest.fixture +def fake_config(tmp_path): + """A real frozen Config built directly (never from .env), pointed at tmp_path.""" + return config.Config( + bot_token="123456789:" + "A" * 35, + owner_user_id=12345, + openai_api_key="", + openai_base_url="", + model_backend="openai", + codex_model="", + fernet_key="", + default_model="gpt-4.1-mini", + default_auto_reply=False, + default_approval_mode=True, + db_path=tmp_path / "smartel.db", + uploads_dir=tmp_path / "uploads", + vector_store_dir=tmp_path / "vector_store", + worker_threads=4, + debug=False, + ) + + +@pytest.fixture +def db(fake_config): + """A fully initialised temp SQLite database; resets module state on teardown.""" + database.init_db(fake_config) + try: + yield database + finally: + conn = getattr(database._local, "conn", None) + if conn is not None: + conn.close() + del database._local.conn + database._DB_PATH = None + database._SEED_DEFAULTS = {} + + +@pytest.fixture +def fernet_env(monkeypatch): + """Set a fresh Fernet key in the environment; returns the key string.""" + from cryptography.fernet import Fernet + key = Fernet.generate_key().decode() + monkeypatch.setenv("SMARTEL_FERNET_KEY", key) + return key + + +@pytest.fixture +def make_message(): + """Factory for a duck-typed Telegram Message (override any field via kwargs).""" + def _make(**kw): + from_user = SimpleNamespace( + id=kw.pop("user_id", 777), + first_name=kw.pop("first_name", "Cara"), + last_name=kw.pop("last_name", None), + username=kw.pop("username", None), + ) + fields = dict( + chat=SimpleNamespace(id=kw.pop("chat_id", 5001)), + from_user=from_user, + message_id=kw.pop("message_id", 9001), + text=kw.pop("text", "hello"), + caption=kw.pop("caption", None), + content_type=kw.pop("content_type", "text"), + business_connection_id=kw.pop("business_connection_id", None), + sender_business_bot=kw.pop("sender_business_bot", None), + ) + fields.update(kw) + return SimpleNamespace(**fields) + return _make + + +@pytest.fixture +def make_call(): + """Factory for a duck-typed Telegram CallbackQuery.""" + def _make(**kw): + return SimpleNamespace( + id=kw.pop("id", "cb1"), + data=kw.pop("data", "apr:send:1"), + from_user=SimpleNamespace(id=kw.pop("user_id", 12345)), + message=SimpleNamespace( + chat=SimpleNamespace(id=kw.pop("chat_id", 12345)), + message_id=kw.pop("message_id", 4242), + ), + ) + return _make + + +@pytest.fixture +def stub_bot(fake_config): + """A MagicMock TeleBot wired into handlers + security; resets globals after.""" + import handlers + import security + bot = MagicMock(name="TeleBot") + handlers.init(bot, fake_config) + security.init(bot, fake_config.owner_user_id) + try: + yield bot + finally: + handlers.bot = None + handlers.cfg = None + security._owner_id = None + security._bot = None + security._last_notice.clear() + + +@pytest.fixture +def file_service_init(fake_config): + """Initialise file_service against the temp uploads dir; resets on teardown.""" + from services import file_service + file_service.init(fake_config.uploads_dir) + try: + yield file_service + finally: + file_service._uploads_dir = None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b3c592 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +# Tooling configuration for SmarTel. +# This file holds only test + coverage configuration — the app itself is run as +# scripts (python main.py) and is not packaged. + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +# The project uses flat, top-level imports (import database, from services import +# kb_service). Put the repo root on sys.path so tests resolve them exactly as +# production does. +pythonpath = ["."] +addopts = "-ra -q --strict-markers --strict-config" +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.coverage.run] +branch = true +omit = [ + "tests/*", + "main.py", # polling entrypoint; covered by manual/e2e, not unit tests + ".venv/*", +] + +[tool.coverage.report] +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..cbc992b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +# Development / test dependencies. Installs the production deps too, so a single +# pip install -r requirements-dev.txt +# gives a runnable, testable environment. +-r requirements.txt + +pytest>=8.2,<9 +pytest-cov>=5.0,<6 +pytest-mock>=3.14,<4 +freezegun>=1.5,<2 diff --git a/tests/unit/handlers/test_approvals.py b/tests/unit/handlers/test_approvals.py new file mode 100644 index 0000000..dd6122d --- /dev/null +++ b/tests/unit/handlers/test_approvals.py @@ -0,0 +1,86 @@ +"""Tests for handlers/approvals.py: card rendering, request creation/reuse, and +the restart-safe edit flow.""" + +from __future__ import annotations + +from handlers import approvals + + +# --- render_approval_card ---------------------------------------------------- +def test_render_card_sensitive(db, make_message): + conn = {"business_connection_id": "bc1"} + card = approvals.render_approval_card(conn, make_message(chat_id=5), + "Hi ", "A reply", True, 7) + assert card.startswith("🚨") + assert "Approval #7" in card + assert "<there>" in card # customer text HTML-escaped + assert "chat 5" in card + + +def test_render_card_not_sensitive(db, make_message): + conn = {"business_connection_id": "bc1"} + card = approvals.render_approval_card(conn, make_message(chat_id=5), + "Hi", "R", False, 3) + assert not card.startswith("🚨") + assert "Approval #3" in card + + +# --- create_request ---------------------------------------------------------- +def test_create_request_inserts_and_notifies(db, stub_bot, make_message, mocker): + send = mocker.patch("telegram_api.send_reply") + conn = {"business_connection_id": "bc1"} + aid = approvals.create_request(conn, make_message(chat_id=5, message_id=22), + "ctext", "atext", sensitive=False) + assert db.get_approval(aid) is not None + assert db.count_pending_approvals() == 1 + send.assert_called() # owner was notified + + +def test_create_request_reuses_open_approval(db, stub_bot, make_message, mocker): + mocker.patch("telegram_api.send_reply") + conn = {"business_connection_id": "bc1"} + msg = make_message(chat_id=5, message_id=22) + a1 = approvals.create_request(conn, msg, "c", "a", sensitive=False) + a2 = approvals.create_request(conn, msg, "c", "a", sensitive=False) + assert a1 == a2 + assert db.count_pending_approvals() == 1 + + +# --- edit flow --------------------------------------------------------------- +def test_apply_edit_sends_edited_reply(db, stub_bot, make_message, mocker): + send = mocker.patch("telegram_api.send_reply") + db.upsert_business_connection("bc1", 999, True, True) + aid = db.insert_pending_approval("bc1", 5, 22, "orig customer", "orig reply") + db.set_owner_pending_input(12345, "edit_approval", {"approval_id": aid}, 15) + + approvals._apply_edit(make_message(text="the edited reply"), {"approval_id": aid}) + + appr = db.get_approval(aid) + assert appr["status"] == "sent_edited" + assert appr["ai_reply_text"] == "the edited reply" + send.assert_called() + + +def test_apply_edit_empty_keeps_pending(db, stub_bot, make_message, mocker): + mocker.patch("telegram_api.send_reply") + db.upsert_business_connection("bc1", 999, True, True) + aid = db.insert_pending_approval("bc1", 5, 22, "c", "r") + approvals._apply_edit(make_message(text=" ", caption=None), {"approval_id": aid}) + assert db.get_approval(aid)["status"] == "pending" + + +def test_apply_edit_not_pending(db, stub_bot, make_message, mocker): + mocker.patch("telegram_api.send_reply") + aid = db.insert_pending_approval("bc1", 5, 22, "c", "r") + db.mark_approval(aid, "sent") + approvals._apply_edit(make_message(text="new text"), {"approval_id": aid}) + assert db.get_approval(aid)["status"] == "sent" # unchanged + + +# --- pending list ------------------------------------------------------------ +def test_render_pending_list(db): + assert "No pending approvals" in approvals.render_pending_list() + db.insert_pending_approval("bc1", 5, 22, "a customer message", "r") + rendered = approvals.render_pending_list() + assert "Pending approvals (1)" in rendered + assert "a customer message" in rendered diff --git a/tests/unit/handlers/test_business_updates.py b/tests/unit/handlers/test_business_updates.py new file mode 100644 index 0000000..4e33adb --- /dev/null +++ b/tests/unit/handlers/test_business_updates.py @@ -0,0 +1,171 @@ +"""Tests for handlers/business_updates.py: the customer-message decision +pipeline, branch by branch. Network/model calls are mocked; the DB is real.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from handlers import business_updates as bu +from services.openai_service import OpenAIServiceError +from services.rate_limit_service import RateLimitDecision + +EPOCH = "1970-01-01T00:00:00+00:00" + + +@pytest.fixture +def penv(db, stub_bot, mocker): + """Pipeline environment: real DB + a registered connection, with the + outbound Telegram calls patched. Owner (business_user_id) is 999.""" + send = mocker.patch("telegram_api.send_reply") + chat_action = mocker.patch("telegram_api.chat_action") + db.upsert_business_connection("bc1", 999, True, True) + return SimpleNamespace(db=db, send=send, chat_action=chat_action, bot=stub_bot) + + +def _out_count(db, bcid="bc1", chat=5): + return db.count_messages_in_window(bcid, chat, EPOCH, direction="out") + + +# --- early guards ------------------------------------------------------------ +def test_no_bcid_ignored(penv, make_message): + bu.process_business_message(make_message(business_connection_id=None)) + penv.send.assert_not_called() + + +def test_unknown_connection_ignored(penv, make_message, mocker): + mocker.patch("services.business_service.fetch_and_store_via_raw", return_value=None) + bu.process_business_message(make_message(business_connection_id="unknown")) + penv.send.assert_not_called() + + +def test_echo_guard(penv, make_message): + msg = make_message(business_connection_id="bc1", sender_business_bot=object()) + bu.process_business_message(msg) + penv.send.assert_not_called() + + +def test_owner_takeover_pauses(penv, make_message): + msg = make_message(business_connection_id="bc1", user_id=999, chat_id=5) + bu.process_business_message(msg) + assert penv.db.get_pause("bc1", 5) is not None + penv.send.assert_not_called() + + +# --- non-text / monitoring --------------------------------------------------- +def test_non_text_monitoring_notice(penv, make_message): + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, + text=None, content_type="photo") + bu.process_business_message(msg) + penv.send.assert_called() + + +def test_auto_off_notifies_incoming(penv, make_message): + # auto_reply default OFF, monitoring default ON + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="hi there") + bu.process_business_message(msg) + penv.send.assert_called() + assert penv.db.count_pending_approvals() == 0 + + +# --- paused / rate-limited --------------------------------------------------- +def test_paused_chat_skips(penv, make_message): + penv.db.set_setting("auto_reply_enabled", True) + penv.db.upsert_pause("bc1", 5, "manual", None) # indefinite + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="hi") + bu.process_business_message(msg) + penv.send.assert_not_called() + + +def test_rate_limit_exceeded_notifies(penv, make_message, mocker): + penv.db.set_setting("auto_reply_enabled", True) + mocker.patch("services.rate_limit_service.enforce", + return_value=RateLimitDecision(False, 21, 20, 0)) + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="hi") + bu.process_business_message(msg) + penv.send.assert_called() + assert penv.db.count_pending_approvals() == 0 + + +# --- generation paths -------------------------------------------------------- +def test_strict_kb_short_circuit_no_model_call(penv, make_message, mocker): + penv.db.set_setting("auto_reply_enabled", True) + penv.db.set_setting("approval_mode_enabled", False) + penv.db.set_setting("strict_kb_mode", True) # no chunks -> short circuit + chat_spy = mocker.patch("services.openai_service.chat") + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="anything") + bu.process_business_message(msg) + chat_spy.assert_not_called() + assert _out_count(penv.db) == 1 # short-circuit reply delivered + + +def test_openai_error_notifies_owner(penv, make_message, mocker): + penv.db.set_setting("auto_reply_enabled", True) + mocker.patch("services.openai_service.chat", + side_effect=OpenAIServiceError("bad key", kind="auth")) + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="hello") + bu.process_business_message(msg) + penv.send.assert_called() + assert penv.db.count_pending_approvals() == 0 + assert _out_count(penv.db) == 0 + + +# --- dispatch: approval vs auto --------------------------------------------- +def test_approval_mode_creates_request(penv, make_message, mocker): + penv.db.set_setting("auto_reply_enabled", True) + penv.db.set_setting("approval_mode_enabled", True) + mocker.patch("services.openai_service.chat", return_value="AI reply") + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="hello") + bu.process_business_message(msg) + assert penv.db.count_pending_approvals() == 1 + assert _out_count(penv.db) == 0 # not delivered yet, awaiting approval + + +def test_auto_delivery(penv, make_message, mocker): + penv.db.set_setting("auto_reply_enabled", True) + penv.db.set_setting("approval_mode_enabled", False) + mocker.patch("services.openai_service.chat", return_value="AI reply") + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, text="hello") + bu.process_business_message(msg) + assert penv.db.count_pending_approvals() == 0 + assert _out_count(penv.db) == 1 + + +def test_sensitive_forces_approval(penv, make_message, mocker): + penv.db.set_setting("auto_reply_enabled", True) + penv.db.set_setting("approval_mode_enabled", False) # approval OFF... + penv.db.set_setting("sensitive_requires_approval", True) + mocker.patch("services.openai_service.chat", return_value="AI reply") + # ...but a sensitive message forces the approval path + msg = make_message(business_connection_id="bc1", user_id=10, chat_id=5, + text="I want a refund") + bu.process_business_message(msg) + assert penv.db.count_pending_approvals() == 1 + + +# --- _deliver_auto permission guard ----------------------------------------- +def test_deliver_auto_without_permission(db, stub_bot, make_message, mocker): + send = mocker.patch("telegram_api.send_reply") + db.upsert_business_connection("bc1", 999, True, False) # can_reply False + conn = db.get_business_connection("bc1") + bu._deliver_auto(conn, make_message(chat_id=5), "ctext", "reply") + send.assert_called() # owner warned + assert _out_count(db) == 0 # nothing recorded as sent to customer + + +def test_deliver_auto_records_outbound(db, stub_bot, make_message, mocker): + mocker.patch("telegram_api.send_reply") + db.upsert_business_connection("bc1", 999, True, True) + conn = db.get_business_connection("bc1") + bu._deliver_auto(conn, make_message(chat_id=5, message_id=22), "ctext", "reply text") + assert _out_count(db) == 1 + + +# --- deleted messages -------------------------------------------------------- +def test_deleted_invalidates_approval(penv): + aid = penv.db.insert_pending_approval("bc1", 5, 22, "c", "r") + deleted = SimpleNamespace(business_connection_id="bc1", + chat=SimpleNamespace(id=5), message_ids=[22]) + bu.on_deleted_business_messages(deleted) + assert penv.db.get_approval(aid)["status"] == "invalidated" diff --git a/tests/unit/handlers/test_handlers_init.py b/tests/unit/handlers/test_handlers_init.py new file mode 100644 index 0000000..f19ffaf --- /dev/null +++ b/tests/unit/handlers/test_handlers_init.py @@ -0,0 +1,26 @@ +"""Tests for handlers/__init__.py: notify_owner best-effort delivery.""" + +from __future__ import annotations + +import handlers + + +def test_notify_owner_sends_to_owner(stub_bot, mocker): + send = mocker.patch("telegram_api.send_reply") + handlers.notify_owner("hello owner") + send.assert_called_once() + # owner id (12345 from stub_bot) is the recipient + assert send.call_args.args[1] == 12345 + + +def test_notify_owner_no_bot_is_noop(mocker): + send = mocker.patch("telegram_api.send_reply") + handlers.bot = None # not initialised + handlers.notify_owner("ignored") + send.assert_not_called() + + +def test_notify_owner_swallows_send_errors(stub_bot, mocker): + mocker.patch("telegram_api.send_reply", side_effect=RuntimeError("network")) + # Must not propagate — notify_owner is best-effort. + handlers.notify_owner("hello") diff --git a/tests/unit/services/test_business_service.py b/tests/unit/services/test_business_service.py new file mode 100644 index 0000000..c3ac90b --- /dev/null +++ b/tests/unit/services/test_business_service.py @@ -0,0 +1,84 @@ +"""Tests for business_service.py: can_reply resolution, display names, and pause +lifecycle (incl. lazy expiry cleanup).""" + +from __future__ import annotations + +from types import SimpleNamespace + +from freezegun import freeze_time + +from services import business_service as bs + + +# --- resolve_can_reply ------------------------------------------------------- +def test_resolve_can_reply_rights_true(): + bc = SimpleNamespace(rights=SimpleNamespace(can_reply=True)) + assert bs.resolve_can_reply(bc) is True + + +def test_resolve_can_reply_rights_false(): + bc = SimpleNamespace(rights=SimpleNamespace(can_reply=False)) + assert bs.resolve_can_reply(bc) is False + + +def test_resolve_can_reply_legacy_fallback(): + bc = SimpleNamespace(rights=None, can_reply=True) + assert bs.resolve_can_reply(bc) is True + + +def test_resolve_can_reply_default_true(): + assert bs.resolve_can_reply(SimpleNamespace()) is True + + +# --- connections ------------------------------------------------------------- +def test_can_reply_requires_enabled_and_permission(db): + db.upsert_business_connection("bc1", 999, True, True) + assert bs.can_reply("bc1") is True + db.upsert_business_connection("bc1", 999, False, True) + assert bs.can_reply("bc1") is False + assert bs.can_reply("missing") is False + + +# --- display names ----------------------------------------------------------- +def test_display_name_full(db): + db.upsert_customer("bc1", 5, "Ann", "Lee", "annlee") + assert bs.customer_display_name("bc1", 5) == "Ann Lee (@annlee)" + + +def test_display_name_first_only(db): + db.upsert_customer("bc1", 6, "Bob", None, None) + assert bs.customer_display_name("bc1", 6) == "Bob" + + +def test_display_name_username_only(db): + db.upsert_customer("bc1", 7, None, None, "ghost") + assert bs.customer_display_name("bc1", 7) == "(@ghost)" + + +def test_display_name_no_row(db): + assert bs.customer_display_name("bc1", 999) == "chat 999" + + +# --- pause lifecycle --------------------------------------------------------- +def test_pause_indefinite(db): + bs.pause_chat("bc1", 5, "manual", None) + assert bs.is_chat_paused("bc1", 5) is True + + +def test_pause_timed_expiry_lazy_cleanup(db): + with freeze_time("2024-01-01 12:00:00"): + bs.pause_chat("bc1", 5, "rate_limit", 30) + assert bs.is_chat_paused("bc1", 5) is True + with freeze_time("2024-01-01 13:00:00"): + assert bs.is_chat_paused("bc1", 5) is False + assert db.get_pause("bc1", 5) is None # lazily deleted on read + + +def test_no_pause_row(db): + assert bs.is_chat_paused("bc1", 5) is False + + +def test_resume(db): + bs.pause_chat("bc1", 5, "manual", None) + assert bs.resume_chat("bc1", 5) is True + assert bs.resume_chat("bc1", 5) is False diff --git a/tests/unit/services/test_codex_service.py b/tests/unit/services/test_codex_service.py new file mode 100644 index 0000000..ed80240 --- /dev/null +++ b/tests/unit/services/test_codex_service.py @@ -0,0 +1,122 @@ +"""Tests for codex_service.py: prompt building, flag-set selection, error +classification, timeout parsing, and the chat flow (subprocess fully mocked).""" + +from __future__ import annotations + +import pytest + +from services import codex_service as cx +from services.openai_service import OpenAIServiceError + + +# --- binary / availability --------------------------------------------------- +def test_binary_default_and_override(monkeypatch): + assert cx.binary() == "codex" + monkeypatch.setenv("CODEX_BIN", "/opt/codex") + assert cx.binary() == "/opt/codex" + + +def test_is_available(monkeypatch): + monkeypatch.setattr(cx.shutil, "which", lambda b: "/usr/bin/codex") + assert cx.is_available() is True + monkeypatch.setattr(cx.shutil, "which", lambda b: None) + monkeypatch.setenv("CODEX_BIN", "definitely-not-a-real-binary-xyz") + assert cx.is_available() is False + + +# --- _build_prompt ----------------------------------------------------------- +def test_build_prompt_structure(): + messages = [ + {"role": "system", "content": "Be nice"}, + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello"}, + {"role": "user", "content": "Bye"}, + ] + p = cx._build_prompt(messages) + assert cx.CODEX_INSTRUCTION in p + assert "Business assistant instructions:\nBe nice" in p + assert "--- Conversation ---" in p and "--- End conversation ---" in p + assert p.index("Customer: Hi") < p.index("Assistant: Hello") < p.index("Customer: Bye") + + +def test_build_prompt_without_system(): + p = cx._build_prompt([{"role": "user", "content": "Hi"}]) + assert "Business assistant instructions" not in p + assert "Customer: Hi" in p + + +# --- _flag_sets -------------------------------------------------------------- +def test_flag_sets_default_then_minimal(monkeypatch): + sets = cx._flag_sets() + assert sets == [cx._DEFAULT_EXEC_FLAGS, cx._MINIMAL_EXEC_FLAGS] + + +def test_flag_sets_override(monkeypatch): + monkeypatch.setenv("CODEX_EXEC_ARGS", "--foo bar") + assert cx._flag_sets() == [["--foo", "bar"]] + + +# --- error classification ---------------------------------------------------- +def test_is_arg_error(): + assert cx._is_arg_error("error: unexpected argument '--sandbox'") is True + assert cx._is_arg_error("error: bad thing\nusage: codex exec") is True + assert cx._is_arg_error("something else entirely") is False + + +def test_looks_like_auth_error(): + assert cx._looks_like_auth_error("401 Unauthorized") is True + assert cx._looks_like_auth_error("please run codex login") is True + assert cx._looks_like_auth_error("disk full") is False + + +def test_timeout(monkeypatch): + assert cx._timeout() == cx.DEFAULT_TIMEOUT + monkeypatch.setenv("CODEX_EXEC_TIMEOUT", "30") + assert cx._timeout() == 30 + monkeypatch.setenv("CODEX_EXEC_TIMEOUT", "not-a-number") + assert cx._timeout() == cx.DEFAULT_TIMEOUT + + +# --- chat -------------------------------------------------------------------- +def test_chat_unavailable_raises(monkeypatch): + monkeypatch.setattr(cx, "is_available", lambda: False) + with pytest.raises(OpenAIServiceError) as exc: + cx.chat([{"role": "user", "content": "hi"}]) + assert exc.value.kind == "unknown" + + +def test_chat_happy_path(monkeypatch): + monkeypatch.setattr(cx, "is_available", lambda: True) + monkeypatch.setattr(cx, "_run_exec", lambda *a, **k: (0, "the reply", "")) + assert cx.chat([{"role": "user", "content": "hi"}]) == "the reply" + + +def test_chat_retries_on_arg_error(monkeypatch): + monkeypatch.setattr(cx, "is_available", lambda: True) + seq = [(1, "", "error: unexpected argument '--sandbox'"), (0, "fallback reply", "")] + calls = {"n": 0} + + def fake_run(*a, **k): + result = seq[calls["n"]] + calls["n"] += 1 + return result + + monkeypatch.setattr(cx, "_run_exec", fake_run) + assert cx.chat([{"role": "user", "content": "hi"}]) == "fallback reply" + assert calls["n"] == 2 + + +def test_chat_auth_error(monkeypatch): + monkeypatch.setattr(cx, "is_available", lambda: True) + monkeypatch.setattr(cx, "_run_exec", lambda *a, **k: (1, "", "please run codex login")) + with pytest.raises(OpenAIServiceError) as exc: + cx.chat([{"role": "user", "content": "hi"}]) + assert exc.value.kind == "auth" + + +def test_chat_empty_reply(monkeypatch): + monkeypatch.setattr(cx, "is_available", lambda: True) + monkeypatch.setattr(cx, "_run_exec", lambda *a, **k: (0, "", "")) + with pytest.raises(OpenAIServiceError) as exc: + cx.chat([{"role": "user", "content": "hi"}]) + assert "empty" in exc.value.owner_message.lower() diff --git a/tests/unit/services/test_embedding_service.py b/tests/unit/services/test_embedding_service.py new file mode 100644 index 0000000..275a63a --- /dev/null +++ b/tests/unit/services/test_embedding_service.py @@ -0,0 +1,100 @@ +"""Tests for embedding_service.py: cosine similarity, tokenization, keyword +search, embedding-matrix loading, and vector search (with embed mocked).""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest + +from services import embedding_service as es + + +# --- cosine_similarity ------------------------------------------------------- +def test_cosine_identical(): + v = np.array([1.0, 2.0, 3.0]) + assert es.cosine_similarity(v, v) == pytest.approx(1.0) + + +def test_cosine_orthogonal(): + assert es.cosine_similarity(np.array([1.0, 0.0]), np.array([0.0, 1.0])) == pytest.approx(0.0, abs=1e-9) + + +def test_cosine_opposite(): + assert es.cosine_similarity(np.array([1.0, 0.0]), np.array([-1.0, 0.0])) == pytest.approx(-1.0) + + +def test_cosine_zero_vector_is_safe(): + # No division by zero thanks to the +1e-12 epsilon. + result = es.cosine_similarity(np.array([0.0, 0.0]), np.array([1.0, 0.0])) + assert np.isfinite(result) + assert result == pytest.approx(0.0, abs=1e-6) + + +# --- _tokenize --------------------------------------------------------------- +def test_tokenize_drops_stopwords_and_short_tokens(): + assert es._tokenize("How do I get a Refund?") == {"get", "refund"} + + +def test_tokenize_empty(): + assert es._tokenize("") == set() + assert es._tokenize(None) == set() + + +# --- keyword_search ---------------------------------------------------------- +def test_keyword_search_ranks_overlap(): + chunks = [ + {"content": "Our refund policy allows returns within 30 days"}, + {"content": "We ship worldwide on weekdays"}, + ] + hits = es.keyword_search("refund policy", chunks) + assert hits[0][0] is chunks[0] + assert hits[0][1] > 0 + + +def test_keyword_search_empty_query_returns_empty(): + assert es.keyword_search("the a an", [{"content": "anything"}]) == [] + + +def test_keyword_search_respects_top_k(): + chunks = [{"content": f"refund number {i}"} for i in range(10)] + assert len(es.keyword_search("refund", chunks, top_k=3)) == 3 + + +# --- _load_matrix ------------------------------------------------------------ +def test_load_matrix_filters_bad_rows(monkeypatch): + monkeypatch.setattr(es, "EMBED_DIM", 3) + chunks = [ + {"content": "good", "embedding_json": json.dumps([1.0, 0.0, 0.0])}, + {"content": "missing", "embedding_json": None}, + {"content": "malformed", "embedding_json": "{not json"}, + {"content": "wrong dim", "embedding_json": json.dumps([1.0, 2.0])}, + ] + matrix, kept = es._load_matrix(chunks) + assert matrix.shape == (1, 3) + assert [c["content"] for c in kept] == ["good"] + + +def test_load_matrix_none_when_no_usable(): + matrix, kept = es._load_matrix([{"content": "x", "embedding_json": None}]) + assert matrix is None and kept == [] + + +# --- search_chunks ----------------------------------------------------------- +def test_search_chunks_no_embeddings_returns_empty(monkeypatch): + monkeypatch.setattr(es, "EMBED_DIM", 3) + # No usable embeddings -> [] so the caller can fall back to keyword search. + assert es.search_chunks("q", [{"content": "x", "embedding_json": None}]) == [] + + +def test_search_chunks_sorted_by_score(monkeypatch): + monkeypatch.setattr(es, "EMBED_DIM", 3) + monkeypatch.setattr(es, "embed_texts", lambda texts: [[1.0, 0.0, 0.0]]) + chunks = [ + {"content": "match", "embedding_json": json.dumps([1.0, 0.0, 0.0])}, + {"content": "nomatch", "embedding_json": json.dumps([0.0, 1.0, 0.0])}, + ] + hits = es.search_chunks("q", chunks, top_k=2) + assert hits[0][0]["content"] == "match" + assert hits[0][1] > hits[1][1] diff --git a/tests/unit/services/test_file_service.py b/tests/unit/services/test_file_service.py new file mode 100644 index 0000000..5e029b2 --- /dev/null +++ b/tests/unit/services/test_file_service.py @@ -0,0 +1,104 @@ +"""Tests for file_service.py: filename sanitization, extension allowlist, safe +storage, and text extraction (with binary extractors mocked).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from services import file_service as fs +from services.file_service import FileServiceError + + +# --- sanitize_filename ------------------------------------------------------- +def test_sanitize_strips_path_traversal(): + assert fs.sanitize_filename("../../etc/passwd") == "passwd" + assert fs.sanitize_filename("a/b/c.txt") == "c.txt" + + +def test_sanitize_replaces_unsafe_chars(): + out = fs.sanitize_filename("My Document (draft).docx") + assert out.endswith(".docx") + assert " " not in out and "(" not in out and ")" not in out + + +def test_sanitize_empty_defaults_to_upload(): + assert fs.sanitize_filename("") == "upload" + assert fs.sanitize_filename(None) == "upload" + + +def test_sanitize_lowercases_extension(): + assert fs.sanitize_filename("notes.TXT").endswith(".txt") + + +# --- is_allowed -------------------------------------------------------------- +@pytest.mark.parametrize("name,allowed", [ + ("a.txt", True), ("a.MD", True), ("a.pdf", True), ("a.docx", True), + ("a.exe", False), ("a.zip", False), ("noext", False), +]) +def test_is_allowed(name, allowed): + assert fs.is_allowed(name) is allowed + + +# --- save_upload ------------------------------------------------------------- +def test_save_upload_rejects_bad_type(file_service_init): + with pytest.raises(FileServiceError): + fs.save_upload(b"data", "bad.exe") + + +def test_save_upload_rejects_empty(file_service_init): + with pytest.raises(FileServiceError): + fs.save_upload(b"", "ok.txt") + + +def test_save_upload_rejects_oversize(file_service_init, monkeypatch): + monkeypatch.setattr(fs, "MAX_FILE_BYTES", 10) + with pytest.raises(FileServiceError): + fs.save_upload(b"x" * 11, "ok.txt") + + +def test_save_upload_writes_file(file_service_init): + path = fs.save_upload(b"hello", "notes.txt") + assert path.exists() + assert path.read_bytes() == b"hello" + assert path.name.endswith("_notes.txt") + + +def test_save_upload_dedupes_by_content(file_service_init): + p1 = fs.save_upload(b"same", "a.txt") + p2 = fs.save_upload(b"same", "a.txt") + assert p1 == p2 # content hash prefix is identical + + +# --- extract_text ------------------------------------------------------------ +def test_extract_text_txt(file_service_init, tmp_path): + p = tmp_path / "a.txt" + p.write_text("hello world", encoding="utf-8") + assert fs.extract_text(p) == "hello world" + + +def test_extract_text_unknown_suffix(file_service_init): + with pytest.raises(FileServiceError): + fs.extract_text(Path("a.xyz")) + + +def test_extract_text_empty_raises(file_service_init, tmp_path): + p = tmp_path / "empty.txt" + p.write_text(" ", encoding="utf-8") + with pytest.raises(FileServiceError): + fs.extract_text(p) + + +def test_extract_text_pdf_uses_extractor(file_service_init, monkeypatch): + monkeypatch.setattr(fs, "_extract_pdf", lambda path: "pdf body text") + assert fs.extract_text(Path("doc.pdf")) == "pdf body text" + + +def test_extract_text_extractor_failure_wrapped(file_service_init, monkeypatch): + def boom(path): + raise ValueError("corrupt") + + monkeypatch.setattr(fs, "_extract_docx", boom) + with pytest.raises(FileServiceError): + fs.extract_text(Path("doc.docx")) diff --git a/tests/unit/services/test_kb_service.py b/tests/unit/services/test_kb_service.py new file mode 100644 index 0000000..f280ccb --- /dev/null +++ b/tests/unit/services/test_kb_service.py @@ -0,0 +1,131 @@ +"""Tests for kb_service.py: chunking, context-block formatting, retrieval +(vector floor + keyword fallback), search_for_prompt heuristics, and add_text.""" + +from __future__ import annotations + +from services import kb_service as kb +from services.openai_service import OpenAIServiceError + + +# --- _chunk_text ------------------------------------------------------------- +def test_chunk_empty(): + assert kb._chunk_text("") == [] + assert kb._chunk_text(" ") == [] + + +def test_chunk_short_single(): + assert kb._chunk_text("a short note") == ["a short note"] + + +def test_chunk_collapses_whitespace(): + assert kb._chunk_text("a b\tc") == ["a b c"] + + +def test_chunk_packs_with_overlap(): + para1 = "A" * 500 + para2 = "B" * 500 + chunks = kb._chunk_text(f"{para1}\n\n{para2}") + assert len(chunks) == 2 + assert chunks[0] == para1 + assert chunks[1].startswith("A" * kb.CHUNK_OVERLAP) # overlap tail carried over + assert para2 in chunks[1] + assert all(len(c) <= kb.CHUNK_SIZE for c in chunks) + + +def test_split_long_hard_splits_oversized_sentence(): + pieces = kb._split_long("X" * 1000) + assert all(len(p) <= kb.CHUNK_SIZE for p in pieces) + assert "".join(pieces) == "X" * 1000 + + +# --- build_context_block ----------------------------------------------------- +def test_context_block_empty(): + assert kb.build_context_block([]) == "" + + +def test_context_block_numbers_and_footer(): + chunks = [{"content": "first"}, {"content": "second"}] + block = kb.build_context_block(chunks) + assert block.startswith("KNOWLEDGE BASE CONTEXT:") + assert "[1] first" in block and "[2] second" in block + assert block.rstrip().endswith("customer's question.") + + +def test_context_block_respects_budget(): + chunks = [{"content": "C" * 1000} for _ in range(10)] + block = kb.build_context_block(chunks) + assert block.startswith("KNOWLEDGE BASE CONTEXT:") + assert len(block) < kb.CONTEXT_CHAR_BUDGET + 300 + + +# --- search ------------------------------------------------------------------ +def test_search_kb_disabled(db): + db.set_setting("kb_enabled", False) + assert kb.search("q") == [] + + +def test_search_no_chunks(db): + assert kb.search("q") == [] + + +def test_search_vector_below_floor_falls_back_to_keyword(db, monkeypatch): + item = db.insert_kb_item("t", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "our refund policy", None)]) + chunks = db.get_all_chunks() + monkeypatch.setattr("services.embedding_service.search_chunks", + lambda q, c, k: [(chunks[0], 0.05)]) # below MIN_VECTOR_SCORE + res = kb.search("refund") + assert len(res) == 1 and res[0]["content"] == "our refund policy" + + +def test_search_vector_hit(db, monkeypatch): + item = db.insert_kb_item("t", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "our refund policy", None)]) + chunks = db.get_all_chunks() + monkeypatch.setattr("services.embedding_service.search_chunks", + lambda q, c, k: [(chunks[0], 0.9)]) + res = kb.search("unrelated wording") + assert len(res) == 1 and res[0]["content"] == "our refund policy" + + +def test_search_embedding_error_falls_back(db, monkeypatch): + item = db.insert_kb_item("t", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "our refund policy", None)]) + + def boom(q, c, k): + raise OpenAIServiceError("no key", kind="auth") + + monkeypatch.setattr("services.embedding_service.search_chunks", boom) + assert len(kb.search("refund")) == 1 + + +# --- search_for_prompt ------------------------------------------------------- +def test_search_for_prompt_small_kb_includes_all(db): + item = db.insert_kb_item("t", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "fact one", None), (item, 1, "fact two", None)]) + chunks, short = kb.search_for_prompt("totally unrelated query") + assert short is None and len(chunks) == 2 + + +def test_search_for_prompt_strict_no_chunks(db): + db.set_setting("strict_kb_mode", True) + chunks, short = kb.search_for_prompt("q") + assert chunks == [] and short == kb.STRICT_NO_INFO + + +def test_search_for_prompt_kb_disabled(db): + db.set_setting("kb_enabled", False) + assert kb.search_for_prompt("q") == ([], None) + + +# --- add_text ---------------------------------------------------------------- +def test_add_text_stores_chunks(db, monkeypatch): + monkeypatch.setattr("services.embedding_service.embed_texts", lambda chunks: []) + result = kb.add_text("My note", "Hello world. This is a stored fact.") + assert result["chunk_count"] >= 1 + assert db.count_kb_chunks() == result["chunk_count"] + + +def test_add_text_empty(db): + result = kb.add_text("Empty note", " ") + assert result["chunk_count"] == 0 diff --git a/tests/unit/services/test_memory_service.py b/tests/unit/services/test_memory_service.py new file mode 100644 index 0000000..0159b94 --- /dev/null +++ b/tests/unit/services/test_memory_service.py @@ -0,0 +1,43 @@ +"""Tests for memory_service.py: chronological retrieval, trimming, and the +memory-enabled gate.""" + +from __future__ import annotations + +from services import memory_service as mem + + +def test_get_recent_is_chronological(db): + mem.append("bc1", 5, "user", "u1") + mem.append("bc1", 5, "assistant", "a1") + mem.append("bc1", 5, "user", "u2") + recent = mem.get_recent("bc1", 5) + assert [m["content"] for m in recent] == ["u1", "a1", "u2"] + assert recent[0]["role"] == "user" + + +def test_append_trims_to_max(db): + for i in range(mem.MAX_MEMORY_TURNS + 3): + mem.append("bc1", 5, "user", f"m{i}") + rows = db.get_recent_memory("bc1", 5, 100) + assert len(rows) == mem.MAX_MEMORY_TURNS + recent = mem.get_recent("bc1", 5, 100) + assert recent[-1]["content"] == f"m{mem.MAX_MEMORY_TURNS + 2}" # newest kept + + +def test_append_exchange(db): + mem.append_exchange("bc1", 5, "question", "answer") + recent = mem.get_recent("bc1", 5) + assert [m["content"] for m in recent] == ["question", "answer"] + + +def test_memory_disabled_is_noop(db): + db.set_setting("memory_enabled", False) + mem.append("bc1", 5, "user", "ignored") + db.set_setting("memory_enabled", True) + assert mem.get_recent("bc1", 5) == [] # nothing was stored while disabled + + +def test_clear(db): + mem.append("bc1", 5, "user", "x") + mem.clear("bc1", 5) + assert mem.get_recent("bc1", 5) == [] diff --git a/tests/unit/services/test_openai_service.py b/tests/unit/services/test_openai_service.py new file mode 100644 index 0000000..818565b --- /dev/null +++ b/tests/unit/services/test_openai_service.py @@ -0,0 +1,201 @@ +"""Tests for openai_service.py: key protection, env routing, error mapping, +chat (incl. max_tokens retry), embeddings, and key validation. The OpenAI SDK is +never constructed against the network — get_client / OpenAI are patched.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from services import openai_service as svc +from services.openai_service import OpenAIServiceError + + +def _resp(content): + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=content))]) + + +# --- fingerprint / protect / unprotect -------------------------------------- +def test_fingerprint_is_deterministic(): + fp = svc._fingerprint("sk-abc") + assert fp == svc._fingerprint("sk-abc") + assert fp != svc._fingerprint("sk-xyz") + assert len(fp) == 12 + + +def test_protect_unprotect_fernet_roundtrip(fernet_env): + stored = svc._protect("sk-secret-value") + assert stored.startswith("fernet:") + assert svc._unprotect(stored) == "sk-secret-value" + + +def test_protect_plain_fallback_without_key(): + stored = svc._protect("sk-secret") + assert stored.startswith("plain:") + assert svc._unprotect(stored) == "sk-secret" + + +def test_unprotect_handles_missing_and_legacy(): + assert svc._unprotect(None) is None + assert svc._unprotect("legacy-unprefixed") == "legacy-unprefixed" + + +def test_unprotect_fernet_without_key_returns_none(monkeypatch): + monkeypatch.delenv("SMARTEL_FERNET_KEY", raising=False) + assert svc._unprotect("fernet:Z0FBQUFB") is None + + +def test_unprotect_wrong_key_returns_none(monkeypatch): + from cryptography.fernet import Fernet + monkeypatch.setenv("SMARTEL_FERNET_KEY", Fernet.generate_key().decode()) + stored = svc._protect("sk-secret") + monkeypatch.setenv("SMARTEL_FERNET_KEY", Fernet.generate_key().decode()) # different key + assert svc._unprotect(stored) is None + + +# --- env routing ------------------------------------------------------------- +def test_resolve_api_key_prefers_env(monkeypatch, db): + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + assert svc._resolve_api_key() == "sk-from-env" + + +def test_resolve_api_key_from_db(db): + db.upsert_api_key("openai", "sk-...end", "plain:sk-stored") + assert svc._resolve_api_key() == "sk-stored" + + +def test_base_url_and_custom_endpoint(monkeypatch): + assert svc.using_custom_endpoint() is False + monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1") + assert svc._resolve_base_url() == "http://localhost:1234/v1" + assert svc.using_custom_endpoint() is True + + +def test_is_ready(monkeypatch, db): + assert svc.is_ready() is False # no key, no endpoint + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + assert svc.is_ready() is True + + +def test_is_ready_codex_delegates(monkeypatch): + monkeypatch.setenv("MODEL_BACKEND", "codex") + monkeypatch.setattr("services.codex_service.is_available", lambda: True) + assert svc.is_ready() is True + + +def test_backend_label(monkeypatch): + assert svc.backend_label() == "OpenAI API" + monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1") + assert "custom endpoint" in svc.backend_label() + monkeypatch.setenv("MODEL_BACKEND", "codex") + assert svc.backend_label() == "ChatGPT via Codex" + + +# --- error mapping ----------------------------------------------------------- +@pytest.mark.parametrize("attr,kind", [ + ("AuthenticationError", "auth"), + ("RateLimitError", "rate_limit"), + ("BadRequestError", "bad_request"), + ("APITimeoutError", "timeout"), + ("APIConnectionError", "connection"), +]) +def test_map_error_each_type(monkeypatch, attr, kind): + class DummyError(Exception): + pass + + monkeypatch.setattr(svc, attr, DummyError) + err = svc._map_error(DummyError("boom")) + assert err.kind == kind + assert isinstance(err.cause, DummyError) + assert err.owner_message # non-empty sanitized message + + +def test_map_error_unknown(): + err = svc._map_error(ValueError("weird")) + assert err.kind == "unknown" + + +# --- chat -------------------------------------------------------------------- +def test_chat_codex_delegates(monkeypatch): + monkeypatch.setenv("MODEL_BACKEND", "codex") + monkeypatch.setattr("services.codex_service.chat", lambda messages, model=None: "codex reply") + assert svc.chat([{"role": "user", "content": "hi"}]) == "codex reply" + + +def test_chat_happy_path(db, monkeypatch): + client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kw: _resp(" hello "))) + ) + monkeypatch.setattr(svc, "get_client", lambda: client) + assert svc.chat([{"role": "user", "content": "hi"}]) == "hello" + + +def test_chat_retries_with_max_completion_tokens(db, monkeypatch): + class BadReq(Exception): + pass + + monkeypatch.setattr(svc, "BadRequestError", BadReq) + calls = [] + + def fake_create(**kwargs): + calls.append(kwargs) + if "max_tokens" in kwargs: + raise BadReq("Unsupported parameter: 'max_tokens'") + return _resp("ok") + + client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=fake_create)) + ) + monkeypatch.setattr(svc, "get_client", lambda: client) + + assert svc.chat([{"role": "user", "content": "hi"}]) == "ok" + assert len(calls) == 2 + assert "max_completion_tokens" in calls[1] and "max_tokens" not in calls[1] + + +def test_chat_maps_errors(db, monkeypatch): + def boom(**kwargs): + raise ValueError("network down") + + client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=boom)) + ) + monkeypatch.setattr(svc, "get_client", lambda: client) + with pytest.raises(OpenAIServiceError) as exc: + svc.chat([{"role": "user", "content": "hi"}]) + assert exc.value.kind == "unknown" + + +# --- embeddings -------------------------------------------------------------- +def test_embed_empty_returns_empty(): + assert svc.embed([]) == [] + + +def test_embed_returns_vectors(monkeypatch): + data = SimpleNamespace(data=[SimpleNamespace(embedding=[0.1, 0.2]), + SimpleNamespace(embedding=[0.3, 0.4])]) + client = SimpleNamespace( + embeddings=SimpleNamespace(create=lambda **kw: data) + ) + monkeypatch.setattr(svc, "get_client", lambda: client) + assert svc.embed(["a", "b"]) == [[0.1, 0.2], [0.3, 0.4]] + + +# --- validate_key ------------------------------------------------------------ +def test_validate_key_no_sdk(monkeypatch): + monkeypatch.setattr(svc, "OpenAI", None) + ok, msg = svc.validate_key("sk-x") + assert ok is False and "not installed" in msg + + +def test_validate_key_rejects_non_sk(monkeypatch): + ok, msg = svc.validate_key("bad-key") + assert ok is False and "sk-" in msg + + +def test_validate_key_success(monkeypatch): + fake_client = SimpleNamespace(models=SimpleNamespace(list=lambda: [])) + monkeypatch.setattr(svc, "OpenAI", lambda **kw: fake_client) + ok, msg = svc.validate_key("sk-valid") + assert ok is True diff --git a/tests/unit/services/test_prompt_service.py b/tests/unit/services/test_prompt_service.py new file mode 100644 index 0000000..9e871ab --- /dev/null +++ b/tests/unit/services/test_prompt_service.py @@ -0,0 +1,51 @@ +"""Tests for prompt_service.py: sensitive wrappers and build_messages assembly, +including the strict-KB short-circuit.""" + +from __future__ import annotations + +from services import kb_service, prompt_service as ps + + +def test_sensitive_wrappers(): + assert ps.is_sensitive("please refund me") is True + assert ps.is_sensitive("hello there") is False + assert "refund" in ps.sensitive_categories("please refund me") + + +def test_build_messages_basic(db): + messages, short = ps.build_messages("bc1", 5, "Hello") + assert short is None + assert messages[0]["role"] == "system" + assert ps.BASE_SYSTEM_PROMPT in messages[0]["content"] + assert messages[-1] == {"role": "user", "content": "Hello"} + + +def test_build_messages_custom_instructions(db): + db.set_setting("custom_instructions", "Reply in Persian.") + messages, _ = ps.build_messages("bc1", 5, "Hi") + assert "Reply in Persian." in messages[0]["content"] + + +def test_build_messages_includes_memory_chronologically(db): + db.insert_memory("bc1", 5, "user", "earlier question") + db.insert_memory("bc1", 5, "assistant", "earlier answer") + messages, _ = ps.build_messages("bc1", 5, "now") + assert [m["role"] for m in messages] == ["system", "user", "assistant", "user"] + assert messages[1]["content"] == "earlier question" + assert messages[-1]["content"] == "now" + + +def test_build_messages_strict_with_chunks(db): + item = db.insert_kb_item("policy", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "Refund window is 30 days", None)]) + db.set_setting("strict_kb_mode", True) + messages, short = ps.build_messages("bc1", 5, "refund?") + assert short is None + assert "STRICT KNOWLEDGE BASE MODE" in messages[0]["content"] + assert "KNOWLEDGE BASE CONTEXT" in messages[0]["content"] + + +def test_build_messages_strict_short_circuit(db): + db.set_setting("strict_kb_mode", True) + messages, short = ps.build_messages("bc1", 5, "anything") + assert messages == [] and short == kb_service.STRICT_NO_INFO diff --git a/tests/unit/services/test_rate_limit_service.py b/tests/unit/services/test_rate_limit_service.py new file mode 100644 index 0000000..c823c32 --- /dev/null +++ b/tests/unit/services/test_rate_limit_service.py @@ -0,0 +1,54 @@ +"""Tests for rate_limit_service.py: rolling-window check and enforce (pause).""" + +from __future__ import annotations + +from freezegun import freeze_time + +from services import rate_limit_service as rl + + +def test_check_at_and_over_limit(db): + db.set_setting("max_messages_per_customer_per_hour", 3) + with freeze_time("2024-01-01 12:00:00"): + for _ in range(3): + db.insert_message("bc1", 5, None, "in", "user", "hi") + decision = rl.check("bc1", 5) + assert decision.used == 3 and decision.allowed and decision.remaining == 0 + db.insert_message("bc1", 5, None, "in", "user", "hi") + over = rl.check("bc1", 5) + assert over.used == 4 and over.allowed is False + + +def test_check_excludes_old_messages(db): + db.set_setting("max_messages_per_customer_per_hour", 3) + with freeze_time("2024-01-01 10:00:00"): + db.insert_message("bc1", 5, None, "in", "user", "old") + with freeze_time("2024-01-01 12:00:00"): + db.insert_message("bc1", 5, None, "in", "user", "new") + assert rl.check("bc1", 5).used == 1 + + +def test_check_ignores_outbound(db): + db.set_setting("max_messages_per_customer_per_hour", 3) + with freeze_time("2024-01-01 12:00:00"): + db.insert_message("bc1", 5, None, "out", "assistant", "reply") + assert rl.check("bc1", 5).used == 0 + + +def test_enforce_pauses_when_exceeded(db): + db.set_setting("max_messages_per_customer_per_hour", 1) + with freeze_time("2024-01-01 12:00:00"): + db.insert_message("bc1", 5, None, "in", "user", "1") + db.insert_message("bc1", 5, None, "in", "user", "2") + decision = rl.enforce("bc1", 5) + assert decision.allowed is False + assert db.get_pause("bc1", 5) is not None + + +def test_enforce_no_pause_within_limit(db): + db.set_setting("max_messages_per_customer_per_hour", 5) + with freeze_time("2024-01-01 12:00:00"): + db.insert_message("bc1", 5, None, "in", "user", "1") + decision = rl.enforce("bc1", 5) + assert decision.allowed is True + assert db.get_pause("bc1", 5) is None diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..b0f95db --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,129 @@ +"""Tests for config.py: bool/int/path parsing, token & owner-id validators, and +Config.from_env / validate / masked_summary.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import config + +VALID_TOKEN = "123456789:" + "A" * 35 + + +# --- _parse_bool ------------------------------------------------------------- +@pytest.mark.parametrize("value,expected", [ + ("yes", True), ("on", True), ("1", True), ("y", True), ("true", True), + ("no", False), ("off", False), ("0", False), ("n", False), ("", False), +]) +def test_parse_bool_known_values(value, expected): + assert config._parse_bool(value) is expected + + +def test_parse_bool_none_and_junk_use_default(): + assert config._parse_bool(None, default=True) is True + assert config._parse_bool("banana", default=True) is True + assert config._parse_bool("banana", default=False) is False + + +def test_parse_bool_passes_through_actual_bool(): + assert config._parse_bool(True) is True + assert config._parse_bool(False) is False + + +# --- _to_int ----------------------------------------------------------------- +def test_to_int(): + assert config._to_int("42") == 42 + assert config._to_int(None, default=7) == 7 + assert config._to_int("nope", default=3) == 3 + + +# --- _resolve_path ----------------------------------------------------------- +def test_resolve_path_relative_under_base(): + p = config._resolve_path("storage/x.db", "fallback") + assert p.is_absolute() + assert p == config.BASE_DIR / "storage/x.db" + + +def test_resolve_path_absolute_unchanged(): + p = config._resolve_path("/tmp/abs.db", "fallback") + assert p == Path("/tmp/abs.db") + + +def test_resolve_path_uses_fallback_when_blank(): + p = config._resolve_path("", "storage/smartel.db") + assert p == config.BASE_DIR / "storage/smartel.db" + + +# --- validators -------------------------------------------------------------- +def test_validate_token(): + assert config._validate_token(VALID_TOKEN)[0] is True + assert config._validate_token("nope")[0] is False + assert config._validate_token("123:short")[0] is False # suffix < 30 chars + + +def test_validate_owner_id(): + assert config._validate_owner_id("123")[0] is True + assert config._validate_owner_id("0")[0] is False + assert config._validate_owner_id("-5")[0] is False + assert config._validate_owner_id("abc")[0] is False + + +# --- Config.from_env --------------------------------------------------------- +def test_from_env_reads_environment(monkeypatch): + monkeypatch.setenv("BOT_TOKEN", VALID_TOKEN) + monkeypatch.setenv("OWNER_USER_ID", "555") + monkeypatch.setenv("MODEL_BACKEND", "CODEX") # should be lowercased + monkeypatch.setenv("WORKER_THREADS", "0") # floored to >= 1 + cfg = config.Config.from_env() + assert cfg.bot_token == VALID_TOKEN + assert cfg.owner_user_id == 555 + assert cfg.model_backend == "codex" + assert cfg.worker_threads == 1 + + +def test_from_env_defaults(monkeypatch): + cfg = config.Config.from_env() + assert cfg.bot_token == "" + assert cfg.model_backend == "openai" + assert cfg.default_model == "gpt-4.1-mini" + + +# --- Config.validate --------------------------------------------------------- +def _cfg(**overrides): + base = dict( + bot_token=VALID_TOKEN, owner_user_id=5, openai_api_key="", openai_base_url="", + model_backend="openai", codex_model="", fernet_key="", default_model="m", + default_auto_reply=False, default_approval_mode=True, + db_path=Path("x"), uploads_dir=Path("u"), vector_store_dir=Path("v"), + worker_threads=4, debug=False, + ) + base.update(overrides) + return config.Config(**base) + + +def test_validate_ok_without_openai_key(): + _cfg(openai_api_key="").validate() # must not raise + + +def test_validate_missing_token_raises(): + with pytest.raises(config.ConfigError): + _cfg(bot_token="").validate() + + +def test_validate_bad_token_raises(): + with pytest.raises(config.ConfigError): + _cfg(bot_token="not-a-token").validate() + + +def test_validate_non_positive_owner_raises(): + with pytest.raises(config.ConfigError): + _cfg(owner_user_id=0).validate() + + +# --- masked_summary ---------------------------------------------------------- +def test_masked_summary_hides_token(): + summary = _cfg(bot_token=VALID_TOKEN).masked_summary() + assert VALID_TOKEN not in summary + assert "Bot token:" in summary diff --git a/tests/unit/test_database_dal.py b/tests/unit/test_database_dal.py new file mode 100644 index 0000000..3eeea48 --- /dev/null +++ b/tests/unit/test_database_dal.py @@ -0,0 +1,188 @@ +"""Tests for database.py data-access layer: connections, customers, messages, +memory, pauses, approvals (incl. atomicity), KB, api_keys, owner_pending_input, +and reset_runtime_state.""" + +from __future__ import annotations + +from freezegun import freeze_time + +import utils + +EPOCH = "1970-01-01T00:00:00+00:00" + + +# --- business_connections ---------------------------------------------------- +def test_business_connection_roundtrip(db): + db.upsert_business_connection("bc1", 999, True, True) + row = db.get_business_connection("bc1") + assert row["business_user_id"] == 999 + assert row["is_enabled"] == 1 and row["can_reply"] == 1 + db.upsert_business_connection("bc1", 999, True, False) # conflict update + assert db.get_business_connection("bc1")["can_reply"] == 0 + db.set_bc_enabled("bc1", False) + assert db.get_business_connection("bc1")["is_enabled"] == 0 + assert len(db.list_business_connections()) == 1 + + +# --- customers --------------------------------------------------------------- +def test_customer_roundtrip(db): + db.upsert_customer("bc1", 5, "Ann", "Lee", "annlee") + assert db.get_customer("bc1", 5)["username"] == "annlee" + assert db.find_bcid_for_chat(5) == "bc1" + + +def test_find_bcid_fallback_single_connection(db): + db.upsert_business_connection("only", 1, True, True) + assert db.find_bcid_for_chat(424242) == "only" + + +def test_find_bcid_none_with_multiple(db): + db.upsert_business_connection("a", 1, True, True) + db.upsert_business_connection("b", 2, True, True) + assert db.find_bcid_for_chat(424242) is None + + +# --- messages ---------------------------------------------------------------- +def test_messages_window_by_direction(db): + with freeze_time("2024-01-01 12:00:00"): + db.insert_message("bc1", 5, 1, "in", "user", "a") + db.insert_message("bc1", 5, 2, "in", "user", "b") + db.insert_message("bc1", 5, 3, "out", "assistant", "c") + since = utils.future_iso(-60) + assert db.count_messages_in_window("bc1", 5, since, "in") == 2 + assert db.count_messages_in_window("bc1", 5, since, "out") == 1 + assert db.oldest_message_in_window("bc1", 5, since, "in") is not None + + +# --- conversation_memory ----------------------------------------------------- +def test_memory_dal_order_and_trim(db): + for i in range(5): + db.insert_memory("bc1", 5, "user", f"m{i}") + newest_first = [r["content"] for r in db.get_recent_memory("bc1", 5, 3)] + assert newest_first == ["m4", "m3", "m2"] + db.trim_memory("bc1", 5, 2) + remaining = db.get_recent_memory("bc1", 5, 100) + assert [r["content"] for r in remaining] == ["m4", "m3"] + assert db.clear_all_memory() == 2 + + +# --- paused_chats ------------------------------------------------------------ +def test_pauses_dal(db): + db.upsert_pause("bc1", 5, "manual", "2099-01-01T00:00:00+00:00") + assert db.get_pause("bc1", 5)["reason"] == "manual" + db.upsert_pause("bc1", 5, "rate_limit", None) # conflict update -> indefinite + row = db.get_pause("bc1", 5) + assert row["reason"] == "rate_limit" and row["paused_until"] is None + assert len(db.list_active_pauses()) == 1 + assert db.delete_pause("bc1", 5) == 1 + assert db.delete_pause("bc1", 5) == 0 + + +# --- pending_approvals ------------------------------------------------------- +def test_mark_approval_is_atomic(db): + aid = db.insert_pending_approval("bc1", 5, 22, "c", "r") + assert db.mark_approval(aid, "sent") is True + assert db.mark_approval(aid, "sent") is False # double-tap loses + assert db.get_approval(aid)["status"] == "sent" + + +def test_find_open_approval_matches_message_id(db): + aid = db.insert_pending_approval("bc1", 5, 22, "c", "r") + assert db.find_open_approval("bc1", 5, 22)["id"] == aid + assert db.find_open_approval("bc1", 5, 999) is None + aid2 = db.insert_pending_approval("bc1", 6, None, "c", "r") + assert db.find_open_approval("bc1", 6, None)["id"] == aid2 + + +def test_invalidate_and_count(db): + a1 = db.insert_pending_approval("bc1", 5, 22, "c", "r") + assert db.count_pending_approvals() == 1 + assert db.invalidate_approvals_for_messages("bc1", 5, [22]) == 1 + assert db.get_approval(a1)["status"] == "invalidated" + assert db.invalidate_approvals_for_messages("bc1", 5, [22]) == 0 + + +def test_expire_stale_approvals(db): + with freeze_time("2024-01-01 12:00:00"): + aid = db.insert_pending_approval("bc1", 5, 22, "c", "r") + with freeze_time("2024-01-01 18:00:00"): + assert db.expire_stale_approvals(60) == 1 + assert db.get_approval(aid)["status"] == "expired" + + +def test_cancel_all_pending(db): + db.insert_pending_approval("bc1", 5, 22, "c", "r") + assert db.cancel_all_pending_approvals() == 1 + + +# --- kb_items / kb_chunks ---------------------------------------------------- +def test_kb_cascade_delete(db): + item = db.insert_kb_item("Title", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "hello", None), (item, 1, "world", None)]) + db.set_kb_item_chunk_count(item, 2) + assert db.get_kb_item(item)["chunk_count"] == 2 + assert db.count_kb_chunks() == 2 + assert db.delete_kb_item(item) == 1 + assert db.count_kb_chunks() == 0 # FK ON DELETE CASCADE + + +def test_clear_kb(db): + item = db.insert_kb_item("T", "text", None) + db.insert_kb_chunks_bulk([(item, 0, "x", None)]) + db.clear_kb() + assert db.count_kb_chunks() == 0 and db.list_kb_items() == [] + + +# --- api_keys ---------------------------------------------------------------- +def test_api_key_roundtrip(db): + db.upsert_api_key("openai", "sk-...123", "plain:secret") + assert db.get_api_key_row("openai")["masked_key"] == "sk-...123" + db.upsert_api_key("openai", "sk-...456", "plain:secret2") + assert db.get_api_key_row("openai")["encrypted_key_or_plain_local_key"] == "plain:secret2" + assert db.delete_api_key("openai") == 1 + + +# --- owner_pending_input ----------------------------------------------------- +def test_owner_pending_input_roundtrip(db): + db.set_owner_pending_input(12345, "kb_add", {"foo": "bar"}, 15) + state = db.get_owner_pending_input(12345) + assert state["kind"] == "kb_add" and state["context"] == {"foo": "bar"} + assert db.has_owner_pending_input(12345) is True + db.clear_owner_pending_input(12345) + assert db.get_owner_pending_input(12345) is None + + +def test_owner_pending_input_expiry_self_clears(db): + with freeze_time("2024-01-01 12:00:00"): + db.set_owner_pending_input(1, "x", {}, 5) + with freeze_time("2024-01-01 12:10:00"): + assert db.get_owner_pending_input(1) is None + + +def test_owner_pending_input_bad_json(db): + db.set_owner_pending_input(1, "x", {}, 15) + with db.write_tx() as cur: + cur.execute("UPDATE owner_pending_input SET context_json='{bad' WHERE owner_user_id=1") + assert db.get_owner_pending_input(1)["context"] == {} + + +# --- reset_runtime_state ----------------------------------------------------- +def test_reset_runtime_state(db): + db.set_setting("auto_reply_enabled", True) + db.upsert_pause("bc1", 5, "manual", None) + db.set_owner_pending_input(1, "x", {}, 15) + aid = db.insert_pending_approval("bc1", 5, 22, "c", "r") + db.upsert_business_connection("bc1", 999, True, True) + db.upsert_customer("bc1", 5, "A", None, None) + db.insert_kb_item("T", "text", None) + + db.reset_runtime_state() + + assert db.get_bool("auto_reply_enabled") is False # back to seeded default + assert db.list_active_pauses() == [] + assert db.get_owner_pending_input(1) is None + assert db.get_approval(aid)["status"] == "cancelled" + # Preserved data: + assert db.get_business_connection("bc1") is not None + assert db.get_customer("bc1", 5) is not None + assert len(db.list_kb_items()) == 1 diff --git a/tests/unit/test_database_settings.py b/tests/unit/test_database_settings.py new file mode 100644 index 0000000..f74b72f --- /dev/null +++ b/tests/unit/test_database_settings.py @@ -0,0 +1,79 @@ +"""Tests for database.py settings layer: coercion, serialization, typed +round-trips, fallbacks, and default seeding.""" + +from __future__ import annotations + +import pytest + +import database + + +# --- _coerce / _serialize (pure) -------------------------------------------- +def test_coerce_bool(): + assert database._coerce("true", bool) is True + assert database._coerce("off", bool) is False + assert database._coerce("", bool) is False + with pytest.raises(ValueError): + database._coerce("maybe", bool) + + +def test_coerce_numeric_and_str(): + assert database._coerce("42", int) == 42 + assert database._coerce("0.4", float) == 0.4 + assert database._coerce("hi", str) == "hi" + + +def test_serialize(): + assert database._serialize(True, bool) == "true" + assert database._serialize(False, bool) == "false" + assert database._serialize(42, int) == "42" + assert database._serialize("x", str) == "x" + + +# --- typed round-trips ------------------------------------------------------- +def test_setting_roundtrips(db): + db.set_setting("temperature", 0.7) + assert db.get_float("temperature") == 0.7 + db.set_setting("max_tokens", 500) + assert db.get_int("max_tokens") == 500 + db.set_setting("auto_reply_enabled", True) + assert db.get_bool("auto_reply_enabled") is True + db.set_setting("openai_model", "gpt-4o") + assert db.get_str("openai_model") == "gpt-4o" + + +def test_empty_string_setting_roundtrip(db): + db.set_setting("custom_instructions", "") + assert db.get_str("custom_instructions") == "" + + +# --- error handling ---------------------------------------------------------- +def test_unknown_key_raises(db): + with pytest.raises(KeyError): + db.get_setting("nope") + with pytest.raises(KeyError): + db.set_setting("nope", 1) + + +def test_corrupt_value_falls_back_to_default(db): + with db.write_tx() as cur: + cur.execute("UPDATE settings SET value='notanint' WHERE key='max_tokens'") + assert db.get_int("max_tokens") == 800 # schema default + + +# --- seeding ----------------------------------------------------------------- +def test_seed_does_not_overwrite_existing(db): + db.set_setting("openai_model", "custom-model") + db.seed_default_settings() # INSERT OR IGNORE -> keeps the user value + assert db.get_str("openai_model") == "custom-model" + + +def test_init_seeds_config_defaults(db): + # fake_config seeds default_model gpt-4.1-mini, approval mode True, auto False. + assert db.get_str("openai_model") == "gpt-4.1-mini" + assert db.get_bool("approval_mode_enabled") is True + assert db.get_bool("auto_reply_enabled") is False + + +def test_get_all_settings_has_every_key(db): + assert set(db.get_all_settings().keys()) == set(database.SETTINGS_SCHEMA.keys()) diff --git a/tests/unit/test_keyboards.py b/tests/unit/test_keyboards.py new file mode 100644 index 0000000..20ec5f7 --- /dev/null +++ b/tests/unit/test_keyboards.py @@ -0,0 +1,82 @@ +"""Tests for keyboards.py: callback_data length safety and builder integrity.""" + +from __future__ import annotations + +import pytest + +import keyboards + + +def _all_callback_data(markup): + return [btn.callback_data for row in markup.keyboard for btn in row] + + +# --- cb() -------------------------------------------------------------------- +def test_cb_joins_with_colon(): + assert keyboards.cb("apr", "send", 42) == "apr:send:42" + + +def test_cb_exactly_64_bytes_ok(): + data = keyboards.cb("x" * 64) + assert len(data.encode("utf-8")) == 64 + + +def test_cb_65_bytes_raises(): + with pytest.raises(ValueError): + keyboards.cb("x" * 65) + + +def test_cb_multibyte_boundary(): + # "é" is 2 UTF-8 bytes: 32 chars == 64 bytes (ok), 33 chars == 66 bytes (raises). + assert len(keyboards.cb("é" * 32).encode("utf-8")) == 64 + with pytest.raises(ValueError): + keyboards.cb("é" * 33) + + +# --- builders emit safe callback data ---------------------------------------- +def test_control_panel_buttons_within_limit(): + kb = keyboards.control_panel(auto_on=True, approval_on=False) + data = _all_callback_data(kb) + assert data and all(len(d.encode("utf-8")) <= 64 for d in data) + + +def test_approval_actions_buttons(): + kb = keyboards.approval_actions(999999) + data = _all_callback_data(kb) + assert "apr:send:999999" in data + assert all(len(d.encode("utf-8")) <= 64 for d in data) + + +def test_settings_root_and_enum(): + root = keyboards.settings_root([{"label": "X", "callback_data": "set:open:x"}]) + assert all(len(d.encode("utf-8")) <= 64 for d in _all_callback_data(root)) + enum = keyboards.settings_enum("openai_model", ["gpt-4o", "gpt-4.1"], "gpt-4o") + assert all(len(d.encode("utf-8")) <= 64 for d in _all_callback_data(enum)) + + +def test_kb_menu_and_confirm(): + assert all(len(d.encode("utf-8")) <= 64 for d in _all_callback_data(keyboards.kb_menu())) + assert all(len(d.encode("utf-8")) <= 64 for d in _all_callback_data(keyboards.confirm("tok123"))) + + +# --- kb_list_keyboard pagination -------------------------------------------- +def _items(n): + return [{"id": i, "title": f"Item {i}", "chunk_count": i} for i in range(n)] + + +def test_kb_list_first_page_has_next_only(): + kb = keyboards.kb_list_keyboard(_items(12), page=0, per_page=5) + data = _all_callback_data(kb) + assert any(d == "kb:list:1" for d in data) # Next + assert not any(d == "kb:list:-1" for d in data) # no Prev + + +def test_kb_list_middle_page_has_both(): + data = _all_callback_data(keyboards.kb_list_keyboard(_items(12), page=1, per_page=5)) + assert "kb:list:0" in data and "kb:list:2" in data + + +def test_kb_list_last_page_has_prev_only(): + data = _all_callback_data(keyboards.kb_list_keyboard(_items(12), page=2, per_page=5)) + assert "kb:list:1" in data + assert "kb:list:3" not in data diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..b92a214 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,77 @@ +"""Tests for security.py: ownership checks, the owner-only decorators, and the +throttled non-owner notice.""" + +from __future__ import annotations + +from datetime import timedelta + +from freezegun import freeze_time + +import security + + +# --- ownership predicates ---------------------------------------------------- +def test_is_owner(stub_bot): + # stub_bot wires security.init(bot, 12345) + assert security.is_owner(12345) is True + assert security.is_owner(1) is False + assert security.is_owner(None) is False + + +def test_is_owner_msg_and_call(stub_bot, make_message, make_call): + assert security.is_owner_msg(make_message(user_id=12345)) is True + assert security.is_owner_msg(make_message(user_id=99)) is False + assert security.is_owner_call(make_call(user_id=12345)) is True + assert security.is_owner_call(make_call(user_id=99)) is False + + +# --- decorators -------------------------------------------------------------- +def test_owner_only_allows_owner(stub_bot, make_message): + called = [] + + @security.owner_only + def handler(message): + called.append(message) + return "ok" + + assert handler(make_message(user_id=12345)) == "ok" + assert len(called) == 1 + + +def test_owner_only_blocks_non_owner(stub_bot, db, make_message, mocker): + notice = mocker.patch("security.handle_non_owner_dm") + called = [] + + @security.owner_only + def handler(message): + called.append(message) + + handler(make_message(user_id=99)) + assert called == [] + notice.assert_called_once() + + +def test_owner_only_callback_blocks_non_owner(stub_bot, make_call): + called = [] + + @security.owner_only_callback + def handler(call): + called.append(call) + + handler(make_call(user_id=99)) + assert called == [] + stub_bot.answer_callback_query.assert_called_once() + + +# --- non-owner notice throttle ---------------------------------------------- +def test_non_owner_notice_throttled(stub_bot, db, make_message): + security._last_notice.clear() + msg = make_message(user_id=99, chat_id=99) + with freeze_time("2024-01-01 00:00:00") as frozen: + security.handle_non_owner_dm(msg) + assert stub_bot.send_message.call_count == 1 + security.handle_non_owner_dm(msg) # within cooldown -> suppressed + assert stub_bot.send_message.call_count == 1 + frozen.tick(timedelta(seconds=601)) # past 600s cooldown + security.handle_non_owner_dm(msg) + assert stub_bot.send_message.call_count == 2 diff --git a/tests/unit/test_telegram_api.py b/tests/unit/test_telegram_api.py new file mode 100644 index 0000000..d66842a --- /dev/null +++ b/tests/unit/test_telegram_api.py @@ -0,0 +1,110 @@ +"""Tests for telegram_api.py: markup serialization, the chunking send adapter +with raw-HTTP fallback, and the low-level _post response handling.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import telegram_api as tg + + +@pytest.fixture(autouse=True) +def reset_api_state(): + yield + tg._TOKEN = tg._API_BASE = tg._FILE_BASE = None + + +# --- _serialize_markup ------------------------------------------------------- +def test_serialize_markup_variants(): + assert tg._serialize_markup(None) is None + assert tg._serialize_markup({"a": 1}) == json.dumps({"a": 1}) + obj = SimpleNamespace(to_dict=lambda: {"k": "v"}) + assert tg._serialize_markup(obj) == json.dumps({"k": "v"}) + assert tg._serialize_markup("rawstring") == "rawstring" + + +# --- send_reply -------------------------------------------------------------- +def test_send_reply_single_chunk(): + bot = MagicMock() + tg.send_reply(bot, 5, "hello") + bot.send_message.assert_called_once() + args, kwargs = bot.send_message.call_args + assert args[0] == 5 and args[1] == "hello" + + +def test_send_reply_chunks_long_text(): + bot = MagicMock() + tg.send_reply(bot, 5, "x" * 5000, reply_markup="mk", reply_to_message_id=9) + assert bot.send_message.call_count == 2 + first = bot.send_message.call_args_list[0] + assert first.kwargs.get("reply_to_message_id") == 9 + assert "reply_markup" in first.kwargs + # Only the first chunk carries reply context / markup. + second = bot.send_message.call_args_list[1] + assert "reply_markup" not in second.kwargs + assert "reply_to_message_id" not in second.kwargs + + +def test_send_reply_falls_back_to_raw(monkeypatch): + monkeypatch.setattr(tg, "_post", lambda method, data, **k: {"ok": True}) + bot = MagicMock() + bot.send_message.side_effect = RuntimeError("telebot down") + assert tg.send_reply(bot, 5, "hello") == [{"ok": True}] + + +# --- chat_action / answer_callback ------------------------------------------ +def test_chat_action_uses_bot(): + bot = MagicMock() + tg.chat_action(bot, 5, "typing", business_connection_id="bc1") + bot.send_chat_action.assert_called_once() + + +def test_chat_action_never_raises(monkeypatch): + bot = MagicMock() + bot.send_chat_action.side_effect = RuntimeError("x") + monkeypatch.setattr(tg, "send_chat_action", MagicMock(side_effect=RuntimeError("y"))) + tg.chat_action(bot, 5, "typing") # swallowed; must not raise + + +def test_answer_callback_uses_bot(): + bot = MagicMock() + tg.answer_callback(bot, "cb1", "ok") + bot.answer_callback_query.assert_called_once() + + +# --- _post ------------------------------------------------------------------- +def test_post_ok(monkeypatch): + tg.init("123:abc") + resp = MagicMock() + resp.json.return_value = {"ok": True, "result": {"message_id": 1}} + monkeypatch.setattr(tg.requests, "post", lambda *a, **k: resp) + assert tg._post("sendMessage", {"chat_id": 5}) == {"message_id": 1} + + +def test_post_error_raises(monkeypatch): + tg.init("123:abc") + resp = MagicMock() + resp.json.return_value = {"ok": False, "error_code": 400, "description": "bad request"} + monkeypatch.setattr(tg.requests, "post", lambda *a, **k: resp) + with pytest.raises(tg.TelegramAPIError): + tg._post("sendMessage", {"chat_id": 5}) + + +def test_post_network_error(monkeypatch): + tg.init("123:abc") + + def boom(*a, **k): + raise tg.requests.RequestException("connection refused") + + monkeypatch.setattr(tg.requests, "post", boom) + with pytest.raises(tg.TelegramAPIError): + tg._post("sendMessage", {}) + + +def test_post_requires_init(): + with pytest.raises(RuntimeError): + tg._post("sendMessage", {}) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..7987feb --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,174 @@ +"""Tests for utils.py: HTML escaping, message splitting, sensitive-content +detection, secret masking + redaction, and UTC time helpers.""" + +from __future__ import annotations + +import logging + +import pytest +from freezegun import freeze_time + +import utils + + +# --- escape ------------------------------------------------------------------ +def test_escape_none_returns_empty(): + assert utils.escape(None) == "" + + +def test_escape_escapes_markup(): + out = utils.escape("a&c") + assert "<" in out and ">" in out and "&" in out + assert "" not in out + + +def test_escape_coerces_non_str(): + assert utils.escape(123) == "123" + + +# --- split_message ----------------------------------------------------------- +def test_split_none_and_empty(): + assert utils.split_message(None) == [""] + assert utils.split_message("") == [""] + + +def test_split_short_single_chunk(): + assert utils.split_message("hello", limit=10) == ["hello"] + + +def test_split_exact_limit_single_chunk(): + text = "x" * 10 + assert utils.split_message(text, limit=10) == [text] + + +def test_split_over_limit_chunks_bounded(): + text = "y" * 25 + chunks = utils.split_message(text, limit=10) + assert len(chunks) >= 3 + assert all(len(c) <= 10 for c in chunks) + assert "".join(chunks) == text + + +# --- sensitive detection ----------------------------------------------------- +@pytest.mark.parametrize( + "text,category", + [ + ("I will contact my attorney about this", "legal"), + ("I need a prescription refilled", "medical"), + ("Can you give me investment advice", "financial"), + ("Please process a refund for my order", "refund"), + ("What is the password for my account", "credentials"), + ("email me at john@example.com", "personal_data"), + ("my ssn is 123-45-6789", "personal_data"), + ("this is a total scam", "anger_threat"), + ], +) +def test_detect_sensitive_categories(text, category): + assert category in utils.detect_sensitive_categories(text) + + +def test_detect_sensitive_shouting_heuristic(): + assert "anger_threat" in utils.detect_sensitive_categories("THIS IS COMPLETELY UNACCEPTABLE!") + + +def test_detect_sensitive_benign_is_empty(): + assert utils.detect_sensitive_categories("hello, nice to meet you") == [] + assert utils.detect_sensitive_categories("") == [] + assert utils.detect_sensitive_categories(None) == [] + + +def test_detect_sensitive_bool_wrapper(): + assert utils.detect_sensitive("please refund me") is True + assert utils.detect_sensitive("hello there") is False + + +# --- masking ----------------------------------------------------------------- +def test_mask_token(): + assert utils.mask_token(None) == "" + assert utils.mask_token("abc") == "****" + assert utils.mask_token("1234567890") == "...7890" + + +def test_mask_key(): + assert utils.mask_key(None) == "" + assert utils.mask_key("short") == "****" + assert utils.mask_key("sk-abcdef1234") == "sk-...1234" + + +def test_mask_id(): + assert utils.mask_id(None) == "" + assert utils.mask_id(12) == "***" + assert utils.mask_id(123456) == "***456" + + +def test_mask_connection(): + assert utils.mask_connection(None) == "" + assert utils.mask_connection("abcdef") == "****" + assert utils.mask_connection("abcdefghij") == "abcd..ij" + + +# --- redaction filter -------------------------------------------------------- +def _record(msg): + return logging.LogRecord("t", logging.INFO, __file__, 1, msg, None, None) + + +def test_redaction_filter_replaces_known_secret(): + f = utils.SecretRedactionFilter(["supersecrettoken"]) + rec = _record("leaking supersecrettoken now") + assert f.filter(rec) is True + assert "supersecrettoken" not in rec.getMessage() + assert "***REDACTED***" in rec.getMessage() + + +def test_redaction_filter_ignores_short_secret(): + f = utils.SecretRedactionFilter(["short"]) # < 8 chars, not registered + rec = _record("contains short word") + f.filter(rec) + assert rec.getMessage() == "contains short word" + + +def test_redaction_filter_add_secret_dedups(): + f = utils.SecretRedactionFilter() + f.add_secret("longsecret123") + f.add_secret("longsecret123") + assert f._secrets.count("longsecret123") == 1 + f.add_secret("tiny") # too short + assert "tiny" not in f._secrets + + +# --- time helpers ------------------------------------------------------------ +@freeze_time("2024-06-01 12:00:00") +def test_now_iso_and_ts(): + assert utils.now_iso().startswith("2024-06-01T12:00:00") + assert utils.now_ts() == int(utils.now().timestamp()) + + +@freeze_time("2024-06-01 12:00:00") +def test_future_iso_is_later(): + assert utils.parse_iso(utils.future_iso(10)) > utils.now() + + +def test_parse_iso_handles_bad_input(): + assert utils.parse_iso(None) is None + assert utils.parse_iso("not-a-date") is None + + +def test_parse_iso_attaches_utc(): + dt = utils.parse_iso("2024-06-01T12:00:00") + assert dt is not None and dt.tzinfo is not None + + +@freeze_time("2024-06-01 12:00:00") +def test_is_expired_and_seconds_until(): + past = utils.future_iso(-5) + future = utils.future_iso(5) + assert utils.is_expired(past) is True + assert utils.is_expired(future) is False + assert utils.is_expired(None) is False + assert utils.seconds_until(past) == 0 + assert utils.seconds_until(future) > 0 + + +def test_fmt_dt(): + assert utils.fmt_dt(None) == "n/a" + assert utils.fmt_dt("2024-06-01T12:00:00+00:00").endswith("UTC")