diff --git a/.gitignore b/.gitignore index 85c6595..da2b79b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ build/ venv/ env/ .venv/ +.venv-test/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ # Flask / instance instance/ diff --git a/config.py b/config.py index 841847d..3713795 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ import os from datetime import timedelta from dotenv import load_dotenv +from sqlalchemy.pool import StaticPool # Load .env from the project root (only takes effect locally; no-op in production # 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. +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 = { 'development': DevelopmentConfig, 'production': ProductionConfig, + 'testing': TestingConfig, 'default': DevelopmentConfig, } \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d51730f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -ra -q diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..390d473 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development / CI dependencies (not installed in production). +# pip install -r requirements-dev.txt +-r requirements.txt +pytest diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e1f55b1 --- /dev/null +++ b/tests/README.md @@ -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/`, `/f/area/`): 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. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..74dad71 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..bed8c02 --- /dev/null +++ b/tests/test_models.py @@ -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'] diff --git a/tests/test_public_pages.py b/tests/test_public_pages.py new file mode 100644 index 0000000..404e783 --- /dev/null +++ b/tests/test_public_pages.py @@ -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/) and the phase39 per-area page (/f/area/). +""" +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/ and /f/area/ 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 diff --git a/tests/test_score.py b/tests/test_score.py new file mode 100644 index 0000000..8387e26 --- /dev/null +++ b/tests/test_score.py @@ -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) diff --git a/tests/test_sla.py b/tests/test_sla.py new file mode 100644 index 0000000..1366305 --- /dev/null +++ b/tests/test_sla.py @@ -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