- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style. - Adjusted punctuation and phrasing for better flow and understanding.
113 lines
6.3 KiB
Markdown
113 lines
6.3 KiB
Markdown
# Testing
|
|
|
|
## Running tests
|
|
|
|
From `backend/`:
|
|
|
|
```bash
|
|
uv run pytest
|
|
```
|
|
|
|
`backend/pytest.ini` sets `addopts` to include coverage by default,
|
|
`--cov=. --cov-config=../.coveragerc --cov-report=term-missing --cov-report=html:htmlcov
|
|
--cov-report=xml:coverage.xml`, alongside `--strict-markers --tb=short -v`. So a plain `uv run
|
|
pytest` always computes coverage and writes `backend/htmlcov/` and `backend/coverage.xml`, even for
|
|
a single test file.
|
|
|
|
Scope a run to one app, one file, or a keyword the same way you would with any pytest project:
|
|
|
|
```bash
|
|
uv run pytest accounts/tests/ # one app's test package
|
|
uv run pytest leads/tests/test_leads_kanban.py # one file
|
|
uv run pytest -k "test_org_isolation" # by keyword: matches the cross-tenant
|
|
# isolation test present in most API test
|
|
# modules (leads, cases, contacts, ...)
|
|
```
|
|
|
|
## Fast iteration
|
|
|
|
Coverage instrumentation and the three report formats above add real overhead. For the
|
|
edit-run-edit loop, drop coverage and stop at the first failure:
|
|
|
|
```bash
|
|
uv run pytest --no-cov -x
|
|
```
|
|
|
|
`--strict-markers` (also set in `addopts`) means a typo'd **custom** marker fails the run immediately
|
|
rather than being silently ignored. Every custom marker you use has to be declared in `pytest.ini`'s
|
|
`markers` list. It doesn't touch markers a plugin registers itself, like `@pytest.mark.django_db`
|
|
(from `pytest-django`). Those work without appearing in `pytest.ini` at all, which is why every
|
|
example on this page uses `django_db` freely alongside the two markers that actually are declared.
|
|
|
|
## Markers
|
|
|
|
`pytest.ini` declares exactly two custom markers:
|
|
|
|
```ini
|
|
markers =
|
|
slow: marks tests as slow
|
|
postgres_only: marks tests that require PostgreSQL
|
|
```
|
|
|
|
`postgres_only` is in active use. Exactly six tests carry it, confirmed by collecting the marker
|
|
directly (`uv run pytest -m postgres_only --collect-only` → `6/2776 tests collected`), across four
|
|
files: `common/tests/test_pat_auth.py` (one test), `common/tests/test_pat_model.py` (one),
|
|
`common/tests/test_rls_pipeline_tables.py` (two), and `invoices/tests/test_portal_rls.py` (two).
|
|
(`common/tests/test_pat_integration.py` mentions `postgres_only` only in a comment explaining that
|
|
its own tests deliberately *don't* carry the marker and run on SQLite instead. It contributes zero
|
|
of the six.) Four of the six assert RLS policy state directly (`pg_policies`,
|
|
`pg_class.relrowsecurity`): the two in `test_rls_pipeline_tables.py`, plus one each in
|
|
`test_pat_model.py` and `test_portal_rls.py` confirming `personal_access_token` and
|
|
`portal_access_token` deliberately carry *no* policy. The other two assert isolation *behaviour*
|
|
under a real, RLS-enforcing role instead of reading policy metadata:
|
|
`test_pat_auth.py::test_cross_org_pat_cannot_read_other_orgs_leads` calls the real `/api/leads/`
|
|
endpoint and checks a foreign org's lead never appears, and
|
|
`test_portal_rls.py::test_resolution_reads_estimate_that_empty_context_hides` exercises the full
|
|
portal-token resolution path. `slow` is declared but,
|
|
as of this writing, applied to nothing (`grep -rn "pytest.mark.slow" --include='*.py' .`, excluding
|
|
`venv/`, returns no matches in application code). It's reserved for future use, not a marker you'll
|
|
see filtering anything today.
|
|
|
|
## The SQLite caveat
|
|
|
|
`crm.test_settings` (`backend/crm/test_settings.py`) is the settings module `pytest.ini` points at,
|
|
and its `DATABASES` is hardcoded to an in-memory SQLite database, not read from an environment
|
|
variable, not conditional on anything. Its own module docstring states why: "RLS (Row-Level
|
|
Security) is PostgreSQL-only and is skipped on SQLite." That means **the default `uv run pytest`
|
|
run never exercises a single RLS policy**. Every test runs against a database engine that has no
|
|
concept of row security at all. Everything you'd normally rely on RLS to catch, an org-filter bug
|
|
included, is invisible to this default suite; only the explicit `Model.objects.filter(org=...)` in
|
|
application code is being tested. See [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract)
|
|
for why that ORM filter has to be correct on its own merits, independent of RLS.
|
|
|
|
The `postgres_only`-marked tests exist to close part of that gap, and they self-skip safely rather
|
|
than fail when there's no PostgreSQL connection: each one checks `connection.vendor != "postgresql"`
|
|
and calls `pytest.skip(...)` if so. Under the SQLite settings this repository ships, that check is
|
|
always true, so these six tests always skip. They don't fail, and a green `uv run pytest` run tells
|
|
you nothing about whether they'd pass. One of them, `test_portal_rls.py`'s module docstring,
|
|
describes an intended way to actually run them for real, against a non-superuser PostgreSQL role:
|
|
`RLS_ENFORCE=1`, pointed at a `crm.test_settings_postgres` module. A comment in `backend/.env`
|
|
(gitignored, `.gitignore:20`, and absent from `.env.example`, so a fresh clone has neither the
|
|
file nor the reference) names the same two pieces. Neither source is authoritative, because that
|
|
settings module does not exist anywhere in this checkout as of this writing, so this documented path
|
|
does not currently work. If you need to prove RLS isolation holds against real data rather than
|
|
just reading the policy SQL, use `manage.py manage_rls --test` against a real, non-superuser-configured PostgreSQL
|
|
database instead. See [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation)
|
|
for exactly what it proves and how to set up that role.
|
|
|
|
## What CI runs
|
|
|
|
`.github/workflows/tests.yml`'s `backend-tests` job runs on the same SQLite settings described
|
|
above, there's no PostgreSQL service in that job, so `postgres_only` tests skip there too, and
|
|
CI itself has no path that runs them for real. Two steps matter, in this order:
|
|
|
|
```bash
|
|
uv run python manage.py makemigrations --check --dry-run
|
|
uv run pytest -m "not postgres_only" --tb=short -v
|
|
```
|
|
|
|
The migration check runs first and fails the build if any model change is missing its migration.
|
|
See [Pull requests](pull-requests.md#what-ci-runs) for what that means for your workflow. The
|
|
`-m "not postgres_only"` filter on the test step is redundant with the tests self-skipping, but
|
|
makes the exclusion explicit in the command itself rather than relying on every future
|
|
`postgres_only` test remembering its own skip guard.
|