Aug 4 - Update code to follow up - MT13b

This commit is contained in:
2026-08-04 14:51:53 -04:00
parent 0b20e16e1f
commit 45ad924df8
9 changed files with 628 additions and 19 deletions
+21 -1
View File
@@ -36,7 +36,10 @@ import sys
# ── Import-time env (must be set BEFORE `import app`) ────────────────────────
# Assigned, NOT setdefault — see the module docstring.
os.environ['SECRET_KEY'] = 'test-secret-key'
# SECRET_KEY doubles as the JWT signing key (app/api/jwt_utils.py), and PyJWT
# warns below 32 bytes for HMAC-SHA256 (RFC 7518 §3.2). Throwaway, but sized so
# the API tests do not emit InsecureKeyLengthWarning on every token.
os.environ['SECRET_KEY'] = 'test-secret-key-not-for-production-use-0123456789'
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
os.environ['MULTI_TENANT_ENABLED'] = 'false'
os.environ['BILLING_ENABLED'] = 'false'
@@ -65,6 +68,23 @@ import pytest # noqa: E402
@pytest.fixture(scope='session')
def app():
"""A minimal single-tenant app on in-memory SQLite (multi-tenancy inert)."""
# Flask-Limiter uses in-memory storage keyed on the remote address, and this
# fixture is session-scoped — so every login across the WHOLE suite shares
# one counter against /auth/login's '20 per minute'. Past that the login
# returns 429, the test client stays anonymous, and whatever the test does
# next is redirected to the login page. The failure surfaces as an unrelated
# assertion ("the edit did not apply"), only in full runs, and only once
# enough tests have logged in — so it moves around as tests are added or
# reordered.
#
# This MUST happen before create_app(). Limiter.init_app() does
# `self.enabled = config.setdefault('RATELIMIT_ENABLED', self.enabled)` and
# returns early when false, registering no request hooks — and `enabled` is
# never consulted again at request time (flask-limiter 4.x). Setting it
# afterwards is silently a no-op. Production limits are untouched.
from app import limiter
limiter.enabled = False
from app import create_app
application = create_app('default')
application.config.update(TESTING=True, WTF_CSRF_ENABLED=False, SQLALCHEMY_ECHO=False)
+336
View File
@@ -0,0 +1,336 @@
"""
tests/test_followup_requests.py
--------------------------------
Behaviour tests for phase49 — follow-up request attribution and
customer-raised follow-ups.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* a customer can request a follow-up on a completed inspection at THEIR
facility, and both attribution columns are set
* a customer cannot reach another client's inspection with a crafted POST,
cannot request on a draft, and cannot overwrite a pending request
* a customer cannot CLEAR a follow-up — request-only
* admin/director keep their existing flag behaviour, now attributed
* inspector and auditor are refused (they were before too, via
@supervisor_required; phase49 must not widen access to them)
* clear_followup() nulls the attribution along with the flag
* the request routes through notify_by_matrix as EVENT_FOLLOWUP_REQUESTED,
reaching the managers who action it while excluding the actor and the
inspection's own inspector (who is notified directly instead)
"""
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
with app.app_context():
from app import db
# get_inspector_scope() imports this model lazily, so the mapper is not
# registered at create_all() time and the table is missing when a
# logged-in inspector hits the dashboard. Import it up front.
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _user(username, role, **kw):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True, **kw)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _seed():
"""Two facilities, an inspector, a manager pool, and two customers."""
from app import db
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
from app.models.project import Project, CustomerAssignment
# MT scopes customers through a Project; customer_assignments.project_id is
# NOT NULL, so each facility needs one even for a facility-level assignment.
proj_a = Project(name='Contract A', active=True)
proj_b = Project(name='Contract B', active=True)
tmpl = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean',
'row': 0, 'col': 0,
'rowSpan': 1, 'colSpan': 1}])
db.session.add_all([proj_a, proj_b, tmpl])
db.session.commit()
fac_a = Facility(name='Client A Site', active=True, project_id=proj_a.id)
fac_b = Facility(name='Client B Site', active=True, project_id=proj_b.id)
db.session.add_all([fac_a, fac_b])
db.session.commit()
inspector = _user('ivy', 'inspector')
admin = _user('ada', 'admin')
director = _user('dan', 'director')
pm = _user('pat', 'project_manager')
cust_a = _user('cara', 'customer')
cust_b = _user('carl', 'customer')
db.session.add_all([
CustomerAssignment(user_id=cust_a.id, project_id=proj_a.id,
facility_id=fac_a.id),
CustomerAssignment(user_id=cust_b.id, project_id=proj_b.id,
facility_id=fac_b.id),
])
db.session.commit()
return dict(tmpl=tmpl, fac_a=fac_a, fac_b=fac_b, inspector=inspector,
admin=admin, director=director, pm=pm,
cust_a=cust_a, cust_b=cust_b)
def _inspection(env, facility, status='completed'):
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
insp = Inspection(template_id=env['tmpl'].id, facility_id=facility.id,
inspector_id=env['inspector'].id,
inspection_date=now_eastern(), status=status,
completed_at=now_eastern() if status == 'completed' else None,
overall_score=71.0)
db.session.add(insp)
db.session.commit()
return insp
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
def _flag(client, inspection_id, note=None):
data = {'follow_up_note': note} if note else {}
return client.post(f'/inspections/{inspection_id}/flag-followup',
data=data, follow_redirects=False)
# ── Customer requests ────────────────────────────────────────────────────────
def test_customer_can_request_follow_up_at_their_own_facility(client):
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['cust_a'])
resp = _flag(client, insp.id, note='Stalls still dirty')
assert resp.status_code == 302
db.session.expire_all()
insp = db.session.get(Inspection, insp.id)
assert insp.follow_up_required is True
assert insp.follow_up_note == 'Stalls still dirty'
assert insp.follow_up_requested_by == env['cust_a'].id
assert insp.follow_up_requested_at is not None
# The relationship is what the template renders the badge from.
assert insp.follow_up_requester.role == 'customer'
def test_customer_cannot_reach_another_clients_inspection(client):
"""A crafted POST must not cross the facility scope."""
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_b']) # Client B's facility
_login(client, env['cust_a']) # Client A's customer
assert _flag(client, insp.id).status_code == 403
db.session.expire_all()
assert db.session.get(Inspection, insp.id).follow_up_required is False
def test_customer_cannot_request_on_a_draft(client):
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'], status='in_progress')
_login(client, env['cust_a'])
_flag(client, insp.id)
db.session.expire_all()
assert db.session.get(Inspection, insp.id).follow_up_required is False
def test_repeat_customer_request_does_not_overwrite_the_pending_one(client):
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['cust_a'])
_flag(client, insp.id, note='First note')
db.session.expire_all()
first_at = db.session.get(Inspection, insp.id).follow_up_requested_at
_flag(client, insp.id, note='Second note')
db.session.expire_all()
reloaded = db.session.get(Inspection, insp.id)
assert reloaded.follow_up_note == 'First note'
assert reloaded.follow_up_requested_at == first_at
def test_customer_cannot_clear_a_follow_up(client):
"""Customers may REQUEST only — clearing stays @supervisor_required."""
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['cust_a'])
_flag(client, insp.id)
resp = client.post(f'/inspections/{insp.id}/clear-followup',
follow_redirects=False)
assert resp.status_code in (302, 403)
db.session.expire_all()
# Whether it redirected or 403'd, the flag must still be up.
assert db.session.get(Inspection, insp.id).follow_up_required is True
# ── Staff behaviour is preserved ─────────────────────────────────────────────
def test_admin_flag_still_works_and_is_now_attributed(client):
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['admin'])
_flag(client, insp.id, note='Rework required')
db.session.expire_all()
insp = db.session.get(Inspection, insp.id)
assert insp.follow_up_required is True
assert insp.follow_up_requested_by == env['admin'].id
assert insp.follow_up_requester.role == 'admin'
@pytest.mark.parametrize('role_key', ['inspector', 'pm'])
def test_roles_without_permission_are_refused(client, role_key):
"""phase49 removed @supervisor_required from this route. It must still
refuse everyone who could not flag before."""
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env[role_key])
assert _flag(client, insp.id).status_code == 403
db.session.expire_all()
assert db.session.get(Inspection, insp.id).follow_up_required is False
def test_auditor_is_refused(client):
from app import db
from app.models.inspection import Inspection
env = _seed()
auditor = _user('aud', 'auditor')
insp = _inspection(env, env['fac_a'])
_login(client, auditor)
assert _flag(client, insp.id).status_code == 403
db.session.expire_all()
assert db.session.get(Inspection, insp.id).follow_up_required is False
def test_clear_followup_nulls_the_attribution(client):
"""Leaving it behind would make the NEXT unattributed follow-up appear to
have been requested by whoever raised the previous one."""
from app import db
from app.models.inspection import Inspection
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['cust_a'])
_flag(client, insp.id, note='Please recheck')
client.get('/auth/logout', follow_redirects=True)
_login(client, env['admin'])
client.post(f'/inspections/{insp.id}/clear-followup', follow_redirects=True)
db.session.expire_all()
insp = db.session.get(Inspection, insp.id)
assert insp.follow_up_required is False
assert insp.follow_up_note is None
assert insp.follow_up_requested_by is None
assert insp.follow_up_requested_at is None
assert insp.follow_up_requester is None
# ── Notification routing ─────────────────────────────────────────────────────
def test_request_notifies_managers_via_the_matrix(client):
from app import db
from app.models.notification import Notification, EVENT_FOLLOWUP_REQUESTED
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['cust_a'])
_flag(client, insp.id, note='Still dirty')
rows = Notification.query.filter_by(event_type=EVENT_FOLLOWUP_REQUESTED).all()
recipients = {n.user_id for n in rows}
# Defaults: admin ✓ director ✓ project_manager ✓
assert env['admin'].id in recipients
assert env['director'].id in recipients
assert env['pm'].id in recipients
# The actor never notifies themselves.
assert env['cust_a'].id not in recipients
# The inspection's own inspector is notified DIRECTLY instead, so the matrix
# must exclude them rather than double-notifying.
assert env['inspector'].id not in recipients
# ...and that direct notification did happen.
direct = Notification.query.filter_by(user_id=env['inspector'].id).all()
assert any('Follow-Up Required' in n.title for n in direct)
def test_matrix_defaults_include_followup_requested(client):
from app.models.notification_matrix import MATRIX_EVENTS, MATRIX_DEFAULTS
assert 'followup_requested' in MATRIX_EVENTS
assert MATRIX_DEFAULTS[('followup_requested', 'admin')] is True
assert MATRIX_DEFAULTS[('followup_requested', 'director')] is True
assert MATRIX_DEFAULTS[('followup_requested', 'project_manager')] is True
# Off, or every request would alert the entire inspector pool.
assert MATRIX_DEFAULTS[('followup_requested', 'inspector')] is False
assert MATRIX_DEFAULTS[('followup_requested', 'customer')] is False
def test_notification_body_marks_a_customer_request_as_such(client):
"""Staff must be able to tell a client request from an internal one at a
glance, not just from the badge on the detail page."""
from app.models.notification import Notification, EVENT_FOLLOWUP_REQUESTED
env = _seed()
insp = _inspection(env, env['fac_a'])
_login(client, env['cust_a'])
_flag(client, insp.id)
row = Notification.query.filter_by(
event_type=EVENT_FOLLOWUP_REQUESTED, user_id=env['admin'].id).first()
assert row is not None
assert 'customer' in row.body.lower()