Jul 10 - Add test suite
This commit is contained in:
@@ -17,6 +17,12 @@ build/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv-test/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
# Flask / instance
|
# Flask / instance
|
||||||
instance/
|
instance/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
# Load .env from the project root (only takes effect locally; no-op in production
|
# Load .env from the project root (only takes effect locally; no-op in production
|
||||||
# if variables are already set in the environment)
|
# if variables are already set in the environment)
|
||||||
@@ -80,8 +81,34 @@ class ProductionConfig(Config):
|
|||||||
# Inherits SESSION_COOKIE_SECURE = True from Config — no override needed.
|
# Inherits SESSION_COOKIE_SECURE = True from Config — no override needed.
|
||||||
|
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
"""Config for the automated test suite (pytest).
|
||||||
|
|
||||||
|
Uses an in-memory SQLite database with a StaticPool so the single
|
||||||
|
connection — and therefore the schema created by db.create_all() — persists
|
||||||
|
across every request the test client makes within one app instance. CSRF and
|
||||||
|
rate limiting are disabled so tests can POST directly, and mail is
|
||||||
|
suppressed. DEBUG=True short-circuits the file-logging block in create_app()
|
||||||
|
so the suite never writes to logs/jqc.log.
|
||||||
|
"""
|
||||||
|
TESTING = True
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite://' # in-memory
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
'connect_args': {'check_same_thread': False},
|
||||||
|
'poolclass': StaticPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
RATELIMIT_ENABLED = False
|
||||||
|
MAIL_SUPPRESS_SEND = True
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
'development': DevelopmentConfig,
|
'development': DevelopmentConfig,
|
||||||
'production': ProductionConfig,
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
'default': DevelopmentConfig,
|
'default': DevelopmentConfig,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts = -ra -q
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Development / CI dependencies (not installed in production).
|
||||||
|
# pip install -r requirements-dev.txt
|
||||||
|
-r requirements.txt
|
||||||
|
pytest
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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']
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user