419 lines
17 KiB
Python
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
|