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
58 changes: 58 additions & 0 deletions .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Unit tests

# Runs when a pull request is created or its head is updated
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
on: pull_request

jobs:
unit:
name: Python unit tests
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- uses: actions/checkout@v6

- name: Install Poetry
run: pipx install poetry==2.3.2

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: poetry

- name: Install dependencies
run: poetry install

- name: Run unit tests
run: poetry run pytest services/*/tests/unit

bun:
name: Bun unit tests
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- uses: actions/checkout@v6

- name: Set up Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Install Poetry
run: pipx install poetry==2.3.2

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: poetry

- name: Install dependencies
run: poetry install

- name: Run unit tests
run: bun test
72 changes: 72 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Repo-root pytest configuration.

- Auto-applies a tier marker (`unit` / `service` / `integration` /
`acceptance`) based on the test's path. The directory IS the marker.
- For tests marked `unit`, blocks network, subprocess, DB, and LLM client
construction so accidental I/O fails loud instead of timing out.
"""

from unittest.mock import patch

import pytest


_TIER_DIRS = ("unit", "service", "integration", "acceptance")

_BLOCKED_TARGETS = (
("socket.socket.connect", "socket.connect()"),
("subprocess.run", "subprocess.run()"),
("subprocess.Popen", "subprocess.Popen()"),
("psycopg2.connect", "psycopg2.connect()"),
# Block LLM client construction, not first request — earlier failure,
# easier to trace.
("anthropic.Anthropic.__init__", "anthropic.Anthropic()"),
("anthropic.AsyncAnthropic.__init__", "anthropic.AsyncAnthropic()"),
("openai.OpenAI.__init__", "openai.OpenAI()"),
("openai.AsyncOpenAI.__init__", "openai.AsyncOpenAI()"),
)


class UnitTestViolation(RuntimeError):
"""Raised when a unit test attempts a forbidden operation."""


def _make_blocker(operation):
def _block(*_args, **_kwargs):
raise UnitTestViolation(
f"Unit tests may not perform `{operation}`. Move this test to "
"tests/service/ or tests/integration/ if real I/O is needed. "
"See conftest.py at the repo root for the policy."
)

return _block


def pytest_collection_modifyitems(items):
for item in items:
for tier in _TIER_DIRS:
if tier in item.path.parts:
item.add_marker(getattr(pytest.mark, tier))
break


@pytest.fixture(autouse=True)
def _enforce_unit_isolation(request):
if "unit" not in request.keywords:
yield
return

patches = []
for target, label in _BLOCKED_TARGETS:
try:
p = patch(target, side_effect=_make_blocker(label))
p.start()
patches.append(p)
except (AttributeError, ModuleNotFoundError):
continue

try:
yield
finally:
for p in patches:
p.stop()
40 changes: 40 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,46 @@ ruff = "^0.15.10"
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
# Make `services/` importable the same way `services/entry.py` does it.
# Without this, `from workflow_chat.workflow_chat import ...` fails inside tests.
pythonpath = ["services"]

# Discovery roots. pytest walks these for test_*.py files.
testpaths = [
"services/global_chat/tests",
"services/workflow_chat/tests",
"services/job_chat/tests",
"services/tools",
]

python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

markers = [
"unit: fast, isolated, no I/O. Runs on every PR push.",
"service: mocks HTTP/LLM clients; exercises service handlers. Runs on merge.",
"integration: hits real external services (LLM, Pinecone, Postgres). Manual/nightly.",
"acceptance: end-to-end acceptance criteria. Manual/nightly.",
]

addopts = [
"--strict-markers",
"--strict-config",
"-ra",
"--tb=short",
]

filterwarnings = [
"default::DeprecationWarning",
"ignore::DeprecationWarning:anthropic.*",
"ignore::DeprecationWarning:pydantic.*",
]

[tool.black]
line-length = 120

[tool.ruff]
select = [
"E", # pycodestyle
Expand Down
10 changes: 6 additions & 4 deletions services/global_chat/tests/test_good_morning_workflow.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import pytest
import yaml
from testing.yaml_assertions import (
assert_yaml_has_ids,
assert_yaml_jobs_have_body,
)
from .test_utils import (
assert_routed_to,
call_global_chat_service,
get_attachment,
make_service_input,
print_response_details,
assert_routed_to,
get_attachment,
assert_yaml_has_ids,
assert_yaml_jobs_have_body,
)


Expand Down
10 changes: 6 additions & 4 deletions services/global_chat/tests/test_planner_multistep.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import pytest
import yaml
from testing.yaml_assertions import (
assert_yaml_has_ids,
assert_yaml_jobs_have_body,
)
from .test_utils import (
assert_routed_to,
call_global_chat_service,
get_attachment,
make_service_input,
print_response_details,
assert_routed_to,
get_attachment,
assert_yaml_has_ids,
assert_yaml_jobs_have_body,
)


Expand Down
Loading
Loading