Jul 10 - Add test suite
This commit is contained in:
@@ -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