""" 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 def _today(): """Today in the app's timezone, not the machine's. The routes validate due dates against now_eastern(). Using _today() here made these tests fail on a UTC host between 20:00 ET and midnight, when the two calendars disagree — "yesterday" by UTC is still today in Eastern, so a date the test expected to be rejected was legitimately accepted. """ from app.utils.time_utils import now_eastern return now_eastern().date() @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 import db from app.models.inspection_schedule import InspectionSchedule user, tmpl, fac = _seed('mk', role='project_manager') parent = _completed_inspection(user, tmpl, fac) due = _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 = db.session.get(InspectionSchedule, 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=(_today() + timedelta(days=3)).isoformat(), facility_id=other_fac.id, template_id=other_tmpl.id, frequency='daily', mode='auto') s = db.session.get(InspectionSchedule, 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 = _today() + timedelta(days=5) second_due = _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=(_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=(_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=(_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 = (_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 = (_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=_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=(_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=(_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': _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=_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=_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 import db 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=_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 = db.session.get(Inspection, 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 import db 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=_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 db.session.get(Inspection, 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