Skip to content

fix(ci): run the test suite, and repair the tests it was hiding - #1063

Open
rdahis wants to merge 2 commits into
mainfrom
fix/ci-test-suite
Open

fix(ci): run the test suite, and repair the tests it was hiding#1063
rdahis wants to merge 2 commits into
mainfrom
fix/ci-test-suite

Conversation

@rdahis

@rdahis rdahis commented Aug 20, 2026

Copy link
Copy Markdown
Member

Purpose

The CI test job has not executed a single test since February 2024, and has
reported success the whole time.

poetry install --only=test installs pytest without Django, so pytest aborts
with ModuleNotFoundError: No module named 'django'. Because the command pipes
into tee, the step exits 0 and the job goes green. Every PR since has merged
against a check that measured nothing. The most recent run before this PR
(32311142291)
shows the traceback under a passing job.

This PR fixes CI, repairs the tests that were rotting behind it, and adds
coverage for the code where a wrong answer is silent rather than loud.

Suite: 4 → 448 passing. Coverage: 44% → 73%.

Description

CI (ci-python.yaml)

  • poetry install --only main,test — Django and the project's apps are needed
    by the suite; dev is excluded to keep the job lean
  • a postgres:14 service with the DB_* env the settings expect
  • shell: bash for pipefail, so a pytest failure is no longer swallowed by tee
  • if: always() on the coverage comment, so failures still report

Repairing the existing suite

With CI fixed, 27 of the 31 existing tests failed or errored against code that
had moved on beneath them:

What broke When
Dataset.organization became the organizations m2m; the conftest fixture still passed the old kwarg Feb 2025 — cascaded to 24 tests
Table.raw_data_url removed from the model, not the fixtures
full_coverage / Dataset.coverage replaced by full_temporal_coverage / temporal_coverage
backend.apps.payment renamed account_payment; patch targets pointed at a dead module May 2024
GraphQL endpoint is /api/v1/graphql, tests used /api/graphql/
CreateStripeSubscription in tests.gql queried a field the mutation no longer exposes, invalidating the whole document and with it the login mutation every payment test depends on
the account fixture used objects.create, storing the password unhashed so login could never succeed
activation-email assertions predate the is_prd() guard Jun 2024

Assertions were rewritten against the current model API, not deleted. Where a
data shape changed (full_coveragefull_temporal_coverage), the new
expectations were derived by observing actual behaviour and checking it still
matched the original tests' intent.

New tests

Aimed at authorization, entitlement routing, and the aggregates behind the
public catalogue counts:

  • Dataset aggregates (31) — ~15 rollup properties each re-spell the same
    "exclude under_review/excluded status and the dictionary tables" rule
    longhand, with nothing checking the copies agree
  • Model validation (40) — Coverage, Update, Poll and QualityCheck each
    reimplement the same "exactly one foreign key" rule; plus DateTimeRange,
    which assembles datetimes out of seven nullable columns
  • Account HTTP views (40) — activation, password reset, Google OAuth,
    including the state check against CSRF and the redirect-origin allowlist
    (that redirect carries a JWT, so an origin leaking onto the list is a token
    disclosure)
  • account_auth gateway (32) — /auth/ decides whether a reverse proxy
    lets a request through, so a wrongly-returned 200 is an authorization bypass;
    all six refusal paths covered
  • GraphQL decorators (24) — owner_required and subscription_member are
    the authorization boundary for the API
  • Prefect flow-failure webhook (42) — including the reactivated_at guard
    that stops a fixed flow being re-disabled by its own past failures
  • Stripe entitlements (39) — chatbot-vs-BD-Pro detection, trial
    eligibility, and the admin guard that stops a webhook revoking a staff
    member's chatbot access
  • Table/Column relations (25), api/v1 REST endpoints (20), Account
    subscription state
    (19), environment allowlist (5)
Module Before After
custom/environment.py 56% 100%
account_auth/views.py 18% 96%
account/views.py 40% 93%
account/models.py 78% 91%
custom/graphql_jwt.py 56% 85%
api/v1/models.py 68% 85%
admin_data_tools/views.py 25% 72%

backend/settings/test.py

New test settings module, wired via pytest.ini. Points haystack at the
in-memory backend and drops the search signal processor — without it every
save() in the suite tries to reach Elasticsearch and fails. Also supplies a
JWT algorithm and an in-memory email backend. Runtime drops from 37s to 27s.

Bugs found by writing the tests

Fixed (each with a test that fails without the fix):

  1. CloudTable.clean guarded the dataset-id check with gcp_project_id
    instead of gcp_dataset_id, so a malformed dataset id passed validation
    whenever the project id was blank.
  2. Token.save() was declared def save(self) — no *args, **kwargs — so
    Token.objects.create() raised TypeError on force_insert. No Token
    could be created through the normal Django API.

Documented in tests, not changed — each alters runtime behaviour beyond
what a testing PR should decide alone:

  1. Token.save() regenerates the token on every save. Editing a Token in
    the admin — setting an expiry, toggling is_active — silently invalidates
    the value the client is already using. Revoking becomes rotating. This is
    the one most worth a second opinion.
  2. DatasetRedirectView indexes URL_MAPPING directly, so any host outside
    the four it knows about is a KeyError and a 500, not a redirect.
  3. Area similarity compares names by prefix, so an Area saved with a blank name
    scores as similar to every area and inflates the related-tables ranking.

Worth checking separately: GRAPHQL_JWT["JWT_ALGORITHM"] reads
DJANGO_JWT_ALGORITHM from the environment and falls back to None, which
makes django-graphql-jwt sign with alg="none". Worth confirming every
deployment sets it.

Checklist

  • I have reviewed the code changes.
  • I have tested the changes locally.
  • I have updated the documentation if needed.
  • I have added/modified tests to ensure the changes are valid.

Testing and evidence

Run locally against Python 3.11 at the poetry.lock versions, from an empty
database:

448 passed in 51.57s
TOTAL    9608   2610    73%

ruff check passes; all pre-commit hooks pass.

The CI change itself was validated by installing a real Poetry and confirming
poetry install --only main,test resolves (main + test, no dev), and that the
pytest console script imports backend without the project root on
sys.path.

Next steps

Per the repo's Git Flow this same branch should also be PR'd into dev and
staging — happy to open those on request.

The largest remaining gaps are account_payment/graphql.py (46%) and
webhooks.py (30%): the Stripe mutation and handler bodies, which need real
djstripe fixture scaffolding. Getting past ~80% overall means committing to
that; worth doing as a follow-up rather than growing this PR.

🤖 Generated with Claude Code

rdahis added 2 commits August 20, 2026 12:45
The CI test job has not executed a single test since February 2024.
`poetry install --only=test` installs pytest without Django, so pytest
aborts with `ModuleNotFoundError: No module named 'django'`, and because
the command pipes into `tee`, the step exits 0 and the job reports
success. Every PR since has merged against a green check that measured
nothing.

CI:
- install `--only main,test` so Django and the project's apps are present
- add a postgres:14 service with the DB_* env the settings expect
- `shell: bash` for pipefail, so a pytest failure is no longer swallowed
- `if: always()` on the coverage comment so failures still report

With CI fixed, 27 of the 31 existing tests failed or errored against
code that had moved on beneath them:
- `Dataset.organization` became the `organizations` m2m; the conftest
  fixture still passed the old kwarg, breaking 24 tests downstream
- `Table.raw_data_url` was removed from the model but not the fixtures
- `Table.full_coverage` and `Dataset.coverage` were replaced by
  `full_temporal_coverage` / `temporal_coverage`; assertions rewritten
  against the current shape
- `backend.apps.payment` was renamed `account_payment`; patch targets
  pointed at a module that no longer exists
- the GraphQL endpoint is `/api/v1/graphql`, not `/api/graphql/`
- `CreateStripeSubscription` in tests.gql queried a `subscription` field
  the mutation no longer exposes, which invalidated the whole document
  and with it the login mutation every payment test depends on
- the account fixture used `objects.create`, storing the password
  unhashed so login could never succeed
- activation email assertions predate the `is_prd()` guard added in
  June 2024

New tests, 100 of them, covering logic that had none:
- `custom/environment.py` — the production gate gating activation emails
- `custom/utils.py` — snake/kebab case validation
- `get_spatial_coverage` — turns its documented hierarchy rules into a
  contract
- `Area.clean` — administrative level and parent hierarchy validation
- `CloudTable.clean` and the Table slug properties derived from it
- `Account` password handling, where a mistake either stores a password
  in the clear or locks the user out, silently either way

Writing the CloudTable tests surfaced a real bug: the dataset id check
was guarded by `gcp_project_id` rather than `gcp_dataset_id`, so a
malformed dataset id passed validation whenever the project id was
blank. Fixed, with a test that fails without it.

Also adds `backend/settings/test.py`, which points haystack at the
in-memory backend and drops the search signal processor. Without it
every `save()` in the suite tries to reach Elasticsearch and fails.
Runtime drops from 37s to 27s.

Suite: 4 passing -> 131 passing. Coverage: 44% -> 59%.
Second pass on coverage, aimed at the code where a silent wrong answer costs
something: authorization decisions, Stripe entitlement routing, and the
dataset aggregates the public catalogue counts are built from.

- Dataset aggregates (31 tests). Every one of the ~15 rollup properties
  excludes under_review/excluded tables and the dictionary tables, longhand,
  in each copy. That rule is now pinned down, along with open/closed data,
  the direct-download size thresholds and the first_* pointers.
- Model validation (40). Coverage, Update, Poll and QualityCheck each
  reimplement the same "exactly one foreign key" rule; each copy is checked.
  Plus DateTimeRange, which assembles datetimes out of seven nullable columns.
- Account HTTP views (40). Activation, password reset, and Google OAuth,
  including the two security boundaries: the `state` check against CSRF and
  the redirect-origin allowlist that keeps a JWT-bearing redirect on a known
  domain.
- account_auth gateway (32). `/auth/` decides whether a reverse proxy lets a
  request through, so a wrongly returned 200 is an authorization bypass. All
  six refusal paths and the token expiry/domain rules are covered.
- GraphQL decorators (24). owner_required and subscription_member are the
  authorization boundary for the API. owner_required identifies its target by
  regex over the raw request body, which is unusual enough to pin down.
- Prefect flow-failure webhook (42). Decides whether a pipeline's schedule is
  switched off; the reactivated_at guard that stops a fixed flow being
  re-disabled by its own past failures is covered directly.
- Stripe entitlements (39). Chatbot-vs-BD-Pro product detection, trial
  eligibility, and the admin guard that stops a webhook revoking a staff
  member's chatbot access.
- Table/Column relations (25), api/v1 REST endpoints (20), Account
  subscription state (19), environment allowlist (5).

One more bug found and fixed: Token.save() was declared `def save(self)`, so
Token.objects.create() raised TypeError on force_insert and no Token could be
created through the normal Django API.

Three further findings are documented in tests rather than changed, since each
alters behaviour beyond a testing PR:
- Token.save() regenerates the token on every save, so editing a Token in the
  admin (setting an expiry, toggling is_active) silently invalidates the value
  the client is using.
- DatasetRedirectView indexes URL_MAPPING directly, so any host outside the
  four it knows about is a KeyError, not a redirect.
- Area similarity compares names by prefix, so an Area saved with a blank name
  scores as similar to every other area.

Suite: 131 -> 448 passing. Coverage: 59% -> 73%.
@rdahis
rdahis requested a review from Winzen August 20, 2026 06:51
@rdahis rdahis self-assigned this Aug 20, 2026
@github-actions

Copy link
Copy Markdown

Coverage

Tests Skipped Failures Errors Time
448 0 💤 0 ❌ 0 🔥 1m 29s ⏱️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant