# Developer Testing Guidelines

How to write and run tests for Gitea-Tools. This guide reflects the current
repository behavior and the safety model documented in
[`safety-model.md`](safety-model.md),
[`credential-isolation.md`](credential-isolation.md), and
[`gitea-execution-profiles.md`](gitea-execution-profiles.md).

Core principle: **tests never make real network calls and never touch real
credentials.** Every test mocks the HTTP client and the keychain/auth lookup.

---

## 1. Standard test commands

### Canonical runner: `./run-tests.sh`

The canonical full-validation command is the root-level runner. It invokes the
project virtualenv interpreter and passes any extra arguments straight through
to `pytest`:

```bash
# Full validation
./run-tests.sh

# Focused validation (extra args forward to pytest)
./run-tests.sh tests/test_mcp_server.py -q
```

`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear
setup message if the virtualenv Python is missing (so a session never silently
falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest`
forms below remain valid and equivalent.

The test suite needs the project virtualenv (it provides the MCP SDK):

```bash
# From the repository root
source venv/bin/activate
python3 -m pytest tests/ -q
```

Or invoke the venv interpreter directly without activating:

```bash
./venv/bin/python -m pytest tests/ -q
```

Use `-q` for a compact summary and `-v` to see individual test names.

### Run the full suite

```bash
./venv/bin/python -m pytest tests/ -q
```

### Web UI suite (#436)

Hermetic unittest modules matching `test_webui_*.py` cover route rendering,
read-only guards, registry/prompt/queue loaders, and optional child-issue
modules when present on the branch.

```bash
./scripts/test-webui
./scripts/ci-webui-check          # skip unless the diff touches web UI paths
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check
```

`scripts/test-webui` defaults `WEBUI_TEST_OFFLINE=1` so route coverage never
needs Gitea credentials or MCP daemon credential access. Set
`WEBUI_TEST_OFFLINE=0` only for explicit operator live-fetch checks.

Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch
`webui/`, `tests/test_webui_*`, or `docs/webui*`.

### Run targeted tests

```bash
# One file
./venv/bin/python -m pytest tests/test_mcp_server.py -q

# One class
./venv/bin/python -m pytest tests/test_merge_pr.py::TestMergeDisabled -q

# One test, by node id
./venv/bin/python -m pytest tests/test_review_pr.py::TestAPIPayload::test_payload_fields_and_workflow -q

# By keyword expression
./venv/bin/python -m pytest tests/ -q -k "merge and fails_closed"
```

---

## 2. Syntax and formatting checks

These are fast and belong in any pre-PR loop:

```bash
# Byte-compile the main modules (catches syntax errors)
python3 -m py_compile mcp_server.py
python3 -m py_compile manage_labels.py

# Lint shell scripts without executing them
bash -n scripts/clear-provenance

# Detect stray conflict markers and whitespace errors in the diff
git diff --check
```

Run `git diff --check` before every commit; it flags leftover merge-conflict
markers and trailing-whitespace/whitespace-error lines.

---

## 3. How to add an MCP tool test

MCP tools live in `mcp_server.py` and are exercised in
`tests/test_mcp_server.py`. Tests call the underlying tool function directly
with the network layer and auth mocked. The established pattern:

```python
from unittest.mock import patch, MagicMock

FAKE_AUTH = "Basic ZmFrZTpmYWtl"  # not a real credential

class TestCreateIssue(unittest.TestCase):
    @patch("mcp_server.api_request")
    @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
    def test_creates_issue(self, _auth, mock_api):
        mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}

        result = mcp_server.gitea_create_issue(title="Add tests", remote="prgs")

        # Assert on the request the tool would have made
        mock_api.assert_called_once()
        method, url = mock_api.call_args[0][0], mock_api.call_args[0][1]
        self.assertEqual(method, "POST")
        self.assertIn("/issues", url)
```

Checklist when adding a tool test:

* Patch `mcp_server.api_request` — never hit the network.
* Patch `mcp_server.get_auth_header` to return a fake header — never read the
  keychain.
* Assert on the **method, URL, and payload** the tool builds, and on the shape
  of the returned payload.
* Cover both `dadeschools` and `prgs` remotes when the tool takes `remote`, and
  confirm the correct host/org/repo are used.
* Cover the error path (e.g. `api_request` raising) and confirm the tool
  surfaces a clear message without leaking secrets.

---

## 4. How to mock API requests safely

* **Always patch `mcp_server.api_request`** (or `gitea_auth.api_request` for the
  CLI/auth-level tests). No test should open a socket.
* **Always patch the auth lookup** (`get_auth_header` / `get_credentials`) and
  return an obviously fake value. Do not put a real token or password in a test,
  a fixture, or an environment file.
* Prefer asserting on `mock_api.call_args` (method/URL/payload) over asserting on
  a real response body.
* For keychain behavior specifically, see `tests/test_credentials.py`, which
  mocks the `git credential fill` subprocess (`Popen`) and the environment.

---

## 5. How to test profile / allowed-operation failures

The execution-profile model (see
[`gitea-execution-profiles.md`](gitea-execution-profiles.md)) enforces that a
tool may only perform operations in its profile's `allowed_operations`, and that
`forbidden_operations` always override `allowed_operations`. Mutating tools must
**fail closed** when the active profile does not permit the operation.

When adding or changing a gated tool, add tests that:

* Configure a profile whose `allowed_operations` does **not** include the
  requested operation, and assert the tool refuses **without** calling
  `api_request` (assert `mock_api.assert_not_called()`).
* Configure a profile where the operation is in **both** allowed and forbidden,
  and assert forbidden wins (still refused).
* Confirm the refusal message names the missing operation and does not include
  any secret material.
* Confirm the happy path (operation allowed) still reaches `api_request`.

The guiding assertion is: **no mutation path may reach `api_request` unless the
profile/allowed-operation check passed first.**

---

## 6. How to test self-review / self-merge gates

Author-cannot-review and author-cannot-merge are hard safety gates. The merge
path is gated (`gitea_merge_pr`), the legacy review wrapper fails closed on
`merge=True`, and `gitea_submit_pr_review` never merges. Existing coverage lives
in `tests/test_merge_pr.py` and `tests/test_review_pr.py`.

Patterns to follow (see those files for concrete examples):

* **Self-merge blocked:** authenticated user == PR author → the tool returns a
  refusal and **never calls the merge endpoint** (`mock_api.assert_not_called()`
  or assert no `POST .../merge`).
* **Fail-closed inputs:** missing confirmation string, or an unexpected
  `expected_head_sha`/changed-file set → refuse before any API call.
* **Legacy wrapper:** `merge=True` on the review wrapper fails closed and points
  to the gated workflow, with no API call
  (`test_merge_flag_fails_closed_without_api_call`).
* **Self-approval blocked:** authenticated user == PR author → `approve` /
  `request_changes` refused.

Every new gate should have a test proving the mutating endpoint is **not**
reached when the gate should block.

---

## 7. No-secret / no-token regression expectations

Secrets must never appear in logs, tool return values, audit records, or test
output (see [`safety-model.md`](safety-model.md) §3). The audit module
(`gitea_audit.py`) redacts secret-like keys and value prefixes; see
`tests/test_audit.py`.

Expectations for new tests:

* Assert that token/authorization/password fields are replaced with
  `gitea_audit.REDACTED` in any structured output or audit record
  (`test_redacts_secret_keys`, `test_redacts_nested_and_lists`).
* Assert that credential-looking substrings in free-text (error messages,
  reasons) are redacted (`test_redacts_credential_value_prefixes`,
  `test_metadata_and_reason_redacted`).
* Never commit a real token/password, even in a fixture. Use obviously fake
  values (e.g. `FAKE_AUTH` above).
* When a tool returns identity/profile metadata, assert it contains the
  non-secret fields (username, profile name) and **not** the token.

There is no third-party secret scanner wired into this repo today; secret safety
is enforced by `gitea_audit.redact` plus the regression tests above. A quick
manual sweep before a PR:

```bash
# Look for accidentally committed credentials in the diff
git diff --cached | grep -nEi "authorization: (basic|bearer)|password|token=[A-Za-z0-9]" || echo "clean"
```

---

## 8. Unit tests vs. Docker integration tests

* **Unit tests (default):** fast, fully mocked, no network, no keychain.
  This is where the vast majority of coverage lives and where new tests should
  go. They must stay fast and must not require credentials.
* **Docker/local-Gitea integration tests (#66, `tests/integration/`):** opt-in
  and skipped by default — enabled only by `GITEA_INTEGRATION=1` and run
  against a pinned, disposable Gitea container
  (`tests/integration/gitea-integration up|token|down`). They validate real
  API behavior (pagination, permissions, label endpoints, error payloads) that
  mocks cannot prove. They must not use production credentials and must not
  leak tokens. See [`../tests/integration/README.md`](../tests/integration/README.md).

Rule of thumb: prove **logic and request-shaping** with unit tests; reserve
integration tests for **real-server compatibility**. Do not convert unit tests
into network tests.

---

## 9. Read-only vs. mutating tool expectations

* **Read-only tools** (e.g. `gitea_whoami`, `gitea_view_*`, `gitea_list_*`,
  `gitea_get_profile`): test that they never issue a mutating HTTP method and
  never require a mutation gate. Assert the request method is `GET`.
* **Mutating tools** (create/edit/close/label, review, merge, mirror): test that
  they (a) pass the profile/allowed-operation gate, (b) honor confirmation and
  self-action gates, (c) emit an audit record with the authenticated identity
  and outcome, and (d) fail closed — no `api_request` call — when any gate fails.

Keep this split explicit in test names and assertions so a reviewer can see, per
tool, which category it belongs to and which gates it must respect.
