Aug 4 - Update code to follow up - MT11
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
tests/test_schedule_recurrence.py
|
||||
----------------------------------
|
||||
Behaviour tests for phase45 (frequency ENUM), phase46 (day-of-week /
|
||||
day-of-month recurrence) and phase47 (end date) on `inspection_schedules`.
|
||||
|
||||
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
|
||||
|
||||
* pure date maths — weekly weekday sets, monthly day-of-month with short-month
|
||||
clamping, monthly nth-weekday, the longer month-stepping cadences
|
||||
* align_due_date() snapping a picked start date forward onto the rule
|
||||
* fulfill() rolling forward on the rule, deactivating a 'once' schedule, and
|
||||
deactivating when the next occurrence crosses the end date
|
||||
* fulfill() with NO next_run_fn argument still advances the due date — the
|
||||
pre-phase46 trap where omitting it left the schedule perpetually due
|
||||
* legacy rows (all recurrence columns NULL) keeping their exact old cadence
|
||||
* the cron endpoint's expiry sweep deactivating a schedule that passed its end
|
||||
date without ever running, and reporting it as "expired"
|
||||
"""
|
||||
|
||||
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'):
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.models.inspection import InspectionTemplate
|
||||
|
||||
inspector = User(username=f'insp_{suffix}', full_name='Ivy Inspector',
|
||||
email=f'insp_{suffix}@example.com', role='inspector')
|
||||
inspector.set_password('x')
|
||||
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([inspector, tmpl, fac])
|
||||
db.session.commit()
|
||||
return inspector, tmpl, fac
|
||||
|
||||
|
||||
def _sched(suffix, **kw):
|
||||
"""Persist an InspectionSchedule with sensible defaults."""
|
||||
from app import db
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed(suffix)
|
||||
kw.setdefault('name', 'Test schedule')
|
||||
kw.setdefault('mode', 'plan')
|
||||
kw.setdefault('active', True)
|
||||
s = InspectionSchedule(template_id=tmpl.id, facility_id=fac.id,
|
||||
inspector_id=inspector.id, **kw)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
return s
|
||||
|
||||
|
||||
# ── Pure date maths (phase46) ────────────────────────────────────────────────
|
||||
|
||||
def test_weekly_weekday_set_advances_within_the_week(client):
|
||||
# Mon=0, Wed=2, Fri=4
|
||||
s = _sched('wk', frequency='weekly')
|
||||
s.set_weekdays([0, 2, 4])
|
||||
assert s.weekday_list == [0, 2, 4]
|
||||
assert s.weekdays == '0,2,4'
|
||||
|
||||
monday = date(2026, 8, 3) # a Monday
|
||||
assert monday.weekday() == 0
|
||||
assert s.next_occurrence_after(monday) == date(2026, 8, 5) # Wed
|
||||
assert s.next_occurrence_after(date(2026, 8, 5)) == date(2026, 8, 7) # Fri
|
||||
# Friday wraps to the following Monday, not +7 days.
|
||||
assert s.next_occurrence_after(date(2026, 8, 7)) == date(2026, 8, 10)
|
||||
|
||||
|
||||
def test_align_due_date_snaps_forward_onto_the_rule(client):
|
||||
s = _sched('align', frequency='weekly')
|
||||
s.set_weekdays([0, 2, 4])
|
||||
tuesday = date(2026, 8, 4)
|
||||
assert tuesday.weekday() == 1
|
||||
# Picking a Tuesday on a Mon/Wed/Fri schedule yields that Wednesday.
|
||||
assert s.align_due_date(tuesday) == date(2026, 8, 5)
|
||||
# A date already on the rule is left alone.
|
||||
assert s.align_due_date(date(2026, 8, 5)) == date(2026, 8, 5)
|
||||
|
||||
|
||||
def test_monthly_day_of_month_clamps_to_short_months(client):
|
||||
from app.models.inspection_schedule import MONTH_MODE_DAY
|
||||
s = _sched('dom', frequency='monthly')
|
||||
s.month_mode = MONTH_MODE_DAY
|
||||
s.day_of_month = 31
|
||||
assert s.next_occurrence_after(date(2026, 1, 31)) == date(2026, 2, 28)
|
||||
assert s.next_occurrence_after(date(2026, 3, 31)) == date(2026, 4, 30)
|
||||
|
||||
|
||||
def test_monthly_nth_weekday(client):
|
||||
from app.models.inspection_schedule import MONTH_MODE_NTH
|
||||
s = _sched('nth', frequency='monthly')
|
||||
s.month_mode = MONTH_MODE_NTH
|
||||
s.nth_week = 2
|
||||
s.nth_weekday = 1 # Tuesday
|
||||
nxt = s.next_occurrence_after(date(2026, 8, 20))
|
||||
assert nxt == date(2026, 9, 8)
|
||||
assert nxt.weekday() == 1
|
||||
|
||||
s.nth_week = -1 # last Tuesday
|
||||
assert s.next_occurrence_after(date(2026, 8, 20)) == date(2026, 9, 29)
|
||||
|
||||
|
||||
def test_longer_cadences_step_whole_months(client):
|
||||
s = _sched('long', frequency='quarterly')
|
||||
assert s.next_occurrence_after(date(2026, 1, 15)) == date(2026, 4, 15)
|
||||
s.frequency = 'bi-annually'
|
||||
assert s.next_occurrence_after(date(2026, 1, 15)) == date(2026, 7, 15)
|
||||
s.frequency = 'annually'
|
||||
assert s.next_occurrence_after(date(2026, 1, 15)) == date(2027, 1, 15)
|
||||
|
||||
|
||||
def test_legacy_row_with_null_recurrence_keeps_old_cadence(client):
|
||||
"""A pre-phase46 row has every recurrence column NULL and must not change."""
|
||||
s = _sched('legacy', frequency='weekly')
|
||||
assert s.weekdays is None and s.month_mode is None
|
||||
assert s.next_occurrence_after(date(2026, 8, 4)) == date(2026, 8, 11)
|
||||
s.frequency = 'monthly'
|
||||
assert s.next_occurrence_after(date(2026, 8, 4)) == date(2026, 9, 4)
|
||||
|
||||
|
||||
# ── fulfill() (phase46/47) ───────────────────────────────────────────────────
|
||||
|
||||
def test_fulfill_without_next_run_fn_still_advances(client):
|
||||
"""The pre-phase46 trap: omitting next_run_fn used to leave next_run_at
|
||||
untouched, leaving the schedule perpetually due."""
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
due = now_eastern().replace(hour=6, minute=0, second=0, microsecond=0)
|
||||
s = _sched('nofn', frequency='daily', next_run_at=due)
|
||||
before = s.next_run_at
|
||||
|
||||
s.fulfill() # NO next_run_fn
|
||||
db.session.commit()
|
||||
|
||||
assert s.next_run_at > before
|
||||
assert s.next_run_at.date() > now_eastern().date()
|
||||
assert s.active is True
|
||||
assert s.last_completed_at is not None
|
||||
# Time-of-day preserved.
|
||||
assert s.next_run_at.hour == 6
|
||||
|
||||
|
||||
def test_fulfill_rolls_forward_on_the_weekday_rule(client):
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
today = now_eastern().date()
|
||||
s = _sched('roll', frequency='weekly',
|
||||
next_run_at=datetime.combine(today, datetime.min.time()).replace(hour=6))
|
||||
# Every weekday, so the next occurrence is simply tomorrow.
|
||||
s.set_weekdays([0, 1, 2, 3, 4, 5, 6])
|
||||
db.session.commit()
|
||||
|
||||
s.fulfill()
|
||||
db.session.commit()
|
||||
assert s.next_run_at.date() == today + timedelta(days=1)
|
||||
assert s.active is True
|
||||
|
||||
|
||||
def test_fulfill_deactivates_a_once_schedule(client):
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
due = now_eastern().replace(hour=6, minute=0, second=0, microsecond=0)
|
||||
s = _sched('once', frequency='once', next_run_at=due)
|
||||
s.fulfill()
|
||||
db.session.commit()
|
||||
assert s.active is False
|
||||
# The due date is left where it was — the row still shows the occurrence.
|
||||
assert s.next_run_at == due
|
||||
|
||||
|
||||
def test_fulfill_deactivates_when_next_occurrence_passes_end_date(client):
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
today = now_eastern().date()
|
||||
due = datetime.combine(today, datetime.min.time()).replace(hour=6)
|
||||
# Daily schedule whose end date is today: tomorrow's occurrence is past it.
|
||||
s = _sched('end', frequency='daily', next_run_at=due, end_date=today)
|
||||
assert s.is_within_end_date(today) is True
|
||||
assert s.is_within_end_date(today + timedelta(days=1)) is False
|
||||
|
||||
s.fulfill()
|
||||
db.session.commit()
|
||||
assert s.active is False
|
||||
assert s.next_run_at.date() == today + timedelta(days=1)
|
||||
|
||||
|
||||
def test_no_end_date_repeats_indefinitely(client):
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
due = now_eastern().replace(hour=6, minute=0, second=0, microsecond=0)
|
||||
s = _sched('noend', frequency='daily', next_run_at=due)
|
||||
assert s.end_date is None
|
||||
assert s.is_expired is False
|
||||
for _ in range(5):
|
||||
s.fulfill()
|
||||
db.session.commit()
|
||||
assert s.active is True
|
||||
|
||||
|
||||
# ── Expiry sweep (phase47) ───────────────────────────────────────────────────
|
||||
|
||||
def test_expire_if_past_end_date_is_idempotent(client):
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
today = now_eastern().date()
|
||||
s = _sched('exp', frequency='daily', end_date=today - timedelta(days=1))
|
||||
assert s.is_expired is True
|
||||
assert s.expire_if_past_end_date(today) is True
|
||||
assert s.active is False
|
||||
# Second call changes nothing.
|
||||
assert s.expire_if_past_end_date(today) is False
|
||||
|
||||
|
||||
def test_cron_expires_a_schedule_that_never_ran(client):
|
||||
"""A schedule can reach its end date without ever producing an occurrence,
|
||||
so fulfill() never runs and the boundary is checked nowhere else."""
|
||||
from app import db
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
today = now_eastern().date()
|
||||
s = _sched('cron', frequency='daily', mode='plan',
|
||||
next_run_at=datetime.combine(today - timedelta(days=10),
|
||||
datetime.min.time()).replace(hour=6),
|
||||
end_date=today - timedelta(days=5))
|
||||
sid = s.id
|
||||
|
||||
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
||||
assert resp.status_code == 200
|
||||
payload = resp.get_json()
|
||||
assert payload['ok'] is True
|
||||
assert payload['expired'] == 1
|
||||
|
||||
db.session.expire_all()
|
||||
reloaded = db.session.get(InspectionSchedule, sid)
|
||||
assert reloaded.active is False
|
||||
# And it must not have generated an overdue alert on the way out.
|
||||
assert reloaded.overdue_notified is False
|
||||
|
||||
|
||||
def test_cron_leaves_a_live_schedule_alone(client):
|
||||
from app import db
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
today = now_eastern().date()
|
||||
s = _sched('live', frequency='daily', mode='plan',
|
||||
next_run_at=datetime.combine(today + timedelta(days=3),
|
||||
datetime.min.time()).replace(hour=6),
|
||||
end_date=today + timedelta(days=30))
|
||||
sid = s.id
|
||||
|
||||
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
||||
assert resp.get_json()['expired'] == 0
|
||||
|
||||
db.session.expire_all()
|
||||
assert db.session.get(InspectionSchedule, sid).active is True
|
||||
|
||||
|
||||
# ── Routes: create / edit form handling ──────────────────────────────────────
|
||||
|
||||
def _seed_manager(username='mgr'):
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
u = User(username=username, full_name='Mo Manager',
|
||||
email=f'{username}@example.com', role='admin', active=True)
|
||||
u.set_password('pw-correct1')
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
return u
|
||||
|
||||
|
||||
def _login(client, username='mgr'):
|
||||
return client.post('/auth/login',
|
||||
data={'username': username, 'password': 'pw-correct1'},
|
||||
follow_redirects=True)
|
||||
|
||||
|
||||
def test_create_route_stores_recurrence_and_end_date(client):
|
||||
from app import db
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed('crt')
|
||||
_seed_manager()
|
||||
_login(client)
|
||||
|
||||
start = date(2026, 8, 4) # a Tuesday
|
||||
end = date(2026, 12, 31)
|
||||
resp = client.post('/inspection-schedules/new', data={
|
||||
'name': 'MWF restrooms', 'template_id': tmpl.id, 'facility_id': fac.id,
|
||||
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
|
||||
'weekdays': ['0', '2', '4'],
|
||||
'next_due_date': start.isoformat(), 'end_date': end.isoformat(),
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
s = InspectionSchedule.query.filter_by(name='MWF restrooms').one()
|
||||
assert s.weekday_list == [0, 2, 4]
|
||||
assert s.end_date == end
|
||||
# Tuesday snapped forward onto the Wednesday.
|
||||
assert s.due_date == date(2026, 8, 5)
|
||||
assert s.recurrence_label == 'Weekly · Mon, Wed, Fri'
|
||||
|
||||
|
||||
def test_create_rejects_weekly_with_no_weekdays(client):
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed('nowd')
|
||||
_seed_manager()
|
||||
_login(client)
|
||||
|
||||
client.post('/inspection-schedules/new', data={
|
||||
'name': 'No days', 'template_id': tmpl.id, 'facility_id': fac.id,
|
||||
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
|
||||
}, follow_redirects=True)
|
||||
assert InspectionSchedule.query.filter_by(name='No days').first() is None
|
||||
|
||||
|
||||
def test_create_rejects_end_date_on_a_one_time_schedule(client):
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed('onceend')
|
||||
_seed_manager()
|
||||
_login(client)
|
||||
|
||||
client.post('/inspection-schedules/new', data={
|
||||
'name': 'Once with end', 'template_id': tmpl.id, 'facility_id': fac.id,
|
||||
'inspector_id': inspector.id, 'frequency': 'once', 'mode': 'plan',
|
||||
'next_due_date': '2026-09-01', 'end_date': '2026-10-01',
|
||||
}, follow_redirects=True)
|
||||
assert InspectionSchedule.query.filter_by(name='Once with end').first() is None
|
||||
|
||||
|
||||
def test_create_rejects_first_occurrence_past_the_end_date(client):
|
||||
"""The end date clears the *picked* date but not the *aligned* one: a
|
||||
Mon/Wed/Fri schedule started on a Tuesday first runs on the Wednesday."""
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed('past')
|
||||
_seed_manager()
|
||||
_login(client)
|
||||
|
||||
client.post('/inspection-schedules/new', data={
|
||||
'name': 'Impossible', 'template_id': tmpl.id, 'facility_id': fac.id,
|
||||
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
|
||||
'weekdays': ['0', '2', '4'],
|
||||
'next_due_date': '2026-08-04', # Tuesday -> aligns to Wed the 5th
|
||||
'end_date': '2026-08-04', # ...which is past this
|
||||
}, follow_redirects=True)
|
||||
assert InspectionSchedule.query.filter_by(name='Impossible').first() is None
|
||||
|
||||
|
||||
def test_edit_does_not_move_the_due_date_on_an_unrelated_change(client):
|
||||
"""Root-cause regression: edit() used to recompute next_run_at from now on
|
||||
every save, discarding the manager's chosen due date and re-arming every
|
||||
reminder."""
|
||||
from app import db
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed('edit')
|
||||
_seed_manager()
|
||||
_login(client)
|
||||
|
||||
s = InspectionSchedule(
|
||||
name='Original', template_id=tmpl.id, facility_id=fac.id,
|
||||
inspector_id=inspector.id, frequency='weekly', mode='plan', active=True,
|
||||
next_run_at=datetime(2026, 9, 2, 6, 0), weekdays='0,2,4',
|
||||
advance_notified=True, due_notified=True,
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
sid, original_due = s.id, s.next_run_at
|
||||
|
||||
# Rename only — resubmit the same recurrence and due date.
|
||||
client.post(f'/inspection-schedules/{sid}/edit', data={
|
||||
'name': 'Renamed', 'template_id': tmpl.id, 'facility_id': fac.id,
|
||||
'inspector_id': inspector.id, 'frequency': 'weekly', 'mode': 'plan',
|
||||
'weekdays': ['0', '2', '4'],
|
||||
'next_due_date': original_due.date().isoformat(),
|
||||
'active': 'on',
|
||||
}, follow_redirects=True)
|
||||
|
||||
db.session.expire_all()
|
||||
reloaded = db.session.get(InspectionSchedule, sid)
|
||||
assert reloaded.name == 'Renamed'
|
||||
assert reloaded.next_run_at == original_due # unchanged
|
||||
assert reloaded.advance_notified is True # reminders NOT re-armed
|
||||
assert reloaded.due_notified is True
|
||||
|
||||
|
||||
def test_edit_resets_reminders_when_the_due_date_moves(client):
|
||||
from app import db
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
inspector, tmpl, fac = _seed('move')
|
||||
_seed_manager()
|
||||
_login(client)
|
||||
|
||||
s = InspectionSchedule(
|
||||
name='Movable', template_id=tmpl.id, facility_id=fac.id,
|
||||
inspector_id=inspector.id, frequency='daily', mode='plan', active=True,
|
||||
next_run_at=datetime(2026, 9, 2, 6, 0),
|
||||
advance_notified=True, due_notified=True, overdue_notified=True,
|
||||
)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
sid = s.id
|
||||
|
||||
client.post(f'/inspection-schedules/{sid}/edit', data={
|
||||
'name': 'Movable', 'template_id': tmpl.id, 'facility_id': fac.id,
|
||||
'inspector_id': inspector.id, 'frequency': 'daily', 'mode': 'plan',
|
||||
'next_due_date': '2026-09-20', 'active': 'on',
|
||||
}, follow_redirects=True)
|
||||
|
||||
db.session.expire_all()
|
||||
reloaded = db.session.get(InspectionSchedule, sid)
|
||||
assert reloaded.next_run_at.date() == date(2026, 9, 20)
|
||||
assert reloaded.next_run_at.hour == 6 # time-of-day preserved
|
||||
assert reloaded.advance_notified is False
|
||||
assert reloaded.due_notified is False
|
||||
assert reloaded.overdue_notified is False
|
||||
|
||||
|
||||
# ── Labels (phase45/46) ──────────────────────────────────────────────────────
|
||||
|
||||
def test_recurrence_label(client):
|
||||
from app.models.inspection_schedule import MONTH_MODE_DAY, MONTH_MODE_NTH
|
||||
|
||||
s = _sched('lbl', frequency='weekly')
|
||||
s.set_weekdays([0, 2, 4])
|
||||
assert s.recurrence_label == 'Weekly · Mon, Wed, Fri'
|
||||
|
||||
s.frequency = 'monthly'
|
||||
s.weekdays = None
|
||||
s.month_mode = MONTH_MODE_DAY
|
||||
s.day_of_month = 15
|
||||
assert s.recurrence_label == 'Monthly · day 15'
|
||||
|
||||
s.month_mode = MONTH_MODE_NTH
|
||||
s.day_of_month = None
|
||||
s.nth_week = 2
|
||||
s.nth_weekday = 1
|
||||
assert s.recurrence_label == 'Monthly · 2nd Tuesday'
|
||||
|
||||
s.frequency = 'bi-annually'
|
||||
assert s.recurrence_label.startswith('Every 6 months')
|
||||
|
||||
s.frequency = 'once'
|
||||
assert s.recurrence_label == 'One-time'
|
||||
Reference in New Issue
Block a user