Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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."
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
junit.xml
*.cover
*.py.cover
.hypothesis/
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
166 changes: 166 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
]
9 changes: 9 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading