Jul 10 - Add test suite

This commit is contained in:
2026-07-10 15:49:55 -04:00
parent 512a80837a
commit 7a9814119f
10 changed files with 732 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Tests
Automated test suite (pytest). Runs entirely against an **in-memory SQLite**
database (`TestingConfig`) — it never touches MySQL, sends mail, or hits the
network, so it's safe to run anywhere.
## Running
```bash
pip install -r requirements-dev.txt
pytest
```
## Layout
| File | Covers |
|---|---|
| `test_score.py` | Inspection score calc — correctness **and** web/API parity (the two hand-mirrored implementations must agree; CLAUDE.md §9 / rule 40). |
| `test_sla.py` | SLA engine: `sla_status` / `sla_deadline` / `sla_hours_remaining` across every severity tier and boundary. |
| `test_public_pages.py` | Login-free QR pages (`/f/<token>`, `/f/area/<token>`): rule 74 occupant-safety (no leaked checklist names / scores / descriptions), 404s, and the report-a-problem flow. |
| `test_models.py` | `User.display_name`, `Issue.handler_label` / `resolved_facility`, public-token minting, and the authenticated area-QR route. |
## How the fixtures work (`conftest.py`)
- Required env vars (`SECRET_KEY`, `DATABASE_URL`, `DIGEST_SECRET`) are set at
import top **before** `app`/`config` import, because `config.py` reads them at
import time.
- The Flask app is built **once per session**`create_app()` can't run twice
(the `api_bp` sub-blueprints are registered on a module-level singleton).
- A **fresh app context + schema** is created per test. The per-test context
matters: Flask-Login caches the current user on `g` (bound to the app
context), so a shared context would leak an authenticated user between tests.
- Factory fixtures (`make_user`, `make_facility`, `make_area`, `make_template`,
`make_inspection`, `make_issue`) build persisted rows; pass only what you care
about. `login(user)` authenticates via the real login route.
+218
View File
@@ -0,0 +1,218 @@
"""
Pytest fixtures for the JQC test suite.
IMPORTANT: config.py evaluates `_require_env('SECRET_KEY')` / `DATABASE_URL` at
import time, so the required environment variables must be set *before* anything
imports `app` or `config`. pytest imports this conftest before collecting any
test module, so setting them at module top here is sufficient — the
TestingConfig then overrides the DB URI with in-memory SQLite regardless.
"""
import os
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('DATABASE_URL', 'sqlite://') # overridden by TestingConfig
os.environ.setdefault('DIGEST_SECRET', 'test-digest-secret')
from datetime import timedelta
import pytest
from app import create_app, db as _db
from app.utils.time_utils import now_eastern
# Import model modules so their tables are registered on the metadata before
# create_all() runs (they are also imported transitively via blueprints).
from app.models.user import User
from app.models.project import Project
from app.models.facility import Facility, Area
from app.models.inspection import InspectionTemplate, Inspection
from app.models.issue import Issue
# ── Core fixtures ─────────────────────────────────────────────────────────────
@pytest.fixture(scope='session')
def _app():
"""Build the Flask app exactly once for the whole session.
create_app() cannot be called more than once per process: register_api()
attaches sub-blueprints to the module-level `api_bp` singleton, and Flask
forbids re-registering a blueprint. So the app object is session-scoped.
"""
return create_app('testing')
@pytest.fixture
def app(_app):
"""A fresh app context + empty in-memory schema for each test.
A NEW app context is pushed per test (not once per session): Flask-Login
caches the current user in `g`, which is bound to the app context, so a
shared context would leak an authenticated user from one test into the next.
"""
with _app.app_context():
_db.create_all()
try:
yield _app
finally:
_db.session.remove()
_db.drop_all()
@pytest.fixture
def db(app):
"""The active SQLAlchemy session, bound to the per-test app context."""
return _db
@pytest.fixture
def client(app):
"""A Flask test client for the per-test app."""
return app.test_client()
@pytest.fixture
def login(client):
"""Return a helper that logs the given user in via the real login route.
Usage: login(user, password='pw')
CSRF is disabled in TestingConfig, so a plain form POST authenticates.
"""
def _login(user, password='pw'):
# Don't follow the redirect: the session cookie is already set on the
# 302 response, and this avoids coupling the helper to whatever the
# post-login landing page renders.
return client.post(
'/auth/login',
data={'username': user.username, 'password': password},
follow_redirects=False,
)
return _login
# ── Model factory helpers ─────────────────────────────────────────────────────
# Small builders that commit and return a persisted row. Kept deliberately thin;
# tests pass only the attributes they care about.
_counter = {'n': 0}
def _uniq():
_counter['n'] += 1
return _counter['n']
@pytest.fixture
def make_user(db):
def _make(role='inspector', password='pw', username=None, **kw):
n = _uniq()
user = User(
username=username or f'{role}{n}',
email=kw.pop('email', f'{role}{n}@example.com'),
full_name=kw.pop('full_name', None),
role=role,
active=kw.pop('active', True),
)
user.set_password(password)
for k, v in kw.items():
setattr(user, k, v)
db.session.add(user)
db.session.commit()
return user
return _make
@pytest.fixture
def make_facility(db):
def _make(active=True, project=None, with_token=True, **kw):
n = _uniq()
fac = Facility(
name=kw.pop('name', f'Facility {n}'),
address=kw.pop('address', f'{n} Test St'),
active=active,
project_id=project.id if project else None,
)
if with_token:
fac.ensure_public_token()
for k, v in kw.items():
setattr(fac, k, v)
db.session.add(fac)
db.session.commit()
return fac
return _make
@pytest.fixture
def make_area(db):
def _make(facility, with_token=True, **kw):
n = _uniq()
area = Area(
facility_id=facility.id,
name=kw.pop('name', f'Area {n}'),
area_type=kw.pop('area_type', 'restroom'),
)
if with_token:
area.ensure_public_token()
for k, v in kw.items():
setattr(area, k, v)
db.session.add(area)
db.session.commit()
return area
return _make
@pytest.fixture
def make_template(db):
def _make(**kw):
n = _uniq()
tpl = InspectionTemplate(
name=kw.pop('name', f'Template {n}'),
active=kw.pop('active', True),
)
for k, v in kw.items():
setattr(tpl, k, v)
db.session.add(tpl)
db.session.commit()
return tpl
return _make
@pytest.fixture
def make_inspection(db):
def _make(facility, inspector, template, area=None,
status='completed', overall_score=90.0, days_ago=5, **kw):
insp = Inspection(
template_id=template.id,
facility_id=facility.id,
area_id=area.id if area else None,
inspector_id=inspector.id,
inspection_date=now_eastern() - timedelta(days=days_ago),
overall_score=overall_score,
status=status,
)
for k, v in kw.items():
setattr(insp, k, v)
db.session.add(insp)
db.session.commit()
return insp
return _make
@pytest.fixture
def make_issue(db):
def _make(facility=None, area=None, status='open', severity='medium',
description='Test issue', **kw):
issue = Issue(
facility_id=facility.id if facility else None,
area_id=area.id if area else None,
status=status,
severity=severity,
description=description,
reported_at=kw.pop('reported_at', now_eastern()),
)
for k, v in kw.items():
setattr(issue, k, v)
db.session.add(issue)
db.session.commit()
return issue
return _make
+123
View File
@@ -0,0 +1,123 @@
"""
Model-property unit tests + one authenticated route test.
The property tests need an app context (for the DB session the factories use)
but exercise pure Python logic. The final test drives the phase39 area-QR route
through the real login flow, proving the auth fixture and QR endpoint work.
"""
import pytest
from app.models.issue import Issue
# ── User.display_name ─────────────────────────────────────────────────────────
def test_display_name_prefers_full_name(make_user):
u = make_user(role='inspector', full_name=' Dana Nguyen ')
assert u.display_name == 'Dana Nguyen' # trimmed
def test_display_name_falls_back_to_username(make_user):
u = make_user(role='inspector', username='jdoe', full_name=None)
assert u.display_name == 'jdoe'
def test_display_name_falls_back_when_full_name_blank(make_user):
u = make_user(role='inspector', username='jdoe', full_name=' ')
assert u.display_name == 'jdoe'
# ── Issue.handler_label ───────────────────────────────────────────────────────
@pytest.mark.parametrize('handler,label', [
('internal', 'Janitorial Staff'),
('facility', 'Facility Staff'),
('vendor', 'External Vendor'),
])
def test_handler_label(make_facility, make_issue, handler, label):
fac = make_facility()
iss = make_issue(facility=fac, handler_type=handler)
assert iss.handler_label == label
def test_handler_label_defaults_to_internal(make_facility, make_issue):
fac = make_facility()
iss = make_issue(facility=fac) # handler_type defaults to 'internal'
assert iss.handler_label == 'Janitorial Staff'
# ── Issue.resolved_facility ───────────────────────────────────────────────────
def test_resolved_facility_direct(make_facility, make_issue):
fac = make_facility()
iss = make_issue(facility=fac)
assert iss.resolved_facility.id == fac.id
def test_resolved_facility_via_area(make_facility, make_area, make_issue):
fac = make_facility()
area = make_area(fac)
iss = make_issue(area=area) # facility_id is None; resolves via area
assert iss.resolved_facility.id == fac.id
def test_resolved_facility_none(db, make_issue):
# No facility, no area -> None
iss = Issue(severity='low', description='orphan', status='open')
db.session.add(iss)
db.session.commit()
assert iss.resolved_facility is None
# ── Public token minting ──────────────────────────────────────────────────────
def test_facility_ensure_public_token_is_idempotent(make_facility):
fac = make_facility(with_token=True)
first = fac.public_token
assert first
assert fac.ensure_public_token() == first # does not regenerate
def test_area_tokens_are_unique(make_facility, make_area):
fac = make_facility()
a1 = make_area(fac)
a2 = make_area(fac)
assert a1.public_token and a2.public_token
assert a1.public_token != a2.public_token
# ── Authenticated route: phase39 area QR ──────────────────────────────────────
def test_area_qr_page_mints_token_and_png(
client, login, make_user, make_facility, make_area):
pytest.importorskip('qrcode') # skip if optional dep absent locally
director = make_user(role='director', password='pw')
fac = make_facility()
area = make_area(fac, with_token=False) # no token yet
assert area.public_token is None
login(director, password='pw')
# Visiting the printable QR page mints and persists a token.
resp = client.get(f'/facilities/areas/{area.id}/qr')
assert resp.status_code == 200
from app.models.facility import Area
from app import db
refreshed = db.session.get(Area, area.id)
assert refreshed.public_token is not None
# The PNG endpoint returns an actual image.
png = client.get(f'/facilities/areas/{area.id}/qr.png')
assert png.status_code == 200
assert png.mimetype == 'image/png'
def test_area_qr_requires_login(client, make_facility, make_area):
fac = make_facility()
area = make_area(fac)
resp = client.get(f'/facilities/areas/{area.id}/qr', follow_redirects=False)
# Unauthenticated -> redirected to the login page.
assert resp.status_code == 302
assert '/auth/login' in resp.headers['Location']
+138
View File
@@ -0,0 +1,138 @@
"""
Integration tests for the login-free public QR pages (app/routes/public.py).
These lock in CLAUDE.md rule 74 (occupant-safe): the pages are token-addressed,
404 on inactive/unknown facilities, and must never leak checklist/template
names, per-inspection scores, or issue descriptions. Covers both the facility
page (/f/<token>) and the phase39 per-area page (/f/area/<token>).
"""
from app.models.issue import Issue
# ── Facility page ─────────────────────────────────────────────────────────────
def test_facility_page_renders_and_shows_counts(
client, make_facility, make_user, make_template, make_inspection, make_issue):
fac = make_facility()
inspector = make_user(role='inspector')
tpl = make_template(name='SECRET-CHECKLIST-NAME')
make_inspection(fac, inspector, tpl, overall_score=93.8, days_ago=5)
make_inspection(fac, inspector, tpl, overall_score=88.0, days_ago=10)
make_issue(facility=fac, status='open')
resp = client.get(f'/f/{fac.public_token}')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert fac.name in body
# Aggregate rating is shown...
assert 'Avg Score' in body
assert 'Report a Problem' in body
def test_facility_page_does_not_leak_internal_detail(
client, make_facility, make_user, make_template, make_inspection, make_issue):
fac = make_facility()
inspector = make_user(role='inspector')
tpl = make_template(name='SECRET-CHECKLIST-NAME')
make_inspection(fac, inspector, tpl, overall_score=77.0, days_ago=3)
make_issue(facility=fac, status='open',
description='CONFIDENTIAL broken toilet in stall 3')
body = client.get(f'/f/{fac.public_token}').get_data(as_text=True)
# Rule 74: no checklist/template names, no issue descriptions.
assert 'SECRET-CHECKLIST-NAME' not in body
assert 'CONFIDENTIAL' not in body
assert 'stall 3' not in body
def test_inactive_facility_404(client, make_facility):
fac = make_facility(active=False)
assert client.get(f'/f/{fac.public_token}').status_code == 404
def test_unknown_token_404(client):
assert client.get('/f/does-not-exist').status_code == 404
def test_facility_report_creates_open_issue(client, db, make_facility):
fac = make_facility()
resp = client.post(
f'/f/{fac.public_token}/report',
data={'description': 'The lobby floor is very slippery today.'},
follow_redirects=True,
)
assert resp.status_code == 200
issues = Issue.query.filter_by(facility_id=fac.id).all()
assert len(issues) == 1
iss = issues[0]
assert iss.status == 'open'
assert iss.severity == 'medium'
assert iss.reported_by is None # public reporter is not a User
assert 'slippery' in iss.description
def test_report_honeypot_silently_drops(client, make_facility):
fac = make_facility()
resp = client.post(
f'/f/{fac.public_token}/report',
data={'description': 'spam spam spam', 'website': 'http://bot.example'},
follow_redirects=True,
)
assert resp.status_code == 200
assert Issue.query.filter_by(facility_id=fac.id).count() == 0
def test_report_rejects_too_short_description(client, make_facility):
fac = make_facility()
resp = client.post(
f'/f/{fac.public_token}/report',
data={'description': 'x'}, # under the 5-char minimum
)
assert resp.status_code == 400
assert Issue.query.filter_by(facility_id=fac.id).count() == 0
# ── Area page (phase39) ───────────────────────────────────────────────────────
def test_area_page_renders(client, make_facility, make_area):
fac = make_facility()
area = make_area(fac)
resp = client.get(f'/f/area/{area.public_token}')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert area.name in body
assert fac.name in body
assert 'Report a Problem' in body
def test_area_page_404_when_facility_inactive(client, make_facility, make_area):
fac = make_facility(active=False)
area = make_area(fac)
# Area token is valid, but its parent facility is inactive -> 404.
assert client.get(f'/f/area/{area.public_token}').status_code == 404
def test_area_report_sets_area_id(client, make_facility, make_area):
fac = make_facility()
area = make_area(fac)
resp = client.post(
f'/f/area/{area.public_token}/report',
data={'description': 'Paper towel dispenser is empty in this restroom.'},
follow_redirects=True,
)
assert resp.status_code == 200
iss = Issue.query.filter_by(area_id=area.id).one()
assert iss.facility_id == fac.id
assert iss.area_id == area.id
assert iss.status == 'open'
assert iss.reported_by is None
def test_area_and_facility_tokens_do_not_collide(
client, make_facility, make_area):
"""A facility token hitting the area route (and vice-versa) must 404,
proving the /f/<token> and /f/area/<token> routes stay distinct."""
fac = make_facility()
area = make_area(fac)
# Facility token on the area route -> no area with that token -> 404
assert client.get(f'/f/area/{fac.public_token}').status_code == 404
+98
View File
@@ -0,0 +1,98 @@
"""
Tests for inspection score calculation.
Two implementations must stay in lock-step (CLAUDE.md §9, rule 40):
- web: app/routes/inspections.py :: _compute_score_from_form
- api: app/api/inspections.py :: _compute_score
These are pure functions, so no app context is needed. The `test_parity_*`
cases are the drift detector: identical realistic input must yield an identical
score from both implementations.
"""
import pytest
from app.routes.inspections import _compute_score_from_form as web_score
from app.api.inspections import _compute_score as api_score
def field(fid, ftype):
return {'id': fid, 'type': ftype}
# ── Correctness (canonical expected values) ───────────────────────────────────
def test_no_scoreable_fields_returns_none():
fields = [field('q1', 'text'), field('q2', 'textarea')]
assert web_score(fields, {'q1': 'hi'}) is None
def test_rating_average_percent():
# two ratings: 5 and 3 -> earned 8 / total 10 -> 80%
fields = [field('q1', 'rating'), field('q2', 'rating')]
assert web_score(fields, {'q1': '5', 'q2': '3'}) == 80.0
def test_rating_zero_is_unanswered_and_excluded():
# '0' means unanswered: only the '4' counts -> 4/5 -> 80%
fields = [field('q1', 'rating'), field('q2', 'rating')]
assert web_score(fields, {'q1': '4', 'q2': '0'}) == 80.0
def test_all_ratings_unanswered_returns_none():
fields = [field('q1', 'rating'), field('q2', 'rating')]
assert web_score(fields, {'q1': '0', 'q2': '0'}) is None
def test_checkbox_true_and_false():
fields = [field('c1', 'checkbox'), field('c2', 'checkbox')]
# one checked (1), one unchecked (0) -> 1/2 -> 50%
assert web_score(fields, {'c1': 'true', 'c2': 'false'}) == 50.0
@pytest.mark.parametrize('val,expected', [
('pass', 100.0), ('yes', 100.0), ('ok', 100.0), ('good', 100.0),
('acceptable', 100.0), ('compliant', 100.0),
('fail', 0.0), ('no', 0.0), ('bad', 0.0),
])
def test_radio_pass_synonyms(val, expected):
fields = [field('r1', 'radio')]
assert web_score(fields, {'r1': val}) == expected
def test_pass_fail_empty_is_skipped():
# empty pass_fail is skipped entirely; the answered one is 'pass' -> 100%
fields = [field('p1', 'pass_fail'), field('p2', 'pass_fail')]
assert web_score(fields, {'p1': '', 'p2': 'pass'}) == 100.0
def test_mixed_field_types():
fields = [
field('q1', 'rating'), # 4 -> earned 4, total 5
field('c1', 'checkbox'), # true -> earned 1, total 1
field('r1', 'radio'), # fail -> earned 0, total 1
field('t1', 'text'), # ignored
]
# earned 5 / total 7 -> 71.43
assert web_score(fields, {'q1': '4', 'c1': 'true', 'r1': 'fail', 't1': 'x'}) == 71.43
# ── Parity: web and API must agree on identical realistic input ───────────────
PARITY_CASES = [
([field('q1', 'rating')], {'q1': '5'}),
([field('q1', 'rating'), field('q2', 'rating')], {'q1': '3', 'q2': '0'}),
([field('c1', 'checkbox')], {'c1': 'true'}),
([field('c1', 'checkbox')], {'c1': 'false'}),
([field('r1', 'radio')], {'r1': 'pass'}),
([field('r1', 'radio')], {'r1': 'fail'}),
([field('p1', 'pass_fail'), field('p2', 'pass_fail')], {'p1': '', 'p2': 'yes'}),
([field('t1', 'text')], {'t1': 'hello'}), # -> None
([field('q1', 'rating'), field('c1', 'checkbox'),
field('r1', 'radio')], {'q1': '4', 'c1': 'true', 'r1': 'good'}),
([field('q1', 'rating')], {}), # missing response
]
@pytest.mark.parametrize('fields,responses', PARITY_CASES)
def test_parity_web_matches_api(fields, responses):
assert web_score(fields, responses) == api_score(fields, responses)
+77
View File
@@ -0,0 +1,77 @@
"""
Tests for the SLA engine (app/utils/sla.py).
sla_status/deadline/hours_remaining read only .status, .severity, .reported_at,
so a lightweight stand-in object is enough — no DB or app context needed. Time
is controlled by positioning reported_at relative to now_eastern().
"""
from datetime import timedelta
from types import SimpleNamespace
import pytest
from app.utils.sla import (
sla_status, sla_deadline, sla_hours_remaining, SLA_HOURS, AT_RISK_THRESHOLD,
)
from app.utils.time_utils import now_eastern
def issue(severity='high', status='open', hours_ago=0):
return SimpleNamespace(
severity=severity,
status=status,
reported_at=now_eastern() - timedelta(hours=hours_ago),
)
def test_resolved_issue_has_no_sla():
assert sla_status(issue(status='resolved', hours_ago=999)) is None
def test_unknown_severity_returns_none():
assert sla_status(issue(severity='nonsense', hours_ago=1)) is None
def test_fresh_issue_is_ok():
# high = 24h window, at-risk at 18h; 1h in is comfortably OK
assert sla_status(issue(severity='high', hours_ago=1)) == 'ok'
def test_issue_past_at_risk_threshold():
# high at-risk at 18h; 20h in is at_risk but not yet breached (24h)
assert sla_status(issue(severity='high', hours_ago=20)) == 'at_risk'
def test_issue_past_deadline_is_breached():
assert sla_status(issue(severity='high', hours_ago=25)) == 'breached'
@pytest.mark.parametrize('severity', list(SLA_HOURS.keys()))
def test_at_risk_boundary_for_each_severity(severity):
window = SLA_HOURS[severity]
# Just past the at-risk fraction, still before the deadline.
at_risk_hours = window * AT_RISK_THRESHOLD
assert sla_status(issue(severity=severity, hours_ago=at_risk_hours + 0.1)) == 'at_risk'
# Just before the at-risk fraction is still OK.
assert sla_status(issue(severity=severity, hours_ago=at_risk_hours - 0.5)) == 'ok'
def test_deadline_is_reported_at_plus_window():
iss = issue(severity='medium', hours_ago=0)
expected = iss.reported_at + timedelta(hours=SLA_HOURS['medium'])
assert sla_deadline(iss) == expected
def test_hours_remaining_positive_when_within_window():
remaining = sla_hours_remaining(issue(severity='critical', hours_ago=1))
# critical window is 4h; ~3h should remain
assert 2.5 < remaining < 3.5
def test_hours_remaining_negative_when_overdue():
remaining = sla_hours_remaining(issue(severity='critical', hours_ago=6))
assert remaining < 0
def test_hours_remaining_none_for_resolved():
assert sla_hours_remaining(issue(status='resolved', hours_ago=1)) is None