Files
JQC_multi_tenant/tests/test_schedule_follow_up.py
T

419 lines
17 KiB
Python

"""
tests/test_schedule_follow_up.py
---------------------------------
Behaviour tests for phase48 — scheduled follow-up.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* POST /api/v1/scheduled-inspections/follow-up creates a one-time, plan-mode
schedule carrying parent_inspection_id, with facility/area/template/assignee
all derived from the parent rather than taken from the client
* idempotency — a retry reuses the existing active follow-up and updates its
date instead of creating a duplicate row
* validation — missing/incorrect parent, non-completed parent, bad or past
due_date, auditor role
* an inspector may only follow up their own work
* starting the schedule from the web produces an inspection that INHERITS
parent_inspection_id (what makes it a real linked re-inspection)
* start() resumes an in-progress run instead of opening a second one
* the cron materialiser inherits the link too
* the mobile API infers the parent from the schedule when the client omits it
"""
from datetime import date, datetime, timedelta
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
app.config['DIGEST_SECRET'] = 'test-digest'
with app.app_context():
from app import db
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _seed(suffix='a', role='inspector'):
from app import db
from app.models.user import User
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
user = User(username=f'u_{suffix}', full_name='Ivy Inspector',
email=f'u_{suffix}@example.com', role=role, active=True)
user.set_password('pw-correct1')
tmpl = InspectionTemplate(name='Restroom Check', active=True,
form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean'}])
fac = Facility(name='Main Office', active=True)
db.session.add_all([user, tmpl, fac])
db.session.commit()
return user, tmpl, fac
def _completed_inspection(user, tmpl, fac, area_id=None):
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
insp = Inspection(template_id=tmpl.id, facility_id=fac.id, area_id=area_id,
inspector_id=user.id, inspection_date=now_eastern(),
status='completed', completed_at=now_eastern(),
overall_score=62.5, follow_up_required=True)
db.session.add(insp)
db.session.commit()
return insp
def _auth(user):
from app.api.jwt_utils import generate_access_token
return {'Authorization': f'Bearer {generate_access_token(user)}'}
def _post_follow_up(client, user, **body):
return client.post('/api/v1/scheduled-inspections/follow-up',
json=body, headers=_auth(user))
# ── Creation ─────────────────────────────────────────────────────────────────
def test_follow_up_creates_one_time_plan_schedule_from_the_parent(client):
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('mk', role='project_manager')
parent = _completed_inspection(user, tmpl, fac)
due = date.today() + timedelta(days=7)
resp = _post_follow_up(client, user,
parent_inspection_id=parent.id,
due_date=due.isoformat(),
notes='Recheck the stalls')
assert resp.status_code == 201
data = resp.get_json()['data']
assert data['created'] is True
s = InspectionSchedule.query.get(data['scheduled']['id'])
assert s.parent_inspection_id == parent.id
assert s.is_follow_up is True
# Everything derived from the parent, nothing taken from the client.
assert s.facility_id == parent.facility_id
assert s.template_id == parent.template_id
assert s.inspector_id == parent.inspector_id
assert s.area_id == parent.area_id
# A follow-up is a single planned visit the inspector goes and does.
assert s.frequency == 'once'
assert s.mode == 'plan'
assert s.active is True
assert s.due_date == due
assert s.notes == 'Recheck the stalls'
assert str(parent.id) in s.name
def test_follow_up_ignores_client_supplied_facility_and_template(client):
"""The endpoint is narrow on purpose — a follow-up can only target the thing
it is a follow-up of."""
from app import db
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('narrow', role='admin')
parent = _completed_inspection(user, tmpl, fac)
other_fac = Facility(name='Other Site', active=True)
other_tmpl = InspectionTemplate(name='Other', active=True, form_schema=[])
db.session.add_all([other_fac, other_tmpl])
db.session.commit()
resp = _post_follow_up(client, user,
parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=3)).isoformat(),
facility_id=other_fac.id,
template_id=other_tmpl.id,
frequency='daily', mode='auto')
s = InspectionSchedule.query.get(resp.get_json()['data']['scheduled']['id'])
assert s.facility_id == fac.id
assert s.template_id == tmpl.id
assert s.frequency == 'once'
assert s.mode == 'plan'
def test_follow_up_is_idempotent_on_retry(client):
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('idem', role='admin')
parent = _completed_inspection(user, tmpl, fac)
first_due = date.today() + timedelta(days=5)
second_due = date.today() + timedelta(days=9)
r1 = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=first_due.isoformat())
assert r1.status_code == 201 and r1.get_json()['data']['created'] is True
r2 = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=second_due.isoformat(), notes='Updated')
assert r2.status_code == 200
assert r2.get_json()['data']['created'] is False
rows = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).all()
assert len(rows) == 1
assert rows[0].due_date == second_due
assert rows[0].notes == 'Updated'
def test_follow_up_reschedule_rearms_reminders(client):
from app import db
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('rearm', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=2)).isoformat())
s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
s.advance_notified = s.due_notified = s.overdue_notified = True
db.session.commit()
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=12)).isoformat())
db.session.expire_all()
s = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
assert s.advance_notified is False
assert s.due_notified is False
assert s.overdue_notified is False
# ── Validation ───────────────────────────────────────────────────────────────
def test_follow_up_requires_a_completed_parent(client):
from app import db
from app.models.inspection import Inspection
from app.utils.time_utils import now_eastern
user, tmpl, fac = _seed('draft', role='admin')
draft = Inspection(template_id=tmpl.id, facility_id=fac.id,
inspector_id=user.id, inspection_date=now_eastern(),
status='in_progress')
db.session.add(draft)
db.session.commit()
r = _post_follow_up(client, user, parent_inspection_id=draft.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
assert r.status_code == 400
def test_follow_up_rejects_bad_input(client):
user, tmpl, fac = _seed('bad', role='admin')
parent = _completed_inspection(user, tmpl, fac)
ok_due = (date.today() + timedelta(days=1)).isoformat()
# Missing parent id.
assert _post_follow_up(client, user, due_date=ok_due).status_code == 400
# Non-integer parent id.
assert _post_follow_up(client, user, parent_inspection_id='7',
due_date=ok_due).status_code == 400
# Unknown parent.
assert _post_follow_up(client, user, parent_inspection_id=999999,
due_date=ok_due).status_code == 404
# Malformed date.
assert _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date='next tuesday').status_code == 400
# Past date.
past = (date.today() - timedelta(days=1)).isoformat()
assert _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=past).status_code == 400
def test_follow_up_allows_today(client):
""""Later today" is a legitimate plan; yesterday is not."""
user, tmpl, fac = _seed('today', role='admin')
parent = _completed_inspection(user, tmpl, fac)
r = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
assert r.status_code == 201
def test_follow_up_rejects_auditor(client):
"""Auditor is read-only everywhere else; keep it that way here."""
user, tmpl, fac = _seed('aud', role='auditor')
parent = _completed_inspection(user, tmpl, fac)
r = _post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
assert r.status_code == 403
def test_inspector_cannot_follow_up_someone_elses_inspection(client):
from app import db
from app.models.user import User
owner, tmpl, fac = _seed('own', role='inspector')
parent = _completed_inspection(owner, tmpl, fac)
other = User(username='other_insp', full_name='Otto', role='inspector',
email='otto@example.com', active=True)
other.set_password('pw-correct1')
db.session.add(other)
db.session.commit()
r = _post_follow_up(client, other, parent_inspection_id=parent.id,
due_date=(date.today() + timedelta(days=1)).isoformat())
assert r.status_code == 403
def test_follow_up_requires_auth(client):
user, tmpl, fac = _seed('noauth', role='admin')
parent = _completed_inspection(user, tmpl, fac)
r = client.post('/api/v1/scheduled-inspections/follow-up',
json={'parent_inspection_id': parent.id,
'due_date': date.today().isoformat()})
assert r.status_code == 401
# ── The link actually propagates ─────────────────────────────────────────────
def test_web_start_inherits_the_parent_link(client):
"""This is the whole point: starting the schedule must produce a LINKED
re-inspection, not an ordinary one."""
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('start', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
client.post('/auth/login', data={'username': user.username,
'password': 'pw-correct1'},
follow_redirects=True)
resp = client.get(f'/inspection-schedules/{sched.id}/start',
follow_redirects=False)
assert resp.status_code == 302
run = (Inspection.query
.filter_by(inspection_schedule_id=sched.id, status='in_progress')
.one())
assert run.parent_inspection_id == parent.id
def test_web_start_resumes_instead_of_duplicating(client):
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('resume', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
client.post('/auth/login', data={'username': user.username,
'password': 'pw-correct1'},
follow_redirects=True)
client.get(f'/inspection-schedules/{sched.id}/start')
client.get(f'/inspection-schedules/{sched.id}/start')
runs = Inspection.query.filter_by(inspection_schedule_id=sched.id).all()
assert len(runs) == 1
def test_cron_materialiser_inherits_the_parent_link(client):
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
user, tmpl, fac = _seed('auto', role='admin')
parent = _completed_inspection(user, tmpl, fac)
sched = InspectionSchedule(
name='Auto follow-up', template_id=tmpl.id, facility_id=fac.id,
inspector_id=user.id, frequency='once', mode='auto', active=True,
next_run_at=now_eastern() - timedelta(hours=1),
parent_inspection_id=parent.id,
)
db.session.add(sched)
db.session.commit()
sid = sched.id
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
assert resp.get_json()['created'] == 1
run = Inspection.query.filter_by(inspection_schedule_id=sid).one()
assert run.parent_inspection_id == parent.id
# 'once' closes the schedule after its single run.
db.session.expire_all()
assert db.session.get(InspectionSchedule, sid).active is False
def test_api_create_infers_parent_from_the_schedule(client):
"""An older iPad build submits the schedule id but no parent. Without the
inference the parent would stay flagged forever."""
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('infer', role='admin')
parent = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent.id).one()
resp = client.post('/api/v1/inspections', headers=_auth(user), json={
'template_id': tmpl.id, 'facility_id': fac.id, 'status': 'completed',
'scheduled_inspection_id': sched.id, # no parent_inspection_id
'form_data': {'f1': 5},
})
assert resp.status_code in (200, 201)
new_id = resp.get_json()['data']['inspection_id']
run = Inspection.query.get(new_id)
assert run.parent_inspection_id == parent.id
# And the whole point of the link: the parent's flag is cleared.
assert parent.follow_up_required is False
def test_api_explicit_parent_wins_over_the_schedule(client):
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
user, tmpl, fac = _seed('explicit', role='admin')
parent_a = _completed_inspection(user, tmpl, fac)
parent_b = _completed_inspection(user, tmpl, fac)
_post_follow_up(client, user, parent_inspection_id=parent_a.id,
due_date=date.today().isoformat())
sched = InspectionSchedule.query.filter_by(parent_inspection_id=parent_a.id).one()
resp = client.post('/api/v1/inspections', headers=_auth(user), json={
'template_id': tmpl.id, 'facility_id': fac.id, 'status': 'completed',
'scheduled_inspection_id': sched.id,
'parent_inspection_id': parent_b.id,
'form_data': {'f1': 5},
})
new_id = resp.get_json()['data']['inspection_id']
assert Inspection.query.get(new_id).parent_inspection_id == parent_b.id
def test_ordinary_schedule_produces_no_parent_link(client):
"""Every pre-phase48 row is NULL here and must stay that way."""
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule
from app.utils.time_utils import now_eastern
user, tmpl, fac = _seed('plain', role='admin')
sched = InspectionSchedule(
name='Plain weekly', template_id=tmpl.id, facility_id=fac.id,
inspector_id=user.id, frequency='weekly', mode='auto', active=True,
next_run_at=now_eastern() - timedelta(hours=1),
)
db.session.add(sched)
db.session.commit()
assert sched.is_follow_up is False
client.post('/inspection-schedules/run', data={'token': 'test-digest'})
run = Inspection.query.filter_by(inspection_schedule_id=sched.id).one()
assert run.parent_inspection_id is None